Settings modal: billing tab with manage plan button (#1915)

Add Billing tab to webapp settings

- New "Billing" tab in the settings modal, between Account and API Keys
- Shows your current plan (Free/Basic/Pro/Max)
- Subscribers get a "Manage Plan" button that opens the Stripe portal,
  same as the pricing page, plus a "View Plans" link for switching
- Free users get a "View Plans" button to the pricing page
- Handles signed-out and loading states like the other tabs
This commit is contained in:
BFlat authored and GitHub committed 2026-08-25 20:27:13 +07:00
1 parent e3f48033f9
commit bde7760080
4 files changed
+186 -8

No files matched your search

@@ -0,0 +1,139 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@storyteller/ui-button";
import { LoaderCircleIcon } from "lucide-react";
import { BillingApi } from "@storyteller/api";
import {
FREE_PLAN,
SUBSCRIPTION_PLANS_BY_SLUG,
} from "@storyteller/subscription";
import { toast } from "../toast/toast";
interface BillingSectionProps {
/** Closes the settings modal before navigating to the pricing page. */
onCloseModal: () => void;
}
export function BillingSection({ onCloseModal }: BillingSectionProps) {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(true);
const [activePlanSlug, setActivePlanSlug] = useState<string | null>(null);
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
useEffect(() => {
let cancelled = false;
const fetchSubscription = async () => {
try {
const response = await new BillingApi().ListActiveSubscriptions();
if (cancelled) return;
const artcraftSub = response.data?.active_subscriptions.find(
(sub) => sub.namespace === "artcraft",
);
setActivePlanSlug(artcraftSub?.product_slug ?? null);
} catch (error) {
console.error("Error fetching subscriptions:", error);
} finally {
if (!cancelled) setIsLoading(false);
}
};
fetchSubscription();
return () => {
cancelled = true;
};
}, []);
const handleManagePlan = async () => {
setIsOpeningPortal(true);
try {
const response = await new BillingApi().GetPortalUrl();
if (!response.success || !response.data) {
throw new Error(
response.errorMessage || "Failed to open the billing portal",
);
}
// Keep the button in its loading state until the browser navigates
// away to the Stripe portal.
window.location.href = response.data.stripePortalUrl;
} catch (error) {
console.error("Error opening billing portal:", error);
toast.error("Could not open the billing portal. Please try again.");
setIsOpeningPortal(false);
}
};
const handleViewPlans = () => {
onCloseModal();
navigate("/pricing");
};
if (isLoading) {
return <div className="text-xs opacity-60">Loading billing details...</div>;
}
const hasPaidPlan = activePlanSlug !== null && activePlanSlug !== "free";
const planName = activePlanSlug
? (SUBSCRIPTION_PLANS_BY_SLUG.get(activePlanSlug)?.name ?? activePlanSlug)
: FREE_PLAN.name;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">
Current plan: <span className="font-semibold">{planName}</span>
</p>
<p className="text-xs opacity-70">
{hasPaidPlan
? "Update your payment method, view invoices, or cancel your subscription in the billing portal."
: "Subscribe to a plan for monthly credits and full access to ArtCraft tools."}
</p>
</div>
{hasPaidPlan ? (
<Button
type="button"
variant="primary"
className="h-9 px-4 shrink-0"
onClick={handleManagePlan}
disabled={isOpeningPortal}
>
{isOpeningPortal ? (
<LoaderCircleIcon className="animate-spin" />
) : (
"Manage Plan"
)}
</Button>
) : (
<Button
type="button"
variant="primary"
className="h-9 px-4 shrink-0"
onClick={handleViewPlans}
>
View Plans
</Button>
)}
</div>
{hasPaidPlan && (
<>
<hr className="border-ui-panel-border" />
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Switch plans</p>
<p className="text-xs opacity-70">
Compare plans and upgrade or downgrade on the pricing page.
</p>
</div>
<Button
type="button"
variant="secondary"
className="h-9 px-3 shrink-0"
onClick={handleViewPlans}
>
View Plans
</Button>
</div>
</>
)}
</div>
);
}
@@ -1,6 +1,12 @@
import { useEffect, useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { KeyIcon, KeyboardIcon, SettingsIcon, UserIcon } from "lucide-react";
import {
CreditCardIcon,
KeyIcon,
KeyboardIcon,
SettingsIcon,
UserIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Switch } from "@storyteller/ui-switch";
import { KeybindsSettings, useKeybindsStore } from "@storyteller/keybinds";
@@ -11,8 +17,9 @@ import { useLightboxSoundStore } from "../../lib/lightbox-sound-store";
import { useSession } from "../../lib/session";
import { AccountSection } from "./AccountSection";
import { ApiKeySection } from "./ApiKeySection";
import { BillingSection } from "./BillingSection";
type Tab = "general" | "keybinds" | "account" | "apiKeys";
type Tab = "general" | "keybinds" | "account" | "billing" | "apiKeys";
interface SettingsModalProps {
isOpen: boolean;
@@ -23,6 +30,7 @@ const TABS: { id: Tab; label: string; icon: typeof SettingsIcon }[] = [
{ id: "general", label: "General", icon: SettingsIcon },
{ id: "keybinds", label: "Keybinds", icon: KeyboardIcon },
{ id: "account", label: "Account", icon: UserIcon },
{ id: "billing", label: "Billing", icon: CreditCardIcon },
{ id: "apiKeys", label: "API Keys", icon: KeyIcon },
];
@@ -80,6 +88,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
)}
{tab === "account" && <AccountPanel />}
{tab === "billing" && <BillingPanel onCloseModal={onClose} />}
{tab === "apiKeys" && <ApiKeysPanel />}
</div>
</div>
@@ -190,6 +199,30 @@ function AccountPanel() {
);
}
function BillingPanel({ onCloseModal }: { onCloseModal: () => void }) {
const { user, authChecked } = useSession();
if (!authChecked) {
return (
<div className="pt-3 text-xs opacity-60">Loading billing details...</div>
);
}
if (!user) {
return (
<div className="pt-3 text-xs opacity-60">
You need to be signed in to manage billing.
</div>
);
}
return (
<div className="pt-3">
<BillingSection onCloseModal={onCloseModal} />
</div>
);
}
function ApiKeysPanel() {
const { user, authChecked } = useSession();
@@ -80,10 +80,10 @@
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/keybinds/tsconfig.lib.json"
"path": "../../libs/components/switch/tsconfig.lib.json"
},
{
"path": "../../libs/components/switch/tsconfig.lib.json"
"path": "../../libs/state/subscription/tsconfig.lib.json"
},
{
"path": "../../libs/components/promptbox/tsconfig.lib.json"
@@ -127,6 +127,9 @@
{
"path": "../../libs/components/button/tsconfig.lib.json"
},
{
"path": "../../libs/icons/tsconfig.lib.json"
},
{
"path": "../../libs/components/vfx/tsconfig.lib.json"
},
@@ -137,7 +140,7 @@
"path": "../../libs/api/tsconfig.lib.json"
},
{
"path": "../../libs/icons/tsconfig.lib.json"
"path": "../../libs/components/keybinds/tsconfig.lib.json"
}
]
}
+6 -3
View File
@@ -45,10 +45,10 @@
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/keybinds"
"path": "../../libs/components/switch"
},
{
"path": "../../libs/components/switch"
"path": "../../libs/state/subscription"
},
{
"path": "../../libs/components/promptbox"
@@ -92,6 +92,9 @@
{
"path": "../../libs/components/button"
},
{
"path": "../../libs/icons"
},
{
"path": "../../libs/components/vfx"
},
@@ -102,7 +105,7 @@
"path": "../../libs/api"
},
{
"path": "../../libs/icons"
"path": "../../libs/components/keybinds"
},
{
"path": "./tsconfig.app.json"