Remove free trial: subscription paywall + 30-day money-back guarantee (#251)
This commit is contained in:
parent
ffded88e33
commit
9abed9278f
11
src/client/features/billing/managed-access.ts
Normal file
11
src/client/features/billing/managed-access.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { getManagedAccessStatus } from "@/serverFunctions/billing";
|
||||
|
||||
export const MANAGED_ACCESS_QUERY_KEY = ["managedAccessStatus"];
|
||||
|
||||
export const managedAccessQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: MANAGED_ACCESS_QUERY_KEY,
|
||||
queryFn: () => getManagedAccessStatus(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@ -46,36 +46,57 @@ describe("getBillingRouteState", () => {
|
||||
});
|
||||
|
||||
describe("getSubscribeRouteState", () => {
|
||||
const base = {
|
||||
hasSession: true,
|
||||
isCustomerLoading: false,
|
||||
isCustomerError: false,
|
||||
hasManagedAccess: false,
|
||||
planStatus: "free" as const,
|
||||
isUpgradeFlow: false,
|
||||
checkoutCompleted: false,
|
||||
};
|
||||
|
||||
it("shows an error state on billing lookup failures", () => {
|
||||
expect(
|
||||
getSubscribeRouteState({
|
||||
hasSession: true,
|
||||
isCustomerLoading: false,
|
||||
isCustomerError: true,
|
||||
planStatus: "free",
|
||||
}),
|
||||
).toBe("error");
|
||||
expect(getSubscribeRouteState({ ...base, isCustomerError: true })).toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects paying customers away from onboarding", () => {
|
||||
expect(
|
||||
getSubscribeRouteState({
|
||||
hasSession: true,
|
||||
isCustomerLoading: false,
|
||||
isCustomerError: false,
|
||||
planStatus: "paid",
|
||||
}),
|
||||
).toBe("redirectToApp");
|
||||
it("keeps the page blank while billing data is still loading", () => {
|
||||
expect(getSubscribeRouteState({ ...base, isCustomerLoading: true })).toBe(
|
||||
"loading",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows welcome page for free plan users", () => {
|
||||
it("redirects paying customers into the app", () => {
|
||||
expect(getSubscribeRouteState({ ...base, planStatus: "paid" })).toBe(
|
||||
"redirectToApp",
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects grandfathered free-plan users into the app outside the upgrade flow", () => {
|
||||
expect(getSubscribeRouteState({ ...base, hasManagedAccess: true })).toBe(
|
||||
"redirectToApp",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the paywall to grandfathered users in the upgrade flow", () => {
|
||||
expect(
|
||||
getSubscribeRouteState({
|
||||
hasSession: true,
|
||||
isCustomerLoading: false,
|
||||
isCustomerError: false,
|
||||
planStatus: "free",
|
||||
...base,
|
||||
hasManagedAccess: true,
|
||||
isUpgradeFlow: true,
|
||||
}),
|
||||
).toBe("showWelcome");
|
||||
).toBe("showPaywall");
|
||||
});
|
||||
|
||||
it("finalizes instead of re-showing the paywall right after checkout", () => {
|
||||
expect(getSubscribeRouteState({ ...base, checkoutCompleted: true })).toBe(
|
||||
"finalizing",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the paywall to users without managed access", () => {
|
||||
expect(getSubscribeRouteState(base)).toBe("showPaywall");
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,7 +21,10 @@ export function getSubscribeRouteState(args: {
|
||||
hasSession: boolean;
|
||||
isCustomerLoading: boolean;
|
||||
isCustomerError: boolean;
|
||||
hasManagedAccess: boolean;
|
||||
planStatus: PlanStatus;
|
||||
isUpgradeFlow: boolean;
|
||||
checkoutCompleted: boolean;
|
||||
}) {
|
||||
if (!args.hasSession || args.isCustomerLoading) {
|
||||
return "loading" as const;
|
||||
@ -35,5 +38,17 @@ export function getSubscribeRouteState(args: {
|
||||
return "redirectToApp" as const;
|
||||
}
|
||||
|
||||
return "showWelcome" as const;
|
||||
// Grandfathered free-plan users landing here outside the upgrade flow
|
||||
// belong in the app, not on the paywall.
|
||||
if (args.hasManagedAccess && !args.isUpgradeFlow) {
|
||||
return "redirectToApp" as const;
|
||||
}
|
||||
|
||||
// Back from Stripe but Autumn hasn't reflected the subscription yet — poll
|
||||
// instead of showing the paywall again (whose only CTA is paying twice).
|
||||
if (args.checkoutCompleted) {
|
||||
return "finalizing" as const;
|
||||
}
|
||||
|
||||
return "showPaywall" as const;
|
||||
}
|
||||
|
||||
70
src/client/features/billing/useSubscribeRedirect.ts
Normal file
70
src/client/features/billing/useSubscribeRedirect.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { managedAccessQueryOptions } from "@/client/features/billing/managed-access";
|
||||
import { onboardingAnswersQueryOptions } from "@/client/features/onboarding/onboardingModel";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import {
|
||||
isEmailVerificationBypassed,
|
||||
isHostedClientAuthMode,
|
||||
} from "@/lib/auth-mode";
|
||||
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
|
||||
|
||||
// Account-management pages stay reachable without a subscription so gated
|
||||
// users can change settings, read docs, or contact support (no dead ends).
|
||||
const GATE_EXEMPT_PATH_PREFIXES = ["/settings", "/support", "/help"];
|
||||
|
||||
// Sends hosted users without managed access (no plan in Autumn) to the
|
||||
// subscribe paywall. Runs only after onboarding completes so the onboarding
|
||||
// redirect always wins first, and fails open on query errors — billing being
|
||||
// down must not lock paying users out (spend is gated server-side anyway).
|
||||
export function useSubscribeRedirect() {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { data: session } = useSession();
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
const isEmailVerified =
|
||||
session?.user?.emailVerified === true || isEmailVerificationBypassed();
|
||||
const isEligible =
|
||||
isHostedMode && Boolean(session?.user?.id) && isEmailVerified;
|
||||
|
||||
const onboardingQuery = useQuery({
|
||||
...onboardingAnswersQueryOptions(),
|
||||
enabled: isEligible,
|
||||
});
|
||||
const hasCompletedOnboarding = Boolean(onboardingQuery.data?.completedAt);
|
||||
|
||||
const accessQuery = useQuery({
|
||||
...managedAccessQueryOptions(),
|
||||
enabled: isEligible && hasCompletedOnboarding,
|
||||
});
|
||||
|
||||
const isExemptPath = GATE_EXEMPT_PATH_PREFIXES.some((prefix) =>
|
||||
pathname.startsWith(prefix),
|
||||
);
|
||||
const shouldRedirect =
|
||||
isEligible &&
|
||||
hasCompletedOnboarding &&
|
||||
accessQuery.data?.hasManagedAccess === false &&
|
||||
!isExemptPath;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRedirect) return;
|
||||
void navigate({
|
||||
to: SUBSCRIBE_ROUTE,
|
||||
search: { redirect: pathname },
|
||||
replace: true,
|
||||
});
|
||||
}, [navigate, pathname, shouldRedirect]);
|
||||
|
||||
// Hold rendering until we know whether the user may see the app, and while
|
||||
// a redirect is imminent. This avoids flashing gated pages (which would
|
||||
// fire their data queries and create default projects for users who never
|
||||
// pass the paywall).
|
||||
const isBlocking =
|
||||
isEligible &&
|
||||
hasCompletedOnboarding &&
|
||||
(accessQuery.isLoading || shouldRedirect);
|
||||
|
||||
return { isBlocking };
|
||||
}
|
||||
@ -107,7 +107,7 @@ export function GscReEngagementModal({
|
||||
</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
Bring your real clicks, impressions, and rankings into OpenSEO and
|
||||
query them from Claude or Codex over MCP. It's free.
|
||||
query them from Claude or Codex over MCP. It never uses credits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -138,7 +138,7 @@ export function SearchConsoleConnectionCard({
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-base-content/70">
|
||||
Real clicks, impressions, and rankings. Free.
|
||||
Real clicks, impressions, and rankings. No credits used.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -182,7 +182,7 @@ function BillingPageContent() {
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">Plan</span>{" "}
|
||||
<span className="text-base-content/50">
|
||||
{isFreePlan ? "Free Trial" : "Base Plan"}
|
||||
{isFreePlan ? "Free Plan" : "Base Plan"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
||||
import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
|
||||
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
||||
import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect";
|
||||
|
||||
export const Route = createFileRoute("/_app")({
|
||||
component: AppRouteLayout,
|
||||
@ -10,8 +11,9 @@ export const Route = createFileRoute("/_app")({
|
||||
function AppRouteLayout() {
|
||||
const authGate = useHostedAuthRouteGuard();
|
||||
useOnboardingRedirect();
|
||||
const subscribeGate = useSubscribeRedirect();
|
||||
|
||||
if (!authGate.canRenderAuthenticatedContent) {
|
||||
if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -11,13 +11,22 @@ import {
|
||||
onboardingAnswersQueryOptions,
|
||||
restoreOnboardingAnswers,
|
||||
} from "@/client/features/onboarding/onboardingModel";
|
||||
import { managedAccessQueryOptions } from "@/client/features/billing/managed-access";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { queryClient } from "@/client/tanstack-db";
|
||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
|
||||
import { saveOnboardingAnswers } from "@/serverFunctions/onboarding";
|
||||
|
||||
const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z";
|
||||
|
||||
// First step that requires a subscription. The earlier steps (interests, who
|
||||
// you work for, how you found us) collect profiling answers we want even from
|
||||
// users who bounce at the paywall, so the gate sits here — after them, before
|
||||
// Search Console + MCP setup.
|
||||
const SUBSCRIBE_GATE_STEP = 3;
|
||||
|
||||
const clampStep = (step: number) =>
|
||||
Math.min(Math.max(0, Math.trunc(step)), ONBOARDING_LAST_STEP);
|
||||
|
||||
@ -81,6 +90,17 @@ function OnboardingFlow({
|
||||
const { step } = Route.useSearch();
|
||||
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
|
||||
|
||||
// Self-hosted has no paywall; hosted users must subscribe before the gated
|
||||
// steps. Answers from earlier steps are already saved, so a user who pays
|
||||
// returns to the gated step with everything intact.
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
const accessQuery = useQuery({
|
||||
...managedAccessQueryOptions(),
|
||||
enabled: isHostedMode,
|
||||
});
|
||||
const needsSubscription =
|
||||
isHostedMode && accessQuery.data?.hasManagedAccess === false;
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (extra: {
|
||||
mcpSetupIntent?: "yes" | "no";
|
||||
@ -97,6 +117,21 @@ function OnboardingFlow({
|
||||
const goToStep = (next: number) =>
|
||||
void navigate({ to: "/onboarding", search: { step: clampStep(next) } });
|
||||
|
||||
// Advance to the next step, but divert to the paywall when crossing into the
|
||||
// first gated step. The just-saved answers let the user resume here on return.
|
||||
const advanceFromCurrentStep = () => {
|
||||
const next = clampStep(step + 1);
|
||||
if (next >= SUBSCRIBE_GATE_STEP && needsSubscription) {
|
||||
void navigate({
|
||||
to: SUBSCRIBE_ROUTE,
|
||||
search: { redirect: `/onboarding?step=${SUBSCRIBE_GATE_STEP}` },
|
||||
replace: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
goToStep(next);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 0) {
|
||||
captureClientEvent("onboarding:interests_selected", {
|
||||
@ -105,13 +140,13 @@ function OnboardingFlow({
|
||||
});
|
||||
}
|
||||
saveMutation.mutate({});
|
||||
goToStep(step + 1);
|
||||
advanceFromCurrentStep();
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
saveMutation.mutate({});
|
||||
captureClientEvent("onboarding:step_skipped", { step });
|
||||
goToStep(step + 1);
|
||||
advanceFromCurrentStep();
|
||||
};
|
||||
|
||||
const handleFinish = async (mcpSetupIntent: "yes" | "no") => {
|
||||
|
||||
@ -6,16 +6,38 @@ import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMen
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution";
|
||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
|
||||
import { MANAGED_ACCESS_QUERY_KEY } from "@/client/features/billing/managed-access";
|
||||
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||
import { queryClient } from "@/client/tanstack-db";
|
||||
import {
|
||||
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
||||
AUTUMN_PAID_PLAN_ID,
|
||||
} from "@/shared/billing";
|
||||
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
|
||||
|
||||
const SUPPORT_EMAIL = "ben@openseo.so";
|
||||
|
||||
const PLAN_FEATURES = [
|
||||
"Keyword research, backlinks, rank tracking, and site audits",
|
||||
"MCP server and agent skills for Claude, Cursor, and ChatGPT",
|
||||
"Search Console integration that never uses credits",
|
||||
"Includes $10.00 of Usage Credits each month",
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/subscribe")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
validateSearch: (
|
||||
search: Record<string, unknown>,
|
||||
): { upgrade?: true; redirect?: string } => ({
|
||||
upgrade:
|
||||
search.upgrade === true || search.upgrade === "true" ? true : undefined,
|
||||
redirect:
|
||||
typeof search.redirect === "string"
|
||||
? normalizeAuthRedirect(search.redirect)
|
||||
: undefined,
|
||||
}),
|
||||
component: SubscribePage,
|
||||
});
|
||||
@ -30,7 +52,7 @@ function SubscribePage() {
|
||||
|
||||
function SubscribePageContent() {
|
||||
const navigate = useNavigate();
|
||||
const { upgrade: isUpgradeFlow } = Route.useSearch();
|
||||
const { upgrade: isUpgradeFlow, redirect } = Route.useSearch();
|
||||
const { data: session } = useSession();
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -38,37 +60,80 @@ function SubscribePageContent() {
|
||||
typeof window !== "undefined" &&
|
||||
new URLSearchParams(window.location.search).get("checkout") === "success";
|
||||
|
||||
const hasSession = Boolean(session?.user?.id);
|
||||
const customerQuery = useCustomer({
|
||||
queryOptions: {
|
||||
enabled: Boolean(session?.user?.id),
|
||||
enabled: hasSession,
|
||||
},
|
||||
});
|
||||
|
||||
// Read managed access from the already-loaded Autumn customer (local, no API
|
||||
// call) instead of a separate server round-trip. Self-hosted has no Autumn
|
||||
// customer, so mirror the server's "always granted" behavior there.
|
||||
const hasManagedAccess = isHostedClientAuthMode()
|
||||
? customerQuery.check({ featureId: AUTUMN_MANAGED_ACCESS_FEATURE_ID })
|
||||
.allowed
|
||||
: true;
|
||||
|
||||
const planStatus = getCustomerPlanStatus(customerQuery.data);
|
||||
const subscribeRouteState = getSubscribeRouteState({
|
||||
hasSession: Boolean(session?.user?.id),
|
||||
hasSession,
|
||||
isCustomerLoading: customerQuery.isLoading,
|
||||
isCustomerError: customerQuery.isError,
|
||||
hasManagedAccess,
|
||||
planStatus,
|
||||
isUpgradeFlow: isUpgradeFlow === true,
|
||||
checkoutCompleted,
|
||||
});
|
||||
|
||||
// Autumn can lag Stripe by a few seconds after checkout; poll until the
|
||||
// subscription shows up so the just-paid user isn't shown the paywall again.
|
||||
const isFinalizing = subscribeRouteState === "finalizing";
|
||||
useEffect(() => {
|
||||
if (!isFinalizing) return;
|
||||
const interval = setInterval(() => {
|
||||
void customerQuery.refetch();
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [customerQuery, isFinalizing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (subscribeRouteState === "redirectToApp") {
|
||||
// The app layouts gate on this query; make sure they see fresh access
|
||||
// state instead of a cached "no access" that would bounce back here.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: MANAGED_ACCESS_QUERY_KEY,
|
||||
});
|
||||
const destination = redirect ?? "/";
|
||||
const [destinationPath, destinationQuery] = destination.split("?");
|
||||
const destinationSearch = destinationQuery
|
||||
? Object.fromEntries(new URLSearchParams(destinationQuery))
|
||||
: undefined;
|
||||
const goToApp = () =>
|
||||
void navigate({
|
||||
to: destinationPath,
|
||||
search: destinationSearch,
|
||||
replace: true,
|
||||
});
|
||||
if (checkoutCompleted) {
|
||||
captureClientEvent("billing:checkout_success");
|
||||
const attribution = getStoredRedditAttribution();
|
||||
if (attribution) {
|
||||
void captureRedditConversionEvent({
|
||||
data: { attribution, eventType: "PURCHASE" },
|
||||
}).finally(() => {
|
||||
void navigate({ to: "/", replace: true });
|
||||
});
|
||||
}).finally(goToApp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
void navigate({ to: "/", replace: true });
|
||||
goToApp();
|
||||
}
|
||||
}, [checkoutCompleted, navigate, subscribeRouteState]);
|
||||
}, [checkoutCompleted, navigate, redirect, subscribeRouteState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (subscribeRouteState === "showPaywall" && !isUpgradeFlow) {
|
||||
captureClientEvent("billing:paywall_viewed");
|
||||
}
|
||||
}, [isUpgradeFlow, subscribeRouteState]);
|
||||
|
||||
if (
|
||||
subscribeRouteState === "loading" ||
|
||||
@ -77,6 +142,32 @@ function SubscribePageContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscribeRouteState === "finalizing") {
|
||||
return (
|
||||
<div className="w-full max-w-xs space-y-4 text-center">
|
||||
<img
|
||||
src="/transparent-logo.png"
|
||||
alt="OpenSEO"
|
||||
className="mx-auto size-10 rounded-lg"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">
|
||||
Finalizing your subscription…
|
||||
</h1>
|
||||
<span className="loading loading-spinner loading-md" />
|
||||
<p className="text-sm text-base-content/60">
|
||||
This usually takes a few seconds.
|
||||
</p>
|
||||
<p className="text-xs text-base-content/50">
|
||||
Taking longer?{" "}
|
||||
<a className="link" href={`mailto:${SUPPORT_EMAIL}`}>
|
||||
Email {SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (subscribeRouteState === "error") {
|
||||
return (
|
||||
<div className="w-full max-w-xs space-y-4">
|
||||
@ -115,10 +206,12 @@ function SubscribePageContent() {
|
||||
|
||||
try {
|
||||
captureClientEvent("billing:checkout_start");
|
||||
const successUrl = new URL(window.location.href);
|
||||
successUrl.searchParams.set("checkout", "success");
|
||||
await customerQuery.attach({
|
||||
planId: AUTUMN_PAID_PLAN_ID,
|
||||
redirectMode: "always",
|
||||
successUrl: `${window.location.origin}${window.location.pathname}?checkout=success`,
|
||||
successUrl: successUrl.toString(),
|
||||
});
|
||||
} catch (err) {
|
||||
setError(
|
||||
@ -162,11 +255,7 @@ function SubscribePageContent() {
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
"Access to all OpenSEO features",
|
||||
"Do keyword research, backlink analysis and site audits",
|
||||
"Includes $10.00 of Usage Credits each month",
|
||||
].map((item) => (
|
||||
{PLAN_FEATURES.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex gap-2.5 text-sm text-base-content/70"
|
||||
@ -190,11 +279,26 @@ function SubscribePageContent() {
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-base-content/50">
|
||||
Cancel anytime — no commitment. Powered by Stripe.
|
||||
<span
|
||||
className="tooltip before:max-w-60 before:whitespace-normal"
|
||||
data-tip={`Not for you yet? Email ${SUPPORT_EMAIL} within 30 days of your charge and we'll refund your subscription.`}
|
||||
>
|
||||
<span className="cursor-help underline decoration-dotted">
|
||||
30-day money-back guarantee
|
||||
</span>
|
||||
</span>
|
||||
. Cancel anytime. Powered by Stripe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm text-base-content/60">
|
||||
Questions?{" "}
|
||||
<a className="link" href={`mailto:${SUPPORT_EMAIL}`}>
|
||||
Email {SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{isUpgradeFlow ? (
|
||||
<button
|
||||
type="button"
|
||||
@ -204,22 +308,7 @@ function SubscribePageContent() {
|
||||
<ArrowRight className="size-3.5 rotate-180" />
|
||||
Back to app
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-base-content/60">
|
||||
Or try it free — you have $0.50 of credits to explore before
|
||||
committing.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 text-sm font-medium text-base-content/70 hover:text-base-content transition-colors"
|
||||
onClick={() => void navigate({ to: "/", replace: true })}
|
||||
>
|
||||
Continue with free trial
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -8,6 +8,7 @@ import { useEffect } from "react";
|
||||
import { setLastProjectId } from "@/client/lib/active-project";
|
||||
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
||||
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
|
||||
import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect";
|
||||
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
||||
import { getErrorCode } from "@/client/lib/error-messages";
|
||||
import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
|
||||
@ -43,6 +44,7 @@ function ProjectLayout() {
|
||||
const { projectId } = Route.useParams();
|
||||
const authGate = useHostedAuthRouteGuard();
|
||||
useOnboardingRedirect();
|
||||
const subscribeGate = useSubscribeRedirect();
|
||||
|
||||
// Remember this as the last-visited project for the landing redirect.
|
||||
// Settings is excluded: editing another project's settings is
|
||||
@ -56,7 +58,7 @@ function ProjectLayout() {
|
||||
setLastProjectId(projectId);
|
||||
}, [projectId, isSettingsPage]);
|
||||
|
||||
if (!authGate.canRenderAuthenticatedContent) {
|
||||
if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
||||
import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing";
|
||||
import {
|
||||
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
||||
AUTUMN_PAID_PLAN_FEATURE_ID,
|
||||
} from "@/shared/billing";
|
||||
import { autumn } from "@/server/billing/autumn";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
|
||||
@ -36,3 +39,12 @@ export async function customerHasPaidPlan(customerId: string) {
|
||||
|
||||
return result.allowed;
|
||||
}
|
||||
|
||||
export async function customerHasManagedAccess(customerId: string) {
|
||||
const result = await autumn.check({
|
||||
customerId,
|
||||
featureId: AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
||||
});
|
||||
|
||||
return result.allowed;
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ export const getRankTrackerTool = {
|
||||
config: {
|
||||
title: "Get rank tracker",
|
||||
description:
|
||||
"Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Free — reads from OpenSEO state, charges no credits. To trigger a new check, use the dashboard.",
|
||||
"Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Uses no credits — reads from OpenSEO state, no DataForSEO call. To trigger a new check, use the dashboard.",
|
||||
inputSchema,
|
||||
outputSchema: z
|
||||
.object({
|
||||
|
||||
@ -13,7 +13,7 @@ export const listProjectsTool = {
|
||||
config: {
|
||||
title: "List projects",
|
||||
description:
|
||||
"Lists all projects in the user's organization. Free — charges no credits. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.",
|
||||
"Lists all projects in the user's organization. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.",
|
||||
inputSchema: {} as Record<string, never>,
|
||||
outputSchema: {
|
||||
projects: z.array(
|
||||
|
||||
@ -33,7 +33,7 @@ export const listSavedKeywordsTool = {
|
||||
config: {
|
||||
title: "List saved keywords",
|
||||
description:
|
||||
"Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC, and tags if available). Free — reads from OpenSEO's database, charges no credits. Use tag filters when the user asks for a saved segment; multiple tags match ANY tag.",
|
||||
"Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC, and tags if available). Uses no credits — reads from OpenSEO's database, no DataForSEO call. Use tag filters when the user asks for a saved segment; multiple tags match ANY tag.",
|
||||
inputSchema,
|
||||
outputSchema: {
|
||||
rows: z.array(looseObjectOutputSchema),
|
||||
|
||||
@ -43,7 +43,7 @@ export const saveKeywordsTool = {
|
||||
config: {
|
||||
title: "Save keywords",
|
||||
description:
|
||||
"Save keywords to a project's saved-keywords list. Free — charges no credits. Idempotent: re-saving an existing keyword is a no-op. If tags are provided, missing tags may be created. By default tags are appended; set tagMode=replace to remove existing tags from these saved keywords before applying the provided tags, which is useful for reorganizing keywords into page/topic clusters. Ask the user for confirmation before applying or replacing tags broadly.",
|
||||
"Save keywords to a project's saved-keywords list. Uses no credits — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op. If tags are provided, missing tags may be created. By default tags are appended; set tagMode=replace to remove existing tags from these saved keywords before applying the provided tags, which is useful for reorganizing keywords into page/topic clusters. Ask the user for confirmation before applying or replacing tags broadly.",
|
||||
inputSchema,
|
||||
outputSchema: {
|
||||
projectId: z.string(),
|
||||
|
||||
@ -154,7 +154,7 @@ export const getSearchConsolePerformanceTool = {
|
||||
config: {
|
||||
title: "Get Google Search Console performance",
|
||||
description:
|
||||
"Query the connected Search Console property's Search Analytics: clicks, impressions, CTR, and average position by query/page/country/device/date. First-party data — use it for what already ranks, near-ranking queries, and pages with real demand. ctr is a 0-1 fraction; position is a 1-based average; dates are Pacific Time; the last ~3 days may be incomplete. Read-only and free (no credits).",
|
||||
"Query the connected Search Console property's Search Analytics: clicks, impressions, CTR, and average position by query/page/country/device/date. First-party data — use it for what already ranks, near-ranking queries, and pages with real demand. ctr is a 0-1 fraction; position is a 1-based average; dates are Pacific Time; the last ~3 days may be incomplete. Read-only; uses no credits.",
|
||||
inputSchema: perfInputSchema,
|
||||
outputSchema: {
|
||||
ok: z.boolean(),
|
||||
@ -303,7 +303,7 @@ export const inspectUrlsTool = {
|
||||
config: {
|
||||
title: "Inspect URLs in Google Search Console",
|
||||
description:
|
||||
"Run Google Search Console's URL Inspection on up to 10 URLs of the connected property: index/coverage state, last crawl time, Google-selected vs declared canonical, and mobile/rich-results verdicts. Use it to answer 'is this page indexed? why not?'. Per-URL failures are reported inline. Read-only and free (no credits).",
|
||||
"Run Google Search Console's URL Inspection on up to 10 URLs of the connected property: index/coverage state, last crawl time, Google-selected vs declared canonical, and mobile/rich-results verdicts. Use it to answer 'is this page indexed? why not?'. Per-URL failures are reported inline. Read-only; uses no credits.",
|
||||
inputSchema: inspectInputSchema,
|
||||
outputSchema: {
|
||||
ok: z.boolean(),
|
||||
|
||||
@ -23,7 +23,7 @@ export const whoamiTool = {
|
||||
config: {
|
||||
title: "Who am I",
|
||||
description:
|
||||
"Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Free — charges no credits. Use this first to confirm connection context before choosing a project or running paid tools.",
|
||||
"Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Uses no credits — does not call DataForSEO. Use this first to confirm connection context before choosing a project or running paid tools.",
|
||||
inputSchema: {} as Record<string, never>,
|
||||
outputSchema: {
|
||||
userId: z.string(),
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { waitUntil } from "cloudflare:workers";
|
||||
import { AuditService } from "@/server/features/audit/services/AuditService";
|
||||
import { customerHasManagedAccess } from "@/server/billing/subscription";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { captureServerEvent } from "@/server/lib/posthog";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
deleteAuditSchema,
|
||||
@ -16,6 +19,15 @@ export const startAudit = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => startAuditSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
// The crawler runs on our Workers compute and isn't credit-metered, so
|
||||
// gate it on plan access in hosted mode (grandfathered free plans pass).
|
||||
if (
|
||||
(await isHostedServerAuthMode()) &&
|
||||
!(await customerHasManagedAccess(context.organizationId))
|
||||
) {
|
||||
throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits");
|
||||
}
|
||||
|
||||
const result = await AuditService.startAudit({
|
||||
actorUserId: context.userId,
|
||||
billingCustomer: context,
|
||||
|
||||
@ -10,6 +10,10 @@ import {
|
||||
isHostedServerAuthMode,
|
||||
} from "@/server/lib/runtime-env";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
customerHasManagedAccess,
|
||||
getOrCreateOrganizationCustomer,
|
||||
} from "@/server/billing/subscription";
|
||||
|
||||
const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list";
|
||||
const EVENT_PAGE_LIMIT = 1000;
|
||||
@ -48,6 +52,20 @@ export type BillingUsageEvent = {
|
||||
properties: Record<string, z.infer<typeof billingUsagePropertySchema>>;
|
||||
};
|
||||
|
||||
// Whether this organization may use the managed product at all. Both the
|
||||
// legacy free plan (grandfathered users) and the paid base plan grant the
|
||||
// feature; new customers get no default product and must subscribe.
|
||||
export const getManagedAccessStatus = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.handler(async ({ context }) => {
|
||||
if (!(await isHostedServerAuthMode())) {
|
||||
return { hasManagedAccess: true };
|
||||
}
|
||||
|
||||
const customer = await getOrCreateOrganizationCustomer(context);
|
||||
return { hasManagedAccess: await customerHasManagedAccess(customer.id) };
|
||||
});
|
||||
|
||||
export const getBillingUsageEvents = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.inputValidator((data: unknown) => billingUsageRangeSchema.parse(data))
|
||||
|
||||
@ -4,6 +4,9 @@ export const SUBSCRIBE_ROUTE = "/subscribe";
|
||||
export const AUTUMN_PAID_PLAN_ID = "base-plan";
|
||||
export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up";
|
||||
export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan";
|
||||
// Granted by both the legacy free plan (grandfathered users) and the paid
|
||||
// base plan. New customers get no default product, so this is the paywall.
|
||||
export const AUTUMN_MANAGED_ACCESS_FEATURE_ID = "managed_service_access";
|
||||
export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits";
|
||||
export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_credits";
|
||||
export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000;
|
||||
|
||||
@ -3,7 +3,7 @@ title: Terms and Conditions
|
||||
description: Terms and conditions for openseo.so.
|
||||
---
|
||||
|
||||
**Last revised on: 4/1/2026**
|
||||
**Last revised on: 6/13/2026**
|
||||
|
||||
The website located at https://openseo.so (the “**Site**”), including the hosted OpenSEO services made available through the Site, is a copyrighted work belonging to Every App, Inc (“**Company**”, “**us**”, “**our**”, and “**we**”). These Terms apply to your use of the Site and the hosted OpenSEO services made available through it. For the avoidance of doubt, these Terms do not govern any self-hosted or open-source version of OpenSEO, which is made available separately under the MIT License. Certain features of the Site may be subject to additional guidelines, terms, or rules, which will be posted on the Site in connection with such features. All such additional terms, guidelines, and rules are incorporated by reference into these Terms.
|
||||
|
||||
@ -31,6 +31,8 @@ THESE TERMS OF USE (THESE “**TERMS**”) SET FORTH THE LEGALLY BINDING TERMS A
|
||||
|
||||
6. **Feedback.** If you provide Company with any feedback or suggestions regarding the Site (“**Feedback**”), you hereby assign to Company all rights in such Feedback and agree that Company shall have the right to use and fully exploit such Feedback and related information in any manner it deems appropriate. Company will treat any Feedback you provide to Company as non-confidential and non-proprietary. You agree that you will not submit to Company any information or ideas that you consider to be confidential or proprietary.
|
||||
|
||||
7. **Subscriptions, Fees, and Refunds.** Access to certain features of the hosted OpenSEO service requires a paid subscription. Fees are billed in advance on a recurring basis and are non-refundable except as expressly provided in this Section or as required by applicable law. We offer a **30-day money-back guarantee**: if you are not satisfied with your paid subscription, you may request a full refund of your most recent subscription charge by emailing ben@openseo.so within thirty (30) days of that charge. The guarantee applies to subscription fees only and does not apply to separately purchased usage or top-up credits that have been consumed. Company reserves the right, in its sole discretion, to review each refund request and to decline a refund where it reasonably suspects fraud, abuse of the guarantee (including repeated subscribe-and-refund cycles), violation of these Terms or the Acceptable Use Policy, or other misuse of the Site or the refund policy. You may cancel your subscription at any time through your billing portal; your access will continue through the end of the then-current billing period, and no further charges will be made.
|
||||
|
||||
3. **User Content**
|
||||
1. **User Content.** “**User Content**” means any and all information and content that a user submits to, or uses with, the Site (e.g., content in the user’s profile or postings). You are solely responsible for your User Content. You assume all risks associated with use of your User Content, including any reliance on its accuracy, completeness or usefulness by others, or any disclosure of your User Content that personally identifies you or any third party. You hereby represent and warrant that your User Content does not violate our Acceptable Use Policy (defined in Section 3.3). You may not represent or imply to others that your User Content is in any way provided, sponsored or endorsed by Company. Since you alone are responsible for your User Content, you may expose yourself to liability if, for example, your User Content violates the Acceptable Use Policy. Company is not obligated to backup any User Content, and your User Content may be deleted at any time without prior notice. You are solely responsible for creating and maintaining your own backup copies of your User Content if you desire.
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ Most Google Search Console (GSC) MCP servers are open source, but they take a lo
|
||||
|
||||
## Setup: no Google Cloud project
|
||||
|
||||
1. Sign up for OpenSEO.
|
||||
1. Sign up for OpenSEO ($10/month).
|
||||
2. Go through the onboarding. When prompted, click **Connect Search Console** and pick your property.
|
||||
3. Add the OpenSEO MCP endpoint to your client (steps below) and approve the OpenSEO login when prompted.
|
||||
|
||||
@ -91,11 +91,11 @@ The Search Console tools use zero credits — Google doesn't charge you to read
|
||||
|
||||
### What does it cost?
|
||||
|
||||
The Search Console MCP tools are included with the $10/month OpenSEO plan and use zero credits. OpenSEO's other tools (keyword research, rank tracking, backlinks) use the usage credits included with the plan, through the same endpoint. Self-hosting is free.
|
||||
The Search Console MCP tools are included with the $10/month OpenSEO plan (which comes with a 30-day money-back guarantee) and use zero credits. OpenSEO's other tools (keyword research, rank tracking, backlinks) use the usage credits included with the plan, through the same endpoint. Self-hosting is free.
|
||||
|
||||
### Is it open source?
|
||||
|
||||
Yes. OpenSEO is open source, so you can self-host the whole thing, including the Search Console MCP. The one-click connection here uses OpenSEO's hosted Google app. If you self-host, you bring your own Google OAuth client, the same Cloud-console step the hosted version saves you. Hosted means no setup; self-hosted means full control.
|
||||
Yes. OpenSEO is open source, so you can self-host the whole thing for free, including the Search Console MCP. The one-click connection here uses OpenSEO's hosted Google app. If you self-host, you bring your own Google OAuth client, the same Cloud-console step the hosted version saves you. Hosted means no setup; self-hosted means full control.
|
||||
|
||||
### Is it read-only?
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ const ROWS: { label: string; cells: Cell[] }[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Price",
|
||||
label: "Cost to run",
|
||||
cells: [
|
||||
{
|
||||
text: "Included in the $10/mo plan, zero credits (free to self-host)",
|
||||
|
||||
21
web/src/components/money-back-guarantee.tsx
Normal file
21
web/src/components/money-back-guarantee.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
const REFUND_PROMISE =
|
||||
"Not for you yet? Email ben@openseo.so within 30 days of your charge and we'll refund your subscription.";
|
||||
|
||||
// Hover/focus tooltip (tabIndex makes it work on tap too). The full refund
|
||||
// terms also live in the pricing FAQ, so the tooltip is reinforcement, not
|
||||
// the only place the promise appears.
|
||||
export function MoneyBackGuarantee() {
|
||||
return (
|
||||
<span className="group relative inline-block" tabIndex={0}>
|
||||
<span className="cursor-help underline decoration-dotted decoration-neutral-400 underline-offset-2">
|
||||
30-day money-back guarantee
|
||||
</span>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden w-64 -translate-x-1/2 rounded-md bg-neutral-900 px-3 py-2 text-xs leading-relaxed text-white shadow-lg group-hover:block group-focus:block"
|
||||
>
|
||||
{REFUND_PROMISE}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -17,6 +17,18 @@ const softwareApplicationLd = {
|
||||
operatingSystem: "Web",
|
||||
url: toCanonicalUrl(PATH),
|
||||
description: frontmatter.description,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "10.00",
|
||||
priceCurrency: "USD",
|
||||
priceSpecification: {
|
||||
"@type": "UnitPriceSpecification",
|
||||
price: "10.00",
|
||||
priceCurrency: "USD",
|
||||
billingDuration: 1,
|
||||
unitCode: "MON",
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
"@type": "Organization",
|
||||
name: "OpenSEO",
|
||||
@ -62,6 +74,10 @@ function GoogleSearchConsoleMcpPage() {
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-neutral-500">
|
||||
$10/month, 30-day money-back guarantee. Search Console tools never
|
||||
use credits.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DocsBody className="min-w-0 text-neutral-800 [&_a]:!text-neutral-950 [&_h2]:!text-neutral-950 [&_h2_a]:!no-underline [&_h3]:!text-neutral-950 [&_h3_a]:!no-underline [&_h4]:!text-neutral-950 [&_h4_a]:!no-underline [&_h5_a]:!no-underline [&_h6_a]:!no-underline [&_li]:!text-neutral-700 [&_li_a]:font-medium [&_li_a]:underline [&_li_a]:decoration-[var(--color-brand-accent)] [&_li_a]:underline-offset-4 [&_li_a:hover]:!text-neutral-700 [&_p]:!text-neutral-700 [&_p_a]:font-medium [&_p_a]:underline [&_p_a]:decoration-[var(--color-brand-accent)] [&_p_a]:underline-offset-4 [&_p_a:hover]:!text-neutral-700 [&_strong]:!text-neutral-950">
|
||||
@ -90,8 +106,8 @@ function GoogleSearchConsoleMcpCta() {
|
||||
Point your AI at your real search data
|
||||
</p>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--color-brand-muted)]">
|
||||
Free to connect. No Google Cloud project. Works with Claude, Codex,
|
||||
OpenClaw, OpenCode, and Gemini.
|
||||
No Google Cloud project. Zero credits to read your own data. Works with
|
||||
Claude, Codex, OpenClaw, OpenCode, and Gemini.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
||||
<a
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { MoneyBackGuarantee } from "@/components/money-back-guarantee";
|
||||
import { buildPageSeo } from "@/lib/seo";
|
||||
|
||||
export const Route = createFileRoute("/_marketing/pricing")({
|
||||
@ -6,7 +7,7 @@ export const Route = createFileRoute("/_marketing/pricing")({
|
||||
buildPageSeo({
|
||||
title: "Pricing",
|
||||
description:
|
||||
"OpenSEO is free to self-host. The managed service is $10/month and includes Usage Credits.",
|
||||
"OpenSEO is free to self-host. The managed service is $10/month with a 30-day money-back guarantee.",
|
||||
path: "/pricing",
|
||||
titleSuffix: "OpenSEO",
|
||||
}),
|
||||
@ -43,9 +44,10 @@ function Pricing() {
|
||||
</div>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{[
|
||||
"Access to OpenSEO's managed service",
|
||||
"Keyword research, backlinks, rank tracking, and site audits",
|
||||
"MCP server and agent skills for Claude, Cursor, and ChatGPT",
|
||||
"Search Console integration that never uses credits",
|
||||
"Includes $10.00 of Usage Credits each billing cycle",
|
||||
"Monthly included credits reset each cycle",
|
||||
].map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
@ -102,6 +104,9 @@ function Pricing() {
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
<p className="mt-3 text-xs text-neutral-500">
|
||||
<MoneyBackGuarantee />.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Self-hosted */}
|
||||
@ -132,6 +137,17 @@ function Pricing() {
|
||||
FAQ
|
||||
</h2>
|
||||
<dl className="mt-5 divide-y divide-[var(--color-border-subtle)] rounded-xl border border-[var(--color-border-subtle)] bg-white">
|
||||
<div className="p-5">
|
||||
<dt className="text-sm font-medium text-neutral-950">
|
||||
Is there a free trial?
|
||||
</dt>
|
||||
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
|
||||
No — instead, every subscription comes with a 30-day money-back
|
||||
guarantee. If OpenSEO isn't for you, email ben@openseo.so
|
||||
within 30 days of your first charge and we'll refund it. You
|
||||
can also self-host the open-source version for free.
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<dt className="text-sm font-medium text-neutral-950">
|
||||
What if I use all my included credits?
|
||||
@ -168,7 +184,9 @@ function Pricing() {
|
||||
</dt>
|
||||
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
|
||||
Yes. Cancel from your billing portal at any time. Your access
|
||||
continues through the end of the current billing period.{" "}
|
||||
continues through the end of the current billing period. Within
|
||||
your first 30 days, you can email ben@openseo.so for a full
|
||||
refund.{" "}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user