diff --git a/frontend/common/hooks/useScript.ts b/frontend/common/hooks/useScript.ts new file mode 100644 index 000000000000..bc47ae74036a --- /dev/null +++ b/frontend/common/hooks/useScript.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' + +type ScriptState = { + ready: boolean + error: boolean +} + +export const useScript = (url: string): ScriptState => { + const [state, setState] = useState({ + error: false, + ready: false, + }) + + useEffect(() => { + const existing = document.querySelector(`script[src="${url}"]`) + if (existing) { + setState({ error: false, ready: true }) + return + } + + const script = document.createElement('script') + script.src = url + script.async = true + + script.addEventListener('load', () => { + setState({ error: false, ready: true }) + }) + + script.addEventListener('error', () => { + setState({ error: true, ready: false }) + }) + + document.head.appendChild(script) + + return () => { + document.head.removeChild(script) + } + }, [url]) + + return state +} diff --git a/frontend/global.d.ts b/frontend/global.d.ts index 7dbeb7d97431..04bd452bb035 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -92,6 +92,10 @@ declare global { } const PanelSearch: typeof Component const CodeHelp: typeof Component + // Chargebee SDK (loaded via useScript) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Chargebee: any + interface Window { E2E: boolean $crisp: Crisp diff --git a/frontend/web/components/modals/payment/Payment.tsx b/frontend/web/components/modals/payment/Payment.tsx new file mode 100644 index 000000000000..fd7b7c9d3800 --- /dev/null +++ b/frontend/web/components/modals/payment/Payment.tsx @@ -0,0 +1,149 @@ +import React, { FC, useEffect } from 'react' +import Constants from 'common/constants' +import InfoMessage from 'components/InfoMessage' +import BlockedOrgInfo from 'components/BlockedOrgInfo' +import { Organisation } from 'common/types/responses' +import { PricingToggle } from './PricingToggle' +import { PricingPanel } from './PricingPanel' +import { startupFeatures, enterpriseFeatures } from './pricingFeatures' +import { + CHARGEBEE_SCRIPT_URL, + CONTACT_US_URL, + ON_PREMISE_HOSTING_URL, + SUPPORT_EMAIL, + SUPPORT_EMAIL_URL, +} from './constants' +import { useScript } from 'common/hooks/useScript' +import { usePaymentState } from './hooks' +import { initChargebee } from './chargebee' + +export type PaymentProps = { + isDisableAccountText?: string + organisation: Organisation + isPaymentsEnabled?: boolean +} + +export const Payment: FC = ({ + isDisableAccountText, + isPaymentsEnabled = false, + organisation, +}) => { + const { error, ready } = useScript(CHARGEBEE_SCRIPT_URL) + const { hasActiveSubscription, isAWS, plan, setYearly, yearly } = + usePaymentState({ organisation }) + + useEffect(() => { + API.trackPage(Constants.modals.PAYMENT) + }, []) + + useEffect(() => { + if (ready && !error) { + initChargebee({ isPaymentsEnabled }) + } + }, [ready, error, isPaymentsEnabled]) + + if (isAWS) { + return ( +
+ + Customers with AWS Marketplace subscriptions will need to{' '} + + contact us + + +
+ ) + } + + if (!ready) { + return ( +
+ +
+ ) + } + + 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..a4b6562940b8 --- /dev/null +++ b/frontend/web/components/modals/payment/PaymentButton.tsx @@ -0,0 +1,54 @@ +import React, { FC, ReactNode } from 'react' +import { useChargebeeCheckout } from './hooks' + +type PaymentButtonProps = { + 'data-cb-plan-id'?: string + className?: string + children?: ReactNode + isDisableAccount?: string + hasActiveSubscription: boolean + organisationId: number +} + +export const PaymentButton: FC = ({ + children, + className, + hasActiveSubscription, + isDisableAccount, + organisationId, + ...rest +}) => { + const planId = rest['data-cb-plan-id'] + const { isLoading, openCheckout } = useChargebeeCheckout({ + onSuccess: isDisableAccount + ? () => { + window.location.href = '/organisations' + } + : undefined, + organisationId, + }) + + if (hasActiveSubscription) { + return ( + + ) + } + + return ( + + ) +} diff --git a/frontend/web/components/modals/payment/PricingFeaturesList.tsx b/frontend/web/components/modals/payment/PricingFeaturesList.tsx new file mode 100644 index 000000000000..c4eb752d733c --- /dev/null +++ b/frontend/web/components/modals/payment/PricingFeaturesList.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import Icon from 'components/Icon' +import { PricingFeature } from './types' + +export type PricingFeaturesListProps = { + features: PricingFeature[] +} + +export const PricingFeaturesList = ({ features }: PricingFeaturesListProps) => { + return ( +
    + {features.map((feature, index) => ( +
  • + + + + +
    {feature.text}
    +
    +
  • + ))} +
+ ) +} diff --git a/frontend/web/components/modals/payment/PricingPanel.tsx b/frontend/web/components/modals/payment/PricingPanel.tsx new file mode 100644 index 000000000000..871573aca5cc --- /dev/null +++ b/frontend/web/components/modals/payment/PricingPanel.tsx @@ -0,0 +1,137 @@ +import React, { ReactNode } from 'react' +import classNames from 'classnames' +import Icon 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 + priceMonthly?: string + priceYearly?: string + isYearly: boolean + chargebeePlanId?: string + isPurchased?: boolean + isEnterprise?: boolean + isDisableAccount?: string + features: PricingFeature[] + headerContent?: ReactNode + hasActiveSubscription: boolean + organisationId: number +} + +export const PricingPanel = ({ + chargebeePlanId, + features, + hasActiveSubscription, + headerContent, + isDisableAccount, + isEnterprise, + isPurchased, + isYearly, + organisationId, + priceMonthly, + priceYearly, + title, +}: PricingPanelProps) => { + return ( + +
+
+
+
+ {headerContent && ( + + {headerContent} + + )} + + +

+ {title} +

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

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

+
+ )} + + {isEnterprise && ( + +
+ Maximum security and control +
+
+ )} +
+ +
+ +
+ {!isEnterprise && chargebeePlanId && ( + + {isPurchased ? 'Purchased' : '14 Day Free Trial'} + + )} + + {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/chargebee.ts b/frontend/web/components/modals/payment/chargebee.ts new file mode 100644 index 000000000000..c6be329bf2aa --- /dev/null +++ b/frontend/web/components/modals/payment/chargebee.ts @@ -0,0 +1,37 @@ +// @ts-ignore +import firstpromoter from 'project/firstPromoter' + +let initialised = false + +type InitChargebeeParams = { + isPaymentsEnabled: boolean +} + +export const initChargebee = ({ isPaymentsEnabled }: InitChargebeeParams) => { + if (initialised || !Project.chargebee?.site) return + + Chargebee.init({ site: Project.chargebee.site }) + Chargebee.registerAgain() + firstpromoter() + Chargebee.getInstance().setCheckoutCallbacks?.(() => ({ + success: (hostedPageId: string) => { + AppActions.updateSubscription(hostedPageId) + }, + })) + + // Handle plan cookie from signup flow + const planId = API.getCookie('plan') + if (planId && isPaymentsEnabled) { + const link = document.createElement('a') + link.setAttribute('data-cb-type', 'checkout') + link.setAttribute('data-cb-plan-id', planId) + link.setAttribute('href', '#') + document.body.appendChild(link) + Chargebee.registerAgain() + link.click() + document.body.removeChild(link) + API.setCookie('plan', null) + } + + initialised = true +} diff --git a/frontend/web/components/modals/payment/constants.ts b/frontend/web/components/modals/payment/constants.ts new file mode 100644 index 000000000000..8d3c629750b0 --- /dev/null +++ b/frontend/web/components/modals/payment/constants.ts @@ -0,0 +1,11 @@ +// Chargebee +export const CHARGEBEE_SCRIPT_URL = 'https://js.chargebee.com/v2/chargebee.js' + +// 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..7d9215277563 --- /dev/null +++ b/frontend/web/components/modals/payment/hooks/useChargebeeCheckout.ts @@ -0,0 +1,36 @@ +import { useState } from 'react' +// @ts-ignore +import _data from 'common/data/base/_data' + +type UseChargebeeCheckoutParams = { + organisationId: number | undefined + onSuccess?: () => void +} + +export const useChargebeeCheckout = ({ + onSuccess, + organisationId, +}: UseChargebeeCheckoutParams) => { + 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..26e2289457b8 --- /dev/null +++ b/frontend/web/components/modals/payment/hooks/usePaymentState.ts @@ -0,0 +1,23 @@ +import { useState } from 'react' +import { Organisation } from 'common/types/responses' + +type UsePaymentStateParams = { + organisation: Organisation +} + +export const usePaymentState = ({ organisation }: UsePaymentStateParams) => { + const [yearly, setYearly] = useState(true) + + const plan = organisation?.subscription?.plan ?? '' + const isAWS = organisation?.subscription?.payment_method === 'AWS_MARKETPLACE' + const hasActiveSubscription = !!organisation?.subscription?.subscription_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..06ea41bf2dc5 --- /dev/null +++ b/frontend/web/components/modals/payment/index.tsx @@ -0,0 +1,2 @@ +export { Payment as default } from './Payment' +export type { PaymentProps } from './Payment' diff --git a/frontend/web/components/modals/payment/pricingFeatures.tsx b/frontend/web/components/modals/payment/pricingFeatures.tsx new file mode 100644 index 000000000000..a41b019f4ca6 --- /dev/null +++ b/frontend/web/components/modals/payment/pricingFeatures.tsx @@ -0,0 +1,76 @@ +import { PricingFeature } from './types' + +const ENTERPRISE_ICON_CLASS = 'text-secondary' + +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[] = [ + { + iconClass: ENTERPRISE_ICON_CLASS, + text: ( + <> + 5,000,000+ requests per month + + ), + }, + { + iconClass: ENTERPRISE_ICON_CLASS, + text: ( + <> + 20+ Team members + + ), + }, + { + iconClass: ENTERPRISE_ICON_CLASS, + text: 'Advanced hosting options', + }, + { + iconClass: ENTERPRISE_ICON_CLASS, + text: 'Priority real time technical support with the engineering team over Slack or Discord', + }, + { + iconClass: ENTERPRISE_ICON_CLASS, + text: 'Governance features – roles, permissions, change requests, audit logs', + }, + { + iconClass: ENTERPRISE_ICON_CLASS, + text: 'Features for maximum security', + }, + { + iconClass: ENTERPRISE_ICON_CLASS, + text: 'Optional on premises installation', + }, + { + iconClass: ENTERPRISE_ICON_CLASS, + 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..2441f6f121c8 --- /dev/null +++ b/frontend/web/components/modals/payment/types.ts @@ -0,0 +1,7 @@ +import { ReactNode } from 'react' + +export type PricingFeature = { + icon?: string + iconClass?: 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..15ab35be5162 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 PaymentNew from 'components/modals/payment' import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' import StatItem, { StatItemProps } from 'components/StatItem' @@ -140,7 +141,14 @@ export const BillingTab = ({ organisation }: BillingTabProps) => { )}
Manage Payment Plan
- + {Utils.getFlagsmithHasFeature('rtk_payment_modal_migration') ? ( + + ) : ( + + )}
) } diff --git a/frontend/web/styles/project/_PricingPage.scss b/frontend/web/styles/project/_PricingPage.scss index de9991446b89..4656a10469e8 100644 --- a/frontend/web/styles/project/_PricingPage.scss +++ b/frontend/web/styles/project/_PricingPage.scss @@ -1,5 +1,22 @@ -.panel-content.p-4 { - padding: unset !important; +.pricing-panel { + border-radius: 4px; +} + +.pricing-panel-content { + background-color: rgba(39, 171, 149, 0.08); + min-height: 320px; + border-radius: 4px; +} + +.pricing-panel-layout { + display: flex; + flex-direction: column; + height: 100%; + min-height: 200px; +} + +.pricing-panel-spacer { + flex: 1 1 auto; } .pricing-features > li { @@ -9,8 +26,3 @@ .pricing-features-item { flex-wrap: nowrap; } - -.pricing-panel, -.panel-content { - border-radius: 4px; -}