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"
+}