hosted: add free trial plan and onboarding (#104)
This commit is contained in:
parent
01c11b0c5f
commit
1d9a503335
@ -47,7 +47,7 @@ export function BillingUsageChart() {
|
|||||||
const totalSpend = chartData.reduce((sum, d) => sum + d.credits, 0);
|
const totalSpend = chartData.reduce((sum, d) => sum + d.credits, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-base-300 p-4 space-y-3">
|
<div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
|
||||||
<div className="flex items-baseline justify-between gap-4">
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
<span className="font-semibold">Usage</span>
|
<span className="font-semibold">Usage</span>
|
||||||
<span className="text-xs text-base-content/50">Last 30 days</span>
|
<span className="text-xs text-base-content/50">Last 30 days</span>
|
||||||
@ -69,7 +69,7 @@ export function BillingUsageChart() {
|
|||||||
width={chartWidth}
|
width={chartWidth}
|
||||||
height={128}
|
height={128}
|
||||||
data={chartData}
|
data={chartData}
|
||||||
margin={{ top: 4, right: 0, bottom: 0, left: -20 }}
|
margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
|
|||||||
129
src/client/features/billing/FreePlanBanner.tsx
Normal file
129
src/client/features/billing/FreePlanBanner.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||||
|
import {
|
||||||
|
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||||
|
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||||
|
BILLING_ROUTE,
|
||||||
|
LOW_CREDITS_THRESHOLD_USD,
|
||||||
|
SUBSCRIBE_ROUTE,
|
||||||
|
autumnSeoDataCreditsToUsd,
|
||||||
|
} from "@/shared/billing";
|
||||||
|
|
||||||
|
export function FreePlanBanner() {
|
||||||
|
return (
|
||||||
|
<AutumnProvider>
|
||||||
|
<FreePlanBannerContent />
|
||||||
|
</AutumnProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FreePlanBannerContent() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const customerQuery = useCustomer({
|
||||||
|
queryOptions: {
|
||||||
|
enabled: Boolean(session?.user?.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (customerQuery.isLoading || !customerQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planStatus = getCustomerPlanStatus(customerQuery.data);
|
||||||
|
const isFreePlan = planStatus === "free";
|
||||||
|
|
||||||
|
const monthlyRemaining = autumnSeoDataCreditsToUsd(
|
||||||
|
customerQuery.data.balances?.[AUTUMN_SEO_DATA_BALANCE_FEATURE_ID]
|
||||||
|
?.remaining ?? 0,
|
||||||
|
);
|
||||||
|
const topUpRemaining = autumnSeoDataCreditsToUsd(
|
||||||
|
customerQuery.data.balances?.[AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID]
|
||||||
|
?.remaining ?? 0,
|
||||||
|
);
|
||||||
|
const totalRemaining = monthlyRemaining + topUpRemaining;
|
||||||
|
|
||||||
|
const isOutOfCredits = totalRemaining <= 0;
|
||||||
|
const isLowCredits =
|
||||||
|
!isOutOfCredits && totalRemaining < LOW_CREDITS_THRESHOLD_USD;
|
||||||
|
|
||||||
|
const creditsActionLink = isFreePlan ? (
|
||||||
|
<Link
|
||||||
|
to={SUBSCRIBE_ROUTE}
|
||||||
|
search={{ upgrade: true }}
|
||||||
|
className="link link-primary font-medium"
|
||||||
|
>
|
||||||
|
Upgrade your plan
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link to={BILLING_ROUTE} className="link link-primary font-medium">
|
||||||
|
Buy more credits
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isOutOfCredits) {
|
||||||
|
return (
|
||||||
|
<BannerShell variant="error">
|
||||||
|
You’ve used all your credits. {creditsActionLink} to continue
|
||||||
|
using OpenSEO.
|
||||||
|
</BannerShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLowCredits) {
|
||||||
|
return (
|
||||||
|
<BannerShell variant="warning">
|
||||||
|
You’re running low on credits. {creditsActionLink} to keep using
|
||||||
|
OpenSEO.
|
||||||
|
</BannerShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFreePlan) {
|
||||||
|
return (
|
||||||
|
<BannerShell variant="info">
|
||||||
|
We hope you’re enjoying OpenSEO!{" "}
|
||||||
|
<Link
|
||||||
|
to={SUBSCRIBE_ROUTE}
|
||||||
|
search={{ upgrade: true }}
|
||||||
|
className="link link-primary font-medium"
|
||||||
|
>
|
||||||
|
Upgrade anytime
|
||||||
|
</Link>{" "}
|
||||||
|
or{" "}
|
||||||
|
<Link to="/support" className="link link-primary font-medium">
|
||||||
|
reach out with questions
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</BannerShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BannerShell({
|
||||||
|
variant,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
variant: "info" | "warning" | "error";
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const alertClass =
|
||||||
|
variant === "error"
|
||||||
|
? "alert-error"
|
||||||
|
: variant === "warning"
|
||||||
|
? "alert-warning"
|
||||||
|
: "alert-info";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 px-4 py-2.5 md:px-6">
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<div className={`alert text-sm ${alertClass}`}>
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/client/features/billing/plan-detection.ts
Normal file
17
src/client/features/billing/plan-detection.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
|
||||||
|
|
||||||
|
export type PlanStatus = "free" | "paid";
|
||||||
|
|
||||||
|
export function getCustomerPlanStatus(
|
||||||
|
customer:
|
||||||
|
| { subscriptions?: Array<{ planId: string; status: string }> }
|
||||||
|
| undefined,
|
||||||
|
): PlanStatus {
|
||||||
|
if (!customer?.subscriptions) return "free";
|
||||||
|
|
||||||
|
const hasActivePaid = customer.subscriptions.some(
|
||||||
|
(sub) => sub.planId === AUTUMN_PAID_PLAN_ID && sub.status === "active",
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasActivePaid ? "paid" : "free";
|
||||||
|
}
|
||||||
@ -2,26 +2,24 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { getBillingRouteState, getSubscribeRouteState } from "./route-state";
|
import { getBillingRouteState, getSubscribeRouteState } from "./route-state";
|
||||||
|
|
||||||
describe("getBillingRouteState", () => {
|
describe("getBillingRouteState", () => {
|
||||||
it("redirects unpaid customers after a successful customer lookup", () => {
|
it("shows ready after successful customer lookup", () => {
|
||||||
expect(
|
expect(
|
||||||
getBillingRouteState({
|
getBillingRouteState({
|
||||||
hasSession: true,
|
hasSession: true,
|
||||||
isSessionPending: false,
|
isSessionPending: false,
|
||||||
isCustomerLoading: false,
|
isCustomerLoading: false,
|
||||||
isCustomerError: false,
|
isCustomerError: false,
|
||||||
hasManagedServiceAccess: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe("redirectToSubscribe");
|
).toBe("ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an error state instead of redirecting on billing lookup failures", () => {
|
it("shows an error state on billing lookup failures", () => {
|
||||||
expect(
|
expect(
|
||||||
getBillingRouteState({
|
getBillingRouteState({
|
||||||
hasSession: true,
|
hasSession: true,
|
||||||
isSessionPending: false,
|
isSessionPending: false,
|
||||||
isCustomerLoading: false,
|
isCustomerLoading: false,
|
||||||
isCustomerError: true,
|
isCustomerError: true,
|
||||||
hasManagedServiceAccess: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe("error");
|
).toBe("error");
|
||||||
});
|
});
|
||||||
@ -33,7 +31,6 @@ describe("getBillingRouteState", () => {
|
|||||||
isSessionPending: true,
|
isSessionPending: true,
|
||||||
isCustomerLoading: false,
|
isCustomerLoading: false,
|
||||||
isCustomerError: false,
|
isCustomerError: false,
|
||||||
hasManagedServiceAccess: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe("loading");
|
).toBe("loading");
|
||||||
|
|
||||||
@ -43,20 +40,19 @@ describe("getBillingRouteState", () => {
|
|||||||
isSessionPending: false,
|
isSessionPending: false,
|
||||||
isCustomerLoading: true,
|
isCustomerLoading: true,
|
||||||
isCustomerError: false,
|
isCustomerError: false,
|
||||||
hasManagedServiceAccess: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe("loading");
|
).toBe("loading");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getSubscribeRouteState", () => {
|
describe("getSubscribeRouteState", () => {
|
||||||
it("shows an error state instead of a subscribe CTA on billing lookup failures", () => {
|
it("shows an error state on billing lookup failures", () => {
|
||||||
expect(
|
expect(
|
||||||
getSubscribeRouteState({
|
getSubscribeRouteState({
|
||||||
hasSession: true,
|
hasSession: true,
|
||||||
isCustomerLoading: false,
|
isCustomerLoading: false,
|
||||||
isCustomerError: true,
|
isCustomerError: true,
|
||||||
hasManagedServiceAccess: false,
|
planStatus: "free",
|
||||||
}),
|
}),
|
||||||
).toBe("error");
|
).toBe("error");
|
||||||
});
|
});
|
||||||
@ -67,8 +63,19 @@ describe("getSubscribeRouteState", () => {
|
|||||||
hasSession: true,
|
hasSession: true,
|
||||||
isCustomerLoading: false,
|
isCustomerLoading: false,
|
||||||
isCustomerError: false,
|
isCustomerError: false,
|
||||||
hasManagedServiceAccess: true,
|
planStatus: "paid",
|
||||||
}),
|
}),
|
||||||
).toBe("redirectToApp");
|
).toBe("redirectToApp");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows welcome page for free plan users", () => {
|
||||||
|
expect(
|
||||||
|
getSubscribeRouteState({
|
||||||
|
hasSession: true,
|
||||||
|
isCustomerLoading: false,
|
||||||
|
isCustomerError: false,
|
||||||
|
planStatus: "free",
|
||||||
|
}),
|
||||||
|
).toBe("showWelcome");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
|
import type { PlanStatus } from "@/client/features/billing/plan-detection";
|
||||||
|
|
||||||
export function getBillingRouteState(args: {
|
export function getBillingRouteState(args: {
|
||||||
hasSession: boolean;
|
hasSession: boolean;
|
||||||
isSessionPending: boolean;
|
isSessionPending: boolean;
|
||||||
isCustomerLoading: boolean;
|
isCustomerLoading: boolean;
|
||||||
isCustomerError: boolean;
|
isCustomerError: boolean;
|
||||||
hasManagedServiceAccess: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
if (args.isSessionPending || !args.hasSession || args.isCustomerLoading) {
|
if (args.isSessionPending || !args.hasSession || args.isCustomerLoading) {
|
||||||
return "loading" as const;
|
return "loading" as const;
|
||||||
@ -13,10 +14,6 @@ export function getBillingRouteState(args: {
|
|||||||
return "error" as const;
|
return "error" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.hasManagedServiceAccess) {
|
|
||||||
return "redirectToSubscribe" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "ready" as const;
|
return "ready" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,7 +21,7 @@ export function getSubscribeRouteState(args: {
|
|||||||
hasSession: boolean;
|
hasSession: boolean;
|
||||||
isCustomerLoading: boolean;
|
isCustomerLoading: boolean;
|
||||||
isCustomerError: boolean;
|
isCustomerError: boolean;
|
||||||
hasManagedServiceAccess: boolean;
|
planStatus: PlanStatus;
|
||||||
}) {
|
}) {
|
||||||
if (!args.hasSession || args.isCustomerLoading) {
|
if (!args.hasSession || args.isCustomerLoading) {
|
||||||
return "loading" as const;
|
return "loading" as const;
|
||||||
@ -34,9 +31,9 @@ export function getSubscribeRouteState(args: {
|
|||||||
return "error" as const;
|
return "error" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.hasManagedServiceAccess) {
|
if (args.planStatus === "paid") {
|
||||||
return "redirectToApp" as const;
|
return "redirectToApp" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "ready" as const;
|
return "showWelcome" as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,6 +130,7 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
|
|||||||
lastSearchKeyword,
|
lastSearchKeyword,
|
||||||
lastSearchLocationCode,
|
lastSearchLocationCode,
|
||||||
researchError,
|
researchError,
|
||||||
|
researchMutationError: researchMutation.error,
|
||||||
searchedKeyword,
|
searchedKeyword,
|
||||||
isLoading: researchMutation.isPending,
|
isLoading: researchMutation.isPending,
|
||||||
beginSearch,
|
beginSearch,
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import { AlertCircle, ArrowLeft } from "lucide-react";
|
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||||
|
import { getErrorCode } from "@/client/lib/error-messages";
|
||||||
|
import { BILLING_ROUTE } from "@/shared/billing";
|
||||||
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
|
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
|
||||||
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
|
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
|
||||||
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
|
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
|
||||||
@ -64,6 +67,9 @@ function KeywordResearchContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (controller.researchError) {
|
if (controller.researchError) {
|
||||||
|
const isCreditsError =
|
||||||
|
getErrorCode(controller.researchMutationError) === "INSUFFICIENT_CREDITS";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pt-1">
|
<div className="space-y-4 pt-1">
|
||||||
{recentSearchesButton}
|
{recentSearchesButton}
|
||||||
@ -73,12 +79,18 @@ function KeywordResearchContent({
|
|||||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||||
<p className="text-sm">{controller.researchError}</p>
|
<p className="text-sm">{controller.researchError}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{isCreditsError ? (
|
||||||
className="btn btn-sm"
|
<Link to={BILLING_ROUTE} className="btn btn-sm">
|
||||||
onClick={() => controller.onSearch()}
|
Go to Billing
|
||||||
>
|
</Link>
|
||||||
Try again
|
) : (
|
||||||
</button>
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => controller.onSearch()}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -133,6 +133,7 @@ export function useKeywordResearchController(
|
|||||||
overviewKeyword: state.overviewKeyword,
|
overviewKeyword: state.overviewKeyword,
|
||||||
removeHistoryItem: state.removeHistoryItem,
|
removeHistoryItem: state.removeHistoryItem,
|
||||||
researchError: state.researchError,
|
researchError: state.researchError,
|
||||||
|
researchMutationError: state.researchMutationError,
|
||||||
resetView,
|
resetView,
|
||||||
resetFilters: state.resetFilters,
|
resetFilters: state.resetFilters,
|
||||||
rows: state.rows,
|
rows: state.rows,
|
||||||
@ -200,6 +201,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
|||||||
lastSearchKeyword,
|
lastSearchKeyword,
|
||||||
lastSearchLocationCode,
|
lastSearchLocationCode,
|
||||||
researchError,
|
researchError,
|
||||||
|
researchMutationError,
|
||||||
searchedKeyword,
|
searchedKeyword,
|
||||||
isLoading,
|
isLoading,
|
||||||
beginSearch,
|
beginSearch,
|
||||||
@ -299,6 +301,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
|||||||
removeHistoryItem,
|
removeHistoryItem,
|
||||||
resetResearch,
|
resetResearch,
|
||||||
researchError,
|
researchError,
|
||||||
|
researchMutationError,
|
||||||
runSearch,
|
runSearch,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@ -25,9 +25,11 @@ const SUPPORT_PATH = "/support";
|
|||||||
export function AuthenticatedAppLayout({
|
export function AuthenticatedAppLayout({
|
||||||
children,
|
children,
|
||||||
projectId,
|
projectId,
|
||||||
|
banner,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
banner?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||||
@ -119,6 +121,8 @@ export function AuthenticatedAppLayout({
|
|||||||
seoApiKeyStatusError={seoApiKeyStatusError}
|
seoApiKeyStatusError={seoApiKeyStatusError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{banner}
|
||||||
|
|
||||||
<AppContent
|
<AppContent
|
||||||
drawerOpen={drawerOpen}
|
drawerOpen={drawerOpen}
|
||||||
projectId={projectId ?? null}
|
projectId={projectId ?? null}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
|
|||||||
"OpenSEO auth is not configured. Follow the README setup steps for Cloudflare Access.",
|
"OpenSEO auth is not configured. Follow the README setup steps for Cloudflare Access.",
|
||||||
PAYMENT_REQUIRED:
|
PAYMENT_REQUIRED:
|
||||||
"An active hosted subscription is required before you can use OpenSEO.",
|
"An active hosted subscription is required before you can use OpenSEO.",
|
||||||
|
INSUFFICIENT_CREDITS:
|
||||||
|
"You've run out of credits. Add more credits or upgrade your plan to continue.",
|
||||||
FORBIDDEN: "You do not have access to this resource.",
|
FORBIDDEN: "You do not have access to this resource.",
|
||||||
NOT_FOUND: "The requested resource was not found.",
|
NOT_FOUND: "The requested resource was not found.",
|
||||||
AUDIT_CAPACITY_REACHED:
|
AUDIT_CAPACITY_REACHED:
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const hostedBaseUrlSchema = z
|
|||||||
|
|
||||||
function createAuth() {
|
function createAuth() {
|
||||||
const baseUrl = getHostedBaseUrl();
|
const baseUrl = getHostedBaseUrl();
|
||||||
|
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||||
|
|
||||||
const auth = betterAuth({
|
const auth = betterAuth({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
@ -31,7 +32,7 @@ function createAuth() {
|
|||||||
...baseAuthConfig,
|
...baseAuthConfig,
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
...baseAuthConfig.emailAndPassword,
|
...baseAuthConfig.emailAndPassword,
|
||||||
requireEmailVerification: true,
|
requireEmailVerification: !bypassEmail,
|
||||||
resetPasswordTokenExpiresIn: 60 * 60,
|
resetPasswordTokenExpiresIn: 60 * 60,
|
||||||
revokeSessionsOnPasswordReset: true,
|
revokeSessionsOnPasswordReset: true,
|
||||||
sendResetPassword: async ({ user, url }) => {
|
sendResetPassword: async ({ user, url }) => {
|
||||||
@ -41,16 +42,18 @@ function createAuth() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emailVerification: {
|
emailVerification: bypassEmail
|
||||||
sendOnSignUp: true,
|
? undefined
|
||||||
autoSignInAfterVerification: true,
|
: {
|
||||||
sendVerificationEmail: async ({ user, url }) => {
|
sendOnSignUp: true,
|
||||||
await sendHostedVerificationEmail({
|
autoSignInAfterVerification: true,
|
||||||
email: user.email,
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
confirmationUrl: url,
|
await sendHostedVerificationEmail({
|
||||||
});
|
email: user.email,
|
||||||
},
|
confirmationUrl: url,
|
||||||
},
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
trustedOrigins: getTrustedOrigins(baseUrl),
|
trustedOrigins: getTrustedOrigins(baseUrl),
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "sqlite",
|
provider: "sqlite",
|
||||||
@ -138,7 +141,10 @@ export function hasHostedAuthConfig() {
|
|||||||
try {
|
try {
|
||||||
getHostedBaseUrl();
|
getHostedBaseUrl();
|
||||||
getHostedSecret();
|
getHostedSecret();
|
||||||
return hasHostedAuthEmailConfig();
|
return (
|
||||||
|
Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" ||
|
||||||
|
hasHostedAuthEmailConfig()
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,20 @@
|
|||||||
import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart";
|
import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart";
|
||||||
import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils";
|
import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils";
|
||||||
import { getBillingRouteState } from "@/client/features/billing/route-state";
|
import { getBillingRouteState } from "@/client/features/billing/route-state";
|
||||||
|
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||||
import {
|
import {
|
||||||
AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID,
|
AUTUMN_PAID_PLAN_ID,
|
||||||
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||||
|
LOW_CREDITS_THRESHOLD_USD,
|
||||||
AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
||||||
AUTUMN_SEO_DATA_TOP_UP_PLAN_ID,
|
AUTUMN_SEO_DATA_TOP_UP_PLAN_ID,
|
||||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||||
SUBSCRIBE_ROUTE,
|
|
||||||
autumnSeoDataCreditsToUsd,
|
autumnSeoDataCreditsToUsd,
|
||||||
} from "@/shared/billing";
|
} from "@/shared/billing";
|
||||||
|
|
||||||
@ -35,7 +36,6 @@ function BillingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BillingPageContent() {
|
function BillingPageContent() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: session, isPending: isSessionPending } = useSession();
|
const { data: session, isPending: isSessionPending } = useSession();
|
||||||
const [topUpAmount, setTopUpAmount] = useState("20");
|
const [topUpAmount, setTopUpAmount] = useState("20");
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
@ -47,15 +47,13 @@ function BillingPageContent() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasManagedServiceAccess = Boolean(
|
const planStatus = getCustomerPlanStatus(customerQuery.data);
|
||||||
customerQuery.data?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID],
|
const isFreePlan = planStatus === "free";
|
||||||
);
|
|
||||||
const billingRouteState = getBillingRouteState({
|
const billingRouteState = getBillingRouteState({
|
||||||
hasSession: Boolean(session?.user?.id),
|
hasSession: Boolean(session?.user?.id),
|
||||||
isSessionPending,
|
isSessionPending,
|
||||||
isCustomerLoading: customerQuery.isLoading,
|
isCustomerLoading: customerQuery.isLoading,
|
||||||
isCustomerError: customerQuery.isError,
|
isCustomerError: customerQuery.isError,
|
||||||
hasManagedServiceAccess,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const monthlyRemaining = autumnSeoDataCreditsToUsd(
|
const monthlyRemaining = autumnSeoDataCreditsToUsd(
|
||||||
@ -71,18 +69,7 @@ function BillingPageContent() {
|
|||||||
const { isValid: isValidTopUp, parsed: parsedTopUpAmount } =
|
const { isValid: isValidTopUp, parsed: parsedTopUpAmount } =
|
||||||
parseTopUpAmount(topUpAmount);
|
parseTopUpAmount(topUpAmount);
|
||||||
|
|
||||||
useEffect(() => {
|
if (billingRouteState === "loading") {
|
||||||
if (billingRouteState !== "redirectToSubscribe") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void navigate({ href: SUBSCRIBE_ROUTE, replace: true });
|
|
||||||
}, [billingRouteState, navigate]);
|
|
||||||
|
|
||||||
if (
|
|
||||||
billingRouteState === "loading" ||
|
|
||||||
billingRouteState === "redirectToSubscribe"
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,7 +126,7 @@ function BillingPageContent() {
|
|||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
{/* Subscription card */}
|
{/* Subscription card */}
|
||||||
<div className="flex flex-col justify-between rounded-lg border border-base-300 p-4 gap-4">
|
<div className="flex flex-col justify-between rounded-lg border border-base-300 bg-base-100 p-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-semibold tabular-nums">
|
<div className="text-2xl font-semibold tabular-nums">
|
||||||
${totalRemaining.toFixed(2)}{" "}
|
${totalRemaining.toFixed(2)}{" "}
|
||||||
@ -147,100 +134,165 @@ function BillingPageContent() {
|
|||||||
remaining
|
remaining
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex gap-3 text-xs text-base-content/50">
|
{!isFreePlan ? (
|
||||||
<span className="tabular-nums">
|
<div className="mt-1 flex gap-3 text-xs text-base-content/50">
|
||||||
Monthly ${monthlyRemaining.toFixed(2)}
|
<span className="tabular-nums">
|
||||||
</span>
|
Monthly ${monthlyRemaining.toFixed(2)}
|
||||||
<span>·</span>
|
</span>
|
||||||
<span className="tabular-nums">
|
<span>·</span>
|
||||||
Top-ups ${topUpRemaining.toFixed(2)}
|
<span className="tabular-nums">
|
||||||
</span>
|
Top-ups ${topUpRemaining.toFixed(2)}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="text-sm">
|
{totalRemaining <= 0 ? (
|
||||||
<span className="font-medium">Subscription</span>{" "}
|
<p className="mt-2 text-xs text-error">
|
||||||
<span className="text-base-content/50">
|
You’ve used all your credits.{" "}
|
||||||
{hasManagedServiceAccess ? "Active" : "Inactive"}
|
{isFreePlan
|
||||||
</span>
|
? "Upgrade your plan to continue."
|
||||||
</div>
|
: "Buy more credits below to continue."}
|
||||||
|
</p>
|
||||||
<button
|
) : totalRemaining < LOW_CREDITS_THRESHOLD_USD ? (
|
||||||
className="btn btn-soft btn-sm w-full"
|
<p className="mt-2 text-xs text-amber-600">
|
||||||
disabled={isPending}
|
You’re running low on credits.{" "}
|
||||||
onClick={() =>
|
{isFreePlan
|
||||||
void runAction(
|
? "Upgrade to get $10/month."
|
||||||
() =>
|
: "Buy more credits below."}
|
||||||
customerQuery.openCustomerPortal({
|
</p>
|
||||||
returnUrl: window.location.href,
|
|
||||||
}),
|
|
||||||
"We couldn't open the billing portal. Please try again.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isPending ? "Redirecting..." : "Manage subscription"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Buy credits card */}
|
|
||||||
<div className="rounded-lg border border-base-300 p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<span className="font-semibold">Buy credits</span>
|
|
||||||
<p className="mt-1 text-sm text-base-content/60">
|
|
||||||
Top-up credits never expire and are used after your monthly
|
|
||||||
credits.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-base-content/60">$</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={10}
|
|
||||||
max={99}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
className="input input-bordered input-sm w-full"
|
|
||||||
value={topUpAmount}
|
|
||||||
onChange={(e) => setTopUpAmount(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{topUpAmount.trim() !== "" && !isValidTopUp ? (
|
|
||||||
<p className="mt-1 text-xs text-error">Enter between $10–$99.</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="text-sm">
|
||||||
className="btn btn-soft btn-sm w-full"
|
<span className="font-medium">Plan</span>{" "}
|
||||||
disabled={isPending || !isValidTopUp || !hasManagedServiceAccess}
|
<span className="text-base-content/50">
|
||||||
onClick={() =>
|
{isFreePlan ? "Free Trial" : "Base Plan"}
|
||||||
void runAction(
|
</span>
|
||||||
() =>
|
</div>
|
||||||
customerQuery.attach({
|
|
||||||
planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID,
|
{isFreePlan ? (
|
||||||
redirectMode: "always",
|
<div className="space-y-3 border-t border-base-300 pt-3">
|
||||||
successUrl: window.location.href,
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
featureQuantities: [
|
<span className="text-sm font-medium">Base Plan</span>
|
||||||
{
|
<span className="text-sm font-medium tabular-nums">
|
||||||
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
$10/month
|
||||||
quantity: Math.round(
|
</span>
|
||||||
parsedTopUpAmount * AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
</div>
|
||||||
),
|
<ul className="space-y-1.5">
|
||||||
},
|
{[
|
||||||
],
|
"Access to all OpenSEO features",
|
||||||
}),
|
"Includes $10.00 of Usage Credits each month",
|
||||||
"We couldn't start the checkout. Please try again.",
|
].map((item) => (
|
||||||
)
|
<li
|
||||||
}
|
key={item}
|
||||||
>
|
className="flex gap-2 text-xs text-base-content/60"
|
||||||
Buy credits
|
>
|
||||||
</button>
|
<span className="text-base-content/30 mt-[1px] shrink-0">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className="btn btn-soft btn-sm w-full"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() =>
|
||||||
|
void runAction(
|
||||||
|
() =>
|
||||||
|
customerQuery.attach({
|
||||||
|
planId: AUTUMN_PAID_PLAN_ID,
|
||||||
|
redirectMode: "always",
|
||||||
|
successUrl: window.location.href,
|
||||||
|
}),
|
||||||
|
"We couldn't start the checkout. Please try again.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Upgrade Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-soft btn-sm w-full"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() =>
|
||||||
|
void runAction(
|
||||||
|
() =>
|
||||||
|
customerQuery.openCustomerPortal({
|
||||||
|
returnUrl: window.location.href,
|
||||||
|
}),
|
||||||
|
"We couldn't open the billing portal. Please try again.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Manage subscription
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Buy credits card — paid plan only */}
|
||||||
|
{!isFreePlan ? (
|
||||||
|
<div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Buy credits</span>
|
||||||
|
<p className="mt-1 text-sm text-base-content/60">
|
||||||
|
Top-up credits never expire and are used after your monthly
|
||||||
|
credits.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-base-content/60">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={99}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="input input-bordered input-sm w-full"
|
||||||
|
value={topUpAmount}
|
||||||
|
onChange={(e) => setTopUpAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{topUpAmount.trim() !== "" && !isValidTopUp ? (
|
||||||
|
<p className="mt-1 text-xs text-error">
|
||||||
|
Enter between $10–$99.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-soft btn-sm w-full"
|
||||||
|
disabled={isPending || !isValidTopUp}
|
||||||
|
onClick={() =>
|
||||||
|
void runAction(
|
||||||
|
() =>
|
||||||
|
customerQuery.attach({
|
||||||
|
planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID,
|
||||||
|
redirectMode: "always",
|
||||||
|
successUrl: window.location.href,
|
||||||
|
featureQuantities: [
|
||||||
|
{
|
||||||
|
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||||
|
quantity: Math.round(
|
||||||
|
parsedTopUpAmount * AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
"We couldn't start the checkout. Please try again.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Buy credits
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage chart */}
|
{/* Usage chart */}
|
||||||
{hasManagedServiceAccess ? <BillingUsageChart /> : null}
|
<BillingUsageChart />
|
||||||
|
|
||||||
{error ? <p className="text-sm text-error">{error}</p> : null}
|
{error ? <p className="text-sm text-error">{error}</p> : null}
|
||||||
|
|
||||||
|
|||||||
@ -71,8 +71,7 @@ function SignUpPage() {
|
|||||||
password: value.password,
|
password: value.password,
|
||||||
callbackURL: (() => {
|
callbackURL: (() => {
|
||||||
const url = new URL("/verify-email", window.location.origin);
|
const url = new URL("/verify-email", window.location.origin);
|
||||||
if (redirectTo !== "/")
|
url.searchParams.set("redirect", "/subscribe");
|
||||||
url.searchParams.set("redirect", redirectTo);
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
})(),
|
})(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { User } from "lucide-react";
|
import { ArrowRight, User } from "lucide-react";
|
||||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
||||||
import {
|
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||||
AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID,
|
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
|
||||||
AUTUMN_PAID_PLAN_ID,
|
|
||||||
} from "@/shared/billing";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/subscribe")({
|
export const Route = createFileRoute("/_authenticated/subscribe")({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
upgrade:
|
||||||
|
search.upgrade === true || search.upgrade === "true" ? true : undefined,
|
||||||
|
}),
|
||||||
component: SubscribePage,
|
component: SubscribePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -26,6 +28,7 @@ function SubscribePage() {
|
|||||||
|
|
||||||
function SubscribePageContent() {
|
function SubscribePageContent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { upgrade: isUpgradeFlow } = Route.useSearch();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [isAttaching, setIsAttaching] = useState(false);
|
const [isAttaching, setIsAttaching] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -39,14 +42,12 @@ function SubscribePageContent() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasManagedServiceAccess = Boolean(
|
const planStatus = getCustomerPlanStatus(customerQuery.data);
|
||||||
customerQuery.data?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID],
|
|
||||||
);
|
|
||||||
const subscribeRouteState = getSubscribeRouteState({
|
const subscribeRouteState = getSubscribeRouteState({
|
||||||
hasSession: Boolean(session?.user?.id),
|
hasSession: Boolean(session?.user?.id),
|
||||||
isCustomerLoading: customerQuery.isLoading,
|
isCustomerLoading: customerQuery.isLoading,
|
||||||
isCustomerError: customerQuery.isError,
|
isCustomerError: customerQuery.isError,
|
||||||
hasManagedServiceAccess,
|
planStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -119,28 +120,41 @@ function SubscribePageContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstName = session?.user?.name?.split(" ")[0] || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-xs space-y-6">
|
<div className="w-full max-w-sm space-y-6">
|
||||||
<SubscribePageAccountMenu email={session?.user?.email} />
|
<SubscribePageAccountMenu email={session?.user?.email} />
|
||||||
|
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<img
|
<img
|
||||||
src="/transparent-logo.png"
|
src="/transparent-logo.png"
|
||||||
alt="OpenSEO"
|
alt="OpenSEO"
|
||||||
className="mx-auto size-10 rounded-lg"
|
className="mx-auto size-10 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-xl font-semibold">Get started</h1>
|
<h1 className="text-xl font-semibold">
|
||||||
|
{isUpgradeFlow
|
||||||
|
? "Upgrade your plan"
|
||||||
|
: firstName
|
||||||
|
? `Welcome to OpenSEO, ${firstName}!`
|
||||||
|
: "Welcome to OpenSEO!"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
SEO on your terms. All your SEO tools in one place at a fair price.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-base-300 p-4">
|
<div className="rounded-lg border border-base-300 p-5 space-y-4">
|
||||||
<div className="flex items-baseline justify-between gap-4">
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
<span className="font-semibold">Base Plan</span>
|
<span className="font-semibold">Base Plan</span>
|
||||||
<span className="text-lg font-semibold tabular-nums">$10/month</span>
|
<span className="text-lg font-semibold tabular-nums">$10/month</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-3 space-y-2">
|
|
||||||
|
<ul className="space-y-2">
|
||||||
{[
|
{[
|
||||||
"Access to OpenSEO's managed service",
|
"Access to all OpenSEO features",
|
||||||
|
"Do keyword research, backlink analysis and site audits",
|
||||||
"Includes $10.00 of Usage Credits each month",
|
"Includes $10.00 of Usage Credits each month",
|
||||||
"Credits are consumed as you go for SEO data and AI features",
|
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<li
|
<li
|
||||||
key={item}
|
key={item}
|
||||||
@ -153,21 +167,49 @@ function SubscribePageContent() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-error">{error}</p> : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-soft w-full"
|
||||||
|
disabled={isAttaching}
|
||||||
|
onClick={() => void handleSubscribe()}
|
||||||
|
>
|
||||||
|
{isAttaching ? "Redirecting..." : "Subscribe"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-base-content/50">
|
||||||
|
Cancel anytime — no commitment. Powered by Stripe.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <p className="text-sm text-error">{error}</p> : null}
|
<div className="text-center space-y-2">
|
||||||
|
{isUpgradeFlow ? (
|
||||||
<button
|
<button
|
||||||
className="btn btn-soft w-full"
|
type="button"
|
||||||
disabled={isAttaching}
|
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 handleSubscribe()}
|
onClick={() => void navigate({ to: "/", replace: true })}
|
||||||
>
|
>
|
||||||
{isAttaching ? "Redirecting..." : "Subscribe"}
|
<ArrowRight className="size-3.5 rotate-180" />
|
||||||
</button>
|
Back to app
|
||||||
|
</button>
|
||||||
<p className="text-center text-xs text-base-content/50">
|
) : (
|
||||||
Cancel anytime — no commitment. Powered by Stripe.
|
<>
|
||||||
</p>
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
|
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
|
||||||
import { getErrorCode } from "@/client/lib/error-messages";
|
import { getErrorCode } from "@/client/lib/error-messages";
|
||||||
import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
|
import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
|
||||||
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import {
|
import {
|
||||||
getCurrentAuthRedirectFromHref,
|
getCurrentAuthRedirectFromHref,
|
||||||
getSignInSearch,
|
getSignInSearch,
|
||||||
@ -33,7 +35,10 @@ function ProjectLayout() {
|
|||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedAppLayout projectId={projectId}>
|
<AuthenticatedAppLayout
|
||||||
|
projectId={projectId}
|
||||||
|
banner={isHostedClientAuthMode() ? <FreePlanBanner /> : undefined}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AuthenticatedAppLayout>
|
</AuthenticatedAppLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -86,6 +86,7 @@ describe("subscription billing", () => {
|
|||||||
|
|
||||||
expect(getOrCreateMock).toHaveBeenCalledWith({
|
expect(getOrCreateMock).toHaveBeenCalledWith({
|
||||||
customerId: "org_123",
|
customerId: "org_123",
|
||||||
|
email: "alice@example.com",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export async function getOrCreateOrganizationCustomer(
|
|||||||
) {
|
) {
|
||||||
const customer = await autumn.customers.getOrCreate({
|
const customer = await autumn.customers.getOrCreate({
|
||||||
customerId: context.organizationId,
|
customerId: context.organizationId,
|
||||||
|
email: context.userEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!customer.id) {
|
if (!customer.id) {
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||||
|
AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
||||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||||
|
SEO_DATA_COST_MARKUP,
|
||||||
} from "@/shared/billing";
|
} from "@/shared/billing";
|
||||||
|
|
||||||
interface TrackCallArg {
|
interface TrackCallArg {
|
||||||
@ -139,10 +141,15 @@ describe("meterDataforseoCall with split balances", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const RAW_COST = 0.05;
|
||||||
|
const EXPECTED_CREDITS = Math.ceil(
|
||||||
|
RAW_COST * SEO_DATA_COST_MARKUP * AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
||||||
|
);
|
||||||
|
|
||||||
it("deducts entirely from monthly when monthly has enough", async () => {
|
it("deducts entirely from monthly when monthly has enough", async () => {
|
||||||
setupHostedMode();
|
setupHostedMode();
|
||||||
mockBalances(5000, 3000);
|
mockBalances(5000, 3000);
|
||||||
mockDataforseoResult(0.05);
|
mockDataforseoResult(RAW_COST);
|
||||||
|
|
||||||
const client = createDataforseoClient(billingCustomer);
|
const client = createDataforseoClient(billingCustomer);
|
||||||
await client.backlinks.summary(backlinksInput);
|
await client.backlinks.summary(backlinksInput);
|
||||||
@ -152,7 +159,7 @@ describe("meterDataforseoCall with split balances", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
customerId: "org_123",
|
customerId: "org_123",
|
||||||
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||||
value: 50,
|
value: EXPECTED_CREDITS,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -160,7 +167,7 @@ describe("meterDataforseoCall with split balances", () => {
|
|||||||
it("deducts entirely from topup when monthly is empty", async () => {
|
it("deducts entirely from topup when monthly is empty", async () => {
|
||||||
setupHostedMode();
|
setupHostedMode();
|
||||||
mockBalances(0, 5000);
|
mockBalances(0, 5000);
|
||||||
mockDataforseoResult(0.05);
|
mockDataforseoResult(RAW_COST);
|
||||||
|
|
||||||
const client = createDataforseoClient(billingCustomer);
|
const client = createDataforseoClient(billingCustomer);
|
||||||
await client.backlinks.summary(backlinksInput);
|
await client.backlinks.summary(backlinksInput);
|
||||||
@ -170,15 +177,16 @@ describe("meterDataforseoCall with split balances", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
customerId: "org_123",
|
customerId: "org_123",
|
||||||
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||||
value: 50,
|
value: EXPECTED_CREDITS,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("splits deduction across monthly and topup when monthly is partially sufficient", async () => {
|
it("splits deduction across monthly and topup when monthly is partially sufficient", async () => {
|
||||||
setupHostedMode();
|
setupHostedMode();
|
||||||
mockBalances(30, 5000);
|
const monthlyAvailable = 30;
|
||||||
mockDataforseoResult(0.05);
|
mockBalances(monthlyAvailable, 5000);
|
||||||
|
mockDataforseoResult(RAW_COST);
|
||||||
|
|
||||||
const client = createDataforseoClient(billingCustomer);
|
const client = createDataforseoClient(billingCustomer);
|
||||||
await client.backlinks.summary(backlinksInput);
|
await client.backlinks.summary(backlinksInput);
|
||||||
@ -188,39 +196,29 @@ describe("meterDataforseoCall with split balances", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
customerId: "org_123",
|
customerId: "org_123",
|
||||||
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||||
value: 30,
|
value: monthlyAvailable,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(trackMock).toHaveBeenCalledWith(
|
expect(trackMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
customerId: "org_123",
|
customerId: "org_123",
|
||||||
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||||
value: 20,
|
value: EXPECTED_CREDITS - monthlyAvailable,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws PAYMENT_REQUIRED when combined balance is below minimum", async () => {
|
it("throws INSUFFICIENT_CREDITS when both balances are exactly zero", async () => {
|
||||||
setupHostedMode();
|
|
||||||
// minimum is 150 credits (0.15 USD * 1000); 50 + 50 = 100 < 150
|
|
||||||
mockBalances(50, 50);
|
|
||||||
|
|
||||||
const client = createDataforseoClient(billingCustomer);
|
|
||||||
await expect(
|
|
||||||
client.backlinks.summary(backlinksInput),
|
|
||||||
).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
|
|
||||||
|
|
||||||
expect(trackMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws PAYMENT_REQUIRED when both balances are zero", async () => {
|
|
||||||
setupHostedMode();
|
setupHostedMode();
|
||||||
mockBalances(0, 0);
|
mockBalances(0, 0);
|
||||||
|
mockDataforseoResult(0.05);
|
||||||
|
|
||||||
const client = createDataforseoClient(billingCustomer);
|
const client = createDataforseoClient(billingCustomer);
|
||||||
await expect(
|
await expect(
|
||||||
client.backlinks.summary(backlinksInput),
|
client.backlinks.summary(backlinksInput),
|
||||||
).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
|
).rejects.toMatchObject({ code: "INSUFFICIENT_CREDITS" });
|
||||||
|
|
||||||
|
expect(trackMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes balanceFeatureId in track properties", async () => {
|
it("includes balanceFeatureId in track properties", async () => {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import {
|
|||||||
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||||
AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
||||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||||
MINIMUM_SEO_DATA_BALANCE_USD,
|
SEO_DATA_COST_MARKUP,
|
||||||
roundUsdForBilling,
|
roundUsdForBilling,
|
||||||
} from "@/shared/billing";
|
} from "@/shared/billing";
|
||||||
import { autumn } from "@/server/billing/autumn";
|
import { autumn } from "@/server/billing/autumn";
|
||||||
@ -222,10 +222,9 @@ async function meterDataforseoCall<T>(
|
|||||||
|
|
||||||
const billingCustomer = await getOrCreateOrganizationCustomer(customer);
|
const billingCustomer = await getOrCreateOrganizationCustomer(customer);
|
||||||
|
|
||||||
const { monthlyRemaining } = await assertSeoDataBalanceAvailable({
|
const { monthlyRemaining } = await assertSeoDataBalanceAvailable(
|
||||||
customerId: billingCustomer.id,
|
billingCustomer.id,
|
||||||
minimumBalanceUsd: MINIMUM_SEO_DATA_BALANCE_USD,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const result = await execute();
|
const result = await execute();
|
||||||
|
|
||||||
@ -239,22 +238,14 @@ async function meterDataforseoCall<T>(
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertSeoDataBalanceAvailable(args: {
|
async function assertSeoDataBalanceAvailable(customerId: string) {
|
||||||
customerId: string;
|
|
||||||
minimumBalanceUsd: number;
|
|
||||||
}) {
|
|
||||||
const minimumCredits = Math.ceil(
|
|
||||||
roundUsdForBilling(args.minimumBalanceUsd) *
|
|
||||||
AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [monthlyCheck, topupCheck] = await Promise.all([
|
const [monthlyCheck, topupCheck] = await Promise.all([
|
||||||
autumn.check({
|
autumn.check({
|
||||||
customerId: args.customerId,
|
customerId,
|
||||||
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||||
}),
|
}),
|
||||||
autumn.check({
|
autumn.check({
|
||||||
customerId: args.customerId,
|
customerId,
|
||||||
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@ -262,8 +253,8 @@ async function assertSeoDataBalanceAvailable(args: {
|
|||||||
const monthlyRemaining = monthlyCheck.balance?.remaining ?? 0;
|
const monthlyRemaining = monthlyCheck.balance?.remaining ?? 0;
|
||||||
const topupRemaining = topupCheck.balance?.remaining ?? 0;
|
const topupRemaining = topupCheck.balance?.remaining ?? 0;
|
||||||
|
|
||||||
if (monthlyRemaining + topupRemaining < minimumCredits) {
|
if (monthlyRemaining + topupRemaining <= 0) {
|
||||||
throw new AppError("PAYMENT_REQUIRED");
|
throw new AppError("INSUFFICIENT_CREDITS");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { monthlyRemaining };
|
return { monthlyRemaining };
|
||||||
@ -275,7 +266,9 @@ async function trackDataforseoCost(args: {
|
|||||||
billing: DataforseoApiCallCost;
|
billing: DataforseoApiCallCost;
|
||||||
monthlyRemaining: number;
|
monthlyRemaining: number;
|
||||||
}) {
|
}) {
|
||||||
const totalCostUsd = roundUsdForBilling(args.billing.costUsd);
|
const totalCostUsd = roundUsdForBilling(
|
||||||
|
args.billing.costUsd * SEO_DATA_COST_MARKUP,
|
||||||
|
);
|
||||||
const totalCostCredits = Math.ceil(
|
const totalCostCredits = Math.ceil(
|
||||||
totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,7 +8,8 @@ export const AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID =
|
|||||||
export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits";
|
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_TOPUP_BALANCE_FEATURE_ID = "topup_credits";
|
||||||
export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000;
|
export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000;
|
||||||
export const MINIMUM_SEO_DATA_BALANCE_USD = 0.15;
|
export const SEO_DATA_COST_MARKUP = 1.28;
|
||||||
|
export const LOW_CREDITS_THRESHOLD_USD = 0.25;
|
||||||
|
|
||||||
export function roundUsdForBilling(value: number) {
|
export function roundUsdForBilling(value: number) {
|
||||||
return Math.round(value * 100000) / 100000;
|
return Math.round(value * 100000) / 100000;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const ERROR_CODES = [
|
|||||||
"UNAUTHENTICATED",
|
"UNAUTHENTICATED",
|
||||||
"AUTH_CONFIG_MISSING",
|
"AUTH_CONFIG_MISSING",
|
||||||
"PAYMENT_REQUIRED",
|
"PAYMENT_REQUIRED",
|
||||||
|
"INSUFFICIENT_CREDITS",
|
||||||
"FORBIDDEN",
|
"FORBIDDEN",
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
"AUDIT_CAPACITY_REACHED",
|
"AUDIT_CAPACITY_REACHED",
|
||||||
@ -24,6 +25,7 @@ const NON_REPORTABLE_ERROR_CODES = new Set<ErrorCode>([
|
|||||||
"UNAUTHENTICATED",
|
"UNAUTHENTICATED",
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
"PAYMENT_REQUIRED",
|
"PAYMENT_REQUIRED",
|
||||||
|
"INSUFFICIENT_CREDITS",
|
||||||
"VALIDATION_ERROR",
|
"VALIDATION_ERROR",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user