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 { 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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" />
|
<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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>·</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";
|
} 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) {
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
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 { 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;
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user