From ba53505a8d22cfc38b7123e0dc4cc216c30a6bc6 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Mon, 13 Jul 2026 21:58:39 -0400 Subject: [PATCH] feat(customer-portal): redesign plans page cards - Collapse duplicate monthly/annual plan cards into a per-product billing interval toggle, defaulting to the interval of the active subscription - Restyle plan cards as standard SaaS pricing tiles: elevation, hover lift, hero price, divider, checkmark feature list parsed from the description - Equal-height cards with full-width CTAs pinned to the bottom so buttons align across a row - Mark the current plan with a blue ring and chip instead of a filled card Co-Authored-By: Claude Opus 4.8 (1M context) --- .../customerPortal/plans/PlansPage.tsx | 294 ++++++++++++++---- 1 file changed, 226 insertions(+), 68 deletions(-) diff --git a/src/components/customerPortal/plans/PlansPage.tsx b/src/components/customerPortal/plans/PlansPage.tsx index 919388a643..5b8921d133 100644 --- a/src/components/customerPortal/plans/PlansPage.tsx +++ b/src/components/customerPortal/plans/PlansPage.tsx @@ -1,4 +1,5 @@ import { gql } from '@apollo/client' +import { Icon } from 'lago-design-system' import { useMemo, useRef, useState } from 'react' import useCustomerPortalNavigation from '~/components/customerPortal/common/hooks/useCustomerPortalNavigation' @@ -7,9 +8,11 @@ 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 { Chip } from '~/components/designSystem/Chip' import { Typography } from '~/components/designSystem/Typography' import { WarningDialog, WarningDialogRef } from '~/components/designSystem/WarningDialog' import { addToast } from '~/core/apolloClient' +import { getIntervalTranslationKey } from '~/core/constants/form' import { intlFormatNumber } from '~/core/formats/intlFormatNumber' import { deserializeAmount } from '~/core/serializers/serializeAmount' import { @@ -22,6 +25,7 @@ import { useCustomerPortalSubscriptionsQuery, useTerminateCustomerPortalSubscriptionMutation, } from '~/generated/graphql' +import { tw } from '~/styles/utils' gql` query customerPortalAvailablePlans($productKey: String, $excludeCurrent: Boolean) { @@ -103,17 +107,58 @@ const isBundle = ( metadata: ReadonlyArray<{ key: string; value?: string | null }> | null | undefined, ): boolean => getMetadataValue(metadata, 'bundle') === 'true' -const formatPrice = ( - amountCents: number, - currency: CurrencyEnum, - interval: PlanInterval, -): string => { +const formatPrice = (amountCents: number, currency: CurrencyEnum): string => { const amount = deserializeAmount(amountCents, currency) - const formatted = intlFormatNumber(amount, { + + return intlFormatNumber(amount, { currencyDisplay: 'symbol', currency, + // Whole amounts read cleaner without trailing cents ($19 rather than $19.00) + minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, }) - return `${formatted}/${interval}` +} + +const INTERVAL_ORDER: PlanInterval[] = [ + PlanInterval.Weekly, + PlanInterval.Monthly, + PlanInterval.Quarterly, + PlanInterval.Semiannual, + PlanInterval.Yearly, +] + +// Interval is already conveyed by the toggle, so a name like "Memory Starter (Annual)" +// only repeats it. +const formatPlanName = (name: string): string => + name.replace(/\s*\((annual|annually|yearly|monthly|weekly|quarterly)\)\s*$/i, '').trim() || name + +const formatProductName = (productKey: string): string => + productKey.charAt(0).toUpperCase() + productKey.slice(1) + +// Plan descriptions are written as a lead-in followed by a comma-separated list of +// what the tier includes ("Scaling teams — more projects, prediction overage, SSO"). +// Split that tail into checkmark bullets when it looks like a list; otherwise the +// description renders as a plain paragraph. +const parseDescription = ( + description: string, +): { lead: string; features: string[] } => { + const [head, ...rest] = description.split(/\s+[—–-]\s+/) + + if (!rest.length) { + return { lead: description, features: [] } + } + + const features = rest + .join(' - ') + .replace(/\.$/, '') + .split(',') + .map((feature) => feature.trim()) + .filter(Boolean) + + if (features.length < 2) { + return { lead: description, features: [] } + } + + return { lead: head.trim(), features } } const PlansPage = () => { @@ -188,6 +233,9 @@ const PlansPage = () => { const cancelDialogRef = useRef(null) const [pendingCancelSubId, setPendingCancelSubId] = useState(null) + // Per-product override of the billing interval shown in the grid; unset products + // fall back to the interval of their active plan (or the shortest one available). + const [selectedIntervals, setSelectedIntervals] = useState>({}) const allReturnedSubs = subsData?.customerPortalSubscriptions?.collection ?? [] // Lago returns ALL subscriptions including terminated; each plan switch creates @@ -208,10 +256,19 @@ const PlansPage = () => { // Map subscribed plan codes to know which to mark as current const subscribedProducts = useMemo(() => { - const set = new Map() + const set = new Map< + string, + { subId: string; planCode: string; planName?: string | null; interval?: PlanInterval } + >() + for (const s of activeSubs) { if (s.plan?.code) { - set.set(extractProductKey(s.plan.code), { subId: s.id, planCode: s.plan.code }) + set.set(extractProductKey(s.plan.code), { + subId: s.id, + planCode: s.plan.code, + planName: s.plan.name, + interval: s.plan.interval, + }) } } return set @@ -246,91 +303,192 @@ const PlansPage = () => { const productKeys = Object.keys(plansByProduct).sort() return ( -
- +
+
+ - - {translate('text_lago_portal_plans_intro')} - + + {translate('text_lago_portal_plans_intro')} + +
{productKeys.map((productKey) => { const productPlans = plansByProduct[productKey] const currentForProduct = subscribedProducts.get(productKey) + const availableIntervals = INTERVAL_ORDER.filter((interval) => + productPlans.some((plan) => plan.interval === interval), + ) + const activeInterval = + selectedIntervals[productKey] ?? + (currentForProduct?.interval && availableIntervals.includes(currentForProduct.interval) + ? currentForProduct.interval + : availableIntervals[0]) + + const visiblePlans = productPlans + .filter((plan) => plan.interval === activeInterval) + .sort((a, b) => a.amountCents - b.amountCents) + return ( -
-
- - {productKey.charAt(0).toUpperCase() + productKey.slice(1)} - - {currentForProduct && ( - - )} +
+
+
+ + {formatProductName(productKey)} + + + {currentForProduct?.planName && ( + + {translate('text_lago_portal_current_plan')}:{' '} + {formatPlanName(currentForProduct.planName)} + + )} +
+ +
+ {availableIntervals.length > 1 && ( +
+ {availableIntervals.map((interval) => { + const isActive = interval === activeInterval + + return ( + + ) + })} +
+ )} + + {currentForProduct && ( + + )} +
-
- {productPlans.map((plan) => { +
+ {visiblePlans.map((plan) => { const isCurrent = currentForProduct?.planCode === plan.code + const { lead, features } = parseDescription(plan.description ?? '') return (
-
+
- {plan.name} + {formatPlanName(plan.name)} - {isBundle(plan.metadata) && ( - - {translate('text_lago_portal_bundle_badge')} - + + {isCurrent ? ( + + ) : ( + isBundle(plan.metadata) && ( + + ) )}
- - {formatPrice(plan.amountCents, plan.amountCurrency, plan.interval)} - - {plan.description && ( - - {plan.description} + +
+ + {formatPrice(plan.amountCents, plan.amountCurrency)} + + + + {`/ ${translate(getIntervalTranslationKey[plan.interval]).toLowerCase()}`} - )} +
- {isCurrent ? ( - - ) : currentForProduct ? ( - - ) : ( - + {!!plan.description && ( + <> +
+ + + {lead} + + + {!!features.length && ( +
    + {features.map((feature) => ( +
  • + + + + {feature} + +
  • + ))} +
+ )} + )} + +
+ {isCurrent ? ( + + ) : currentForProduct ? ( + + ) : ( + + )} +
) })}
-
+
) })}