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:
parent
5617c6b9f3
commit
570b995248
@ -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>
|
||||
);
|
||||
}
|
||||
147
src/client/features/billing/BillingUsageChart.tsx
Normal file
147
src/client/features/billing/BillingUsageChart.tsx
Normal 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)}`;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
74
src/client/features/billing/route-state.test.ts
Normal file
74
src/client/features/billing/route-state.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
42
src/client/features/billing/route-state.ts
Normal file
42
src/client/features/billing/route-state.ts
Normal 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;
|
||||
}
|
||||
@ -156,13 +156,15 @@ function TopNav({
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
) : 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 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
|
||||
</span>
|
||||
</Link>
|
||||
{projectId
|
||||
? projectNavItems.map((item) => {
|
||||
const { icon: Icon, matchSegment, ...linkProps } = item;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoaderCircle className="h-6 w-6 animate-spin text-base-content/60" />
|
||||
<div className="mx-auto w-full max-w-2xl space-y-4 p-4 py-10 md:p-6 md:py-12">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return (
|
||||
<CenteredCard
|
||||
title="Sign in to manage billing"
|
||||
body="Your hosted billing settings are tied to your OpenSEO organization."
|
||||
action={
|
||||
<a className="btn btn-primary" href="/sign-in">
|
||||
Go to sign in
|
||||
</a>
|
||||
async function runAction(
|
||||
callback: () => Promise<unknown>,
|
||||
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 (
|
||||
<CenteredCard
|
||||
title="Billing unavailable"
|
||||
body="We could not load your billing details right now. Please reload and try again."
|
||||
/>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-base-content/50">Redirecting to Stripe...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HostedBillingContent
|
||||
customerQuery={customerQuery}
|
||||
plans={plansQuery.data ?? []}
|
||||
<div className="mx-auto w-full max-w-2xl space-y-5 p-4 py-10 md:p-6 md:py-12">
|
||||
<h1 className="text-xl font-semibold">Billing</h1>
|
||||
|
||||
<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>·</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 (
|
||||
<AuthenticatedAppLayout>
|
||||
<Outlet />
|
||||
|
||||
162
src/routes/_authenticated.subscribe.tsx
Normal file
162
src/routes/_authenticated.subscribe.tsx
Normal 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">
|
||||
—
|
||||
</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>
|
||||
);
|
||||
}
|
||||
35
src/routes/_authenticated.tsx
Normal file
35
src/routes/_authenticated.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user