mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-01 16:08:30 +00:00
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:
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user