From 6cbf64a47308948357d4f4a361d2d42c374630b2 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 31 Jan 2026 13:21:25 +0100 Subject: [PATCH] feat: add billing tab and subscription management to OrganizationSettings component; implement loading and error handling for subscription details --- components/settings/OrganizationSettings.tsx | 142 ++++++++++++++++++- lib/api/billing.ts | 47 ++++++ 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 lib/api/billing.ts diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 296fcb0..1b1814f 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -16,6 +16,7 @@ import { OrganizationInvitation, Organization } from '@/lib/api/organization' +import { getSubscription, createPortalSession, SubscriptionDetails } from '@/lib/api/billing' import { toast } from '@ciphera-net/ui' import { motion, AnimatePresence } from 'framer-motion' import { @@ -33,7 +34,7 @@ import { Button, Input } from '@ciphera-net/ui' export default function OrganizationSettings() { const { user } = useAuth() const router = useRouter() - const [activeTab, setActiveTab] = useState<'general' | 'members'>('general') + const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing'>('general') const [showDeletePrompt, setShowDeletePrompt] = useState(false) const [deleteConfirm, setDeleteConfirm] = useState('') @@ -44,6 +45,11 @@ export default function OrganizationSettings() { const [invitations, setInvitations] = useState([]) const [isLoadingMembers, setIsLoadingMembers] = useState(true) + // Billing State + const [subscription, setSubscription] = useState(null) + const [isLoadingSubscription, setIsLoadingSubscription] = useState(false) + const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false) + // Invite State const [inviteEmail, setInviteEmail] = useState('') const [inviteRole, setInviteRole] = useState('member') @@ -88,6 +94,20 @@ export default function OrganizationSettings() { } }, [currentOrgId]) + const loadSubscription = useCallback(async () => { + if (!currentOrgId) return + setIsLoadingSubscription(true) + try { + const sub = await getSubscription() + setSubscription(sub) + } catch (error) { + console.error('Failed to load subscription:', error) + // toast.error('Failed to load subscription details') + } finally { + setIsLoadingSubscription(false) + } + }, [currentOrgId]) + useEffect(() => { if (currentOrgId) { loadMembers() @@ -96,6 +116,12 @@ export default function OrganizationSettings() { } }, [currentOrgId, loadMembers]) + useEffect(() => { + if (activeTab === 'billing' && currentOrgId) { + loadSubscription() + } + }, [activeTab, currentOrgId, loadSubscription]) + // If no org ID, we are in personal workspace, so don't show org settings if (!currentOrgId) { return ( @@ -105,6 +131,17 @@ export default function OrganizationSettings() { ) } + const handleManageSubscription = async () => { + setIsRedirectingToPortal(true) + try { + const { url } = await createPortalSession() + window.location.href = url + } catch (error: any) { + toast.error(error.message || 'Failed to redirect to billing portal') + setIsRedirectingToPortal(false) + } + } + const handleDelete = async () => { if (deleteConfirm !== 'DELETE') return @@ -230,6 +267,17 @@ export default function OrganizationSettings() { Members + {/* Content Area */} @@ -468,6 +516,98 @@ export default function OrganizationSettings() { )} )} + + {activeTab === 'billing' && ( +
+
+

Billing & Subscription

+

Manage your subscription plan and payment methods.

+
+ + {isLoadingSubscription ? ( +
+
+ Loading subscription details... +
+ ) : !subscription ? ( +
+

Could not load subscription details.

+ +
+ ) : ( +
+ {/* Current Plan */} +
+
+
+

Current Plan

+
+ + {subscription.plan_id === 'price_1Q...' ? 'Pro' : subscription.plan_id || 'Free'} Plan + + + {subscription.subscription_status} + +
+
+ {subscription.has_payment_method && ( + + )} +
+ +
+
+
Billing Interval
+
+ {subscription.billing_interval}ly +
+
+
+
Pageview Limit
+
+ {subscription.pageview_limit.toLocaleString()} / month +
+
+
+
Renews On
+
+ {new Date(subscription.current_period_end).toLocaleDateString()} +
+
+
+
+ + {!subscription.has_payment_method && ( +
+

Upgrade to Pro

+

+ Get higher limits, more data retention, and priority support. +

+ +
+ )} +
+ )} +
+ )} diff --git a/lib/api/billing.ts b/lib/api/billing.ts new file mode 100644 index 0000000..b7da62d --- /dev/null +++ b/lib/api/billing.ts @@ -0,0 +1,47 @@ +import { API_URL } from './client' + +export interface SubscriptionDetails { + plan_id: string + subscription_status: string + current_period_end: string + billing_interval: string + pageview_limit: number + has_payment_method: boolean +} + +async function billingFetch(endpoint: string, options: RequestInit = {}): Promise { + const url = `${API_URL}${endpoint}` + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + } + + const response = await fetch(url, { + ...options, + headers, + credentials: 'include', // Send cookies + }) + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({ + error: 'Unknown error', + message: `HTTP ${response.status}: ${response.statusText}`, + })) + throw new Error(errorBody.message || errorBody.error || 'Request failed') + } + + return response.json() +} + +export async function getSubscription(): Promise { + return await billingFetch('/api/billing/subscription', { + method: 'GET', + }) +} + +export async function createPortalSession(): Promise<{ url: string }> { + return await billingFetch<{ url: string }>('/api/billing/portal', { + method: 'POST', + }) +}