Refactor billing + onboarding (#61)

* 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
This commit is contained in:
Ben Senescu 2026-04-03 13:23:00 -04:00 committed by Ben Senescu
parent 5617c6b9f3
commit 570b995248
16 changed files with 759 additions and 636 deletions

View File

@ -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 (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-base-content/60">
<CreditCard className="h-4 w-4" />
Hosted billing
</div>
<h1 className="text-3xl font-semibold">
{args.hasManagedServiceAccess ? "Billing" : "Choose a plan"}
</h1>
<p className="max-w-2xl text-base-content/70">
{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.`}
</p>
</div>
);
}
export function SubscriptionIntro(args: {
hasManagedServiceAccess: boolean;
basePlanName: string;
}) {
return (
<div>
<h2 className="text-xl font-semibold">
{args.hasManagedServiceAccess
? "Subscription"
: "Managed service access"}
</h2>
<p className="text-sm text-base-content/65">
{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.`}
</p>
</div>
);
}
export function SubscriptionStatusCard(args: {
hasManagedServiceAccess: boolean;
basePlanName: string;
basePlanPrice: string;
includedCreditsLabel: string;
}) {
return (
<div className="rounded-2xl border border-base-300 bg-base-200/50 p-4">
<div className="text-sm text-base-content/60">
{args.hasManagedServiceAccess ? "Current status" : args.basePlanName}
</div>
<div className="mt-1 text-2xl font-semibold">
{args.hasManagedServiceAccess ? "Active" : args.basePlanPrice}
</div>
<div className="mt-2 text-sm text-base-content/70">
{args.hasManagedServiceAccess
? "Your organization can use hosted OpenSEO features."
: `Includes ${args.includedCreditsLabel} of usage credits every cycle.`}
</div>
</div>
);
}
export function BillingAlerts(args: {
actionError: string | null;
hasManagedServiceAccess: boolean;
basePlanName: string;
}) {
return (
<>
{args.actionError ? (
<div className="alert alert-error">
<span>{args.actionError}</span>
</div>
) : null}
{!args.hasManagedServiceAccess ? (
<div className="alert alert-warning">
<span>
Subscribe to {args.basePlanName} first. After that, you can manage
your plan and buy more credits here.
</span>
</div>
) : null}
</>
);
}
export function CenteredCard(args: {
title: string;
body: string;
action?: ReactNode;
}) {
return (
<div className="mx-auto flex h-full w-full max-w-3xl items-center justify-center p-6">
<div className="card w-full max-w-xl border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-3">
<h1 className="text-2xl font-semibold">{args.title}</h1>
<p className="text-base-content/70">{args.body}</p>
{args.action ? (
<div className="card-actions justify-start pt-2">{args.action}</div>
) : null}
</div>
</div>
</div>
);
}

View File

@ -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<HTMLDivElement>(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 (
<div className="rounded-lg border border-base-300 p-4 space-y-3">
<div className="flex items-baseline justify-between gap-4">
<span className="font-semibold">Usage</span>
<span className="text-xs text-base-content/50">Last 30 days</span>
</div>
<div className="text-2xl font-semibold tabular-nums">
${totalSpend.toFixed(2)}
</div>
<div ref={containerRef} className="w-full h-32 min-w-0">
{eventsQuery.isLoading ? null : chartData.length === 0 ? (
<div className="flex h-full items-center justify-center">
<span className="text-sm text-base-content/40">
No usage recorded yet
</span>
</div>
) : chartWidth > 0 ? (
<BarChart
width={chartWidth}
height={128}
data={chartData}
margin={{ top: 4, right: 0, bottom: 0, left: -20 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="currentColor"
opacity={0.06}
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fontSize: 10, fill: "#888" }}
tickLine={false}
axisLine={false}
minTickGap={40}
/>
<YAxis
tickFormatter={formatUsdAxis}
tick={{ fontSize: 10, fill: "#888" }}
tickLine={false}
axisLine={false}
width={44}
/>
<Tooltip
content={<UsageTooltip />}
cursor={{ fill: "rgba(150,150,150,0.1)" }}
/>
<Bar
dataKey="credits"
fill="#7c3aed"
radius={[2, 2, 0, 0]}
maxBarSize={12}
/>
</BarChart>
) : null}
</div>
</div>
);
}
function UsageTooltip({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{ value: number }>;
label?: number;
}) {
if (!active || !payload?.length || label == null) return null;
return (
<div className="rounded-md border border-base-300 bg-base-100 px-3 py-2 shadow-sm">
<p className="text-xs text-base-content/60">
{new Date(label).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</p>
<p className="text-sm font-medium tabular-nums">
${payload[0].value.toFixed(2)}
</p>
</div>
);
}
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)}`;
}

View File

@ -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<string | null>(null);
const [pendingAction, setPendingAction] = useState<BillingAction>(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<unknown>,
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 (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 md:p-6">
<BillingHeader
hasManagedServiceAccess={hasManagedServiceAccess}
basePlanName={basePlanName}
includedCreditsLabel={includedCreditsLabel}
/>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<SubscriptionSection
basePlanName={basePlanName}
basePlanPrice={basePlanPrice}
hasManagedServiceAccess={hasManagedServiceAccess}
includedCreditsLabel={includedCreditsLabel}
isActionPending={isActionPending}
isPortalPending={pendingAction === "open-portal"}
isStartPlanPending={pendingAction === "start-plan"}
onOpenPortal={() => {
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.",
);
}}
/>
<SeoDataCreditsSection
monthlyBalance={monthlyBalance}
topupBalance={topupBalance}
basePlanName={basePlanName}
hasManagedServiceAccess={hasManagedServiceAccess}
isTopUpPending={pendingAction === "top-up"}
topUpAmount={topUpAmount}
topUpDisabled={topUpDisabled}
onTopUpAmountChange={setTopUpAmount}
onTopUp={() => {
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.",
);
}}
/>
</div>
<BillingAlerts
actionError={actionError}
hasManagedServiceAccess={hasManagedServiceAccess}
basePlanName={basePlanName}
/>
<div className="flex items-center gap-2 text-sm text-base-content/60">
<ExternalLink className="h-4 w-4" />
<span>Hosted billing is powered by Autumn.</span>
</div>
</div>
);
}
function SubscriptionSection(args: {
basePlanName: string;
basePlanPrice: string;
hasManagedServiceAccess: boolean;
includedCreditsLabel: string;
isActionPending: boolean;
isPortalPending: boolean;
isStartPlanPending: boolean;
onOpenPortal: () => void;
onStartPlan: () => void;
}) {
return (
<section className="card border border-base-300 bg-base-100 shadow-sm">
<div className="card-body gap-4">
<SubscriptionIntro
hasManagedServiceAccess={args.hasManagedServiceAccess}
basePlanName={args.basePlanName}
/>
<SubscriptionStatusCard
hasManagedServiceAccess={args.hasManagedServiceAccess}
basePlanName={args.basePlanName}
basePlanPrice={args.basePlanPrice}
includedCreditsLabel={args.includedCreditsLabel}
/>
{args.hasManagedServiceAccess ? (
<button
type="button"
className="btn btn-outline btn-block sm:btn-wide"
disabled={args.isActionPending}
onClick={args.onOpenPortal}
>
{args.isPortalPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Open billing portal
</button>
) : (
<button
type="button"
className="btn btn-primary btn-block sm:btn-wide"
disabled={args.isActionPending}
onClick={args.onStartPlan}
>
{args.isStartPlanPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Start {args.basePlanName}
</button>
)}
</div>
</section>
);
}
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 (
<section className="card border border-base-300 bg-base-100 shadow-sm">
<div className="card-body gap-4">
<div>
<h2 className="text-lg font-semibold text-base-content">
SEO data credits
</h2>
<p className="mt-1 text-sm text-base-content/70">
Monthly credits are used first. Purchased top-ups never expire.
</p>
</div>
<div className="rounded-box border border-base-300 bg-base-200/60 p-4">
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-base-content/70">
Total remaining
</span>
<span className="text-2xl font-semibold text-base-content">
{formatCreditAmount(totalRemaining)}
</span>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<CreditPoolCard
title="Monthly included"
badge={formatResetDate(args.monthlyBalance?.nextResetAt ?? null)}
remaining={args.monthlyBalance?.remaining ?? 0}
granted={args.monthlyBalance?.granted ?? 0}
usage={args.monthlyBalance?.usage ?? 0}
/>
<CreditPoolCard
title="Purchased top-ups"
badge="Never expires"
remaining={args.topupBalance?.remaining ?? 0}
granted={args.topupBalance?.granted ?? 0}
usage={args.topupBalance?.usage ?? 0}
/>
</div>
<label className="form-control gap-2">
<span className="label-text font-medium">Top up amount</span>
<div className="join">
<span className="join-item inline-flex items-center rounded-l-btn border border-base-300 bg-base-200 px-4 text-base-content/70">
$
</span>
<input
type="number"
min={10}
max={99}
step={1}
inputMode="numeric"
className="input join-item input-bordered w-full"
value={args.topUpAmount}
onChange={(event) => args.onTopUpAmountChange(event.target.value)}
/>
</div>
<span className="label-text-alt text-base-content/60">
{args.hasManagedServiceAccess
? "Enter a whole-dollar amount between $10 and $99."
: `Start ${args.basePlanName} before buying extra credits.`}
</span>
</label>
<button
type="button"
className="btn btn-secondary btn-block"
disabled={args.topUpDisabled}
onClick={args.onTopUp}
>
{args.isTopUpPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Buy credits
</button>
<p className="text-xs leading-relaxed text-base-content/60">
Credit purchases use our hosted checkout flow and apply to your
organization's top-up balance.
</p>
</div>
</section>
);
}
function CreditPoolCard(args: {
title: string;
badge: string | null;
remaining: number;
granted: number;
usage: number;
}) {
return (
<div className="rounded-box border border-base-300 bg-base-200/40 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-base-content">
{args.title}
</span>
{args.badge ? (
<span className="badge badge-sm badge-ghost text-base-content/60">
{args.badge}
</span>
) : null}
</div>
<div className="mt-2 text-xl font-semibold text-base-content">
{formatCreditAmount(args.remaining)}
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-base-content/60">
<div>
<span className="block uppercase tracking-wide">Granted</span>
<span className="font-medium text-base-content/80">
{formatCreditAmount(args.granted)}
</span>
</div>
<div>
<span className="block uppercase tracking-wide">Used</span>
<span className="font-medium text-base-content/80">
{formatCreditAmount(args.usage)}
</span>
</div>
</div>
</div>
);
}

View File

@ -1,21 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { parseTopUpAmount } from "./HostedBillingContentUtils";
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();
});
});
describe("parseTopUpAmount", () => { describe("parseTopUpAmount", () => {
it("accepts valid whole-dollar amounts", () => { it("accepts valid whole-dollar amounts", () => {
@ -36,11 +20,3 @@ describe("parseTopUpAmount", () => {
expect(parseTopUpAmount("abc")).toEqual({ isValid: false, parsed: 20 }); 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");
});
});

View File

@ -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) { export function parseTopUpAmount(value: string) {
const trimmed = value.trim(); const trimmed = value.trim();
@ -27,57 +16,3 @@ export function parseTopUpAmount(value: string) {
parsed: isValid ? parsed : 20, 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;
}
}

View File

@ -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");
});
});

View File

@ -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;
}

View File

@ -156,13 +156,15 @@ function TopNav({
<Menu className="h-6 w-6" /> <Menu className="h-6 w-6" />
</button> </button>
) : null} ) : null}
<span className="ml-1 font-semibold text-base-content">OpenSEO</span> <Link to="/" className="ml-1 font-semibold text-base-content">
OpenSEO
</Link>
</div> </div>
<div className="hidden items-center gap-1 md:flex"> <div className="hidden items-center gap-1 md:flex">
<span className="px-2 text-lg font-semibold text-base-content"> <Link to="/" className="px-2 text-lg font-semibold text-base-content">
OpenSEO OpenSEO
</span> </Link>
{projectId {projectId
? projectNavItems.map((item) => { ? projectNavItems.map((item) => {
const { icon: Icon, matchSegment, ...linkProps } = item; const { icon: Icon, matchSegment, ...linkProps } = item;

View File

@ -12,10 +12,12 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as VerifyEmailRouteImport } from './routes/verify-email' import { Route as VerifyEmailRouteImport } from './routes/verify-email'
import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-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 AuthRouteImport } from './routes/_auth'
import { Route as ProjectRouteRouteImport } from './routes/_project/route' import { Route as ProjectRouteRouteImport } from './routes/_project/route'
import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as AppIndexRouteImport } from './routes/_app/index' 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 AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
import { Route as AppBillingRouteImport } from './routes/_app/billing' import { Route as AppBillingRouteImport } from './routes/_app/billing'
@ -48,6 +50,10 @@ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
path: '/forgot-password', path: '/forgot-password',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoute = AuthRouteImport.update({ const AuthRoute = AuthRouteImport.update({
id: '/_auth', id: '/_auth',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
@ -65,6 +71,11 @@ const AppIndexRoute = AppIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => AppRouteRoute, getParentRoute: () => AppRouteRoute,
} as any) } as any)
const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
id: '/subscribe',
path: '/subscribe',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthSignUpRoute = AuthSignUpRouteImport.update({ const AuthSignUpRoute = AuthSignUpRouteImport.update({
id: '/sign-up', id: '/sign-up',
path: '/sign-up', path: '/sign-up',
@ -158,6 +169,7 @@ export interface FileRoutesByFullPath {
'/billing': typeof AppBillingRoute '/billing': typeof AppBillingRoute
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/subscribe': typeof AuthenticatedSubscribeRoute
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
@ -180,6 +192,7 @@ export interface FileRoutesByTo {
'/billing': typeof AppBillingRoute '/billing': typeof AppBillingRoute
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/subscribe': typeof AuthenticatedSubscribeRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
@ -197,12 +210,14 @@ export interface FileRoutesById {
'/_app': typeof AppRouteRouteWithChildren '/_app': typeof AppRouteRouteWithChildren
'/_project': typeof ProjectRouteRouteWithChildren '/_project': typeof ProjectRouteRouteWithChildren
'/_auth': typeof AuthRouteWithChildren '/_auth': typeof AuthRouteWithChildren
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/forgot-password': typeof ForgotPasswordRoute '/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute '/verify-email': typeof VerifyEmailRoute
'/_app/billing': typeof AppBillingRoute '/_app/billing': typeof AppBillingRoute
'/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute '/_auth/sign-up': typeof AuthSignUpRoute
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
'/_app/': typeof AppIndexRoute '/_app/': typeof AppIndexRoute
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
@ -228,6 +243,7 @@ export interface FileRouteTypes {
| '/billing' | '/billing'
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/subscribe'
| '/p/$projectId' | '/p/$projectId'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
@ -250,6 +266,7 @@ export interface FileRouteTypes {
| '/billing' | '/billing'
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/subscribe'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
@ -266,12 +283,14 @@ export interface FileRouteTypes {
| '/_app' | '/_app'
| '/_project' | '/_project'
| '/_auth' | '/_auth'
| '/_authenticated'
| '/forgot-password' | '/forgot-password'
| '/reset-password' | '/reset-password'
| '/verify-email' | '/verify-email'
| '/_app/billing' | '/_app/billing'
| '/_auth/sign-in' | '/_auth/sign-in'
| '/_auth/sign-up' | '/_auth/sign-up'
| '/_authenticated/subscribe'
| '/_app/' | '/_app/'
| '/_project/p/$projectId' | '/_project/p/$projectId'
| '/_app/help/dataforseo-api-key' | '/_app/help/dataforseo-api-key'
@ -292,6 +311,7 @@ export interface RootRouteChildren {
AppRouteRoute: typeof AppRouteRouteWithChildren AppRouteRoute: typeof AppRouteRouteWithChildren
ProjectRouteRoute: typeof ProjectRouteRouteWithChildren ProjectRouteRoute: typeof ProjectRouteRouteWithChildren
AuthRoute: typeof AuthRouteWithChildren AuthRoute: typeof AuthRouteWithChildren
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
ForgotPasswordRoute: typeof ForgotPasswordRoute ForgotPasswordRoute: typeof ForgotPasswordRoute
ResetPasswordRoute: typeof ResetPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute
VerifyEmailRoute: typeof VerifyEmailRoute VerifyEmailRoute: typeof VerifyEmailRoute
@ -322,6 +342,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ForgotPasswordRouteImport preLoaderRoute: typeof ForgotPasswordRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': { '/_auth': {
id: '/_auth' id: '/_auth'
path: '' path: ''
@ -350,6 +377,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppIndexRouteImport preLoaderRoute: typeof AppIndexRouteImport
parentRoute: typeof AppRouteRoute parentRoute: typeof AppRouteRoute
} }
'/_authenticated/subscribe': {
id: '/_authenticated/subscribe'
path: '/subscribe'
fullPath: '/subscribe'
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/sign-up': { '/_auth/sign-up': {
id: '/_auth/sign-up' id: '/_auth/sign-up'
path: '/sign-up' path: '/sign-up'
@ -548,10 +582,23 @@ const AuthRouteChildren: AuthRouteChildren = {
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren,
)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
AppRouteRoute: AppRouteRouteWithChildren, AppRouteRoute: AppRouteRouteWithChildren,
ProjectRouteRoute: ProjectRouteRouteWithChildren, ProjectRouteRoute: ProjectRouteRouteWithChildren,
AuthRoute: AuthRouteWithChildren, AuthRoute: AuthRouteWithChildren,
AuthenticatedRoute: AuthenticatedRouteWithChildren,
ForgotPasswordRoute: ForgotPasswordRoute, ForgotPasswordRoute: ForgotPasswordRoute,
ResetPasswordRoute: ResetPasswordRoute, ResetPasswordRoute: ResetPasswordRoute,
VerifyEmailRoute: VerifyEmailRoute, VerifyEmailRoute: VerifyEmailRoute,

View File

@ -1,10 +1,21 @@
import { createFileRoute, notFound } from "@tanstack/react-router"; import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router";
import { AutumnProvider, useCustomer, useListPlans } from "autumn-js/react"; import { AutumnProvider, useCustomer } from "autumn-js/react";
import { LoaderCircle } from "lucide-react"; import { useEffect, useState } from "react";
import { CenteredCard } from "@/client/features/billing/BillingRouteParts";
import { HostedBillingContent } from "@/client/features/billing/HostedBillingContent";
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 { 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")({ export const Route = createFileRoute("/_app/billing")({
beforeLoad: () => { beforeLoad: () => {
@ -24,7 +35,11 @@ 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 [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const customerQuery = useCustomer({ const customerQuery = useCustomer({
queryOptions: { queryOptions: {
@ -32,50 +47,206 @@ function BillingPageContent() {
}, },
}); });
const plansQuery = useListPlans({ const hasManagedServiceAccess = Boolean(
queryOptions: { customerQuery.data?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID],
enabled: Boolean(session?.user?.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 ( if (
isSessionPending || billingRouteState === "loading" ||
(session?.user?.id && (customerQuery.isLoading || plansQuery.isLoading)) billingRouteState === "redirectToSubscribe"
) { ) {
return null;
}
if (billingRouteState === "error") {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="mx-auto w-full max-w-2xl space-y-4 p-4 py-10 md:p-6 md:py-12">
<LoaderCircle className="h-6 w-6 animate-spin text-base-content/60" /> <h1 className="text-xl font-semibold">Billing unavailable</h1>
<p className="text-sm text-base-content/70">
{getStandardErrorMessage(
customerQuery.error,
"We couldn't load your billing details right now. Please try again.",
)}
</p>
<button
type="button"
className="btn btn-soft btn-sm"
onClick={() => {
void customerQuery.refetch();
}}
>
Try again
</button>
</div> </div>
); );
} }
if (!session?.user?.id) { async function runAction(
return ( callback: () => Promise<unknown>,
<CenteredCard fallbackMessage: string,
title="Sign in to manage billing" ) {
body="Your hosted billing settings are tied to your OpenSEO organization." setError(null);
action={ setIsPending(true);
<a className="btn btn-primary" href="/sign-in"> try {
Go to sign in await callback();
</a> await customerQuery.refetch();
} catch (err) {
setError(getStandardErrorMessage(err, fallbackMessage));
} finally {
setIsPending(false);
} }
/>
);
} }
if (customerQuery.isError || plansQuery.isError) { if (isPending) {
return ( return (
<CenteredCard <div className="flex h-full items-center justify-center">
title="Billing unavailable" <p className="text-sm text-base-content/50">Redirecting to Stripe...</p>
body="We could not load your billing details right now. Please reload and try again." </div>
/>
); );
} }
return ( return (
<HostedBillingContent <div className="mx-auto w-full max-w-2xl space-y-5 p-4 py-10 md:p-6 md:py-12">
customerQuery={customerQuery} <h1 className="text-xl font-semibold">Billing</h1>
plans={plansQuery.data ?? []}
<div className="grid gap-5 md:grid-cols-2">
{/* Subscription card */}
<div className="flex flex-col justify-between rounded-lg border border-base-300 p-4 gap-4">
<div>
<div className="text-2xl font-semibold tabular-nums">
${totalRemaining.toFixed(2)}{" "}
<span className="text-sm font-normal text-base-content/50">
remaining
</span>
</div>
<div className="mt-1 flex gap-3 text-xs text-base-content/50">
<span className="tabular-nums">
Monthly ${monthlyRemaining.toFixed(2)}
</span>
<span>&middot;</span>
<span className="tabular-nums">
Top-ups ${topUpRemaining.toFixed(2)}
</span>
</div>
</div>
<div className="text-sm">
<span className="font-medium">Subscription</span>{" "}
<span className="text-base-content/50">
{hasManagedServiceAccess ? "Active" : "Inactive"}
</span>
</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.",
)
}
>
{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}
</div>
<button
className="btn btn-soft btn-sm w-full"
disabled={isPending || !isValidTopUp || !hasManagedServiceAccess}
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>
</div>
{/* Usage chart */}
{hasManagedServiceAccess ? <BillingUsageChart /> : null}
{error ? <p className="text-sm text-error">{error}</p> : null}
<p className="text-xs text-base-content/40">
Billing is powered by Stripe.
</p>
</div>
); );
} }

View File

@ -8,7 +8,7 @@ import {
} from "@/client/lib/error-messages"; } from "@/client/lib/error-messages";
import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard"; import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard";
import { UnauthenticatedErrorCard } from "@/client/components/UnauthenticatedErrorCard"; import { UnauthenticatedErrorCard } from "@/client/components/UnauthenticatedErrorCard";
import { BILLING_ROUTE } from "@/shared/billing"; import { SUBSCRIBE_ROUTE } from "@/shared/billing";
export const Route = createFileRoute("/_app/")({ export const Route = createFileRoute("/_app/")({
component: IndexRedirect, component: IndexRedirect,
@ -36,7 +36,7 @@ function IndexRedirect() {
return; return;
} }
void navigate({ href: BILLING_ROUTE }); void navigate({ href: SUBSCRIBE_ROUTE });
}, [error, navigate]); }, [error, navigate]);
if (isError) { if (isError) {

View File

@ -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 { 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")({ export const Route = createFileRoute("/_app")({
component: AppRouteLayout, component: AppRouteLayout,
}); });
function 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 ( return (
<AuthenticatedAppLayout> <AuthenticatedAppLayout>
<Outlet /> <Outlet />

View File

@ -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 (
<AutumnProvider>
<SubscribePageContent />
</AutumnProvider>
);
}
function SubscribePageContent() {
const navigate = useNavigate();
const { data: session } = useSession();
const [isAttaching, setIsAttaching] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="w-full max-w-xs space-y-4">
<div className="text-center space-y-3">
<img
src="/transparent-logo.png"
alt="OpenSEO"
className="mx-auto size-10 rounded-lg"
/>
<h1 className="text-xl font-semibold">Billing unavailable</h1>
</div>
<p className="text-sm text-center text-base-content/70">
{getStandardErrorMessage(
customerQuery.error,
"We couldn't verify your billing status right now. Please try again.",
)}
</p>
<button
type="button"
className="btn btn-soft w-full"
onClick={() => {
void customerQuery.refetch();
}}
>
Try again
</button>
</div>
);
}
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 (
<div className="w-full max-w-xs space-y-6">
<div className="text-center space-y-3">
<img
src="/transparent-logo.png"
alt="OpenSEO"
className="mx-auto size-10 rounded-lg"
/>
<h1 className="text-xl font-semibold">Get started</h1>
</div>
<div className="rounded-lg border border-base-300 p-4">
<div className="flex items-baseline justify-between gap-4">
<span className="font-semibold">Base Plan</span>
<span className="text-lg font-semibold tabular-nums">$10/month</span>
</div>
<ul className="mt-3 space-y-2">
{[
"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) => (
<li
key={item}
className="flex gap-2.5 text-sm text-base-content/70"
>
<span className="text-base-content/40 mt-[2px] shrink-0">
&mdash;
</span>
{item}
</li>
))}
</ul>
</div>
{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>
);
}

View File

@ -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 (
<AuthPageShell>
<Outlet />
</AuthPageShell>
);
}

View File

@ -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 { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -94,7 +94,6 @@ function getVerifyEmailPageCopy({
function VerifyEmailPage() { function VerifyEmailPage() {
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = useNavigate();
const redirectTo = normalizeAuthRedirect(search.redirect); const redirectTo = normalizeAuthRedirect(search.redirect);
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();
@ -117,8 +116,13 @@ function VerifyEmailPage() {
return; return;
} }
void navigate({ href: redirectTo, replace: true }); // Full page reload instead of client-side navigation: the auth→app
}, [isVerified, navigate, redirectTo]); // 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() { async function handleResend() {
if (!email) return; if (!email) return;

View File

@ -1,4 +1,5 @@
export const BILLING_ROUTE = "/billing"; export const BILLING_ROUTE = "/billing";
export const SUBSCRIBE_ROUTE = "/subscribe";
export const AUTUMN_PAID_PLAN_ID = "base-plan"; export const AUTUMN_PAID_PLAN_ID = "base-plan";
export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up"; export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up";