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/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/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, + } +} 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) => ( + +) 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 +} 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') ? ( + + ) : ( + + )}
) }