This commit is contained in:
Sergey Garin 2024-05-21 19:06:37 +03:00
parent 39098e2c26
commit 54677f006c
17 changed files with 460 additions and 34 deletions

View File

@ -87,10 +87,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)`',
@ -137,14 +133,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`',
@ -461,11 +449,6 @@ export default [
selector: ':not(TSModuleDeclaration)[declare=true]',
message: 'No ambient declarations',
},
{
selector: 'ExportDefaultDeclaration:has(Identifier.declaration)',
message:
'Use `export default` on the declaration, instead of as a separate statement',
},
],
// This rule does not work with TypeScript, and TypeScript already does this.
'no-undef': 'off',

View File

@ -0,0 +1,49 @@
import * as React from 'react'
import * as tw from 'tailwind-merge'
import Check from 'enso-assets/check_mark.svg'
import SvgMask from '#/components/SvgMask'
/**
*
*/
export interface PaywallBulletPointsProps {
readonly bulletPoints: string[]
readonly className?: string
}
/**
*
*/
export function PaywallBulletPoints(props: PaywallBulletPointsProps) {
const { bulletPoints, className } = props
if (bulletPoints.length === 0) {
return null
} else {
return (
<ul
className={tw.twMerge(
'm-0 flex w-full list-inside list-none flex-col gap-1 text-base',
className
)}
>
{bulletPoints.map(bulletPoint => (
<li key={bulletPoint} className="flex items-start gap-1">
<div className="m-0 flex">
<div className="m-0 flex">
<span className="mt-[5px] flex aspect-square h-4 flex-none place-items-center justify-center rounded-full bg-green/30">
<SvgMask src={Check} className="text-green" />
</span>
</div>
</div>
<div className="flex-grow">{bulletPoint}</div>
</li>
))}
</ul>
)
}
}

View File

@ -0,0 +1,33 @@
/**
* @file
*
* PaywallButton component
*/
import * as React from 'react'
import PaywallBlocked from 'enso-assets/lock.svg'
import * as button from '#/components/AriaComponents'
/**
* Props of the PaywallButton component
*/
export type PaywallButtonProps = button.ButtonProps & {}
/**
* PaywallButton component
*/
export function PaywallButton(props: PaywallButtonProps) {
const { size = 'medium', variant = 'primary' } = props
return (
<button.Button
variant={variant}
size={size}
rounding="full"
icon={PaywallBlocked}
iconPosition="end"
/>
)
}

View File

@ -0,0 +1,77 @@
/**
* @file
*
* A screen that shows a paywall.
*/
import * as React from 'react'
import * as tw from 'tailwind-merge'
import LockIcon from 'enso-assets/lock.svg'
import * as appUtils from '#/appUtils'
import * as billingHooks from '#/hooks/billing'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import SvgMask from '#/components/SvgMask'
import { PaywallBulletPoints } from './PaywallBulletPoints'
/**
* 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, level } = getFeature(feature)
const levelLabel = getText(level.label)
return (
<div className={tw.twMerge('flex flex-col items-start', className)}>
<div className="mb-1 flex flex-col items-center justify-center">
<div className="flex w-full items-center gap-1 text-sm font-normal">
<SvgMask src={LockIcon} role="presentation" className="h-4 w-4" />
{getText('paywallAvailabilityLevel', levelLabel)}
</div>
</div>
<aria.Text elementType="h2" className="text-2xl font-bold text-gray-900">
{getText('paywallScreenTitle')}
</aria.Text>
<PaywallBulletPoints
bulletPoints={getText(bulletPointsTextId).split(';')}
className="mb-6 mt-4"
/>
<p className="text-sm font-normal text-gray-600">
{getText('paywallScreenDescription', levelLabel)}
</p>
<ariaComponents.Button
variant="primary"
size="medium"
className="mt-3"
href={appUtils.SUBSCRIBE_PATH + '?plan=' + level.name}
>
{getText('upgradeTo', levelLabel)}
</ariaComponents.Button>
</div>
)
}

View File

@ -0,0 +1,7 @@
/**
* @file
*
* Barrel file for Paywall components.
*/
export * from './PaywallButton'
export * from './PaywallScreen'

View File

@ -22,11 +22,12 @@ export interface SvgMaskProps {
// underlying `div`.
// eslint-disable-next-line no-restricted-syntax
readonly className?: string | undefined
readonly role?: string
}
/** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */
export default function SvgMask(props: SvgMaskProps) {
const { invert = false, alt, src, style, color, className } = props
const { invert = false, alt, src, style, color, className, role } = props
const urlSrc = `url(${JSON.stringify(src)})`
const mask = invert ? `${urlSrc}, linear-gradient(white 0 0)` : urlSrc
@ -52,7 +53,7 @@ export default function SvgMask(props: SvgMaskProps) {
className={tailwindMerge.twMerge('inline-block h-max w-max', className)}
>
{/* This is required for this component to have the right size. */}
<img alt={alt} src={src} className="transparent" draggable={false} />
<img alt={alt} src={src} className="transparent" draggable={false} role={role} />
</div>
)
}

View File

@ -0,0 +1,121 @@
/**
* @file
*
* Paywall configuration for different plans.
*/
import type * as text from '#/text'
import * as backend from '#/services/Backend'
/**
*
*/
export const PAYWALL_FEATURES = {
userGroups: 'userGroups',
} as const
/**
* Paywall features.
*/
export type PaywallFeatureName = keyof typeof PAYWALL_FEATURES
/**
*
*/
export type PaywallLevelName = backend.Plan | 'free'
/**
*
*/
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 })
export const PAYWALL_LEVELS: Record<PaywallLevelName, PaywallLevelValue> = {
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<PaywallFeatureName, text.TextId> = {
userGroups: 'userGroupsFeatureLabel',
} satisfies { [K in PaywallFeatureName]: `${K}FeatureLabel` }
/**
* Basic feature configuration.
*/
interface BasicFeatureConfiguration {
readonly level: PaywallLevel
readonly bulletPointsTextId: `${PaywallFeatureName}FeatureBulletPoints`
}
/**
* Plan configuration.
*/
interface PlanConfiguration<T extends BasicFeatureConfiguration> {
readonly features: Record<PaywallFeatureName, T>
}
/**
*
*/
export type FeatureConfiguration<Key extends PaywallFeatureName = PaywallFeatureName> =
BasicFeatureConfiguration & {
readonly name: Key
readonly label: (typeof PAYWALL_FEATURES_LABELS)[Key]
}
const PAYWALL_CONFIGURATION: Record<PaywallFeatureName, BasicFeatureConfiguration> = {
userGroups: {
level: PAYWALL_LEVELS.solo,
bulletPointsTextId: 'userGroupsFeatureBulletPoints',
},
}
/**
* 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: PaywallFeatureName): FeatureConfiguration {
const configuration = PAYWALL_CONFIGURATION[feature]
return {
...configuration,
name: feature,
label: PAYWALL_FEATURES_LABELS[feature],
}
}

View File

@ -0,0 +1,10 @@
/**
* @file
*
* Barrel file for billing hooks.
*/
export * from './paywallHooks'
export * from './paywallFeaturesHooks'
// eslint-disable-next-line no-restricted-syntax
export type { PaywallFeatureName } from './FeaturesConfiguration'

View File

@ -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: paywallFeaturesConfiguration.PaywallFeatureName) => {
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
}

View File

@ -0,0 +1,43 @@
/**
* @file
*
* Hooks for paywall-related functionality.
*/
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
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 {
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 getPaywallLevel = eventCallbackHooks.useEventCallback(() =>
paywallConfiguration.mapPlanOnPaywall(plan)
)
const isFeatureUnderPaywall = eventCallbackHooks.useEventCallback(
(feature: paywallConfiguration.PaywallFeatureName) => {
const featureConfig = getFeature(feature)
const { level } = featureConfig
const paywallLevel = getPaywallLevel()
return plan !== undefined && paywallLevel >= level
}
)
return { isFeatureUnderPaywall, getPaywallLevel } as const
}

View File

@ -0,0 +1,32 @@
/** @file Settings tab for viewing and editing roles for all users in the organization. */
import * as React from 'react'
import * as billingHooks from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import * as paywallComponents from '#/components/Paywall'
import * as components from './components'
// =============================
// === UserGroupsSettingsTab ===
// =============================
/** Settings tab for viewing and editing organization members. */
export default function UserGroupsSettingsTab() {
const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const showPaywall = isFeatureUnderPaywall('userGroups')
if (!showPaywall) {
return (
<div className="mt-1">
<paywallComponents.PaywallScreen feature="userGroups" />
</div>
)
} else {
return <components.UserGroupsSettingsTabContent />
}
}

View File

@ -3,6 +3,7 @@ import * as React from 'react'
import * as mimeTypes from '#/data/mimeTypes'
import * as billingHooks from '#/hooks/billing'
import * as scrollHooks from '#/hooks/scrollHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -27,14 +28,10 @@ import * as backendModule from '#/services/Backend'
import * as object from '#/utilities/object'
// =============================
// === UserGroupsSettingsTab ===
// =============================
/** Settings tab for viewing and editing organization members. */
export default function UserGroupsSettingsTab() {
export function UserGroupsSettingsTabContent() {
const { backend } = backendProvider.useBackend()
const { user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useFullUserSession()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
@ -223,14 +220,12 @@ export default function UserGroupsSettingsTab() {
event={position}
userGroups={userGroups}
onSubmit={groupName => {
if (user != null) {
const id = placeholderId
const { organizationId } = user
setUserGroups(oldUserGroups => [
...(oldUserGroups ?? []),
{ organizationId, id, groupName },
])
}
const id = placeholderId
const { organizationId } = user
setUserGroups(oldUserGroups => [
...(oldUserGroups ?? []),
{ organizationId, id, groupName },
])
}}
onSuccess={newUserGroup => {
setUserGroups(
@ -318,6 +313,7 @@ export default function UserGroupsSettingsTab() {
</div>
</SettingsSection>
</div>
<SettingsSection noFocusArea title={getText('users')} className="h-2/5 lg:h-[unset]">
<MembersTable draggable populateWithSelf />
</SettingsSection>

View File

@ -0,0 +1,7 @@
/**
* @file
*
* Barrel file for the UserGroupsSettingsTabContent component.
*/
export * from './UserGroupsSettingsTabContent'

View File

@ -0,0 +1,9 @@
/**
* @file
*
* Barrel file for the UserGroupsSettingsTab component.
*/
import UserGroupsSettingsTab from './UserGroupsSettingsTab'
export default UserGroupsSettingsTab

View File

@ -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'
@ -873,3 +874,14 @@ export function useUserSession() {
// eslint-disable-next-line no-restricted-syntax
return router.useOutletContext<UserSession | undefined>()
}
/**
* A React context hook returning the user session for a user that is fully logged in.
*/
export function useFullUserSession(): FullUserSession {
const session = router.useOutletContext<UserSession>()
invariant(session.type === UserSessionType.full, 'Expected a full user session.')
return session
}

View File

@ -439,6 +439,7 @@
"editorPageAltText": "Graph Editor",
"settingsPageAltText": "Settings",
"freePlanName": "Free",
"soloPlanName": "Solo",
"soloPlanSubtitle": "For individuals",
"soloPlanPricing": "$60 per user / month",
@ -614,5 +615,12 @@
"contactSalesDescription": "Contact our sales team to learn more about our Enterprise plan.",
"ContactSalesButtonLabel": "Contact Us",
"setOrgNameTitle": "Set your organization name"
"setOrgNameTitle": "Set your organization name",
"paywallScreenTitle": "Unlock the full 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 groups to manage permissions;Assign user groups to assets;Assign user groups to users"
}

View File

@ -106,6 +106,9 @@ interface PlaceholderOverrides {
readonly getDefaultVersionBackendError: [string]
readonly subscribeSuccessSubtitle: [string]
readonly paywallAvailabilityLevel: [Plan: string]
readonly paywallScreenDescription: [Plan: string]
}
/** An tuple of `string` for placeholders for each {@link TextId}. */