Skip to content

Commit

Permalink
Initial Support for paywalls (#10202)
Browse files Browse the repository at this point in the history
This PR introduces:
1. Set of Paywall components: PaywallButton, PaywallDialog
2. Adds Devtools
3. Implement Paywall Configuration

This PR partially depends on #10199
  • Loading branch information
MrFlashAccount authored Jun 16, 2024
1 parent 1b7b24f commit 04551f4
Show file tree
Hide file tree
Showing 26 changed files with 1,084 additions and 17 deletions.
12 changes: 0 additions & 12 deletions app/ide-desktop/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)`',
Expand Down Expand Up @@ -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`',
Expand Down
4 changes: 4 additions & 0 deletions app/ide-desktop/lib/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -488,6 +489,9 @@ function AppRouter(props: AppRouterProps) {
</SessionProvider>
)
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
if (detect.IS_DEV_MODE) {
result = <paywall.PaywallDevtools>{result}</paywall.PaywallDevtools>
}
result = (
<rootComponent.Root navigate={navigate} portalRoot={portalRoot}>
{result}
Expand Down
14 changes: 14 additions & 0 deletions app/ide-desktop/lib/dashboard/src/appUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:[email protected]?subject=Upgrading%20to%20Organization%20Plan'
}
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
>
<div
className={tailwindMerge.twMerge(
'relative max-h-autocomplete-suggestions w-full overflow-auto rounded-default',
'relative max-h-autocomplete-suggestions w-full overflow-y-auto overflow-x-hidden rounded-default',
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h-0'
)}
>
Expand Down
9 changes: 8 additions & 1 deletion app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const ACTION_TO_TEXT_ID: Readonly<Record<inputBindings.DashboardBindingKey, text

/** Props for a {@link MenuEntry}. */
export interface MenuEntryProps extends tailwindVariants.VariantProps<typeof MENU_ENTRY_VARIANTS> {
readonly icon?: string
readonly hidden?: boolean
readonly action: inputBindings.DashboardBindingKey
/** Overrides the text for the menu entry. */
Expand All @@ -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.
Expand All @@ -129,7 +132,11 @@ export default function MenuEntry(props: MenuEntryProps) {
>
<div className={MENU_ENTRY_VARIANTS(variantProps)}>
<div title={title} className="flex items-center gap-menu-entry whitespace-nowrap">
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
<SvgMask
src={icon ?? info.icon ?? BlankIcon}
color={info.color}
className="size-icon"
/>
<aria.Text slot="label">{label ?? getText(ACTION_TO_TEXT_ID[action])}</aria.Text>
</div>
<KeyboardShortcut action={action} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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<contextMenuEntry.ContextMenuEntryProps, 'doAction' | 'isDisabled'> {
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 (
<>
<ContextMenuEntryBase
{...rest}
icon={LockIcon}
doAction={() => {
setModal(
<paywallDialog.PaywallDialog modalProps={{ defaultOpen: true }} feature={feature} />
)
}}
/>
</>
)
}
Original file line number Diff line number Diff line change
@@ -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<ariaComponents.AlertProps, 'children'> {
readonly feature: billingHooks.PaywallFeatureName
readonly label: string
readonly showUpgradeButton?: boolean
readonly upgradeButtonProps?: Omit<paywall.UpgradeButtonProps, 'feature'>
}

/**
* A paywall alert.
*/
export function PaywallAlert(props: PaywallAlertProps) {
const {
label,
showUpgradeButton = true,
feature,
upgradeButtonProps,
className,
...alertProps
} = props

return (
<ariaComponents.Alert
variant="outline"
size="small"
rounded="large"
className={clsx('border border-primary/20', className)}
{...alertProps}
>
<div className="flex items-center gap-2">
<SvgMask src={LockIcon} className="h-5 w-5 flex-none text-primary" />

<ariaComponents.Text>
{label}{' '}
{showUpgradeButton && (
<paywall.UpgradeButton
feature={feature}
variant="link"
size="small"
{...upgradeButtonProps}
/>
)}
</ariaComponents.Text>
</div>
</ariaComponents.Alert>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<ariaComponents.Dialog type={type} title={title ?? getText(label)} {...dialogProps}>
<div className="flex flex-col">
<components.PaywallLock feature={feature} className="mb-2" />

<ariaComponents.Text variant="subtitle">{getText(descriptionTextId)}</ariaComponents.Text>

<components.PaywallBulletPoints bulletPointsTextId={bulletPointsTextId} className="my-2" />

<upgradeButton.UpgradeButton
feature={feature}
rounded="xlarge"
className="mt-2"
size="large"
/>
</div>
</ariaComponents.Dialog>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<ariaComponents.DialogTrigger {...dialogTriggerProps}>
<components.PaywallButton feature={feature} {...buttonProps} />

<paywallDialog.PaywallDialog feature={feature} {...dialogProps} />
</ariaComponents.DialogTrigger>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className={tw.twMerge('flex flex-col items-start', className)}>
<components.PaywallLock feature={feature} />

<ariaComponents.Text.Heading level="2">
{getText('paywallScreenTitle')}
</ariaComponents.Text.Heading>

<ariaComponents.Text balance variant="subtitle" className="mt-1 max-w-[720px]">
{getText(descriptionTextId)}
</ariaComponents.Text>

<components.PaywallBulletPoints bulletPointsTextId={bulletPointsTextId} className="my-3" />

<upgradeButton.UpgradeButton feature={feature} className="mt-0.5 min-w-36" />
</div>
)
}
Loading

0 comments on commit 04551f4

Please sign in to comment.