From 570b995248d799cd9e381c3d28fb15b79055713b Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Fri, 3 Apr 2026 13:23:00 -0400
Subject: [PATCH] Refactor billing + onboarding (#61)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: use full page reload after email verification
Client-side navigation via TanStack Router during the auth→app
transition can race with Vite HMR, causing "action is not a function"
server function errors.
* feat: add minimal /subscribe onboarding page
New post-auth subscribe page at /subscribe using the same centered
layout as auth pages. Shows plan details and a single Subscribe CTA.
Redirects PAYMENT_REQUIRED users here instead of /billing.
* redesign: rewrite billing page with usage chart and cleaner layout
Delete the sprawling multi-component billing page and replace it with a
single-file implementation. Two cards sit side by side at the top
(subscription summary + buy credits), with a 30-day usage bar chart
below powered by Autumn's useAggregateEvents hook and recharts.
Removed BillingRouteParts.tsx, HostedBillingContent.tsx, and trimmed
HostedBillingContentUtils to only parseTopUpAmount.
* polish: billing page improvements and OpenSEO nav link
- Two-column layout with subscription summary and buy credits side by side
- Usage bar chart using ResizeObserver instead of ResponsiveContainer
- Input validation with inline error message
- Full-page redirect state when navigating to Stripe
- Make OpenSEO logo in navbar link to /
* fix: guard app routes and include top-up usage
* fix: restore billing onboarding guards
Keep unpaid orgs on /subscribe and avoid misleading billing states when Autumn customer lookups fail.
* fix: split billing usage chart for ci checks
---
.../features/billing/BillingRouteParts.tsx | 113 -----
.../features/billing/BillingUsageChart.tsx | 147 +++++++
.../features/billing/HostedBillingContent.tsx | 389 ------------------
.../billing/HostedBillingContentUtils.test.ts | 26 +-
.../billing/HostedBillingContentUtils.ts | 65 ---
.../features/billing/route-state.test.ts | 74 ++++
src/client/features/billing/route-state.ts | 42 ++
src/client/layout/AppShell.tsx | 8 +-
src/routeTree.gen.ts | 47 +++
src/routes/_app/billing.tsx | 239 +++++++++--
src/routes/_app/index.tsx | 4 +-
src/routes/_app/route.tsx | 31 +-
src/routes/_authenticated.subscribe.tsx | 162 ++++++++
src/routes/_authenticated.tsx | 35 ++
src/routes/verify-email.tsx | 12 +-
src/shared/billing.ts | 1 +
16 files changed, 759 insertions(+), 636 deletions(-)
delete mode 100644 src/client/features/billing/BillingRouteParts.tsx
create mode 100644 src/client/features/billing/BillingUsageChart.tsx
delete mode 100644 src/client/features/billing/HostedBillingContent.tsx
create mode 100644 src/client/features/billing/route-state.test.ts
create mode 100644 src/client/features/billing/route-state.ts
create mode 100644 src/routes/_authenticated.subscribe.tsx
create mode 100644 src/routes/_authenticated.tsx
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.`}
-
- {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.`}
-
+ {[
+ "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";