Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/components/customerPortal/common/CustomerPortalSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -27,6 +28,21 @@ const CustomerPortalSections = () => {
<div className="flex flex-col gap-12" data-test={CUSTOMER_PORTAL_SECTIONS_TEST_ID}>
<WalletSection viewWallet={viewWallet} />
<UsageSection viewSubscription={viewSubscription} />

<div className="flex items-center justify-between rounded-lg border border-grey-300 bg-grey-100 p-6">
<div className="flex flex-col gap-1">
<Typography variant="bodyHl" color="grey700">
{translate('text_lago_portal_manage_plans_title')}
</Typography>
<Typography variant="caption" color="grey600">
{translate('text_lago_portal_manage_plans_caption')}
</Typography>
</div>
<Button variant="primary" onClick={viewPlans}>
{translate('text_lago_portal_view_plans_button')}
</Button>
</div>

<PortalCustomerInfos viewEditInformation={viewEditInformation} />
<PortalInvoicesList />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}

Expand Down
248 changes: 248 additions & 0 deletions src/components/customerPortal/plans/PlansPage.tsx
Original file line number Diff line number Diff line change
@@ -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<string, typeof allPlans> = {}
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<string, { subId: string, planCode: string }>()
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 <SectionError />
}

if (subsLoading || plansLoading) {
return (
<div className="flex flex-col gap-12">
<LoaderUsageSection />
<LoaderUsageSection />
</div>
)
}

const productKeys = Object.keys(plansByProduct).sort()

return (
<div className="flex flex-col gap-12">
<PageTitle title={translate('text_lago_portal_plans_title')} />

<Typography variant="bodyHl" color="grey700">
{translate('text_lago_portal_plans_intro')}
</Typography>

{productKeys.map((productKey) => {
const productPlans = plansByProduct[productKey]
const currentForProduct = subscribedProducts.get(productKey)

return (
<div key={productKey} className="rounded-lg border border-grey-300 p-6">
<div className="mb-4 flex items-center justify-between">
<Typography variant="headline" color="grey700">
{productKey.charAt(0).toUpperCase() + productKey.slice(1)}
</Typography>
{currentForProduct && (
<Button
variant="quaternary"
danger
disabled={terminating}
onClick={() => handleTerminate(currentForProduct.subId)}
>
{translate('text_lago_portal_cancel_plan')}
</Button>
)}
</div>

<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{productPlans.map((plan) => {
const isCurrent = currentForProduct?.planCode === plan.code

return (
<div
key={plan.id}
className={`flex flex-col gap-3 rounded-lg border p-4 ${
isCurrent ? 'border-blue-600 bg-blue-50' : 'border-grey-300'
}`}
>
<Typography variant="bodyHl" color="grey700">
{plan.name}
</Typography>
<Typography variant="body" color="grey600">
{formatPrice(plan.amountCents, plan.amountCurrency, plan.interval)}
</Typography>
{plan.description && (
<Typography variant="caption" color="grey500">
{plan.description}
</Typography>
)}

{isCurrent ? (
<Button variant="quaternary" disabled>
{translate('text_lago_portal_current_plan')}
</Button>
) : currentForProduct ? (
<Button
variant="primary"
disabled={changing}
onClick={() => handleChange(currentForProduct.subId, plan.code)}
>
{translate('text_lago_portal_switch_to_plan')}
</Button>
) : (
<Button
variant="primary"
disabled={creating}
onClick={() => handleAdd(plan.code)}
>
{translate('text_lago_portal_add_to_account')}
</Button>
)}
</div>
)
})}
</div>
</div>
)
})}

{productKeys.length === 0 && (
<Typography variant="body" color="grey600">
{translate('text_lago_portal_no_plans_available')}
</Typography>
)}
</div>
)
}

export default PlansPage
6 changes: 6 additions & 0 deletions src/core/router/CustomerPortalRoutes.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'),
)
Expand All @@ -31,6 +33,10 @@ export const customerPortalChildrenRoutes: CustomRouteObject[] = [
path: [CUSTOMER_PORTAL_WALLET_ROUTE],
element: <WalletPage />,
},
{
path: [CUSTOMER_PORTAL_PLANS_ROUTE],
element: <PlansPage />,
},
{
path: [CUSTOMER_PORTAL_CUSTOMER_EDIT_INFORMATION_ROUTE],
element: <CustomerInformationPage />,
Expand Down
1 change: 1 addition & 0 deletions src/core/router/paths/customerPortal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
15 changes: 13 additions & 2 deletions translations/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -4091,5 +4091,16 @@
"text_1778232548237tdgidv9off9": "Create a new quote",
"text_1778232548237f1f1pja8esj": "Update a quote",
"text_1778232548237p4cirr96hwe": "View quotes",
"text_177823254823762hkchuyv10": "Void a quote"
}
"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"
}
Loading