From 5b75bd85e5826ac695762147aff57e0723016004 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 18 Mar 2026 16:48:49 -0300 Subject: [PATCH 1/4] refactor: extract Payment types, constants, and presentational components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the monolithic Payment.js into smaller focused modules: - types.ts: Chargebee SDK global types and shared PricingFeature type - constants.ts: URLs, support email, icon colours - pricingFeatures.tsx: startup and enterprise feature lists - PricingFeaturesList.tsx: feature list rendering - PricingToggle.tsx: annual/monthly toggle - PricingPanel.tsx: pricing card with plan details and CTA All components are pure presentational — they receive data via props and contain no business logic or store dependencies. Ref: #6319 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../modals/payment/PricingFeaturesList.tsx | 28 +++ .../modals/payment/PricingPanel.tsx | 162 ++++++++++++++++++ .../modals/payment/PricingToggle.tsx | 34 ++++ .../components/modals/payment/constants.ts | 11 ++ .../modals/payment/pricingFeatures.tsx | 75 ++++++++ .../web/components/modals/payment/types.ts | 37 ++++ 6 files changed, 347 insertions(+) create mode 100644 frontend/web/components/modals/payment/PricingFeaturesList.tsx create mode 100644 frontend/web/components/modals/payment/PricingPanel.tsx create mode 100644 frontend/web/components/modals/payment/PricingToggle.tsx create mode 100644 frontend/web/components/modals/payment/constants.ts create mode 100644 frontend/web/components/modals/payment/pricingFeatures.tsx create mode 100644 frontend/web/components/modals/payment/types.ts diff --git a/frontend/web/components/modals/payment/PricingFeaturesList.tsx b/frontend/web/components/modals/payment/PricingFeaturesList.tsx new file mode 100644 index 000000000000..0360f134481e --- /dev/null +++ b/frontend/web/components/modals/payment/PricingFeaturesList.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import Icon from 'components/Icon' +import { PRIMARY_ICON_COLOR } from './constants' +import { PricingFeature } from './types' + +export type PricingFeaturesListProps = { + features: PricingFeature[] +} + +export const PricingFeaturesList = ({ features }: PricingFeaturesListProps) => { + return ( + + ) +} diff --git a/frontend/web/components/modals/payment/PricingPanel.tsx b/frontend/web/components/modals/payment/PricingPanel.tsx new file mode 100644 index 000000000000..c08388e3617e --- /dev/null +++ b/frontend/web/components/modals/payment/PricingPanel.tsx @@ -0,0 +1,162 @@ +import React, { ReactNode } from 'react' +import classNames from 'classnames' +import Icon, { IconName } from 'components/Icon' +import Button from 'components/base/forms/Button' +import { PricingFeaturesList } from './PricingFeaturesList' +import { PaymentButton } from './PaymentButton' +import { openChat } from 'common/loadChat' +import { PricingFeature } from './types' + +export type PricingPanelProps = { + title: string + icon?: string + iconFill?: string + priceMonthly?: string + priceYearly?: string + isYearly: boolean + viewOnly?: boolean + chargebeePlanId?: string + isPurchased?: boolean + isEnterprise?: boolean + isDisableAccount?: string + features: PricingFeature[] + headerContent?: ReactNode + onContactSales?: () => void +} + +export const PricingPanel = ({ + chargebeePlanId, + features, + headerContent, + icon = 'flash', + iconFill, + isDisableAccount, + isEnterprise, + isPurchased, + isYearly, + onContactSales, + priceMonthly, + priceYearly, + title, + viewOnly, +}: PricingPanelProps) => { + return ( + +
+
+
+
+ {headerContent && ( + + {headerContent} + + )} + + +

+ {title} +

+
+ + {priceYearly && priceMonthly && ( + +
$
+

+ {isYearly ? priceYearly : priceMonthly}{' '} +

/mo
+ +
+ )} + + {isEnterprise && ( + +
+ Maximum security and control +
+
+ )} +
+ +
+ +
+ {!viewOnly && !isEnterprise && chargebeePlanId && ( + <> + + {isPurchased ? 'Purchased' : '14 Day Free Trial'} + + + )} + + {!viewOnly && isEnterprise && ( + + )} +
+
+
+ +
+
+ All from{' '} + + {isEnterprise ? 'Start-Up,' : 'Free,'} + {' '} + plus +
+ +
+
+ + ) +} diff --git a/frontend/web/components/modals/payment/PricingToggle.tsx b/frontend/web/components/modals/payment/PricingToggle.tsx new file mode 100644 index 000000000000..4b76db60772c --- /dev/null +++ b/frontend/web/components/modals/payment/PricingToggle.tsx @@ -0,0 +1,34 @@ +import classNames from 'classnames' +import Switch from 'components/Switch' + +export type PricingToggleProps = { + isYearly: boolean + onChange: (isYearly: boolean) => void +} + +export const PricingToggle = ({ isYearly, onChange }: PricingToggleProps) => { + return ( +
+
+ Pay Yearly (Save 10%) +
+ { + onChange(!isYearly) + }} + /> +
+ Pay Monthly +
+
+ ) +} diff --git a/frontend/web/components/modals/payment/constants.ts b/frontend/web/components/modals/payment/constants.ts new file mode 100644 index 000000000000..ffcd3cb25750 --- /dev/null +++ b/frontend/web/components/modals/payment/constants.ts @@ -0,0 +1,11 @@ +export const ENTERPRISE_ICON_COLOR = '#F7D56E' +export const PRIMARY_ICON_COLOR = '#27AB95' + +// URLs +export const CONTACT_US_URL = 'https://www.flagsmith.com/contact-us' +export const ON_PREMISE_HOSTING_URL = + 'https://www.flagsmith.com/on-premises-and-private-cloud-hosting' + +// Support +export const SUPPORT_EMAIL = 'support@flagsmith.com' +export const SUPPORT_EMAIL_URL = 'mailto:support@flagsmith.com' diff --git a/frontend/web/components/modals/payment/pricingFeatures.tsx b/frontend/web/components/modals/payment/pricingFeatures.tsx new file mode 100644 index 000000000000..90b936dfeca7 --- /dev/null +++ b/frontend/web/components/modals/payment/pricingFeatures.tsx @@ -0,0 +1,75 @@ +import { PricingFeature } from './types' +import { ENTERPRISE_ICON_COLOR } from './constants' + +export const startupFeatures: PricingFeature[] = [ + { + text: ( + <> + Up to + 1,000,000 Requests per month + + ), + }, + { + text: ( + <> + 3 Team members + + ), + }, + { + text: 'Unlimited projects', + }, + { + text: 'Email technical support', + }, + { + text: 'Scheduled flags', + }, + { + text: 'Two-factor authentication (2FA)', + }, +] + +export const enterpriseFeatures: PricingFeature[] = [ + { + iconFill: ENTERPRISE_ICON_COLOR, + text: ( + <> + 5,000,000+ requests per month + + ), + }, + { + iconFill: ENTERPRISE_ICON_COLOR, + text: ( + <> + 20+ Team members + + ), + }, + { + iconFill: ENTERPRISE_ICON_COLOR, + text: 'Advanced hosting options', + }, + { + iconFill: ENTERPRISE_ICON_COLOR, + text: 'Priority real time technical support with the engineering team over Slack or Discord', + }, + { + iconFill: ENTERPRISE_ICON_COLOR, + text: 'Governance features – roles, permissions, change requests, audit logs', + }, + { + iconFill: ENTERPRISE_ICON_COLOR, + text: 'Features for maximum security', + }, + { + iconFill: ENTERPRISE_ICON_COLOR, + text: 'Optional on premises installation', + }, + { + iconFill: ENTERPRISE_ICON_COLOR, + text: 'Onboarding & training', + }, +] diff --git a/frontend/web/components/modals/payment/types.ts b/frontend/web/components/modals/payment/types.ts new file mode 100644 index 000000000000..ae17abf4efc4 --- /dev/null +++ b/frontend/web/components/modals/payment/types.ts @@ -0,0 +1,37 @@ +import { ReactNode } from 'react' + +// ============================================================================ +// Global Declarations +// ============================================================================ + +// Chargebee SDK - Global type declaration for the Chargebee payment integration +type ChargebeeInstance = { + openCheckout: (config: { + hostedPage: () => Promise + success: (res: any) => void + }) => void + setCheckoutCallbacks?: (fn: () => { success: (id: string) => void }) => void + getCart: () => { + setCustomer: (customer: { cf_tid?: string }) => void + } +} + +type ChargebeeSDK = { + init: (config: { site: string }) => void + registerAgain: () => void + getInstance: () => ChargebeeInstance +} + +declare global { + const Chargebee: ChargebeeSDK +} + +// ============================================================================ +// Shared Types (used by multiple components) +// ============================================================================ + +export type PricingFeature = { + icon?: string + iconFill?: string + text: ReactNode +} From 3820d9b30ced62aa0b8709a91a67a704ae22d99f Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 18 Mar 2026 16:49:06 -0300 Subject: [PATCH 2/4] refactor: extract Payment business logic into custom hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple business logic from UI to enable independent testing: - usePaymentState: organisation data, plan detection, AWS check, billing toggle - useChargebeeCheckout: Chargebee SDK checkout flow and hosted page URL Hooks currently wrap Flux stores (AccountStore, AppActions) — these will be swapped to RTK Query in a follow-up PR without touching the components or hook API. Ref: #6319 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/modals/payment/hooks/index.ts | 2 + .../payment/hooks/useChargebeeCheckout.ts | 44 +++++++++++++++++++ .../modals/payment/hooks/usePaymentState.ts | 31 +++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 frontend/web/components/modals/payment/hooks/index.ts create mode 100644 frontend/web/components/modals/payment/hooks/useChargebeeCheckout.ts create mode 100644 frontend/web/components/modals/payment/hooks/usePaymentState.ts diff --git a/frontend/web/components/modals/payment/hooks/index.ts b/frontend/web/components/modals/payment/hooks/index.ts new file mode 100644 index 000000000000..8ffd804fc664 --- /dev/null +++ b/frontend/web/components/modals/payment/hooks/index.ts @@ -0,0 +1,2 @@ +export { usePaymentState } from './usePaymentState' +export { useChargebeeCheckout } from './useChargebeeCheckout' diff --git a/frontend/web/components/modals/payment/hooks/useChargebeeCheckout.ts b/frontend/web/components/modals/payment/hooks/useChargebeeCheckout.ts new file mode 100644 index 000000000000..592ea5883e06 --- /dev/null +++ b/frontend/web/components/modals/payment/hooks/useChargebeeCheckout.ts @@ -0,0 +1,44 @@ +import { useState } from 'react' +// @ts-ignore +import _data from 'common/data/base/_data' + +type UseChargebeeCheckoutParams = { + organisationId: number | undefined + onSuccess?: () => void +} + +type ChargebeeCheckout = { + openCheckout: (planId: string) => void + isLoading: boolean +} + +export const useChargebeeCheckout = ({ + onSuccess, + organisationId, +}: UseChargebeeCheckoutParams): ChargebeeCheckout => { + const [isLoading, setIsLoading] = useState(false) + + const openCheckout = (planId: string) => { + if (!organisationId) return + + setIsLoading(true) + Chargebee.getInstance().openCheckout({ + hostedPage() { + return _data.post( + `${Project.api}organisations/${organisationId}/get-hosted-page-url-for-subscription-upgrade/`, + { plan_id: planId }, + ) + }, + success: (res: any) => { + AppActions.updateSubscription(res) + onSuccess?.() + setIsLoading(false) + }, + }) + } + + return { + isLoading, + openCheckout, + } +} diff --git a/frontend/web/components/modals/payment/hooks/usePaymentState.ts b/frontend/web/components/modals/payment/hooks/usePaymentState.ts new file mode 100644 index 000000000000..f1f5d1c73e01 --- /dev/null +++ b/frontend/web/components/modals/payment/hooks/usePaymentState.ts @@ -0,0 +1,31 @@ +import { useState } from 'react' +import AccountStore from 'common/stores/account-store' + +type PaymentState = { + organisation: any + plan: string + isAWS: boolean + hasActiveSubscription: boolean + yearly: boolean + setYearly: (yearly: boolean) => void +} + +export const usePaymentState = (): PaymentState => { + const [yearly, setYearly] = useState(true) + + const organisation = AccountStore.getOrganisation() + const plan = organisation?.subscription?.plan ?? '' + const isAWS = organisation?.subscription?.payment_method === 'AWS_MARKETPLACE' + const hasActiveSubscription = !!AccountStore.getOrganisationPlan( + organisation?.id, + ) + + return { + hasActiveSubscription, + isAWS, + organisation, + plan, + setYearly, + yearly, + } +} From c0516ad707f9a29742da231438828d013d9dc848 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 18 Mar 2026 16:49:18 -0300 Subject: [PATCH 3/4] refactor: add Payment and PaymentButton components using extracted hooks Wire up the main Payment component and PaymentButton to use the extracted hooks (usePaymentState, useChargebeeCheckout) instead of directly accessing Flux stores. Add index.tsx barrel export with Chargebee async script loader wrapper. Ref: #6319 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/modals/payment/Payment.tsx | 128 ++++++++++++++++++ .../modals/payment/PaymentButton.tsx | 50 +++++++ .../web/components/modals/payment/index.tsx | 63 +++++++++ 3 files changed, 241 insertions(+) create mode 100644 frontend/web/components/modals/payment/Payment.tsx create mode 100644 frontend/web/components/modals/payment/PaymentButton.tsx create mode 100644 frontend/web/components/modals/payment/index.tsx diff --git a/frontend/web/components/modals/payment/Payment.tsx b/frontend/web/components/modals/payment/Payment.tsx new file mode 100644 index 000000000000..aafdc8577129 --- /dev/null +++ b/frontend/web/components/modals/payment/Payment.tsx @@ -0,0 +1,128 @@ +import React, { FC, useEffect } from 'react' +import Constants from 'common/constants' +import InfoMessage from 'components/InfoMessage' +import BlockedOrgInfo from 'components/BlockedOrgInfo' +import { PricingToggle } from './PricingToggle' +import { PricingPanel } from './PricingPanel' +import { startupFeatures, enterpriseFeatures } from './pricingFeatures' +import { + CONTACT_US_URL, + ON_PREMISE_HOSTING_URL, + SUPPORT_EMAIL, + SUPPORT_EMAIL_URL, +} from './constants' +import { usePaymentState } from './hooks' + +type PaymentProps = { + viewOnly?: boolean + isDisableAccountText?: string +} + +export const Payment: FC = ({ + isDisableAccountText, + viewOnly, +}) => { + const { isAWS, plan, setYearly, yearly } = usePaymentState() + + useEffect(() => { + API.trackPage(Constants.modals.PAYMENT) + }, []) + + if (isAWS) { + return ( +
+ + Customers with AWS Marketplace subscriptions will need to{' '} + + contact us + + +
+ ) + } + + return ( +
+
+ + {isDisableAccountText && ( +
+
+

+ {isDisableAccountText}{' '} + + {SUPPORT_EMAIL} + +

+
+
+ +
+
+ )} +
+ + + + + + + + Optional{' '} + + On Premise + {' '} + or{' '} + + Private Cloud + {' '} + Install + + } + /> + +
+ *Need something in-between our Enterprise plan for users or API + limits? +
+ Reach out to us and we'll help you out +
+
+
+
+ ) +} diff --git a/frontend/web/components/modals/payment/PaymentButton.tsx b/frontend/web/components/modals/payment/PaymentButton.tsx new file mode 100644 index 000000000000..c708d04f7fb3 --- /dev/null +++ b/frontend/web/components/modals/payment/PaymentButton.tsx @@ -0,0 +1,50 @@ +import React, { FC, ReactNode } from 'react' +import { usePaymentState } from './hooks' +import { useChargebeeCheckout } from './hooks' + +type PaymentButtonProps = { + 'data-cb-plan-id'?: string + className?: string + children?: ReactNode + isDisableAccount?: string +} + +export const PaymentButton: FC = (props) => { + const { hasActiveSubscription, organisation } = usePaymentState() + const { openCheckout } = useChargebeeCheckout({ + onSuccess: props.isDisableAccount + ? () => { + window.location.href = '/organisations' + } + : undefined, + organisationId: organisation?.id, + }) + + if (hasActiveSubscription) { + return ( + { + const planId = props['data-cb-plan-id'] + if (planId) { + openCheckout(planId) + } + }} + className={props.className} + href='#' + > + {props.children} + + ) + } + + return ( + + {props.children} + + ) +} diff --git a/frontend/web/components/modals/payment/index.tsx b/frontend/web/components/modals/payment/index.tsx new file mode 100644 index 000000000000..3ba510908b0a --- /dev/null +++ b/frontend/web/components/modals/payment/index.tsx @@ -0,0 +1,63 @@ +import React, { ComponentProps } from 'react' +// @ts-ignore +import makeAsyncScriptLoader from 'react-async-script' +import ConfigProvider from 'common/providers/ConfigProvider' +import Utils from 'common/utils/utils' +// @ts-ignore +import firstpromoter from 'project/firstPromoter' +import { Payment } from './Payment' + +type PaymentLoadParams = { + errored: boolean +} + +export const onPaymentLoad = ({ errored }: PaymentLoadParams) => { + if (errored) { + // TODO: no error details are available https://github.com/dozoisch/react-async-script/issues/58 + console.error('failed to load chargebee') + return + } + if (!Project.chargebee?.site) { + return + } + const planId = API.getCookie('plan') + let link: HTMLAnchorElement | undefined + + if (planId && Utils.getFlagsmithHasFeature('payments_enabled')) { + // Create a link element with data-cb-plan-id attribute + link = document.createElement('a') + link.setAttribute('data-cb-type', 'checkout') + link.setAttribute('data-cb-plan-id', planId) + link.setAttribute('href', 'javascript:void(0)') + // Append the link to the body + document.body.appendChild(link) + } + + Chargebee.init({ + site: Project.chargebee.site, + }) + Chargebee.registerAgain() + firstpromoter() + Chargebee.getInstance().setCheckoutCallbacks?.(() => ({ + success: (hostedPageId: string) => { + AppActions.updateSubscription(hostedPageId) + }, + })) + + if (link) { + link.click() + document.body.removeChild(link) + API.setCookie('plan', null) + } +} + +const WrappedPayment = makeAsyncScriptLoader( + 'https://js.chargebee.com/v2/chargebee.js', + { + removeOnUnmount: true, + }, +)(ConfigProvider(Payment)) + +export default (props: ComponentProps) => ( + +) From 59f6fb02f0e53add7ddf0897c7e80f408ab3c79d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 18 Mar 2026 16:49:31 -0300 Subject: [PATCH 4/4] feat: gate new Payment component behind rtk_payment flag Toggle between legacy Payment.js and the new modular payment/ components in BillingTab using the rtk_payment feature flag. When the flag is off, behaviour is unchanged. Old Payment.js is preserved untouched for safe rollback. Ref: #6319 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pages/organisation-settings/tabs/BillingTab.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx index 0531f1f7d18f..8c9b13fc5e69 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx @@ -2,7 +2,8 @@ import React from 'react' import { Organisation } from 'common/types/responses' import Icon from 'components/Icon' import Utils from 'common/utils/utils' -import Payment from 'components/modals/Payment' +import PaymentLegacy from 'components/modals/Payment' +import { Payment as PaymentNew } from 'components/modals/payment/Payment' import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' import StatItem, { StatItemProps } from 'components/StatItem' @@ -140,7 +141,11 @@ export const BillingTab = ({ organisation }: BillingTabProps) => { )}
Manage Payment Plan
- + {Utils.getFlagsmithHasFeature('rtk_payment') ? ( + + ) : ( + + )}
) }