From 4ec68e8aaf7bc350dbbdf90264d4fe97abe747ba Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Feb 2026 10:48:55 +0100 Subject: [PATCH] feat: add change plan functionality to OrganizationSettings component --- components/settings/OrganizationSettings.tsx | 160 ++++++++++++++++--- lib/api/billing.ts | 13 ++ lib/plans.ts | 29 ++++ 3 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 lib/plans.ts diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index fa3ae03..ff5d5af 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -16,7 +16,8 @@ import { OrganizationInvitation, Organization } from '@/lib/api/organization' -import { getSubscription, createPortalSession, getInvoices, cancelSubscription, SubscriptionDetails, Invoice } from '@/lib/api/billing' +import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing' +import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex } from '@/lib/plans' import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' @@ -70,6 +71,10 @@ export default function OrganizationSettings() { const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false) const [isCanceling, setIsCanceling] = useState(false) const [showCancelPrompt, setShowCancelPrompt] = useState(false) + const [showChangePlanModal, setShowChangePlanModal] = useState(false) + const [changePlanTierIndex, setChangePlanTierIndex] = useState(2) + const [changePlanYearly, setChangePlanYearly] = useState(false) + const [isChangingPlan, setIsChangingPlan] = useState(false) const [invoices, setInvoices] = useState([]) const [isLoadingInvoices, setIsLoadingInvoices] = useState(false) @@ -273,6 +278,40 @@ export default function OrganizationSettings() { } } + const openChangePlanModal = () => { + if (subscription?.pageview_limit) { + setChangePlanTierIndex(getTierIndexForLimit(subscription.pageview_limit)) + } else { + setChangePlanTierIndex(2) + } + setChangePlanYearly(subscription?.billing_interval === 'year') + setShowChangePlanModal(true) + } + + const hasActiveSubscription = subscription?.has_payment_method && (subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing') + + const handleChangePlanSubmit = async () => { + const interval = changePlanYearly ? 'year' : 'month' + const limit = getLimitForTierIndex(changePlanTierIndex) + setIsChangingPlan(true) + try { + if (hasActiveSubscription) { + await changePlan({ plan_id: PLAN_ID_SOLO, interval, limit }) + toast.success('Plan updated. Changes may take a moment to reflect.') + setShowChangePlanModal(false) + loadSubscription() + } else { + const { url } = await createCheckoutSession({ plan_id: PLAN_ID_SOLO, interval, limit }) + if (url) window.location.href = url + else throw new Error('No checkout URL') + } + } catch (error: any) { + toast.error(getAuthErrorMessage(error) || error.message || 'Something went wrong.') + } finally { + setIsChangingPlan(false) + } + } + const handleDelete = async () => { if (deleteConfirm !== 'DELETE') return @@ -712,15 +751,24 @@ export default function OrganizationSettings() { - {subscription.has_payment_method && ( - - )} + {subscription.has_payment_method && ( + + )} +
@@ -786,7 +834,7 @@ export default function OrganizationSettings() { . You can use the app until then.

- Your data is retained for 30 days after access ends. You can resubscribe anytime from the Stripe portal. + Your data is retained for 30 days after access ends. You can resubscribe anytime using Change plan above.

) : ( @@ -809,18 +857,6 @@ export default function OrganizationSettings() { )} - {!subscription.has_payment_method && ( -
-

Upgrade to Pro

-

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

- -
- )} - {/* Invoice History */}

Invoice History

@@ -1161,6 +1197,88 @@ export default function OrganizationSettings() { )} + + {/* Change plan modal */} + + {showChangePlanModal && ( + + +
+

Change plan

+ +
+

+ Choose your pageview limit and billing interval. {hasActiveSubscription ? 'Your next invoice will reflect prorations.' : 'You’ll start a new subscription.'} +

+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+
+ )} +
) } diff --git a/lib/api/billing.ts b/lib/api/billing.ts index 46206e4..447fd23 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -64,6 +64,19 @@ export async function cancelSubscription(params?: CancelSubscriptionParams): Pro }) } +export interface ChangePlanParams { + plan_id: string + interval: string + limit: number +} + +export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> { + return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', { + method: 'POST', + body: JSON.stringify(params), + }) +} + export interface CreateCheckoutParams { plan_id: string interval: string diff --git a/lib/plans.ts b/lib/plans.ts new file mode 100644 index 0000000..c81a4d5 --- /dev/null +++ b/lib/plans.ts @@ -0,0 +1,29 @@ +/** + * Shared plan and traffic tier definitions for pricing and billing (Change plan). + * Backend supports plan_id "solo" and limit 10k–10M; month/year interval. + */ + +export const PLAN_ID_SOLO = 'solo' + +/** Traffic tiers available for Solo plan (pageview limits). */ +export const TRAFFIC_TIERS = [ + { label: '10k', value: 10000 }, + { label: '50k', value: 50000 }, + { label: '100k', value: 100000 }, + { label: '250k', value: 250000 }, + { label: '500k', value: 500000 }, + { label: '1M', value: 1000000 }, + { label: '2.5M', value: 2500000 }, + { label: '5M', value: 5000000 }, + { label: '10M', value: 10000000 }, +] as const + +export function getTierIndexForLimit(limit: number): number { + const idx = TRAFFIC_TIERS.findIndex((t) => t.value === limit) + return idx >= 0 ? idx : 2 +} + +export function getLimitForTierIndex(index: number): number { + if (index < 0 || index >= TRAFFIC_TIERS.length) return 100000 + return TRAFFIC_TIERS[index].value +}