From 04551f4b8a8c4cfac5a1a3e88a9593360f7978cd Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Sun, 16 Jun 2024 16:57:17 +0300 Subject: [PATCH] Initial Support for paywalls (#10202) This PR introduces: 1. Set of Paywall components: PaywallButton, PaywallDialog 2. Adds Devtools 3. Implement Paywall Configuration This PR partially depends on https://github.com/enso-org/enso/pull/10199 --- app/ide-desktop/eslint.config.js | 12 -- app/ide-desktop/lib/dashboard/src/App.tsx | 4 + .../lib/dashboard/src/appUtils.tsx | 14 ++ .../AriaComponents/Button/Button.tsx | 4 +- .../dashboard/src/components/Autocomplete.tsx | 2 +- .../dashboard/src/components/MenuEntry.tsx | 9 +- .../components/Paywall/ContextMenuEntry.tsx | 48 +++++ .../src/components/Paywall/PaywallAlert.tsx | 67 +++++++ .../src/components/Paywall/PaywallDialog.tsx | 54 ++++++ .../Paywall/PaywallDialogButton.tsx | 36 ++++ .../src/components/Paywall/PaywallScreen.tsx | 56 ++++++ .../src/components/Paywall/UpgradeButton.tsx | 74 ++++++++ .../components/PaywallBulletPoints.tsx | 61 +++++++ .../Paywall/components/PaywallButton.tsx | 56 ++++++ .../Paywall/components/PaywallDevtools.tsx | 137 ++++++++++++++ .../Paywall/components/PaywallLock.tsx | 43 +++++ .../components/Paywall/components/index.ts | 9 + .../dashboard/src/components/Paywall/index.ts | 20 ++ .../hooks/billing/FeaturesConfiguration.ts | 171 ++++++++++++++++++ .../lib/dashboard/src/hooks/billing/index.ts | 12 ++ .../src/hooks/billing/paywallFeaturesHooks.ts | 35 ++++ .../src/hooks/billing/paywallHooks.ts | 52 ++++++ .../src/layouts/Settings/withPaywall.tsx | 68 +++++++ .../dashboard/src/providers/AuthProvider.tsx | 12 ++ .../lib/dashboard/src/text/english.json | 38 +++- .../lib/dashboard/src/text/index.ts | 7 + 26 files changed, 1084 insertions(+), 17 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/ContextMenuEntry.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallAlert.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallDialog.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallDialogButton.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallScreen.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/UpgradeButton.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallBulletPoints.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallButton.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallDevtools.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallLock.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/components/index.ts create mode 100644 app/ide-desktop/lib/dashboard/src/components/Paywall/index.ts create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/billing/FeaturesConfiguration.ts create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/billing/index.ts create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/billing/paywallFeaturesHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/billing/paywallHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/withPaywall.tsx diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index d0cc3df5013d..0d94d956e085 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -86,10 +86,6 @@ const RESTRICTED_SYNTAXES = [ selector: `:matches(ImportDefaultSpecifier[local.name=/^${NAME}/i], ImportNamespaceSpecifier > Identifier[name=/^${NAME}/i])`, message: `Don't prefix modules with \`${NAME}\``, }, - { - selector: 'TSTypeLiteral', - message: 'No object types - use interfaces instead', - }, { selector: 'ForOfStatement > .left[kind=let]', message: 'Use `for (const x of xs)`, not `for (let x of xs)`', @@ -136,14 +132,6 @@ const RESTRICTED_SYNTAXES = [ selector: `TSAsExpression:not(:has(TSTypeReference > Identifier[name=const]))`, message: 'Avoid `as T`. Consider using a type annotation instead.', }, - { - selector: `:matches(\ - TSUndefinedKeyword,\ - Identifier[name=undefined],\ - UnaryExpression[operator=void]:not(:has(CallExpression.argument)), BinaryExpression[operator=/^===?$/]:has(UnaryExpression.left[operator=typeof]):has(Literal.right[value=undefined])\ - )`, - message: 'Use `null` instead of `undefined`, `void 0`, or `typeof x === "undefined"`', - }, { selector: 'ExportNamedDeclaration > VariableDeclaration[kind=let]', message: 'Use `export const` instead of `export let`', diff --git a/app/ide-desktop/lib/dashboard/src/App.tsx b/app/ide-desktop/lib/dashboard/src/App.tsx index 789422fd1327..17dbd7cc1699 100644 --- a/app/ide-desktop/lib/dashboard/src/App.tsx +++ b/app/ide-desktop/lib/dashboard/src/App.tsx @@ -70,6 +70,7 @@ import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess' import * as errorBoundary from '#/components/ErrorBoundary' import * as loader from '#/components/Loader' +import * as paywall from '#/components/Paywall' import * as rootComponent from '#/components/Root' import AboutModal from '#/modals/AboutModal' @@ -488,6 +489,9 @@ function AppRouter(props: AppRouterProps) { ) result = {result} + if (detect.IS_DEV_MODE) { + result = {result} + } result = ( {result} diff --git a/app/ide-desktop/lib/dashboard/src/appUtils.tsx b/app/ide-desktop/lib/dashboard/src/appUtils.tsx index 2661cd347274..17a4f7260c20 100644 --- a/app/ide-desktop/lib/dashboard/src/appUtils.tsx +++ b/app/ide-desktop/lib/dashboard/src/appUtils.tsx @@ -37,3 +37,17 @@ export const ALL_PATHS_REGEX = new RegExp( // === Constants related to URLs === export const SEARCH_PARAMS_PREFIX = 'cloud-ide_' + +/** + * Build a Subscription URL for a given plan. + */ +export function getUpgradeURL(plan: string): string { + return SUBSCRIBE_PATH + '?plan=' + plan +} + +/** + * Build a Subscription URL for contacting sales. + */ +export function getContactSalesURL(): string { + return 'mailto:contact@enso.org?subject=Upgrading%20to%20Organization%20Plan' +} diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx index f3d2c64282e9..cb9e54375c2c 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx @@ -230,11 +230,11 @@ export const BUTTON_STYLES = twv.tv({ { size: 'large', iconOnly: true, - class: { base: 'p-0 rounded-full', icon: 'h-[2.25cap] -mt-[0.1cap]' }, + class: { base: 'p-0 rounded-full', icon: 'h-[3.65cap]' }, }, { size: 'hero', - class: { base: 'p-0 rounded-full', icon: 'h-[2.5cap] -mt-[0.1cap]' }, + class: { base: 'p-0 rounded-full', icon: 'h-[5.5cap]' }, iconOnly: true, }, { variant: 'link', size: 'xxsmall', class: 'font-medium' }, diff --git a/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx b/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx index 9cd3b0afcb7d..926a3d1e2dfd 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Autocomplete.tsx @@ -236,7 +236,7 @@ export default function Autocomplete(props: AutocompleteProps) { >
diff --git a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx index 731732cddc2a..c26adf153eeb 100644 --- a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx @@ -81,6 +81,7 @@ const ACTION_TO_TEXT_ID: Readonly { + readonly icon?: string readonly hidden?: boolean readonly action: inputBindings.DashboardBindingKey /** Overrides the text for the menu entry. */ @@ -100,12 +101,14 @@ export default function MenuEntry(props: MenuEntryProps) { isDisabled = false, title, doAction, + icon, ...variantProps } = props const { getText } = textProvider.useText() const inputBindings = inputBindingsProvider.useInputBindings() const focusChildProps = focusHooks.useFocusChild() const info = inputBindings.metadata[action] + React.useEffect(() => { // This is slower (but more convenient) than registering every shortcut in the context menu // at once. @@ -129,7 +132,11 @@ export default function MenuEntry(props: MenuEntryProps) { >
- + {label ?? getText(ACTION_TO_TEXT_ID[action])}
diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/ContextMenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/ContextMenuEntry.tsx new file mode 100644 index 000000000000..9b1d3b355bb4 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/ContextMenuEntry.tsx @@ -0,0 +1,48 @@ +/** + * @file + * + * A context menu entry that opens a paywall dialog. + */ + +import * as React from 'react' + +import LockIcon from 'enso-assets/lock.svg' + +import type * as billingHooks from '#/hooks/billing' + +import * as modalProvider from '#/providers/ModalProvider' + +import type * as contextMenuEntry from '#/components/ContextMenuEntry' +import ContextMenuEntryBase from '#/components/ContextMenuEntry' + +import * as paywallDialog from './PaywallDialog' + +/** + * Props for {@link ContextMenuEntry}. + */ +export interface ContextMenuEntryProps + extends Omit { + readonly feature: billingHooks.PaywallFeatureName +} + +/** + * A context menu entry that opens a paywall dialog. + */ +export function ContextMenuEntry(props: ContextMenuEntryProps) { + const { feature, ...rest } = props + const { setModal } = modalProvider.useSetModal() + + return ( + <> + { + setModal( + + ) + }} + /> + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallAlert.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallAlert.tsx new file mode 100644 index 000000000000..11007f8386ff --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallAlert.tsx @@ -0,0 +1,67 @@ +/** + * @file + * + * A paywall alert. + */ + +import * as React from 'react' + +import clsx from 'clsx' + +import LockIcon from 'enso-assets/lock.svg' + +import type * as billingHooks from '#/hooks/billing' + +import * as ariaComponents from '#/components/AriaComponents' +import * as paywall from '#/components/Paywall' +import SvgMask from '#/components/SvgMask' + +/** + * Props for {@link PaywallAlert}. + */ +export interface PaywallAlertProps extends Omit { + readonly feature: billingHooks.PaywallFeatureName + readonly label: string + readonly showUpgradeButton?: boolean + readonly upgradeButtonProps?: Omit +} + +/** + * A paywall alert. + */ +export function PaywallAlert(props: PaywallAlertProps) { + const { + label, + showUpgradeButton = true, + feature, + upgradeButtonProps, + className, + ...alertProps + } = props + + return ( + +
+ + + + {label}{' '} + {showUpgradeButton && ( + + )} + +
+
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallDialog.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallDialog.tsx new file mode 100644 index 000000000000..fc8be6a5cd5f --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallDialog.tsx @@ -0,0 +1,54 @@ +/** + * @file + * + * A dialog that prompts the user to upgrade to a paid plan. + */ + +import * as React from 'react' + +import * as billingHooks from '#/hooks/billing' + +import * as textProvider from '#/providers/TextProvider' + +import * as ariaComponents from '#/components/AriaComponents' + +import * as components from './components' +import * as upgradeButton from './UpgradeButton' + +/** + * Props for a {@link PaywallDialog}. + */ +export interface PaywallDialogProps extends ariaComponents.DialogProps { + readonly feature: billingHooks.PaywallFeatureName +} + +/** + * A dialog that prompts the user to upgrade to a paid plan. + */ +export function PaywallDialog(props: PaywallDialogProps) { + const { feature, type = 'modal', title, ...dialogProps } = props + + const { getText } = textProvider.useText() + const { getFeature } = billingHooks.usePaywallFeatures() + + const { bulletPointsTextId, label, descriptionTextId } = getFeature(feature) + + return ( + +
+ + + {getText(descriptionTextId)} + + + + +
+
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallDialogButton.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallDialogButton.tsx new file mode 100644 index 000000000000..75b90ffa2f32 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallDialogButton.tsx @@ -0,0 +1,36 @@ +/** + * @file + * + * A button that opens a paywall dialog when clicked. + */ + +import * as React from 'react' + +import * as ariaComponents from '#/components/AriaComponents' + +import * as components from './components' +import * as paywallDialog from './PaywallDialog' + +/** + * Props for a {@link PaywallDialogButton}. + */ +// eslint-disable-next-line no-restricted-syntax +export type PaywallDialogButtonProps = components.PaywallButtonProps & { + readonly dialogProps?: paywallDialog.PaywallDialogProps + readonly dialogTriggerProps?: ariaComponents.DialogTriggerProps +} + +/** + * A button that opens a paywall dialog when clicked + */ +export function PaywallDialogButton(props: PaywallDialogButtonProps) { + const { feature, dialogProps, dialogTriggerProps, ...buttonProps } = props + + return ( + + + + + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallScreen.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallScreen.tsx new file mode 100644 index 000000000000..361d714c7490 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/PaywallScreen.tsx @@ -0,0 +1,56 @@ +/** + * @file + * + * A screen that shows a paywall. + */ + +import * as React from 'react' + +import * as tw from 'tailwind-merge' + +import * as billingHooks from '#/hooks/billing' + +import * as textProvider from '#/providers/TextProvider' + +import * as ariaComponents from '#/components/AriaComponents' + +import * as components from './components' +import * as upgradeButton from './UpgradeButton' + +/** + * Props for a {@link PaywallScreen}. + */ +export interface PaywallScreenProps { + readonly feature: billingHooks.PaywallFeatureName + readonly className?: string +} + +/** + * A screen that shows a paywall. + */ +export function PaywallScreen(props: PaywallScreenProps) { + const { feature, className } = props + const { getText } = textProvider.useText() + + const { getFeature } = billingHooks.usePaywallFeatures() + + const { bulletPointsTextId, descriptionTextId } = getFeature(feature) + + return ( +
+ + + + {getText('paywallScreenTitle')} + + + + {getText(descriptionTextId)} + + + + + +
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/UpgradeButton.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/UpgradeButton.tsx new file mode 100644 index 000000000000..ec1cb9ebd159 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/UpgradeButton.tsx @@ -0,0 +1,74 @@ +/** + * @file + * + * A button that links to the upgrade page. + */ +import * as React from 'react' + +import * as appUtils from '#/appUtils' + +import * as billingHooks from '#/hooks/billing' + +import * as textProvider from '#/providers/TextProvider' + +import * as ariaComponents from '#/components/AriaComponents' + +/** + * Props for an {@link UpgradeButton}. + */ +// eslint-disable-next-line no-restricted-syntax +export type UpgradeButtonProps = Omit & { + readonly feature: billingHooks.PaywallFeatureName + readonly variant?: ariaComponents.ButtonProps['variant'] +} + +/** + * A button that links to the upgrade page. + */ +export function UpgradeButton(props: UpgradeButtonProps) { + const { + feature, + variant, + href, + size = 'medium', + rounded = 'xlarge', + children, + ...buttonProps + } = props + const { getText } = textProvider.useText() + + const { getFeature } = billingHooks.usePaywallFeatures() + + const { level } = getFeature(feature) + const levelLabel = getText(level.label) + + const isEnterprise = level === billingHooks.PAYWALL_LEVELS.enterprise + const child = + children ?? (isEnterprise ? getText('contactSales') : getText('upgradeTo', levelLabel)) + + return ( + + {child} + + ) +} + +const VARIANT_BY_LEVEL: Record< + billingHooks.PaywallLevelName, + ariaComponents.ButtonProps['variant'] +> = { + free: 'primary', + enterprise: 'primary', + solo: 'outline', + team: 'submit', +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallBulletPoints.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallBulletPoints.tsx new file mode 100644 index 000000000000..2109a8dd0970 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallBulletPoints.tsx @@ -0,0 +1,61 @@ +/** + * @file + * + * A component that renders a list of bullet points for a paywall. + */ +import * as React from 'react' + +import * as tw from 'tailwind-merge' + +import Check from 'enso-assets/check_mark.svg' + +import type * as text from '#/text' + +import * as textProvider from '#/providers/TextProvider' + +import * as ariaComponents from '#/components/AriaComponents' +import SvgMask from '#/components/SvgMask' + +/** + * Props for a {@link PaywallBulletPoints}. + */ +export interface PaywallBulletPointsProps { + readonly bulletPointsTextId: text.TextId + readonly className?: string +} + +/** + * A component that renders a list of bullet points for a paywall. + */ +export function PaywallBulletPoints(props: PaywallBulletPointsProps) { + const { bulletPointsTextId, className } = props + + const { getText } = textProvider.useText() + const bulletPoints = getText(bulletPointsTextId) + .split(';') + .map(bulletPoint => bulletPoint.trim()) + + if (bulletPoints.length === 0) { + return null + } else { + return ( +
    + {bulletPoints.map(bulletPoint => ( +
  • +
    +
    + + + +
    +
    + + + {bulletPoint} + +
  • + ))} +
+ ) + } +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallButton.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallButton.tsx new file mode 100644 index 000000000000..707879547024 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallButton.tsx @@ -0,0 +1,56 @@ +/** + * @file + * + * A styled button that shows that a feature is behind a paywall + */ +import * as React from 'react' + +import PaywallBlocked from 'enso-assets/lock.svg' + +import * as billingHooks from '#/hooks/billing' + +import * as textProvider from '#/providers/TextProvider' + +import * as ariaComponents from '#/components/AriaComponents' + +/** + * Props for {@link PaywallButton}. + */ +// eslint-disable-next-line no-restricted-syntax +export type PaywallButtonProps = ariaComponents.ButtonProps & { + readonly feature: billingHooks.PaywallFeatureName + readonly iconOnly?: boolean + readonly showIcon?: boolean +} + +/** + * A styled button that shows that a feature is behind a paywall + */ +export function PaywallButton(props: PaywallButtonProps) { + const { feature, iconOnly = false, showIcon = true, children, ...buttonProps } = props + + const { getText } = textProvider.useText() + + const { getFeature } = billingHooks.usePaywallFeatures() + + const { level } = getFeature(feature) + const levelLabel = getText(level.label) + + const showChildren = !iconOnly + const childrenContent = children ?? getText('upgradeTo', levelLabel) + + return ( + + {showChildren && childrenContent} + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallDevtools.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallDevtools.tsx new file mode 100644 index 000000000000..d6ef8a23a563 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallDevtools.tsx @@ -0,0 +1,137 @@ +/** + * @file + * + * A component that provides a UI for toggling paywall features. + */ +import * as React from 'react' + +import DevtoolsLogo from 'enso-assets/enso_logo.svg' + +import * as billing from '#/hooks/billing' + +import * as textProvider from '#/providers/TextProvider' + +import * as aria from '#/components/aria' +import * as ariaComponents from '#/components/AriaComponents' +import Portal from '#/components/Portal' + +/** + * Configuration for a paywall feature. + */ +export interface PaywallDevtoolsFeatureConfiguration { + readonly isForceEnabled: boolean | null +} + +// eslint-disable-next-line no-restricted-syntax +const PaywallDevtoolsContext = React.createContext<{ + features: Record +}>({ + features: { + share: { isForceEnabled: null }, + shareFull: { isForceEnabled: null }, + userGroups: { isForceEnabled: null }, + userGroupsFull: { isForceEnabled: null }, + inviteUser: { isForceEnabled: null }, + inviteUserFull: { isForceEnabled: null }, + }, +}) + +/** + * Props for the {@link PaywallDevtools} component. + */ +interface PaywallDevtoolsProps extends React.PropsWithChildren {} + +/** + * A component that provides a UI for toggling paywall features. + */ +export function PaywallDevtools(props: PaywallDevtoolsProps) { + const { children } = props + + const { getText } = textProvider.useText() + + const [features, setFeatures] = React.useState< + Record + >({ + share: { isForceEnabled: null }, + shareFull: { isForceEnabled: null }, + userGroups: { isForceEnabled: null }, + userGroupsFull: { isForceEnabled: null }, + inviteUser: { isForceEnabled: null }, + inviteUserFull: { isForceEnabled: null }, + }) + + const { getFeature } = billing.usePaywallFeatures() + + const onConfigurationChange = React.useCallback( + (feature: billing.PaywallFeatureName, configuration: PaywallDevtoolsFeatureConfiguration) => { + setFeatures(prev => ({ ...prev, [feature]: configuration })) + }, + [] + ) + + return ( + + {children} + + + + + + + + {getText('paywallDevtoolsPopoverHeading')} + + + + +
+ {Object.entries(features).map(([feature, configuration]) => { + // eslint-disable-next-line no-restricted-syntax + const featureName = feature as billing.PaywallFeatureName + const { label, descriptionTextId } = getFeature(featureName) + return ( +
+ { + onConfigurationChange(featureName, { + isForceEnabled: value, + }) + }} + > +
+ +
+ + {getText(label)} +
+ + + {getText(descriptionTextId)} + +
+ ) + })} +
+
+
+
+
+ ) +} + +/** + * A hook that provides access to the paywall devtools. + */ +export function usePaywallDevtools() { + return React.useContext(PaywallDevtoolsContext) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallLock.tsx b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallLock.tsx new file mode 100644 index 000000000000..3bb2b146239e --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/PaywallLock.tsx @@ -0,0 +1,43 @@ +/** + * @file A lock icon with a label indicating the paywall level required to access a feature. + */ +import * as tw from 'tailwind-merge' + +import LockIcon from 'enso-assets/lock.svg' + +import * as billingHooks from '#/hooks/billing' + +import * as textProvider from '#/providers/TextProvider' + +import * as ariaComponents from '#/components/AriaComponents' +import SvgMask from '#/components/SvgMask' + +/** + * Props for a {@link PaywallLock}. + */ +export interface PaywallLockProps { + readonly feature: billingHooks.PaywallFeatureName + readonly className?: string +} + +/** + * A lock icon with a label indicating the paywall level required to access a feature. + */ +export function PaywallLock(props: PaywallLockProps) { + const { feature, className } = props + const { getText } = textProvider.useText() + + const { getFeature } = billingHooks.usePaywallFeatures() + + const { level } = getFeature(feature) + const levelLabel = getText(level.label) + + return ( +
+ + + {getText('paywallAvailabilityLevel', levelLabel)} + +
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/components/index.ts b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/index.ts new file mode 100644 index 000000000000..8f0c30b02b4d --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/components/index.ts @@ -0,0 +1,9 @@ +/** + * @file + * + * Barrel file for the Paywall components. + */ +export * from './PaywallLock' +export * from './PaywallBulletPoints' +export * from './PaywallButton' +export * from './PaywallDevtools' diff --git a/app/ide-desktop/lib/dashboard/src/components/Paywall/index.ts b/app/ide-desktop/lib/dashboard/src/components/Paywall/index.ts new file mode 100644 index 000000000000..a0e8466d83c4 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/Paywall/index.ts @@ -0,0 +1,20 @@ +/** + * @file + * + * Barrel file for Paywall components. + */ +export * from './PaywallScreen' +export * from './PaywallDialogButton' +export * from './PaywallDialog' +export * from './UpgradeButton' +export * from './PaywallAlert' +export * from './ContextMenuEntry' +/* eslint-disable no-restricted-syntax */ +export { + PaywallButton, + type PaywallButtonProps, + PaywallDevtools, + usePaywallDevtools, + type PaywallDevtoolsFeatureConfiguration, +} from './components' +/* eslint-enable no-restricted-syntax */ diff --git a/app/ide-desktop/lib/dashboard/src/hooks/billing/FeaturesConfiguration.ts b/app/ide-desktop/lib/dashboard/src/hooks/billing/FeaturesConfiguration.ts new file mode 100644 index 000000000000..b576875ec985 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/billing/FeaturesConfiguration.ts @@ -0,0 +1,171 @@ +/** + * @file + * + * Paywall configuration for different plans. + */ + +import type * as text from '#/text' + +import * as backend from '#/services/Backend' + +/** + * Registered paywall features. + */ +export const PAYWALL_FEATURES = { + userGroups: 'userGroups', + userGroupsFull: 'userGroupsFull', + inviteUser: 'inviteUser', + inviteUserFull: 'inviteUserFull', + share: 'share', + shareFull: 'shareFull', +} as const + +/** + * Paywall features. + */ +export type PaywallFeatureName = keyof typeof PAYWALL_FEATURES + +/** + * Paywall level names + */ +export type PaywallLevelName = backend.Plan | 'free' + +/** + * Paywall level values. + * Used to define the paywall levels and their corresponding labels. + * The value is a number that represents the level of the paywall. + * Because the paywall levels are ordered and inclusive, the value is used to compare the levels. + */ +export type PaywallLevelValue = + | (0 & { readonly name: PaywallLevelName; readonly label: text.TextId }) + | (1 & { readonly name: PaywallLevelName; readonly label: text.TextId }) + | (2 & { readonly name: PaywallLevelName; readonly label: text.TextId }) + | (3 & { readonly name: PaywallLevelName; readonly label: text.TextId }) + +/** + * Paywall levels configuration. + */ +export const PAYWALL_LEVELS: Record = { + free: Object.assign(0, { name: 'free', label: 'freePlanName' } as const), + [backend.Plan.solo]: Object.assign(1, { + name: backend.Plan.solo, + label: 'soloPlanName', + } as const), + [backend.Plan.team]: Object.assign(2, { + name: backend.Plan.team, + label: 'teamPlanName', + } as const), + [backend.Plan.enterprise]: Object.assign(3, { + name: backend.Plan.enterprise, + label: 'enterprisePlanName', + } as const), +} + +/** + * + */ +export type PaywallLevel = (typeof PAYWALL_LEVELS)[keyof typeof PAYWALL_LEVELS] + +/** + * Paywall feature labels. + */ +const PAYWALL_FEATURES_LABELS: Record = { + userGroups: 'userGroupsFeatureLabel', + userGroupsFull: 'userGroupsFullFeatureLabel', + inviteUser: 'inviteUserFeatureLabel', + inviteUserFull: 'inviteUserFullFeatureLabel', + share: 'shareFeatureLabel', + shareFull: 'shareFullFeatureLabel', +} satisfies { [K in PaywallFeatureName]: `${K}FeatureLabel` } + +const PAYWALL_FEATURE_META = { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + inviteUser: { maxSeats: 10 }, + inviteUserFull: undefined, + userGroups: { maxGroups: 1 }, + userGroupsFull: undefined, + share: undefined, + shareFull: undefined, +} satisfies { [K in PaywallFeatureName]: unknown } + +/** + * Basic feature configuration. + */ +interface BasicFeatureConfiguration { + readonly level: PaywallLevel + readonly bulletPointsTextId: `${PaywallFeatureName}FeatureBulletPoints` + readonly descriptionTextId: `${PaywallFeatureName}FeatureDescription` +} + +/** + * Feature configuration. + */ +export type FeatureConfiguration = + BasicFeatureConfiguration & { + readonly name: Key + readonly label: (typeof PAYWALL_FEATURES_LABELS)[Key] + readonly meta: (typeof PAYWALL_FEATURE_META)[Key] + } + +const PAYWALL_CONFIGURATION: Record = { + userGroups: { + level: PAYWALL_LEVELS.team, + bulletPointsTextId: 'userGroupsFeatureBulletPoints', + descriptionTextId: 'userGroupsFeatureDescription', + }, + userGroupsFull: { + level: PAYWALL_LEVELS.enterprise, + bulletPointsTextId: 'userGroupsFullFeatureBulletPoints', + descriptionTextId: 'userGroupsFullFeatureDescription', + }, + inviteUser: { + level: PAYWALL_LEVELS.team, + bulletPointsTextId: 'inviteUserFeatureBulletPoints', + descriptionTextId: 'inviteUserFeatureDescription', + }, + inviteUserFull: { + level: PAYWALL_LEVELS.enterprise, + bulletPointsTextId: 'inviteUserFullFeatureBulletPoints', + descriptionTextId: 'inviteUserFullFeatureDescription', + }, + share: { + level: PAYWALL_LEVELS.team, + bulletPointsTextId: 'shareFeatureBulletPoints', + descriptionTextId: 'shareFeatureDescription', + }, + shareFull: { + level: PAYWALL_LEVELS.enterprise, + bulletPointsTextId: 'shareFullFeatureBulletPoints', + descriptionTextId: 'shareFullFeatureDescription', + }, +} + +/** + * Map a plan to a paywall level. + */ +export function mapPlanOnPaywall(plan: backend.Plan | undefined): PaywallLevel { + return plan != null ? PAYWALL_LEVELS[plan] : PAYWALL_LEVELS.free +} + +/** + * Check if a given string is a valid feature name. + */ +export function isFeatureName(name: string): name is PaywallFeatureName { + return name in PAYWALL_FEATURES +} + +/** + * Get the configuration for a given feature. + */ +export function getFeatureConfiguration( + feature: Key +): FeatureConfiguration { + const configuration = PAYWALL_CONFIGURATION[feature] + + return { + ...configuration, + name: feature, + label: PAYWALL_FEATURES_LABELS[feature], + meta: PAYWALL_FEATURE_META[feature], + } +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/billing/index.ts b/app/ide-desktop/lib/dashboard/src/hooks/billing/index.ts new file mode 100644 index 000000000000..2302a89e5937 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/billing/index.ts @@ -0,0 +1,12 @@ +/** + * @file + * + * Barrel file for billing hooks. + */ + +export * from './paywallHooks' +export * from './paywallFeaturesHooks' +// eslint-disable-next-line no-restricted-syntax +export type { PaywallFeatureName, PaywallLevel, PaywallLevelName } from './FeaturesConfiguration' +// eslint-disable-next-line no-restricted-syntax +export { PAYWALL_LEVELS } from './FeaturesConfiguration' diff --git a/app/ide-desktop/lib/dashboard/src/hooks/billing/paywallFeaturesHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/billing/paywallFeaturesHooks.ts new file mode 100644 index 000000000000..f8588183ae95 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/billing/paywallFeaturesHooks.ts @@ -0,0 +1,35 @@ +/** + * @file + * + * Hooks for paywall features. + */ + +import * as eventCallbackHooks from '#/hooks/eventCallbackHooks' + +import * as paywallFeaturesConfiguration from './FeaturesConfiguration' + +/** + * A hook that provides access to the paywall features configuration. + */ +export function usePaywallFeatures() { + const getFeature = eventCallbackHooks.useEventCallback( + (feature: Key) => { + return paywallFeaturesConfiguration.getFeatureConfiguration(feature) + } + ) + + const valueIsFeature = eventCallbackHooks.useEventCallback( + (value: string): value is paywallFeaturesConfiguration.PaywallFeatureName => + value in paywallFeaturesConfiguration.PAYWALL_FEATURES + ) + + const getMaybeFeature = eventCallbackHooks.useEventCallback((feature: string) => + valueIsFeature(feature) ? getFeature(feature) : null + ) + + return { + getFeature, + valueIsFeature, + getMaybeFeature, + } as const +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/billing/paywallHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/billing/paywallHooks.ts new file mode 100644 index 000000000000..fa506b138ccb --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/billing/paywallHooks.ts @@ -0,0 +1,52 @@ +/** + * @file + * + * Hooks for paywall-related functionality. + */ +import * as eventCallbackHooks from '#/hooks/eventCallbackHooks' + +import * as paywall from '#/components/Paywall' + +import type * as backend from '#/services/Backend' + +import * as paywallConfiguration from './FeaturesConfiguration' +import * as paywallFeatures from './paywallFeaturesHooks' + +/** + * Props for the {@link usePaywall} hook. + */ +export interface UsePaywallProps { + // eslint-disable-next-line no-restricted-syntax + readonly plan?: backend.Plan | undefined +} + +/** + * A hook that provides paywall-related functionality. + */ +export function usePaywall(props: UsePaywallProps) { + const { plan } = props + + const { getFeature } = paywallFeatures.usePaywallFeatures() + const { features } = paywall.usePaywallDevtools() + + const getPaywallLevel = eventCallbackHooks.useEventCallback(() => + paywallConfiguration.mapPlanOnPaywall(plan) + ) + + const isFeatureUnderPaywall = eventCallbackHooks.useEventCallback( + (feature: paywallConfiguration.PaywallFeatureName) => { + const featureConfig = getFeature(feature) + const { isForceEnabled } = features[feature] + const { level } = featureConfig + const paywallLevel = getPaywallLevel() + + if (isForceEnabled == null) { + return paywallLevel >= level + } else { + return !isForceEnabled + } + } + ) + + return { isFeatureUnderPaywall, getPaywallLevel, getFeature } as const +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/withPaywall.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/withPaywall.tsx new file mode 100644 index 000000000000..11aec0c4fd86 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/withPaywall.tsx @@ -0,0 +1,68 @@ +/** + * @file + * + * This file contains a higher-order component that wraps a component in a paywall. + * The paywall is shown if the user's plan does not include the feature. + * The feature is determined by the `isFeatureUnderPaywall` hook. + */ + +import * as React from 'react' + +import * as twv from 'tailwind-variants' + +import * as billingHooks from '#/hooks/billing' + +import * as authProvider from '#/providers/AuthProvider' + +import * as paywallComponents from '#/components/Paywall' + +/** + * Props for the `withPaywall` HOC. + */ +export interface PaywallSettingsLayoutProps { + readonly feature: billingHooks.PaywallFeatureName + readonly className?: string | undefined +} + +const PAYWALL_LAYOUT_STYLES = twv.tv({ + base: 'mt-1', +}) + +/** + * A layout that shows a paywall for a feature. + */ +export function PaywallSettingsLayout(props: PaywallSettingsLayoutProps) { + const { feature, className } = props + + return ( +
+ +
+ ) +} + +/** + * Wraps a component in a paywall. + * The paywall is shown if the user's plan does not include the feature. + * The feature is determined by the `isFeatureUnderPaywall` hook. + */ +export function withPaywall

>( + // eslint-disable-next-line @typescript-eslint/naming-convention + Component: React.ComponentType

, + props: PaywallSettingsLayoutProps +) { + const { feature, className } = props + + return function WithPaywall(componentProps: P) { + const { user } = authProvider.useFullUserSession() + + const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) + const showPaywall = isFeatureUnderPaywall(feature) + + if (showPaywall) { + return + } else { + return + } + } +} diff --git a/app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx b/app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx index 6ee97bab87c6..5a197bad0207 100644 --- a/app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx +++ b/app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx @@ -9,6 +9,7 @@ import * as sentry from '@sentry/react' import isNetworkError from 'is-network-error' import * as router from 'react-router-dom' import * as toast from 'react-toastify' +import invariant from 'tiny-invariant' import * as detect from 'enso-common/src/detect' import * as gtag from 'enso-common/src/gtag' @@ -844,3 +845,14 @@ export function useUserSession() { // eslint-disable-next-line no-restricted-syntax return router.useOutletContext() } + +/** + * A React context hook returning the user session for a user that is fully logged in. + */ +export function useFullUserSession(): FullUserSession { + const session = router.useOutletContext() + + invariant(session.type === UserSessionType.full, 'Expected a full user session.') + + return session +} diff --git a/app/ide-desktop/lib/dashboard/src/text/english.json b/app/ide-desktop/lib/dashboard/src/text/english.json index a089a9d8e4d5..fd4b3c335eb9 100644 --- a/app/ide-desktop/lib/dashboard/src/text/english.json +++ b/app/ide-desktop/lib/dashboard/src/text/english.json @@ -201,6 +201,8 @@ "profilePicture": "Profile picture", "settingsFor": "Settings for ", "inviteMembers": "Invite Members", + "seatsLeft": "You have $0 / $1 seats left on your plan. Upgrade to invite more users.", + "noSeatsLeft": "You have reached the limit of users for your plan. Upgrade to invite more users.", "status": "Status", "active": "Active", "pendingInvitation": "Pending Invitation", @@ -345,6 +347,8 @@ "confirmPrompt": "Are you sure you want to $0?", "couldNotInviteUser": "Could not invite user $0", "inviteFormDescription": "Invite users to join your organization by entering their email addresses below.", + "inviteFormSeatsLeft": "You have $0 seats left on your plan. Upgrade to invite more", + "inviteFormSeatsLeftError": "You have exceed the number of seats on your plan by $0", "inviteSuccess": "You've invited $0 to join Enso!", "inviteUserLinkCopyDescription": "You can also copy the invite link to send it directly", "inviteSubmit": "Send invites", @@ -448,6 +452,7 @@ "drivePageAltText": "Catalog", "editorPageAltText": "Graph Editor", + "freePlanName": "Free", "soloPlanName": "Solo", "soloPlanSubtitle": "For individuals", "soloPlanPricing": "$60 per user / month", @@ -593,6 +598,8 @@ "userGroups": "User Groups", "userGroup": "User Group", "newUserGroup": "New User Group", + "userGroupsPaywallMessage": "You have reached the limit of user groups for your plan. Upgrade to create more user groups.", + "userGroupsLimitMessage": "You can create up to $0 user group(s)", "userGroupNamePlaceholder": "Enter the name of the user group", "assetSearchFieldLabel": "Search through items", @@ -649,5 +656,34 @@ "team": "Team", "teamPlan": "Team Plan", "enterprise": "Organization", - "enterprisePlan": "Organization" + "enterprisePlan": "Organization", + + "paywallScreenTitle": "Unlock the potential of Enso", + "paywallScreenDescription": "Upgrade to $0 to unlock additional features and get access to priority support.", + "paywallAvailabilityLevel": "Available on $0 plan", + + "userGroupsFeatureLabel": "User Groups", + "userGroupsFeatureBulletPoints": "Create user group to manage permissions;Assign user group to assets;Assign users to group", + "userGroupsFeatureDescription": "Get fine-grained control over who can access your assets by creating user groups and assigning them to assets. Assign users to groups to manage their permissions.", + "userGroupsFullFeatureLabel": "Unlimited User Groups", + "userGroupsFullFeatureBulletPoints": "Create unlimited user groups to manage permissions;Assign user groups to assets;Assign users to groups", + "userGroupsFullFeatureDescription": "Get fine-grained control over who can access your assets by creating user groups and assigning them to assets. Assign users to groups to manage their permissions.", + + "inviteUserFeatureLabel": "Invite Users", + "inviteUserFeatureBulletPoints": "Invite users to join your organization;Manage user access to assets;Assign users to user groups", + "inviteUserFeatureDescription": "Invite users to join your organization and manage their access to assets. Assign users to user groups to manage their permissions.", + "inviteUserFullFeatureLabel": "Unlimited Invitations", + "inviteUserFullFeatureBulletPoints": "Invite unlimited users to join your organization;Manage user access to assets;Assign users to user groups", + "inviteUserFullFeatureDescription": "Invite unlimited users to join your organization and manage their access to assets. Assign users to user groups to manage their permissions.", + + "shareFeatureLabel": "Share Assets", + "shareFeatureBulletPoints": "Share assets with other users;Manage shared assets;Assign shared assets to user groups", + "shareFeatureDescription": "Share assets with other users and manage shared assets. Assign shared assets to user groups to manage their permissions.", + "shareFullFeatureLabel": "Unlimited Sharing", + "shareFullFeatureBulletPoints": "Share unlimited assets with other users;Manage shared assets;Assign shared assets to user groups", + "shareFullFeatureDescription": "Share unlimited assets with other users and manage shared assets. Assign shared assets to user groups to manage their permissions.", + "shareFullPaywallMessage": "You can share assets only with single user group. Upgrade to share assets with multiple user groups and users.", + + "paywallDevtoolsButtonLabel": "Open Enso Devtools", + "paywallDevtoolsPopoverHeading": "Enso Devtools" } diff --git a/app/ide-desktop/lib/dashboard/src/text/index.ts b/app/ide-desktop/lib/dashboard/src/text/index.ts index 013b41f00a4f..c9a0a8487f37 100644 --- a/app/ide-desktop/lib/dashboard/src/text/index.ts +++ b/app/ide-desktop/lib/dashboard/src/text/index.ts @@ -111,6 +111,13 @@ interface PlaceholderOverrides { readonly subscribeSuccessSubtitle: [string] readonly assetsDropFilesDescription: [count: number] + + readonly paywallAvailabilityLevel: [plan: string] + readonly paywallScreenDescription: [plan: string] + readonly userGroupsLimitMessage: [limit: number] + readonly inviteFormSeatsLeftError: [exceedBy: number] + readonly inviteFormSeatsLeft: [seatsLeft: number] + readonly seatsLeft: [seatsLeft: number, seatsTotal: number] } /** An tuple of `string` for placeholders for each {@link TextId}. */