diff --git a/src/client/features/billing/BillingRouteParts.tsx b/src/client/features/billing/BillingRouteParts.tsx deleted file mode 100644 index 1a57f81..0000000 --- a/src/client/features/billing/BillingRouteParts.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { CreditCard } from "lucide-react"; -import type { ReactNode } from "react"; - -export function BillingHeader(args: { - hasManagedServiceAccess: boolean; - basePlanName: string; - includedCreditsLabel: string; -}) { - return ( -
-
- - Hosted billing -
-

- {args.hasManagedServiceAccess ? "Billing" : "Choose a plan"} -

-

- {args.hasManagedServiceAccess - ? `${args.basePlanName} includes ${args.includedCreditsLabel} of usage credits each month. Monthly credits are used first; purchased top-ups never expire.` - : `You need an active ${args.basePlanName} subscription to use OpenSEO's managed service. It includes ${args.includedCreditsLabel} of usage credits each month, and you can buy more at any time.`} -

-
- ); -} - -export function SubscriptionIntro(args: { - hasManagedServiceAccess: boolean; - basePlanName: string; -}) { - return ( -
-

- {args.hasManagedServiceAccess - ? "Subscription" - : "Managed service access"} -

-

- {args.hasManagedServiceAccess - ? "Hosted workspaces need an active paid plan before project pages and DataForSEO-backed features are available." - : `Start ${args.basePlanName} to unlock OpenSEO's managed service and your included monthly credits.`} -

-
- ); -} - -export function SubscriptionStatusCard(args: { - hasManagedServiceAccess: boolean; - basePlanName: string; - basePlanPrice: string; - includedCreditsLabel: string; -}) { - return ( -
-
- {args.hasManagedServiceAccess ? "Current status" : args.basePlanName} -
-
- {args.hasManagedServiceAccess ? "Active" : args.basePlanPrice} -
-
- {args.hasManagedServiceAccess - ? "Your organization can use hosted OpenSEO features." - : `Includes ${args.includedCreditsLabel} of usage credits every cycle.`} -
-
- ); -} - -export function BillingAlerts(args: { - actionError: string | null; - hasManagedServiceAccess: boolean; - basePlanName: string; -}) { - return ( - <> - {args.actionError ? ( -
- {args.actionError} -
- ) : null} - - {!args.hasManagedServiceAccess ? ( -
- - Subscribe to {args.basePlanName} first. After that, you can manage - your plan and buy more credits here. - -
- ) : null} - - ); -} - -export function CenteredCard(args: { - title: string; - body: string; - action?: ReactNode; -}) { - return ( -
-
-
-

{args.title}

-

{args.body}

- {args.action ? ( -
{args.action}
- ) : null} -
-
-
- ); -} diff --git a/src/client/features/billing/BillingUsageChart.tsx b/src/client/features/billing/BillingUsageChart.tsx new file mode 100644 index 0000000..21ff930 --- /dev/null +++ b/src/client/features/billing/BillingUsageChart.tsx @@ -0,0 +1,147 @@ +import { useAggregateEvents } from "autumn-js/react"; +import { useEffect, useRef, useState } from "react"; +import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts"; +import { + AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, + autumnSeoDataCreditsToUsd, +} from "@/shared/billing"; + +const BILLING_USAGE_FEATURE_IDS: string[] = [ + AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, +]; + +export function BillingUsageChart() { + const containerRef = useRef(null); + const [chartWidth, setChartWidth] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const update = () => setChartWidth(el.clientWidth); + update(); + + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const eventsQuery = useAggregateEvents({ + featureId: BILLING_USAGE_FEATURE_IDS, + range: "30d", + binSize: "day", + }); + + const chartData = (eventsQuery.list ?? []).map((row) => ({ + date: row.period, + credits: autumnSeoDataCreditsToUsd( + BILLING_USAGE_FEATURE_IDS.reduce( + (sum, featureId) => sum + (row.values?.[featureId] ?? 0), + 0, + ), + ), + })); + + const totalSpend = chartData.reduce((sum, d) => sum + d.credits, 0); + + return ( +
+
+ Usage + Last 30 days +
+ +
+ ${totalSpend.toFixed(2)} +
+ +
+ {eventsQuery.isLoading ? null : chartData.length === 0 ? ( +
+ + No usage recorded yet + +
+ ) : chartWidth > 0 ? ( + + + + + } + cursor={{ fill: "rgba(150,150,150,0.1)" }} + /> + + + ) : null} +
+
+ ); +} + +function UsageTooltip({ + active, + payload, + label, +}: { + active?: boolean; + payload?: Array<{ value: number }>; + label?: number; +}) { + if (!active || !payload?.length || label == null) return null; + + return ( +
+

+ {new Date(label).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} +

+

+ ${payload[0].value.toFixed(2)} +

+
+ ); +} + +function formatShortDate(timestamp: number) { + return new Date(timestamp).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +function formatUsdAxis(value: number) { + return `$${value % 1 === 0 ? value : value.toFixed(2)}`; +} diff --git a/src/client/features/billing/HostedBillingContent.tsx b/src/client/features/billing/HostedBillingContent.tsx deleted file mode 100644 index bb52885..0000000 --- a/src/client/features/billing/HostedBillingContent.tsx +++ /dev/null @@ -1,389 +0,0 @@ -/* eslint-disable max-lines */ -import type { UseCustomerResult } from "autumn-js/react"; -import { ExternalLink, LoaderCircle } from "lucide-react"; -import { useState } from "react"; -import { getStandardErrorMessage } from "@/client/lib/error-messages"; -import { - BillingAlerts, - BillingHeader, - SubscriptionIntro, - SubscriptionStatusCard, -} from "@/client/features/billing/BillingRouteParts"; -import { - AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, - AUTUMN_PAID_PLAN_ID, - AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, - AUTUMN_SEO_DATA_CREDITS_PER_USD, - AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, - AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, -} from "@/shared/billing"; -import { - formatCreditAmount, - formatPlanPrice, - formatResetDate, - getIncludedFeatureQuantity, - parseTopUpAmount, -} from "@/client/features/billing/HostedBillingContentUtils"; - -type BillingAction = "start-plan" | "open-portal" | "top-up" | null; - -type BillingCustomerQuery = Pick< - UseCustomerResult, - "data" | "attach" | "openCustomerPortal" | "refetch" ->; - -type BillingPlan = { - id: string; - name: string; - items: Array<{ featureId: string; included: number }>; - price?: { - amount?: number | null; - interval?: string | null; - } | null; -}; - -type HostedBillingContentProps = { - customerQuery: BillingCustomerQuery; - plans: BillingPlan[]; -}; - -export function HostedBillingContent({ - customerQuery, - plans, -}: HostedBillingContentProps) { - const [actionError, setActionError] = useState(null); - const [pendingAction, setPendingAction] = useState(null); - const [topUpAmount, setTopUpAmount] = useState("20"); - - const customer = customerQuery.data; - const basePlan = - plans.find((plan) => plan.id === AUTUMN_PAID_PLAN_ID) ?? null; - const monthlyBalance = - customer?.balances?.[AUTUMN_SEO_DATA_BALANCE_FEATURE_ID] ?? null; - const topupBalance = - customer?.balances?.[AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID] ?? null; - const hasManagedServiceAccess = Boolean( - customer?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID], - ); - const isActionPending = pendingAction !== null; - const basePlanName = basePlan?.name ?? "Base Plan"; - const basePlanPrice = formatPlanPrice( - basePlan?.price?.amount, - basePlan?.price?.interval, - ); - const includedCreditsLabel = formatCreditAmount( - getIncludedFeatureQuantity(basePlan, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID), - ); - const { isValid: isValidTopUpAmount, parsed: parsedTopUpAmount } = - parseTopUpAmount(topUpAmount); - const topUpDisabled = - !hasManagedServiceAccess || isActionPending || !isValidTopUpAmount; - - const runBillingAction = async ( - action: BillingAction, - callback: () => Promise, - fallbackMessage: string, - ) => { - setActionError(null); - setPendingAction(action); - - try { - await callback(); - if (action !== "open-portal") { - await customerQuery.refetch(); - } - } catch (error) { - setActionError(getStandardErrorMessage(error, fallbackMessage)); - } finally { - setPendingAction(null); - } - }; - - return ( -
- - -
- { - void runBillingAction( - "open-portal", - () => - customerQuery.openCustomerPortal({ - returnUrl: window.location.href, - }), - "We could not open the billing portal. Please try again.", - ); - }} - onStartPlan={() => { - void runBillingAction( - "start-plan", - () => - customerQuery.attach({ - planId: AUTUMN_PAID_PLAN_ID, - redirectMode: "always", - successUrl: window.location.href, - }), - "We could not open the hosted billing flow. Please try again.", - ); - }} - /> - - { - void runBillingAction( - "top-up", - () => - 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 could not open the credit purchase flow. Please try again.", - ); - }} - /> -
- - - -
- - Hosted billing is powered by Autumn. -
-
- ); -} - -function SubscriptionSection(args: { - basePlanName: string; - basePlanPrice: string; - hasManagedServiceAccess: boolean; - includedCreditsLabel: string; - isActionPending: boolean; - isPortalPending: boolean; - isStartPlanPending: boolean; - onOpenPortal: () => void; - onStartPlan: () => void; -}) { - return ( -
-
- - - - - {args.hasManagedServiceAccess ? ( - - ) : ( - - )} -
-
- ); -} - -type CreditBalance = { - granted: number; - remaining: number; - usage: number; - nextResetAt?: number | null; -} | null; - -function SeoDataCreditsSection(args: { - monthlyBalance: CreditBalance; - topupBalance: CreditBalance; - basePlanName: string; - hasManagedServiceAccess: boolean; - isTopUpPending: boolean; - topUpAmount: string; - topUpDisabled: boolean; - onTopUp: () => void; - onTopUpAmountChange: (value: string) => void; -}) { - const totalRemaining = - (args.monthlyBalance?.remaining ?? 0) + (args.topupBalance?.remaining ?? 0); - - return ( -
-
-
-

- SEO data credits -

-

- Monthly credits are used first. Purchased top-ups never expire. -

-
- -
-
- - Total remaining - - - {formatCreditAmount(totalRemaining)} - -
-
- -
- - -
- - - - - -

- Credit purchases use our hosted checkout flow and apply to your - organization's top-up balance. -

-
-
- ); -} - -function CreditPoolCard(args: { - title: string; - badge: string | null; - remaining: number; - granted: number; - usage: number; -}) { - return ( -
-
- - {args.title} - - {args.badge ? ( - - {args.badge} - - ) : null} -
-
- {formatCreditAmount(args.remaining)} -
-
-
- Granted - - {formatCreditAmount(args.granted)} - -
-
- Used - - {formatCreditAmount(args.usage)} - -
-
-
- ); -} diff --git a/src/client/features/billing/HostedBillingContentUtils.test.ts b/src/client/features/billing/HostedBillingContentUtils.test.ts index a2a9122..cce44ff 100644 --- a/src/client/features/billing/HostedBillingContentUtils.test.ts +++ b/src/client/features/billing/HostedBillingContentUtils.test.ts @@ -1,21 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - formatCreditAmount, - formatResetDate, - parseTopUpAmount, -} from "./HostedBillingContentUtils"; - -describe("formatResetDate", () => { - it("formats a valid Unix timestamp in ms", () => { - const date = new Date(2026, 3, 15); // April 15, local time - const result = formatResetDate(date.getTime()); - expect(result).toBe("Resets Apr 15"); - }); - - it("returns null for null input", () => { - expect(formatResetDate(null)).toBeNull(); - }); -}); +import { parseTopUpAmount } from "./HostedBillingContentUtils"; describe("parseTopUpAmount", () => { it("accepts valid whole-dollar amounts", () => { @@ -36,11 +20,3 @@ describe("parseTopUpAmount", () => { expect(parseTopUpAmount("abc")).toEqual({ isValid: false, parsed: 20 }); }); }); - -describe("formatCreditAmount", () => { - it("converts credits to formatted USD", () => { - expect(formatCreditAmount(5000)).toBe("$5.00"); - expect(formatCreditAmount(1000)).toBe("$1.00"); - expect(formatCreditAmount(0)).toBe("$0.00"); - }); -}); diff --git a/src/client/features/billing/HostedBillingContentUtils.ts b/src/client/features/billing/HostedBillingContentUtils.ts index 3ea5c17..4fcaf2e 100644 --- a/src/client/features/billing/HostedBillingContentUtils.ts +++ b/src/client/features/billing/HostedBillingContentUtils.ts @@ -1,14 +1,3 @@ -import { autumnSeoDataCreditsToUsd } from "@/shared/billing"; - -export function getIncludedFeatureQuantity( - plan: { items: Array<{ featureId: string; included: number }> } | null, - featureId: string, -) { - return ( - plan?.items.find((item) => item.featureId === featureId)?.included ?? 0 - ); -} - export function parseTopUpAmount(value: string) { const trimmed = value.trim(); @@ -27,57 +16,3 @@ export function parseTopUpAmount(value: string) { parsed: isValid ? parsed : 20, }; } - -export function formatCreditAmount(value: number) { - return formatUsd(autumnSeoDataCreditsToUsd(value)); -} - -export function formatPlanPrice( - amount?: number | null, - interval?: string | null, -) { - if (typeof amount !== "number" || !interval) { - return "$5/month"; - } - - return `${formatUsd(amount, amount % 1 === 0 ? 0 : 2)}/${intervalToLabel(interval)}`; -} - -export function formatResetDate(timestampMs: number | null): string | null { - if (timestampMs == null) return null; - - const date = new Date(timestampMs); - if (Number.isNaN(date.getTime())) return null; - - return `Resets ${date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; -} - -function formatUsd(value: number, minimumFractionDigits = 2) { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits, - maximumFractionDigits: 2, - }).format(value); -} - -function intervalToLabel(interval: string) { - switch (interval) { - case "month": - return "month"; - case "year": - return "year"; - case "quarter": - return "quarter"; - case "semi_annual": - return "6 months"; - case "week": - return "week"; - case "day": - return "day"; - case "one_off": - return "one-time"; - default: - return interval; - } -} diff --git a/src/client/features/billing/route-state.test.ts b/src/client/features/billing/route-state.test.ts new file mode 100644 index 0000000..2556e02 --- /dev/null +++ b/src/client/features/billing/route-state.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { getBillingRouteState, getSubscribeRouteState } from "./route-state"; + +describe("getBillingRouteState", () => { + it("redirects unpaid customers after a successful customer lookup", () => { + expect( + getBillingRouteState({ + hasSession: true, + isSessionPending: false, + isCustomerLoading: false, + isCustomerError: false, + hasManagedServiceAccess: false, + }), + ).toBe("redirectToSubscribe"); + }); + + it("shows an error state instead of redirecting on billing lookup failures", () => { + expect( + getBillingRouteState({ + hasSession: true, + isSessionPending: false, + isCustomerLoading: false, + isCustomerError: true, + hasManagedServiceAccess: false, + }), + ).toBe("error"); + }); + + it("keeps the page blank while auth or billing data is still loading", () => { + expect( + getBillingRouteState({ + hasSession: true, + isSessionPending: true, + isCustomerLoading: false, + isCustomerError: false, + hasManagedServiceAccess: false, + }), + ).toBe("loading"); + + expect( + getBillingRouteState({ + hasSession: true, + isSessionPending: false, + isCustomerLoading: true, + isCustomerError: false, + hasManagedServiceAccess: false, + }), + ).toBe("loading"); + }); +}); + +describe("getSubscribeRouteState", () => { + it("shows an error state instead of a subscribe CTA on billing lookup failures", () => { + expect( + getSubscribeRouteState({ + hasSession: true, + isCustomerLoading: false, + isCustomerError: true, + hasManagedServiceAccess: false, + }), + ).toBe("error"); + }); + + it("redirects paying customers away from onboarding", () => { + expect( + getSubscribeRouteState({ + hasSession: true, + isCustomerLoading: false, + isCustomerError: false, + hasManagedServiceAccess: true, + }), + ).toBe("redirectToApp"); + }); +}); diff --git a/src/client/features/billing/route-state.ts b/src/client/features/billing/route-state.ts new file mode 100644 index 0000000..f40a69e --- /dev/null +++ b/src/client/features/billing/route-state.ts @@ -0,0 +1,42 @@ +export function getBillingRouteState(args: { + hasSession: boolean; + isSessionPending: boolean; + isCustomerLoading: boolean; + isCustomerError: boolean; + hasManagedServiceAccess: boolean; +}) { + if (args.isSessionPending || !args.hasSession || args.isCustomerLoading) { + return "loading" as const; + } + + if (args.isCustomerError) { + return "error" as const; + } + + if (!args.hasManagedServiceAccess) { + return "redirectToSubscribe" as const; + } + + return "ready" as const; +} + +export function getSubscribeRouteState(args: { + hasSession: boolean; + isCustomerLoading: boolean; + isCustomerError: boolean; + hasManagedServiceAccess: boolean; +}) { + if (!args.hasSession || args.isCustomerLoading) { + return "loading" as const; + } + + if (args.isCustomerError) { + return "error" as const; + } + + if (args.hasManagedServiceAccess) { + return "redirectToApp" as const; + } + + return "ready" as const; +} diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx index 672e9d0..ef6f8b7 100644 --- a/src/client/layout/AppShell.tsx +++ b/src/client/layout/AppShell.tsx @@ -156,13 +156,15 @@ function TopNav({ ) : null} - OpenSEO + + OpenSEO +
- + OpenSEO - + {projectId ? projectNavItems.map((item) => { const { icon: Icon, matchSegment, ...linkProps } = item; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ba3acbb..17ef7af 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,10 +12,12 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as VerifyEmailRouteImport } from './routes/verify-email' import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as ForgotPasswordRouteImport } from './routes/forgot-password' +import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthRouteImport } from './routes/_auth' import { Route as ProjectRouteRouteImport } from './routes/_project/route' import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppIndexRouteImport } from './routes/_app/index' +import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AppBillingRouteImport } from './routes/_app/billing' @@ -48,6 +50,10 @@ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({ path: '/forgot-password', getParentRoute: () => rootRouteImport, } as any) +const AuthenticatedRoute = AuthenticatedRouteImport.update({ + id: '/_authenticated', + getParentRoute: () => rootRouteImport, +} as any) const AuthRoute = AuthRouteImport.update({ id: '/_auth', getParentRoute: () => rootRouteImport, @@ -65,6 +71,11 @@ const AppIndexRoute = AppIndexRouteImport.update({ path: '/', getParentRoute: () => AppRouteRoute, } as any) +const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({ + id: '/subscribe', + path: '/subscribe', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthSignUpRoute = AuthSignUpRouteImport.update({ id: '/sign-up', path: '/sign-up', @@ -158,6 +169,7 @@ export interface FileRoutesByFullPath { '/billing': typeof AppBillingRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute + '/subscribe': typeof AuthenticatedSubscribeRoute '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -180,6 +192,7 @@ export interface FileRoutesByTo { '/billing': typeof AppBillingRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute + '/subscribe': typeof AuthenticatedSubscribeRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute @@ -197,12 +210,14 @@ export interface FileRoutesById { '/_app': typeof AppRouteRouteWithChildren '/_project': typeof ProjectRouteRouteWithChildren '/_auth': typeof AuthRouteWithChildren + '/_authenticated': typeof AuthenticatedRouteWithChildren '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/_app/billing': typeof AppBillingRoute '/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-up': typeof AuthSignUpRoute + '/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute '/_app/': typeof AppIndexRoute '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute @@ -228,6 +243,7 @@ export interface FileRouteTypes { | '/billing' | '/sign-in' | '/sign-up' + | '/subscribe' | '/p/$projectId' | '/help/dataforseo-api-key' | '/api/auth/$' @@ -250,6 +266,7 @@ export interface FileRouteTypes { | '/billing' | '/sign-in' | '/sign-up' + | '/subscribe' | '/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' @@ -266,12 +283,14 @@ export interface FileRouteTypes { | '/_app' | '/_project' | '/_auth' + | '/_authenticated' | '/forgot-password' | '/reset-password' | '/verify-email' | '/_app/billing' | '/_auth/sign-in' | '/_auth/sign-up' + | '/_authenticated/subscribe' | '/_app/' | '/_project/p/$projectId' | '/_app/help/dataforseo-api-key' @@ -292,6 +311,7 @@ export interface RootRouteChildren { AppRouteRoute: typeof AppRouteRouteWithChildren ProjectRouteRoute: typeof ProjectRouteRouteWithChildren AuthRoute: typeof AuthRouteWithChildren + AuthenticatedRoute: typeof AuthenticatedRouteWithChildren ForgotPasswordRoute: typeof ForgotPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute VerifyEmailRoute: typeof VerifyEmailRoute @@ -322,6 +342,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ForgotPasswordRouteImport parentRoute: typeof rootRouteImport } + '/_authenticated': { + id: '/_authenticated' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthenticatedRouteImport + parentRoute: typeof rootRouteImport + } '/_auth': { id: '/_auth' path: '' @@ -350,6 +377,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppIndexRouteImport parentRoute: typeof AppRouteRoute } + '/_authenticated/subscribe': { + id: '/_authenticated/subscribe' + path: '/subscribe' + fullPath: '/subscribe' + preLoaderRoute: typeof AuthenticatedSubscribeRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_auth/sign-up': { id: '/_auth/sign-up' path: '/sign-up' @@ -548,10 +582,23 @@ const AuthRouteChildren: AuthRouteChildren = { const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) +interface AuthenticatedRouteChildren { + AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute +} + +const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { + AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute, +} + +const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( + AuthenticatedRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { AppRouteRoute: AppRouteRouteWithChildren, ProjectRouteRoute: ProjectRouteRouteWithChildren, AuthRoute: AuthRouteWithChildren, + AuthenticatedRoute: AuthenticatedRouteWithChildren, ForgotPasswordRoute: ForgotPasswordRoute, ResetPasswordRoute: ResetPasswordRoute, VerifyEmailRoute: VerifyEmailRoute, diff --git a/src/routes/_app/billing.tsx b/src/routes/_app/billing.tsx index 60b0ed0..ff05f8f 100644 --- a/src/routes/_app/billing.tsx +++ b/src/routes/_app/billing.tsx @@ -1,10 +1,21 @@ -import { createFileRoute, notFound } from "@tanstack/react-router"; -import { AutumnProvider, useCustomer, useListPlans } from "autumn-js/react"; -import { LoaderCircle } from "lucide-react"; -import { CenteredCard } from "@/client/features/billing/BillingRouteParts"; -import { HostedBillingContent } from "@/client/features/billing/HostedBillingContent"; +import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; +import { AutumnProvider, useCustomer } from "autumn-js/react"; +import { useEffect, useState } from "react"; import { useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart"; +import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils"; +import { getBillingRouteState } from "@/client/features/billing/route-state"; +import { + AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, + AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + AUTUMN_SEO_DATA_CREDITS_PER_USD, + AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, + AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, + SUBSCRIBE_ROUTE, + autumnSeoDataCreditsToUsd, +} from "@/shared/billing"; export const Route = createFileRoute("/_app/billing")({ beforeLoad: () => { @@ -24,7 +35,11 @@ function BillingPage() { } function BillingPageContent() { + const navigate = useNavigate(); const { data: session, isPending: isSessionPending } = useSession(); + const [topUpAmount, setTopUpAmount] = useState("20"); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); const customerQuery = useCustomer({ queryOptions: { @@ -32,50 +47,206 @@ function BillingPageContent() { }, }); - const plansQuery = useListPlans({ - queryOptions: { - enabled: Boolean(session?.user?.id), - }, + const hasManagedServiceAccess = Boolean( + customerQuery.data?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID], + ); + const billingRouteState = getBillingRouteState({ + hasSession: Boolean(session?.user?.id), + isSessionPending, + isCustomerLoading: customerQuery.isLoading, + isCustomerError: customerQuery.isError, + hasManagedServiceAccess, }); + 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 { isValid: isValidTopUp, parsed: parsedTopUpAmount } = + parseTopUpAmount(topUpAmount); + + useEffect(() => { + if (billingRouteState !== "redirectToSubscribe") { + return; + } + + void navigate({ href: SUBSCRIBE_ROUTE, replace: true }); + }, [billingRouteState, navigate]); + if ( - isSessionPending || - (session?.user?.id && (customerQuery.isLoading || plansQuery.isLoading)) + billingRouteState === "loading" || + billingRouteState === "redirectToSubscribe" ) { + return null; + } + + if (billingRouteState === "error") { return ( -
- +
+

Billing unavailable

+

+ {getStandardErrorMessage( + customerQuery.error, + "We couldn't load your billing details right now. Please try again.", + )} +

+
); } - if (!session?.user?.id) { - return ( - - Go to sign in - - } - /> - ); + async function runAction( + callback: () => Promise, + fallbackMessage: string, + ) { + setError(null); + setIsPending(true); + try { + await callback(); + await customerQuery.refetch(); + } catch (err) { + setError(getStandardErrorMessage(err, fallbackMessage)); + } finally { + setIsPending(false); + } } - if (customerQuery.isError || plansQuery.isError) { + if (isPending) { return ( - +
+

Redirecting to Stripe...

+
); } return ( - +
+

Billing

+ +
+ {/* Subscription card */} +
+
+
+ ${totalRemaining.toFixed(2)}{" "} + + remaining + +
+
+ + Monthly ${monthlyRemaining.toFixed(2)} + + · + + Top-ups ${topUpRemaining.toFixed(2)} + +
+
+ +
+ Subscription{" "} + + {hasManagedServiceAccess ? "Active" : "Inactive"} + +
+ + +
+ + {/* Buy credits card */} +
+
+ Buy credits +

+ Top-up credits never expire and are used after your monthly + credits. +

+
+ +
+
+ $ + setTopUpAmount(e.target.value)} + /> +
+ {topUpAmount.trim() !== "" && !isValidTopUp ? ( +

Enter between $10–$99.

+ ) : null} +
+ + +
+
+ + {/* Usage chart */} + {hasManagedServiceAccess ? : null} + + {error ?

{error}

: null} + +

+ Billing is powered by Stripe. +

+
); } diff --git a/src/routes/_app/index.tsx b/src/routes/_app/index.tsx index 3abe662..81b2424 100644 --- a/src/routes/_app/index.tsx +++ b/src/routes/_app/index.tsx @@ -8,7 +8,7 @@ import { } from "@/client/lib/error-messages"; import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard"; import { UnauthenticatedErrorCard } from "@/client/components/UnauthenticatedErrorCard"; -import { BILLING_ROUTE } from "@/shared/billing"; +import { SUBSCRIBE_ROUTE } from "@/shared/billing"; export const Route = createFileRoute("/_app/")({ component: IndexRedirect, @@ -36,7 +36,7 @@ function IndexRedirect() { return; } - void navigate({ href: BILLING_ROUTE }); + void navigate({ href: SUBSCRIBE_ROUTE }); }, [error, navigate]); if (isError) { diff --git a/src/routes/_app/route.tsx b/src/routes/_app/route.tsx index 625dd34..2cf05c9 100644 --- a/src/routes/_app/route.tsx +++ b/src/routes/_app/route.tsx @@ -1,11 +1,40 @@ -import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; +import { useSession } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { + getCurrentAuthRedirectFromHref, + getSignInSearch, +} from "@/lib/auth-redirect"; export const Route = createFileRoute("/_app")({ component: AppRouteLayout, }); function AppRouteLayout() { + const navigate = useNavigate(); + const { data: session, isPending } = useSession(); + const isHostedMode = isHostedClientAuthMode(); + + useEffect(() => { + if (isPending || !isHostedMode || session?.user?.id) { + return; + } + + void navigate({ + to: "/sign-in", + search: getSignInSearch( + getCurrentAuthRedirectFromHref(window.location.href), + ), + replace: true, + }); + }, [isPending, isHostedMode, session?.user?.id, navigate]); + + if (isHostedMode && (isPending || !session?.user?.id)) { + return null; + } + return ( diff --git a/src/routes/_authenticated.subscribe.tsx b/src/routes/_authenticated.subscribe.tsx new file mode 100644 index 0000000..3057ca9 --- /dev/null +++ b/src/routes/_authenticated.subscribe.tsx @@ -0,0 +1,162 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { AutumnProvider, useCustomer } from "autumn-js/react"; +import { useEffect, useState } from "react"; +import { useSession } from "@/lib/auth-client"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { getSubscribeRouteState } from "@/client/features/billing/route-state"; +import { + AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, + AUTUMN_PAID_PLAN_ID, +} from "@/shared/billing"; + +export const Route = createFileRoute("/_authenticated/subscribe")({ + component: SubscribePage, +}); + +function SubscribePage() { + return ( + + + + ); +} + +function SubscribePageContent() { + const navigate = useNavigate(); + const { data: session } = useSession(); + const [isAttaching, setIsAttaching] = useState(false); + const [error, setError] = useState(null); + + const customerQuery = useCustomer({ + queryOptions: { + enabled: Boolean(session?.user?.id), + }, + }); + + const hasManagedServiceAccess = Boolean( + customerQuery.data?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID], + ); + const subscribeRouteState = getSubscribeRouteState({ + hasSession: Boolean(session?.user?.id), + isCustomerLoading: customerQuery.isLoading, + isCustomerError: customerQuery.isError, + hasManagedServiceAccess, + }); + + useEffect(() => { + if (subscribeRouteState === "redirectToApp") { + void navigate({ to: "/", replace: true }); + } + }, [navigate, subscribeRouteState]); + + if ( + subscribeRouteState === "loading" || + subscribeRouteState === "redirectToApp" + ) { + return null; + } + + if (subscribeRouteState === "error") { + return ( +
+
+ OpenSEO +

Billing unavailable

+
+ +

+ {getStandardErrorMessage( + customerQuery.error, + "We couldn't verify your billing status right now. Please try again.", + )} +

+ + +
+ ); + } + + async function handleSubscribe() { + setError(null); + setIsAttaching(true); + + try { + await customerQuery.attach({ + planId: AUTUMN_PAID_PLAN_ID, + redirectMode: "always", + successUrl: window.location.origin, + }); + } catch (err) { + setError( + getStandardErrorMessage( + err, + "We couldn't start the checkout. Please try again.", + ), + ); + setIsAttaching(false); + } + } + + return ( +
+
+ OpenSEO +

Get started

+
+ +
+
+ Base Plan + $10/month +
+
    + {[ + "Access to OpenSEO's managed service", + "Includes $10.00 of Usage Credits each month", + "Credits are consumed as you go for SEO data and AI features", + ].map((item) => ( +
  • + + — + + {item} +
  • + ))} +
+
+ + {error ?

{error}

: null} + + + +

+ Cancel anytime — no commitment. Powered by Stripe. +

+
+ ); +} diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx new file mode 100644 index 0000000..caf92b4 --- /dev/null +++ b/src/routes/_authenticated.tsx @@ -0,0 +1,35 @@ +import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { AuthPageShell } from "@/client/features/auth/AuthPage"; +import { useSession } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; + +export const Route = createFileRoute("/_authenticated")({ + component: AuthenticatedShellLayout, +}); + +function AuthenticatedShellLayout() { + const navigate = useNavigate(); + const { data: session, isPending } = useSession(); + const isHostedMode = isHostedClientAuthMode(); + + useEffect(() => { + if (isPending || !isHostedMode) return; + if (!session?.user?.id) { + void navigate({ + to: "/sign-in", + search: { redirect: window.location.pathname }, + }); + } + }, [isPending, isHostedMode, session?.user?.id, navigate]); + + if (!isHostedMode || isPending || !session?.user?.id) { + return null; + } + + return ( + + + + ); +} diff --git a/src/routes/verify-email.tsx b/src/routes/verify-email.tsx index 6a20857..fa0814e 100644 --- a/src/routes/verify-email.tsx +++ b/src/routes/verify-email.tsx @@ -1,4 +1,4 @@ -import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Link, createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { @@ -94,7 +94,6 @@ function getVerifyEmailPageCopy({ function VerifyEmailPage() { const search = Route.useSearch(); - const navigate = useNavigate(); const redirectTo = normalizeAuthRedirect(search.redirect); const isHostedMode = isHostedClientAuthMode(); const { data: session, isPending } = useSession(); @@ -117,8 +116,13 @@ function VerifyEmailPage() { return; } - void navigate({ href: redirectTo, replace: true }); - }, [isVerified, navigate, redirectTo]); + // Full page reload instead of client-side navigation: the auth→app + // transition needs a clean server-side load so that all server function + // handlers are freshly registered (client-side nav during Vite HMR can + // hit the server before updated handlers are ready, causing + // "action is not a function" errors). + window.location.replace(redirectTo); + }, [isVerified, redirectTo]); async function handleResend() { if (!email) return; diff --git a/src/shared/billing.ts b/src/shared/billing.ts index f857ab9..5f9ee05 100644 --- a/src/shared/billing.ts +++ b/src/shared/billing.ts @@ -1,4 +1,5 @@ export const BILLING_ROUTE = "/billing"; +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";