mirror of
https://github.com/enso-org/enso.git
synced 2024-11-29 16:02:25 +03:00
paywalls
This commit is contained in:
parent
39098e2c26
commit
54677f006c
@ -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',
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for Paywall components.
|
||||
*/
|
||||
export * from './PaywallButton'
|
||||
export * from './PaywallScreen'
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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],
|
||||
}
|
||||
}
|
10
app/ide-desktop/lib/dashboard/src/hooks/billing/index.ts
Normal file
10
app/ide-desktop/lib/dashboard/src/hooks/billing/index.ts
Normal 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'
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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 />
|
||||
}
|
||||
}
|
@ -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>
|
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for the UserGroupsSettingsTabContent component.
|
||||
*/
|
||||
|
||||
export * from './UserGroupsSettingsTabContent'
|
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for the UserGroupsSettingsTab component.
|
||||
*/
|
||||
|
||||
import UserGroupsSettingsTab from './UserGroupsSettingsTab'
|
||||
|
||||
export default UserGroupsSettingsTab
|
@ -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
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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}. */
|
||||
|
Loading…
Reference in New Issue
Block a user