From cc4901c224e3d3f5e9d975e308da0c3aad5964cb Mon Sep 17 00:00:00 2001 From: Ryan Rader Date: Fri, 22 May 2026 14:44:59 -0400 Subject: [PATCH] feat(customer-portal): plans page with cross-product subscription management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End users can now manage their subscriptions self-service from the hosted customer portal — no admin intervention or app-side Plan Selector needed. Adds: - PlansPage component (src/components/customerPortal/plans/PlansPage.tsx) groups plans by product key (extracted from code prefix: aistack-*, growth-*, memory-*, etc.) and renders three actions per plan: * Current plan badge (already subscribed) * Switch to this plan (within same product → in-place plan change via PlanUpgradeService, proration handled) * Add to my account (NEW subscription for a product the customer doesn't yet have) Plus per-product Cancel button with browser confirm dialog. - New /customer-portal/:token/plans route - New viewPlans navigator in useCustomerPortalNavigation hook - "Plans & Subscriptions" card in main portal index linking to the page - 11 translation strings (English base.json — other locales fall back) Powered by 4 customer-portal-scoped GraphQL ops added to lago-api fork: - customerPortalAvailablePlans (query) - changeCustomerPortalSubscriptionPlan (mutation) - createCustomerPortalSubscription (mutation) - terminateCustomerPortalSubscription (mutation) Phase 2 of the ThinkFleet billing integration: Lago portal becomes the single canonical place for end-user subscription self-service across all products in the ecosystem. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../common/CustomerPortalSections.tsx | 18 +- .../hooks/useCustomerPortalNavigation.ts | 9 + .../customerPortal/plans/PlansPage.tsx | 248 ++++++++++++++++++ src/core/router/CustomerPortalRoutes.tsx | 6 + src/core/router/paths/customerPortal.ts | 1 + translations/base.json | 15 +- 6 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 src/components/customerPortal/plans/PlansPage.tsx diff --git a/src/components/customerPortal/common/CustomerPortalSections.tsx b/src/components/customerPortal/common/CustomerPortalSections.tsx index 4771eabdcc..54f7928593 100644 --- a/src/components/customerPortal/common/CustomerPortalSections.tsx +++ b/src/components/customerPortal/common/CustomerPortalSections.tsx @@ -5,6 +5,7 @@ import PortalCustomerInfos from '~/components/customerPortal/PortalCustomerInfos import PortalInvoicesList from '~/components/customerPortal/PortalInvoicesList' import UsageSection from '~/components/customerPortal/usage/UsageSection' import WalletSection from '~/components/customerPortal/wallet/WalletSection' +import { Button } from '~/components/designSystem/Button' import { Typography } from '~/components/designSystem/Typography' import { PremiumIntegrationTypeEnum } from '~/generated/graphql' import Logo from '~/public/images/logo/lago-logo-grey.svg' @@ -17,7 +18,7 @@ const CustomerPortalSections = () => { const { data: portalData } = useCustomerPortalData() - const { viewWallet, viewSubscription, viewEditInformation } = useCustomerPortalNavigation() + const { viewWallet, viewSubscription, viewEditInformation, viewPlans } = useCustomerPortalNavigation() const showPoweredBy = !portalData?.customerPortalOrganization?.premiumIntegrations?.includes( PremiumIntegrationTypeEnum.RemoveBrandingWatermark, @@ -27,6 +28,21 @@ const CustomerPortalSections = () => {
+ +
+
+ + {translate('text_lago_portal_manage_plans_title')} + + + {translate('text_lago_portal_manage_plans_caption')} + +
+ +
+ diff --git a/src/components/customerPortal/common/hooks/useCustomerPortalNavigation.ts b/src/components/customerPortal/common/hooks/useCustomerPortalNavigation.ts index 699046427c..4468bc075e 100644 --- a/src/components/customerPortal/common/hooks/useCustomerPortalNavigation.ts +++ b/src/components/customerPortal/common/hooks/useCustomerPortalNavigation.ts @@ -3,6 +3,7 @@ import { generatePath, useParams } from 'react-router-dom' import { useLocation, useNavigate } from '~/core/router' import { CUSTOMER_PORTAL_CUSTOMER_EDIT_INFORMATION_ROUTE, + CUSTOMER_PORTAL_PLANS_ROUTE, CUSTOMER_PORTAL_ROUTE, CUSTOMER_PORTAL_USAGE_ROUTE, CUSTOMER_PORTAL_WALLET_ROUTE, @@ -40,12 +41,20 @@ const useCustomerPortalNavigation = () => { }), ) + const viewPlans = () => + navigate( + generatePath(CUSTOMER_PORTAL_PLANS_ROUTE, { + token: token as string, + }), + ) + return { pathname, goHome, viewSubscription, viewWallet, viewEditInformation, + viewPlans, } } diff --git a/src/components/customerPortal/plans/PlansPage.tsx b/src/components/customerPortal/plans/PlansPage.tsx new file mode 100644 index 0000000000..cbfcc8cbbe --- /dev/null +++ b/src/components/customerPortal/plans/PlansPage.tsx @@ -0,0 +1,248 @@ +import { gql } from '@apollo/client' +import { useMemo } from 'react' + +import PageTitle from '~/components/customerPortal/common/PageTitle' +import SectionError from '~/components/customerPortal/common/SectionError' +import { LoaderUsageSection } from '~/components/customerPortal/common/SectionLoading' +import useCustomerPortalTranslate from '~/components/customerPortal/common/useCustomerPortalTranslate' +import { Button } from '~/components/designSystem/Button' +import { Typography } from '~/components/designSystem/Typography' +import { intlFormatNumber } from '~/core/formats/intlFormatNumber' +import { deserializeAmount } from '~/core/serializers/serializeAmount' +import { + PlanInterval, + useCustomerPortalAvailablePlansQuery, + useCustomerPortalSubscriptionsQuery, + useChangeCustomerPortalSubscriptionPlanMutation, + useCreateCustomerPortalSubscriptionMutation, + useTerminateCustomerPortalSubscriptionMutation, +} from '~/generated/graphql' + +gql` + query customerPortalAvailablePlans($productKey: String, $excludeCurrent: Boolean) { + customerPortalAvailablePlans(productKey: $productKey, excludeCurrent: $excludeCurrent) { + id + code + name + description + amountCents + amountCurrency + interval + trialPeriod + } + } + + query customerPortalSubscriptions { + customerPortalSubscriptions { + collection { + id + name + plan { id code name amountCents amountCurrency interval } + } + } + } + + mutation changeCustomerPortalSubscriptionPlan($input: ChangeCustomerPortalSubscriptionPlanInput!) { + changeCustomerPortalSubscriptionPlan(input: $input) { + id + plan { id code name } + } + } + + mutation createCustomerPortalSubscription($input: CreateCustomerPortalSubscriptionInput!) { + createCustomerPortalSubscription(input: $input) { + id + plan { id code name } + } + } + + mutation terminateCustomerPortalSubscription($input: TerminateCustomerPortalSubscriptionInput!) { + terminateCustomerPortalSubscription(input: $input) { + id + status + } + } +` + +const extractProductKey = (planCode: string): string => planCode.split('-')[0] || 'other' + +const formatPrice = (amountCents: number, currency: string, interval: PlanInterval): string => { + const amount = deserializeAmount(amountCents, currency as any) + const formatted = intlFormatNumber(amount, { currencyDisplay: 'symbol', currency: currency as any }) + return `${formatted}/${interval}` +} + +const PlansPage = () => { + const { translate } = useCustomerPortalTranslate() + + const { + data: subsData, + loading: subsLoading, + error: subsError, + refetch: refetchSubs, + } = useCustomerPortalSubscriptionsQuery({ fetchPolicy: 'network-only' }) + + const { + data: plansData, + loading: plansLoading, + error: plansError, + } = useCustomerPortalAvailablePlansQuery({ + variables: { excludeCurrent: false }, + fetchPolicy: 'network-only', + }) + + const [changePlan, { loading: changing }] = useChangeCustomerPortalSubscriptionPlanMutation({ + onCompleted: () => refetchSubs(), + }) + const [createSubscription, { loading: creating }] = useCreateCustomerPortalSubscriptionMutation({ + onCompleted: () => refetchSubs(), + }) + const [terminateSubscription, { loading: terminating }] = + useTerminateCustomerPortalSubscriptionMutation({ onCompleted: () => refetchSubs() }) + + const activeSubs = subsData?.customerPortalSubscriptions?.collection ?? [] + const allPlans = plansData?.customerPortalAvailablePlans ?? [] + + // Group plans by product key (aistack, growth, memory, etc.) + const plansByProduct = useMemo(() => { + const groups: Record = {} + for (const plan of allPlans) { + const key = extractProductKey(plan.code) + if (!groups[key]) groups[key] = [] + groups[key].push(plan) + } + return groups + }, [allPlans]) + + // Map subscribed plan codes to know which to mark as current + const subscribedProducts = useMemo(() => { + const set = new Map() + for (const s of activeSubs) { + if (s.plan?.code) { + set.set(extractProductKey(s.plan.code), { subId: s.id, planCode: s.plan.code }) + } + } + return set + }, [activeSubs]) + + const handleChange = (subId: string, planCode: string) => { + changePlan({ variables: { input: { subscriptionId: subId, planCode } } }) + } + + const handleAdd = (planCode: string) => { + createSubscription({ variables: { input: { planCode } } }) + } + + const handleTerminate = (subId: string) => { + if (window.confirm(translate('text_lago_portal_confirm_cancel_subscription'))) { + terminateSubscription({ variables: { input: { subscriptionId: subId } } }) + } + } + + if (subsError || plansError) { + return + } + + if (subsLoading || plansLoading) { + return ( +
+ + +
+ ) + } + + const productKeys = Object.keys(plansByProduct).sort() + + return ( +
+ + + + {translate('text_lago_portal_plans_intro')} + + + {productKeys.map((productKey) => { + const productPlans = plansByProduct[productKey] + const currentForProduct = subscribedProducts.get(productKey) + + return ( +
+
+ + {productKey.charAt(0).toUpperCase() + productKey.slice(1)} + + {currentForProduct && ( + + )} +
+ +
+ {productPlans.map((plan) => { + const isCurrent = currentForProduct?.planCode === plan.code + + return ( +
+ + {plan.name} + + + {formatPrice(plan.amountCents, plan.amountCurrency, plan.interval)} + + {plan.description && ( + + {plan.description} + + )} + + {isCurrent ? ( + + ) : currentForProduct ? ( + + ) : ( + + )} +
+ ) + })} +
+
+ ) + })} + + {productKeys.length === 0 && ( + + {translate('text_lago_portal_no_plans_available')} + + )} +
+ ) +} + +export default PlansPage diff --git a/src/core/router/CustomerPortalRoutes.tsx b/src/core/router/CustomerPortalRoutes.tsx index 24bf325077..94540765ff 100644 --- a/src/core/router/CustomerPortalRoutes.tsx +++ b/src/core/router/CustomerPortalRoutes.tsx @@ -1,5 +1,6 @@ import { CUSTOMER_PORTAL_CUSTOMER_EDIT_INFORMATION_ROUTE, + CUSTOMER_PORTAL_PLANS_ROUTE, CUSTOMER_PORTAL_ROUTE, CUSTOMER_PORTAL_USAGE_ROUTE, CUSTOMER_PORTAL_WALLET_ROUTE, @@ -14,6 +15,7 @@ const CustomerPortalSections = lazyLoad( ) const UsagePage = lazyLoad(() => import('~/components/customerPortal/usage/UsagePage')) const WalletPage = lazyLoad(() => import('~/components/customerPortal/wallet/WalletPage')) +const PlansPage = lazyLoad(() => import('~/components/customerPortal/plans/PlansPage')) const CustomerInformationPage = lazyLoad( () => import('~/components/customerPortal/customerInformation/CustomerInformationPage'), ) @@ -31,6 +33,10 @@ export const customerPortalChildrenRoutes: CustomRouteObject[] = [ path: [CUSTOMER_PORTAL_WALLET_ROUTE], element: , }, + { + path: [CUSTOMER_PORTAL_PLANS_ROUTE], + element: , + }, { path: [CUSTOMER_PORTAL_CUSTOMER_EDIT_INFORMATION_ROUTE], element: , diff --git a/src/core/router/paths/customerPortal.ts b/src/core/router/paths/customerPortal.ts index 3ff0386c3d..e9586360b0 100644 --- a/src/core/router/paths/customerPortal.ts +++ b/src/core/router/paths/customerPortal.ts @@ -3,3 +3,4 @@ export const CUSTOMER_PORTAL_ROUTE = '/customer-portal/:token' export const CUSTOMER_PORTAL_USAGE_ROUTE = `${CUSTOMER_PORTAL_ROUTE}/usage/:itemId` export const CUSTOMER_PORTAL_WALLET_ROUTE = `${CUSTOMER_PORTAL_ROUTE}/wallet/:walletId` export const CUSTOMER_PORTAL_CUSTOMER_EDIT_INFORMATION_ROUTE = `${CUSTOMER_PORTAL_ROUTE}/customer-edit-information` +export const CUSTOMER_PORTAL_PLANS_ROUTE = `${CUSTOMER_PORTAL_ROUTE}/plans` diff --git a/translations/base.json b/translations/base.json index d2b79e2c7e..5868216ccc 100644 --- a/translations/base.json +++ b/translations/base.json @@ -4091,5 +4091,16 @@ "text_1778232548237tdgidv9off9": "Create a new quote", "text_1778232548237f1f1pja8esj": "Update a quote", "text_1778232548237p4cirr96hwe": "View quotes", - "text_177823254823762hkchuyv10": "Void a quote" -} \ No newline at end of file + "text_177823254823762hkchuyv10": "Void a quote", + "text_lago_portal_plans_title": "Plans & Subscriptions", + "text_lago_portal_plans_intro": "Manage your active subscriptions and add new products to your account.", + "text_lago_portal_current_plan": "Current plan", + "text_lago_portal_switch_to_plan": "Switch to this plan", + "text_lago_portal_add_to_account": "Add to my account", + "text_lago_portal_cancel_plan": "Cancel subscription", + "text_lago_portal_confirm_cancel_subscription": "Are you sure you want to cancel this subscription? You will lose access to its features at the end of the current billing period.", + "text_lago_portal_no_plans_available": "No plans are available for your account. Contact support if you need assistance.", + "text_lago_portal_manage_plans_title": "Plans & Subscriptions", + "text_lago_portal_manage_plans_caption": "Switch plans, add products to your account, or cancel a subscription.", + "text_lago_portal_view_plans_button": "View plans" +}