mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 03:32:23 +03:00
Paywalls
This commit is contained in:
parent
d21140e422
commit
91107f3755
@ -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`',
|
||||
@ -453,11 +441,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',
|
||||
|
@ -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'
|
||||
@ -88,7 +89,6 @@ import * as object from '#/utilities/object'
|
||||
import * as authServiceModule from '#/authentication/service'
|
||||
|
||||
import type * as types from '../../types/types'
|
||||
import * as reactQueryDevtools from './ReactQueryDevtools'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
@ -176,7 +176,7 @@ export default function App(props: AppProps) {
|
||||
const routerFuture: Partial<router.FutureConfig> = {
|
||||
/* we want to use startTransition to enable concurrent rendering */
|
||||
/* eslint-disable-next-line @typescript-eslint/naming-convention */
|
||||
v7_startTransition: true,
|
||||
v7_startTransition: false,
|
||||
}
|
||||
|
||||
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
|
||||
@ -200,8 +200,6 @@ export default function App(props: AppProps) {
|
||||
</ModalProvider>
|
||||
</LocalStorageProvider>
|
||||
</router.BrowserRouter>
|
||||
|
||||
<reactQueryDevtools.ReactQueryDevtools />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -486,6 +484,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}
|
||||
|
@ -43,3 +43,17 @@ export const ALL_PATHS_REGEX = new RegExp(
|
||||
// ===========
|
||||
|
||||
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'
|
||||
}
|
||||
|
@ -16,8 +16,8 @@ import SvgMask from '#/components/SvgMask'
|
||||
|
||||
/** Props for a {@link Button}. */
|
||||
export type ButtonProps =
|
||||
| (BaseButtonProps & Omit<aria.ButtonProps, 'onPress' | 'type'> & PropsWithoutHref)
|
||||
| (BaseButtonProps & Omit<aria.LinkProps, 'onPress' | 'type'> & PropsWithHref)
|
||||
| (BaseButtonProps & Omit<aria.ButtonProps, 'children' | 'onPress' | 'type'> & PropsWithoutHref)
|
||||
| (BaseButtonProps & Omit<aria.LinkProps, 'children' | 'onPress' | 'type'> & PropsWithHref)
|
||||
|
||||
/**
|
||||
* Props for a button with an href.
|
||||
@ -54,7 +54,7 @@ export interface BaseButtonProps extends Omit<twv.VariantProps<typeof BUTTON_STY
|
||||
* If the handler returns a promise, the button will be in a loading state until the promise resolves.
|
||||
*/
|
||||
readonly onPress?: (event: aria.PressEvent) => Promise<void> | void
|
||||
|
||||
readonly children?: React.ReactNode
|
||||
readonly testId?: string
|
||||
|
||||
readonly formnovalidate?: boolean
|
||||
@ -65,20 +65,46 @@ export const BUTTON_STYLES = twv.tv({
|
||||
variants: {
|
||||
isDisabled: { true: 'disabled:opacity-50 disabled:cursor-not-allowed' },
|
||||
isFocused: {
|
||||
true: 'focus:outline-none focus-visible:outline focus-visible:outline-primary',
|
||||
true: 'focus:outline-none focus-visible:outline focus-visible:outline-primary focus-visible:outline-offset-2',
|
||||
},
|
||||
loading: { true: { base: 'cursor-wait' } },
|
||||
fullWidth: { true: 'w-full' },
|
||||
size: {
|
||||
custom: '',
|
||||
hero: 'px-8 py-4 text-lg font-bold',
|
||||
large: 'px-6 py-3 text-base font-bold',
|
||||
medium: 'px-4 py-2 text-sm font-bold',
|
||||
small: 'px-3 pt-1 pb-[5px] text-xs font-medium',
|
||||
xsmall: 'px-2 pt-1 pb-[5px] text-xs font-medium',
|
||||
xxsmall: 'px-1.5 pt-1 pb-[5px] text-xs font-medium',
|
||||
custom: { base: '', extraClickZone: 'after:inset-[-12px]' },
|
||||
hero: { base: 'px-8 py-4 text-lg font-bold', content: 'gap-[0.75em]' },
|
||||
large: {
|
||||
base: 'px-2.5 py-[5px] text-sm font-bold',
|
||||
content: 'gap-2',
|
||||
text: 'pt-0.5 pb-1 leading-[22px]',
|
||||
extraClickZone: 'after:inset-[-6px]',
|
||||
},
|
||||
medium: {
|
||||
base: 'px-[7px] py-[3px] text-xs font-bold',
|
||||
text: 'pt-[1px] pb-[3px] leading-5',
|
||||
content: 'gap-2',
|
||||
icon: 'w-4 h-4',
|
||||
extraClickZone: 'after:inset-[-8px]',
|
||||
},
|
||||
small: {
|
||||
base: 'px-3 py-1 text-xs font-medium',
|
||||
content: 'gap-1',
|
||||
icon: 'w-4 h-4',
|
||||
extraClickZone: 'after:inset-[-10px]',
|
||||
},
|
||||
xsmall: {
|
||||
base: 'px-2 py-1 text-xs font-medium',
|
||||
content: 'gap-1',
|
||||
icon: 'w-3.5 h-3.5',
|
||||
extraClickZone: 'after:inset-[-12px]',
|
||||
},
|
||||
xxsmall: {
|
||||
base: 'px-1.5 py-0.5 text-xs font-medium',
|
||||
content: 'gap-0.5',
|
||||
icon: 'w-3.5 h-3.5',
|
||||
extraClickZone: 'after:inset-[-12px]',
|
||||
},
|
||||
},
|
||||
iconOnly: { true: '' },
|
||||
iconOnly: { true: { base: '', icon: 'w-full h-full' } },
|
||||
rounded: {
|
||||
full: 'rounded-full',
|
||||
large: 'rounded-lg',
|
||||
@ -91,17 +117,24 @@ export const BUTTON_STYLES = twv.tv({
|
||||
},
|
||||
variant: {
|
||||
custom: 'focus-visible:outline-offset-2',
|
||||
link: 'inline-flex px-0 py-0 rounded-sm text-primary/50 underline hover:text-primary focus-visible:outline-offset-0',
|
||||
primary: 'bg-primary text-white hover:bg-primary/70 focus-visible:outline-offset-2',
|
||||
tertiary: 'bg-share text-white hover:bg-share/90 focus-visible:outline-offset-2',
|
||||
cancel: 'bg-selected-frame opacity-80 hover:opacity-100 focus-visible:outline-offset-2',
|
||||
delete: 'bg-delete text-white focus-visible:outline-offset-2',
|
||||
link: {
|
||||
base: 'inline-flex px-0 py-0 rounded-sm text-primary/50 underline hover:text-primary border-none',
|
||||
content: 'gap-1',
|
||||
icon: 'h-[1em]',
|
||||
},
|
||||
primary: 'bg-primary text-white hover:bg-primary/70',
|
||||
tertiary: 'bg-share text-white hover:bg-share/90',
|
||||
cancel: 'bg-white/50 hover:bg-white',
|
||||
delete:
|
||||
'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger',
|
||||
icon: {
|
||||
base: 'opacity-70 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-offset-0',
|
||||
base: 'opacity-80 hover:opacity-100 focus-visible:opacity-100',
|
||||
wrapper: 'w-full h-full',
|
||||
content: 'w-full h-full',
|
||||
icon: 'w-fit h-fit',
|
||||
extraClickZone: 'w-full h-full',
|
||||
},
|
||||
ghost:
|
||||
'opacity-80 hover:opacity-100 hover:bg-white focus-visible:opacity-100 focus-visible:bg-white',
|
||||
submit: 'bg-invite text-white opacity-80 hover:opacity-100 focus-visible:outline-offset-2',
|
||||
outline: 'border-primary/40 text-primary hover:border-primary focus-visible:outline-offset-2',
|
||||
},
|
||||
@ -114,11 +147,12 @@ export const BUTTON_STYLES = twv.tv({
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
extraClickZone: 'flex relative after:inset-[-12px] after:absolute',
|
||||
extraClickZone: 'flex relative after:absolute after:cursor-pointer',
|
||||
wrapper: 'relative block',
|
||||
loader: 'absolute inset-0 flex items-center justify-center',
|
||||
content: 'flex items-center gap-[0.5em]',
|
||||
icon: 'h-[1.5em] flex-none',
|
||||
text: '',
|
||||
icon: 'h-[1.5em] flex-none aspect-square',
|
||||
},
|
||||
defaultVariants: {
|
||||
loading: false,
|
||||
@ -130,12 +164,24 @@ export const BUTTON_STYLES = twv.tv({
|
||||
showIconOnHover: false,
|
||||
},
|
||||
compoundVariants: [
|
||||
{ variant: 'icon', size: 'xxsmall', class: 'p-0.5 rounded-full', iconOnly: true },
|
||||
{ variant: 'icon', size: 'xsmall', class: 'p-1 rounded-full', iconOnly: true },
|
||||
{ variant: 'icon', size: 'small', class: 'p-1 rounded-full', iconOnly: true },
|
||||
{ variant: 'icon', size: 'medium', class: 'p-2 rounded-full', iconOnly: true },
|
||||
{ variant: 'icon', size: 'large', class: 'p-3 rounded-full', iconOnly: true },
|
||||
{ variant: 'icon', size: 'hero', class: 'p-4 rounded-full', iconOnly: true },
|
||||
{ variant: 'icon', isFocused: true, class: 'focus-visible:outline-offset-0' },
|
||||
{ variant: 'link', isFocused: true, class: 'focus-visible:outline-offset-1' },
|
||||
{
|
||||
variant: 'icon',
|
||||
size: 'xxsmall',
|
||||
class: 'aspect-square rounded-full w-4 p-0',
|
||||
iconOnly: true,
|
||||
},
|
||||
{
|
||||
variant: 'icon',
|
||||
size: 'xsmall',
|
||||
class: 'aspect-square p-0 rounded-full w-5',
|
||||
iconOnly: true,
|
||||
},
|
||||
{ size: 'small', class: 'aspect-square p-0 rounded-full w-6', iconOnly: true },
|
||||
{ size: 'medium', class: 'aspect-square p-0 rounded-full w-8', iconOnly: true },
|
||||
{ size: 'large', class: 'aspect-square p-0 rounded-full w-10', iconOnly: true },
|
||||
{ size: 'hero', class: 'aspect-square p-0 rounded-full w-16', iconOnly: true },
|
||||
{ variant: 'link', size: 'xxsmall', class: 'font-medium' },
|
||||
{ variant: 'link', size: 'xsmall', class: 'font-medium' },
|
||||
{ variant: 'link', size: 'small', class: 'font-medium' },
|
||||
@ -230,6 +276,7 @@ export const Button = React.forwardRef(function Button(
|
||||
loader,
|
||||
extraClickZone,
|
||||
icon: iconClasses,
|
||||
text,
|
||||
} = BUTTON_STYLES({
|
||||
isDisabled,
|
||||
loading: isLoading,
|
||||
@ -260,7 +307,7 @@ export const Button = React.forwardRef(function Button(
|
||||
return (
|
||||
<>
|
||||
{iconComponent}
|
||||
<>{children}</>
|
||||
<span className={text()}>{children}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -14,12 +14,14 @@ const STYLES = twv.tv({
|
||||
base: 'flex w-full flex-1 shrink-0',
|
||||
variants: {
|
||||
wrap: { true: 'flex-wrap' },
|
||||
direction: { column: 'flex-col justify-center', row: 'flex-row items-center' },
|
||||
direction: { column: 'flex-col', row: 'flex-row' },
|
||||
gap: {
|
||||
custom: '',
|
||||
large: 'gap-3.5',
|
||||
medium: 'gap-2',
|
||||
small: 'gap-1.5',
|
||||
xsmall: 'gap-1',
|
||||
xxsmall: 'gap-0.5',
|
||||
none: 'gap-0',
|
||||
},
|
||||
align: {
|
||||
@ -31,6 +33,11 @@ const STYLES = twv.tv({
|
||||
evenly: 'justify-evenly',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{ direction: 'column', align: 'start', class: 'items-start' },
|
||||
{ direction: 'column', align: 'center', class: 'items-center' },
|
||||
{ direction: 'column', align: 'end', class: 'items-end' },
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -2,7 +2,6 @@
|
||||
* Can be used to display alerts, confirmations, or other content. */
|
||||
import * as React from 'react'
|
||||
|
||||
import clsx from 'clsx'
|
||||
import * as twv from 'tailwind-variants'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
@ -48,10 +47,14 @@ const DIALOG_STYLES = twv.tv({
|
||||
modal: 'w-full max-w-md min-h-[100px] max-h-[90vh]',
|
||||
fullscreen: 'w-full h-full max-w-full max-h-full bg-clip-border',
|
||||
},
|
||||
hideCloseButton: { true: { closeButton: 'hidden' } },
|
||||
},
|
||||
slots: {
|
||||
header:
|
||||
'sticky grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 px-3.5 py-2 text-primary',
|
||||
'sticky grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 px-3.5 pt-1.5 pb-0.5',
|
||||
closeButton: 'col-start-1 col-end-1 mr-auto',
|
||||
heading: 'col-start-2 col-end-2 my-0',
|
||||
content: 'relative flex-auto overflow-y-auto p-3.5',
|
||||
},
|
||||
})
|
||||
|
||||
@ -83,7 +86,7 @@ export function Dialog(props: DialogProps) {
|
||||
|
||||
const root = portal.useStrictPortalContext()
|
||||
const shouldRenderTitle = typeof title === 'string'
|
||||
const dialogSlots = DIALOG_STYLES({ className, type, rounded })
|
||||
const dialogSlots = DIALOG_STYLES({ className, type, rounded, hideCloseButton })
|
||||
|
||||
utlities.useInteractOutside({
|
||||
ref: dialogRef,
|
||||
@ -151,23 +154,21 @@ export function Dialog(props: DialogProps) {
|
||||
{shouldRenderTitle && (
|
||||
<aria.Header className={dialogSlots.header()}>
|
||||
<ariaComponents.CloseButton
|
||||
className={clsx('col-start-1 col-end-1 mr-auto mt-0.5', {
|
||||
hidden: hideCloseButton,
|
||||
})}
|
||||
className={dialogSlots.closeButton()}
|
||||
onPress={opts.close}
|
||||
/>
|
||||
|
||||
<aria.Heading
|
||||
<ariaComponents.Text.Heading
|
||||
slot="title"
|
||||
level={2}
|
||||
className="col-start-2 col-end-2 my-0 text-base font-semibold leading-6"
|
||||
className={dialogSlots.heading()}
|
||||
>
|
||||
{title}
|
||||
</aria.Heading>
|
||||
</ariaComponents.Text.Heading>
|
||||
</aria.Header>
|
||||
)}
|
||||
|
||||
<div className="relative flex-auto overflow-y-auto p-3.5">
|
||||
<div className={dialogSlots.content()}>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<React.Suspense fallback={<loader.Loader minHeight="h32" />}>
|
||||
{typeof children === 'function' ? children(opts) : children}
|
||||
|
@ -31,7 +31,7 @@ export interface PopoverProps
|
||||
|
||||
export const POPOVER_STYLES = twv.tv({
|
||||
extend: variants.DIALOG_BACKGROUND,
|
||||
base: 'shadow-2xl w-full',
|
||||
base: 'shadow-2xl w-full overflow-clip',
|
||||
variants: {
|
||||
isEntering: {
|
||||
true: 'animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200',
|
||||
@ -40,11 +40,11 @@ export const POPOVER_STYLES = twv.tv({
|
||||
true: 'animate-out fade-out placement-bottom:slide-out-to-top-1 placement-top:slide-out-to-bottom-1 placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 ease-in duration-150',
|
||||
},
|
||||
size: {
|
||||
xsmall: { base: 'max-w-xs', content: 'p-2.5' },
|
||||
small: { base: 'max-w-sm', content: 'p-3.5' },
|
||||
medium: { base: 'max-w-md', content: 'p-3.5' },
|
||||
large: { base: 'max-w-lg', content: 'px-4 py-4' },
|
||||
hero: { base: 'max-w-xl', content: 'px-6 py-5' },
|
||||
xsmall: { base: 'max-w-xs', dialog: 'p-2.5' },
|
||||
small: { base: 'max-w-sm', dialog: 'p-3.5' },
|
||||
medium: { base: 'max-w-md', dialog: 'p-3.5' },
|
||||
large: { base: 'max-w-lg', dialog: 'px-4 py-4' },
|
||||
hero: { base: 'max-w-xl', dialog: 'px-6 py-5' },
|
||||
},
|
||||
rounded: {
|
||||
none: '',
|
||||
@ -57,7 +57,7 @@ export const POPOVER_STYLES = twv.tv({
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
content: 'flex-auto overflow-y-auto',
|
||||
dialog: 'max-h-[inherit] overflow-y-auto',
|
||||
},
|
||||
defaultVariants: { rounded: 'xxlarge', size: 'small' },
|
||||
})
|
||||
@ -106,20 +106,36 @@ export function Popover(props: PopoverProps) {
|
||||
>
|
||||
{opts => (
|
||||
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover">
|
||||
<aria.Dialog id={dialogId} ref={dialogRef}>
|
||||
<aria.Dialog
|
||||
id={dialogId}
|
||||
ref={dialogRef}
|
||||
className={POPOVER_STYLES({
|
||||
...opts,
|
||||
size,
|
||||
rounded,
|
||||
className:
|
||||
typeof className === 'function'
|
||||
? className({
|
||||
placement: opts.placement,
|
||||
isExiting: opts.isExiting,
|
||||
isEntering: opts.isEntering,
|
||||
trigger: opts.trigger,
|
||||
defaultClassName: undefined,
|
||||
})
|
||||
: className,
|
||||
}).dialog()}
|
||||
>
|
||||
{({ close }) => {
|
||||
closeRef.current = close
|
||||
|
||||
return (
|
||||
<div className={POPOVER_STYLES({ ...opts, size, rounded }).content()}>
|
||||
<dialogProvider.DialogProvider value={{ close, dialogId }}>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<React.Suspense fallback={<loader.Loader minHeight="h16" />}>
|
||||
{typeof children === 'function' ? children({ ...opts, close }) : children}
|
||||
</React.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</dialogProvider.DialogProvider>
|
||||
</div>
|
||||
<dialogProvider.DialogProvider value={{ close, dialogId }}>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<React.Suspense fallback={<loader.Loader minHeight="h16" />}>
|
||||
{typeof children === 'function' ? children({ ...opts, close }) : children}
|
||||
</React.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</dialogProvider.DialogProvider>
|
||||
)
|
||||
}}
|
||||
</aria.Dialog>
|
||||
|
@ -8,6 +8,7 @@ import * as twv from 'tailwind-variants'
|
||||
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
|
||||
@ -16,7 +17,7 @@ import * as varants from './variants'
|
||||
const CONTENT_EDITABLE_STYLES = twv.tv({
|
||||
extend: varants.INPUT_STYLES,
|
||||
base: '',
|
||||
slots: { placeholder: 'text-primary/25 absolute inset-0 pointer-events-none' },
|
||||
slots: { placeholder: 'opacity-50 absolute inset-0 pointer-events-none' },
|
||||
})
|
||||
|
||||
/**
|
||||
@ -106,22 +107,22 @@ export const ResizableContentEditableInput = React.forwardRef(
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className={placeholderClass({ class: value ? 'hidden' : '' })}>
|
||||
<ariaComponents.Text className={placeholderClass({ class: value ? 'hidden' : '' })}>
|
||||
{placeholder}
|
||||
</span>
|
||||
</ariaComponents.Text>
|
||||
</div>
|
||||
|
||||
{description != null && (
|
||||
<aria.Text slot="description" className={descriptionClass()}>
|
||||
<ariaComponents.Text slot="description" className={descriptionClass()}>
|
||||
{description}
|
||||
</aria.Text>
|
||||
</ariaComponents.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage != null && (
|
||||
<aria.Text slot="errorMessage" className={error()}>
|
||||
<ariaComponents.Text slot="errorMessage" color="danger" className={error()}>
|
||||
{errorMessage}
|
||||
</aria.Text>
|
||||
</ariaComponents.Text>
|
||||
)}
|
||||
</aria.TextField>
|
||||
)
|
||||
|
@ -6,15 +6,23 @@
|
||||
|
||||
import * as twv from 'tailwind-variants'
|
||||
|
||||
import * as text from '../../Text'
|
||||
|
||||
export const INPUT_STYLES = twv.tv({
|
||||
base: 'w-full overflow-hidden block cursor-text rounded-md border-2 border-primary/10 bg-transparent px-1.5 pb-1 pt-2 focus-within:border-primary/50 transition-colors duration-200',
|
||||
base: 'w-full overflow-hidden block cursor-text rounded-md border-2 border-primary/10 bg-transparent px-1.5 pb-1 pt-1.5 focus-within:border-primary/50 transition-colors duration-200',
|
||||
variants: { isInvalid: { true: 'border-red-500/70 focus-within:border-red-500' } },
|
||||
slots: {
|
||||
inputContainer: 'block max-h-32 min-h-5 text-sm font-normal relative overflow-auto',
|
||||
description: 'mt-1 block text-xs text-primary/40 select-none pointer-events-none',
|
||||
error: 'block text-xs text-red-500',
|
||||
inputContainer: text.TEXT_STYLE({
|
||||
className: 'block max-h-32 min-h-6 text-sm font-medium relative overflow-auto',
|
||||
variant: 'body',
|
||||
}),
|
||||
description: 'block select-none pointer-events-none opacity-80',
|
||||
error: 'block',
|
||||
textArea: 'block h-auto w-full max-h-full resize-none bg-transparent',
|
||||
resizableSpan:
|
||||
'pointer-events-none invisible absolute block max-h-32 min-h-10 overflow-y-auto break-all text-sm',
|
||||
resizableSpan: text.TEXT_STYLE({
|
||||
className:
|
||||
'pointer-events-none invisible absolute block max-h-32 min-h-10 overflow-y-auto break-all',
|
||||
variant: 'body',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
@ -24,7 +24,7 @@ export interface TextProps
|
||||
}
|
||||
|
||||
export const TEXT_STYLE = twv.tv({
|
||||
base: 'inline-block',
|
||||
base: 'inline-block flex-col before:block after:block before:flex-none after:flex-none before:w-full after:w-full',
|
||||
variants: {
|
||||
color: {
|
||||
custom: '',
|
||||
@ -39,9 +39,9 @@ export const TEXT_STYLE = twv.tv({
|
||||
// leading should always be after the text size to make sure it is not stripped by twMerge
|
||||
variant: {
|
||||
custom: '',
|
||||
body: 'text-xs leading-[20px] pt-[1px] pb-[3px]',
|
||||
h1: 'text-xl leading-[29px] pt-[2px] pb-[5px]',
|
||||
subtitle: 'text-[13.5px] leading-[20px] pt-[1px] pb-[3px]',
|
||||
body: 'text-xs leading-[20px] before:h-[1px] after:h-[3px]',
|
||||
h1: 'text-xl leading-[29px] before:h-0.5 after:h-[5px]',
|
||||
subtitle: 'text-[13.5px] leading-[20px] before:h-[1px] after:h-[3px]',
|
||||
},
|
||||
weight: {
|
||||
custom: '',
|
||||
@ -102,17 +102,17 @@ export const TEXT_STYLE = twv.tv({
|
||||
{
|
||||
variant: 'h1',
|
||||
disableLineHeightCompensation: true,
|
||||
class: 'pt-[unset] pb-[unset]',
|
||||
class: 'before:h-[unset] after:h-[unset]',
|
||||
},
|
||||
{
|
||||
variant: 'body',
|
||||
disableLineHeightCompensation: true,
|
||||
class: 'pt-[unset] pb-[unset]',
|
||||
class: 'before:h-[unset] after:h-[unset]',
|
||||
},
|
||||
{
|
||||
variant: 'subtitle',
|
||||
disableLineHeightCompensation: true,
|
||||
class: 'pt-[unset] pb-[unset]',
|
||||
class: 'before:h-[unset] after:h-[unset]',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
@ -4,6 +4,8 @@ import * as twv from 'tailwind-variants'
|
||||
import * as aria from '#/components/aria'
|
||||
import * as portal from '#/components/Portal'
|
||||
|
||||
import * as text from '../Text'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -18,7 +20,7 @@ export const TOOLTIP_STYLES = twv.tv({
|
||||
},
|
||||
size: {
|
||||
custom: '',
|
||||
medium: 'text-xs leading-[25px] px-2 py-1',
|
||||
medium: text.TEXT_STYLE({ className: 'px-2 py-1', color: 'custom', balance: true }),
|
||||
},
|
||||
rounded: {
|
||||
custom: '',
|
||||
|
@ -232,7 +232,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative max-h-autocomplete-suggestions w-full overflow-auto rounded-default ${
|
||||
className={`relative max-h-autocomplete-suggestions w-full overflow-y-auto overflow-x-hidden rounded-default ${
|
||||
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h'
|
||||
}`}
|
||||
>
|
||||
|
@ -74,7 +74,13 @@ function DefaultFallbackComponent(props: errorBoundary.FallbackProps): React.JSX
|
||||
subtitle={getText('arbitraryErrorSubtitle')}
|
||||
>
|
||||
<ariaComponents.ButtonGroup align="center">
|
||||
<ariaComponents.Button variant="submit" size="medium" onPress={resetErrorBoundary}>
|
||||
<ariaComponents.Button
|
||||
variant="submit"
|
||||
size="large"
|
||||
rounded="full"
|
||||
className="w-24"
|
||||
onPress={resetErrorBoundary}
|
||||
>
|
||||
{getText('tryAgain')}
|
||||
</ariaComponents.Button>
|
||||
</ariaComponents.ButtonGroup>
|
||||
|
@ -67,6 +67,7 @@ const ACTION_TO_TEXT_ID: Readonly<Record<inputBindings.DashboardBindingKey, text
|
||||
|
||||
/** Props for a {@link MenuEntry}. */
|
||||
export interface MenuEntryProps {
|
||||
readonly icon?: string
|
||||
readonly hidden?: boolean
|
||||
readonly action: inputBindings.DashboardBindingKey
|
||||
/** Overrides the text for the menu entry. */
|
||||
@ -80,11 +81,12 @@ export interface MenuEntryProps {
|
||||
|
||||
/** An item in a menu. */
|
||||
export default function MenuEntry(props: MenuEntryProps) {
|
||||
const { hidden = false, action, label, isDisabled = false, title } = props
|
||||
const { hidden = false, action, label, isDisabled = false, title, icon } = props
|
||||
const { isContextMenuEntry = false, doAction } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const info = inputBindings.metadata[action]
|
||||
|
||||
React.useEffect(() => {
|
||||
// This is slower (but more convenient) than registering every shortcut in the context menu
|
||||
// at once.
|
||||
@ -109,9 +111,10 @@ export default function MenuEntry(props: MenuEntryProps) {
|
||||
}`}
|
||||
>
|
||||
<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="h-4 w-4" />
|
||||
<aria.Text slot="label">{label ?? getText(ACTION_TO_TEXT_ID[action])}</aria.Text>
|
||||
</div>
|
||||
|
||||
<KeyboardShortcut action={action} />
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
|
@ -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} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @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}.
|
||||
*/
|
||||
export interface PaywallDialogButtonProps extends 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @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}.
|
||||
*/
|
||||
export type UpgradeButtonProps = Omit<ariaComponents.ButtonProps, 'variant'> & {
|
||||
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 (
|
||||
<ariaComponents.Button
|
||||
variant={variant ?? VARIANT_BY_LEVEL[level.name]}
|
||||
size={size}
|
||||
rounded={rounded}
|
||||
href={
|
||||
isEnterprise ? appUtils.getContactSalesURL() : href ?? appUtils.getUpgradeURL(level.name)
|
||||
}
|
||||
/* This is safe because we are passing all props to the button */
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
|
||||
{...(buttonProps as any)}
|
||||
>
|
||||
{child}
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
||||
|
||||
const VARIANT_BY_LEVEL: Record<
|
||||
billingHooks.PaywallLevelName,
|
||||
ariaComponents.ButtonProps['variant']
|
||||
> = {
|
||||
free: 'primary',
|
||||
enterprise: 'primary',
|
||||
solo: 'outline',
|
||||
team: 'submit',
|
||||
}
|
@ -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 (
|
||||
<ul className={tw.twMerge('m-0 flex w-full list-inside list-none flex-col gap-1', className)}>
|
||||
{bulletPoints.map(bulletPoint => (
|
||||
<li key={bulletPoint} className="flex items-start gap-1.5">
|
||||
<div className="m-0 flex">
|
||||
<div className="m-0 flex">
|
||||
<span className="mt-1 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>
|
||||
|
||||
<ariaComponents.Text className="flex-grow" variant="body">
|
||||
{bulletPoint}
|
||||
</ariaComponents.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @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}.
|
||||
*/
|
||||
export type PaywallButtonProps = Omit<ariaComponents.ButtonProps, 'variant'> & {
|
||||
readonly feature: billingHooks.PaywallFeatureName
|
||||
readonly variant?: ariaComponents.ButtonProps['variant']
|
||||
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 (
|
||||
<ariaComponents.Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
icon={showIcon === true ? PaywallBlocked : null}
|
||||
iconPosition="end"
|
||||
tooltip={getText('paywallScreenDescription', levelLabel)}
|
||||
{...buttonProps}
|
||||
>
|
||||
{showChildren && childrenContent}
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
|
||||
const PaywallDevtoolsContext = React.createContext<{
|
||||
features: Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
|
||||
}>({
|
||||
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<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
|
||||
>({
|
||||
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 (
|
||||
<PaywallDevtoolsContext.Provider value={{ features }}>
|
||||
{children}
|
||||
|
||||
<Portal>
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button
|
||||
icon={DevtoolsLogo}
|
||||
aria-label={getText('paywallDevtoolsButtonLabel')}
|
||||
variant="icon"
|
||||
rounded="full"
|
||||
size="large"
|
||||
className="fixed bottom-16 right-4 z-50"
|
||||
data-ignore-click-outside
|
||||
/>
|
||||
|
||||
<ariaComponents.Popover>
|
||||
<ariaComponents.Text.Heading>
|
||||
{getText('paywallDevtoolsPopoverHeading')}
|
||||
</ariaComponents.Text.Heading>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{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 (
|
||||
<div key={feature} className="flex flex-col">
|
||||
<aria.Switch
|
||||
className="group flex items-center gap-1"
|
||||
isSelected={configuration.isForceEnabled ?? true}
|
||||
onChange={value => {
|
||||
onConfigurationChange(featureName, {
|
||||
isForceEnabled: value,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="box-border flex h-[14px] w-[22px] shrink-0 cursor-default rounded-full border border-solid border-white/30 bg-yellow-600 bg-clip-padding p-[2px] shadow-inner outline-none ring-black transition duration-200 ease-in-out group-focus-visible:ring-2 group-pressed:bg-yellow-700 group-selected:bg-amber-800 group-selected:group-pressed:bg-amber-900">
|
||||
<span className="h-2 w-2 translate-x-0 transform rounded-full bg-white shadow transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
|
||||
</div>
|
||||
|
||||
<ariaComponents.Text className="flex-1">{getText(label)}</ariaComponents.Text>
|
||||
</aria.Switch>
|
||||
<ariaComponents.Text variant="body" color="disabled">
|
||||
{getText(descriptionTextId)}
|
||||
</ariaComponents.Text>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ariaComponents.Popover>
|
||||
</ariaComponents.DialogTrigger>
|
||||
</Portal>
|
||||
</PaywallDevtoolsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that provides access to the paywall devtools.
|
||||
*/
|
||||
export function usePaywallDevtools() {
|
||||
return React.useContext(PaywallDevtoolsContext)
|
||||
}
|
@ -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 (
|
||||
<div className={tw.twMerge('flex w-full items-center gap-1', className)}>
|
||||
<SvgMask src={LockIcon} className="-mt-0.5 h-4 w-4" />
|
||||
<ariaComponents.Text variant="subtitle">
|
||||
{getText('paywallAvailabilityLevel', levelLabel)}
|
||||
</ariaComponents.Text>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for the Paywall components.
|
||||
*/
|
||||
export * from './PaywallLock'
|
||||
export * from './PaywallBulletPoints'
|
||||
export * from './PaywallButton'
|
||||
export * from './PaywallDevtools'
|
@ -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 */
|
@ -82,14 +82,17 @@ export function Result(props: ResultProps) {
|
||||
) : null}
|
||||
|
||||
{typeof title === 'string' ? (
|
||||
<aria.Heading level={2} className="mb-2 text-2xl leading-10">
|
||||
<aria.Heading level={2} className="mb-2 text-2xl leading-10 text-primary/60">
|
||||
{title}
|
||||
</aria.Heading>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
|
||||
<aria.Text elementType="p" className="max-w-[750px] text-balance text-lg leading-6">
|
||||
<aria.Text
|
||||
elementType="p"
|
||||
className="max-w-[750px] text-balance text-lg leading-6 text-primary/60"
|
||||
>
|
||||
{subtitle}
|
||||
</aria.Text>
|
||||
|
||||
|
@ -3,6 +3,8 @@ import * as React from 'react'
|
||||
|
||||
import Plus2Icon from 'enso-assets/plus2.svg'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
@ -10,9 +12,10 @@ import AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
|
||||
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||
|
||||
@ -41,6 +44,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
const { category, dispatchAssetEvent, setQuery } = state
|
||||
const asset = item.item
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const self = asset.permissions?.find(
|
||||
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||
@ -83,10 +91,21 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
{backendModule.getAssetPermissionName(other)}
|
||||
</PermissionDisplay>
|
||||
))}
|
||||
{managesThisAsset && (
|
||||
<UnstyledButton
|
||||
ref={plusButtonRef}
|
||||
className="shrink-0 rounded-full transparent group-hover:opacity-100 focus-visible:opacity-100"
|
||||
{isUnderPaywall && (
|
||||
<paywall.PaywallDialogButton
|
||||
feature="share"
|
||||
variant="icon"
|
||||
size="xxsmall"
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
children={false}
|
||||
/>
|
||||
)}
|
||||
{managesThisAsset && !isUnderPaywall && (
|
||||
<ariaComponents.Button
|
||||
variant="icon"
|
||||
size="xsmall"
|
||||
icon={Plus2Icon}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
@ -104,9 +123,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<img className="size-plus-icon" src={Plus2Icon} />
|
||||
</UnstyledButton>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
@ -3,12 +3,16 @@ import * as React from 'react'
|
||||
|
||||
import PeopleIcon from 'enso-assets/people.svg'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import Button from '#/components/styled/Button'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
|
||||
/** A heading for the "Shared with" column. */
|
||||
export default function SharedWithColumnHeading(props: column.AssetColumnHeadingProps) {
|
||||
@ -16,18 +20,35 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading
|
||||
const { hideColumn } = state
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
|
||||
return (
|
||||
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
|
||||
<Button
|
||||
active
|
||||
image={PeopleIcon}
|
||||
className="size-icon"
|
||||
alt={getText('sharedWithColumnHide')}
|
||||
<ariaComponents.Button
|
||||
variant="icon"
|
||||
size="xsmall"
|
||||
icon={PeopleIcon}
|
||||
aria-label={getText('sharedWithColumnHide')}
|
||||
onPress={() => {
|
||||
hideColumn(columnUtils.Column.sharedWith)
|
||||
}}
|
||||
/>
|
||||
<aria.Text className="text-header">{getText('sharedWithColumnName')}</aria.Text>
|
||||
<aria.Text className="text-header flex items-center gap-0.5">
|
||||
{getText('sharedWithColumnName')}
|
||||
|
||||
{isUnderPaywall && (
|
||||
<paywall.PaywallDialogButton
|
||||
feature="share"
|
||||
variant="icon"
|
||||
children={false}
|
||||
size="xxsmall"
|
||||
/>
|
||||
)}
|
||||
</aria.Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ export default function HorizontalMenuBar(props: HorizontalMenuBarProps) {
|
||||
return (
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<div className="flex h-row gap-drive-bar" {...innerProps}>
|
||||
<div className="flex items-center gap-drive-bar" {...innerProps}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,10 +1,8 @@
|
||||
/** @file A styled button representing a tab on a sidebar. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
import type * as aria from '#/components/aria'
|
||||
import * as ariaComponent from '#/components/AriaComponents'
|
||||
|
||||
// ========================
|
||||
// === SidebarTabButton ===
|
||||
@ -20,38 +18,23 @@ export interface SidebarTabButtonProps {
|
||||
readonly icon: string
|
||||
readonly label: string
|
||||
readonly onPress: (event: aria.PressEvent) => void
|
||||
readonly isPending?: boolean
|
||||
}
|
||||
|
||||
/** A styled button representing a tab on a sidebar. */
|
||||
export default function SidebarTabButton(props: SidebarTabButtonProps) {
|
||||
const {
|
||||
isDisabled = false,
|
||||
autoFocus = false,
|
||||
active = false,
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
isPending = false,
|
||||
} = props
|
||||
const { isDisabled = false, active = false, icon, label, onPress } = props
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
autoFocus={autoFocus}
|
||||
<ariaComponent.Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
onPress={onPress}
|
||||
isDisabled={isDisabled}
|
||||
className={`relative rounded-full ${active ? 'focus-default' : ''}`}
|
||||
icon={icon}
|
||||
rounded="full"
|
||||
className={active ? 'bg-white opacity-100' : ''}
|
||||
>
|
||||
<div
|
||||
className={`button icon-with-text h-row px-button-x transition-colors selectable hover:bg-selected-frame ${active ? 'disabled bg-selected-frame active' : ''}`}
|
||||
>
|
||||
{active && isPending ? (
|
||||
<StatelessSpinner state={statelessSpinner.SpinnerState.loadingMedium} size={16} />
|
||||
) : (
|
||||
<SvgMask src={icon} />
|
||||
)}
|
||||
<aria.Text className="text">{label}</aria.Text>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
{label}
|
||||
</ariaComponent.Button>
|
||||
)
|
||||
}
|
||||
|
@ -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<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',
|
||||
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<Key extends PaywallFeatureName = PaywallFeatureName> =
|
||||
BasicFeatureConfiguration & {
|
||||
readonly name: Key
|
||||
readonly label: (typeof PAYWALL_FEATURES_LABELS)[Key]
|
||||
readonly meta: (typeof PAYWALL_FEATURE_META)[Key]
|
||||
}
|
||||
|
||||
const PAYWALL_CONFIGURATION: Record<PaywallFeatureName, BasicFeatureConfiguration> = {
|
||||
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<Key extends PaywallFeatureName>(
|
||||
feature: Key
|
||||
): FeatureConfiguration<Key> {
|
||||
const configuration = PAYWALL_CONFIGURATION[feature]
|
||||
|
||||
return {
|
||||
...configuration,
|
||||
name: feature,
|
||||
label: PAYWALL_FEATURES_LABELS[feature],
|
||||
meta: PAYWALL_FEATURE_META[feature],
|
||||
}
|
||||
}
|
12
app/ide-desktop/lib/dashboard/src/hooks/billing/index.ts
Normal file
12
app/ide-desktop/lib/dashboard/src/hooks/billing/index.ts
Normal file
@ -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'
|
@ -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(
|
||||
<Key extends paywallFeaturesConfiguration.PaywallFeatureName>(feature: Key) => {
|
||||
return paywallFeaturesConfiguration.getFeatureConfiguration<Key>(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,51 @@
|
||||
/**
|
||||
* @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 {
|
||||
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
|
||||
}
|
@ -19,6 +19,8 @@ import LoadingScreen from '#/pages/authentication/LoadingScreen'
|
||||
|
||||
import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
|
||||
import * as reactQueryDevtools from './ReactQueryDevtools'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -101,13 +103,15 @@ function run(props: Omit<app.AppProps, 'portalRoot'>) {
|
||||
{...props}
|
||||
supportsDeepLinks={actuallySupportsDeepLinks}
|
||||
portalRoot={portalRoot}
|
||||
/>
|
||||
)}
|
||||
</React.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</reactQuery.QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
/>)}
|
||||
</React.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
|
||||
<reactQueryDevtools.ReactQueryDevtools />
|
||||
</reactQuery.QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
/** Global configuration for the {@link App} component. */
|
||||
|
@ -3,6 +3,7 @@ import * as React from 'react'
|
||||
|
||||
import * as toast from 'react-toastify'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
@ -22,6 +23,7 @@ import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
import type * as assetRow from '#/components/dashboard/AssetRow'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import Separator from '#/components/styled/Separator'
|
||||
|
||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
@ -74,6 +76,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
const self = asset.permissions?.find(
|
||||
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||
)
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
|
||||
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
|
||||
@ -310,28 +316,38 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
/>
|
||||
)}
|
||||
{isCloud && <Separator hidden={hidden} />}
|
||||
|
||||
{isCloud && managesThisAsset && self != null && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="share"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
self={self}
|
||||
eventTarget={eventTarget}
|
||||
doRemoveSelf={() => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.removeSelf,
|
||||
id: asset.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{isUnderPaywall && (
|
||||
<paywall.ContextMenuEntry feature="share" action="share" hidden={hidden} />
|
||||
)}
|
||||
|
||||
{!isUnderPaywall && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="share"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
self={self}
|
||||
eventTarget={eventTarget}
|
||||
doRemoveSelf={() => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.removeSelf,
|
||||
id: asset.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCloud && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
|
@ -7,8 +7,8 @@ import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal'
|
||||
|
||||
@ -31,8 +31,10 @@ export default function DeleteUserAccountSettingsSection() {
|
||||
className="flex flex-col items-start gap-settings-section-header rounded-2.5xl border-2 border-danger px-[1rem] pb-[0.9375rem] pt-[0.5625rem]"
|
||||
>
|
||||
<div className="flex gap-buttons">
|
||||
<UnstyledButton
|
||||
className="button bg-danger px-delete-user-account-button-x text-inversed opacity-full hover:opacity-full"
|
||||
<ariaComponents.Button
|
||||
variant="delete"
|
||||
size="medium"
|
||||
rounded="full"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteUserModal
|
||||
@ -44,11 +46,9 @@ export default function DeleteUserAccountSettingsSection() {
|
||||
)
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text inline-block">
|
||||
{getText('deleteUserAccountButtonLabel')}
|
||||
</aria.Text>
|
||||
</UnstyledButton>
|
||||
<aria.Text className="text my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
|
||||
{getText('deleteUserAccountButtonLabel')}
|
||||
</ariaComponents.Button>
|
||||
<aria.Text className="text-md my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
|
@ -3,6 +3,9 @@ import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -15,6 +18,8 @@ import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import * as paywallSettingsLayout from './withPaywall'
|
||||
|
||||
// ==========================
|
||||
// === MembersSettingsTab ===
|
||||
// ==========================
|
||||
@ -22,9 +27,16 @@ import type Backend from '#/services/Backend'
|
||||
const LIST_USERS_STALE_TIME = 60_000
|
||||
|
||||
/** Settings tab for viewing and editing organization members. */
|
||||
export default function MembersSettingsTab() {
|
||||
|
||||
/**
|
||||
* Settings tab for viewing and editing organization members.
|
||||
*/
|
||||
function MembersSettingsTab() {
|
||||
const { getText } = textProvider.useText()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
|
||||
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
|
||||
|
||||
const [{ data: members }, { data: invitations }] = reactQuery.useSuspenseQueries({
|
||||
queries: [
|
||||
@ -41,10 +53,21 @@ export default function MembersSettingsTab() {
|
||||
],
|
||||
})
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('inviteUserFull')
|
||||
const feature = getFeature('inviteUser')
|
||||
|
||||
const seatsLeft = isUnderPaywall
|
||||
? feature.meta.maxSeats - (members.length + invitations.length)
|
||||
: null
|
||||
|
||||
return (
|
||||
<SettingsPage>
|
||||
<SettingsSection noFocusArea title={getText('members')} className="overflow-hidden">
|
||||
<MembersSettingsTabBar />
|
||||
<MembersSettingsTabBar
|
||||
seatsLeft={seatsLeft}
|
||||
seatsTotal={feature.meta.maxSeats}
|
||||
feature="inviteUserFull"
|
||||
/>
|
||||
|
||||
<table className="table-fixed self-start rounded-rows">
|
||||
<thead>
|
||||
@ -57,6 +80,7 @@ export default function MembersSettingsTab() {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="select-text">
|
||||
{members.map(member => (
|
||||
<tr key={member.email} className="group h-row rounded-rows-child">
|
||||
@ -157,17 +181,12 @@ function RemoveMemberButton(props: RemoveMemberButtonProps) {
|
||||
const { email } = props
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
|
||||
const removeMutation = reactQuery.useMutation({
|
||||
mutationKey: ['removeUser', email],
|
||||
mutationFn: async () => {
|
||||
// TODO: Implement remove member mutation
|
||||
},
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['listUsers'],
|
||||
}),
|
||||
meta: { invalidates: [['listUsers']], awaitInvalidates: true },
|
||||
})
|
||||
|
||||
return (
|
||||
@ -188,15 +207,11 @@ function RemoveInvitationButton(props: RemoveMemberButtonProps) {
|
||||
const { backend, email } = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
|
||||
const removeMutation = reactQuery.useMutation({
|
||||
mutationKey: ['resendInvitation', email],
|
||||
mutationFn: () => backend.deleteInvitation(email),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['listInvitations'],
|
||||
}),
|
||||
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
|
||||
})
|
||||
|
||||
return (
|
||||
@ -212,3 +227,5 @@ function RemoveInvitationButton(props: RemoveMemberButtonProps) {
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default paywallSettingsLayout.withPaywall(MembersSettingsTab, { feature: 'inviteUser' })
|
||||
|
@ -1,9 +1,12 @@
|
||||
/** @file Button bar for managing organization members. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as paywallComponents from '#/components/Paywall'
|
||||
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||
|
||||
import InviteUsersModal from '#/modals/InviteUsersModal'
|
||||
@ -12,19 +15,42 @@ import InviteUsersModal from '#/modals/InviteUsersModal'
|
||||
// === MembersSettingsTabBar ===
|
||||
// =============================
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface MembersSettingsTabBarProps {
|
||||
readonly seatsLeft: number | null
|
||||
readonly seatsTotal: number
|
||||
readonly feature: billingHooks.PaywallFeatureName
|
||||
}
|
||||
|
||||
/** Button bar for managing organization members. */
|
||||
export default function MembersSettingsTabBar() {
|
||||
export default function MembersSettingsTabBar(props: MembersSettingsTabBarProps) {
|
||||
const { seatsLeft, seatsTotal, feature } = props
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
return (
|
||||
<HorizontalMenuBar>
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button variant="cancel" rounded="full" size="small">
|
||||
<ariaComponents.Button variant="cancel" rounded="full" size="medium">
|
||||
{getText('inviteMembers')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
<InviteUsersModal />
|
||||
</ariaComponents.DialogTrigger>
|
||||
|
||||
<div>
|
||||
{seatsLeft != null && (
|
||||
<ariaComponents.Text>
|
||||
{seatsLeft <= 0 ? getText('noSeatsLeft') : getText('seatsLeft', seatsLeft, seatsTotal)}{' '}
|
||||
<paywallComponents.PaywallDialogButton
|
||||
feature={feature}
|
||||
variant="link"
|
||||
showIcon={false}
|
||||
/>
|
||||
</ariaComponents.Text>
|
||||
)}
|
||||
</div>
|
||||
</HorizontalMenuBar>
|
||||
)
|
||||
}
|
||||
|
@ -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'
|
||||
|
||||
@ -16,10 +17,11 @@ import UserGroupRow from '#/layouts/Settings/UserGroupRow'
|
||||
import UserGroupUserRow from '#/layouts/Settings/UserGroupUserRow'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as paywallComponents from '#/components/Paywall'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import NewUserGroupModal from '#/modals/NewUserGroupModal'
|
||||
|
||||
@ -27,14 +29,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.useStrictBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
@ -48,6 +46,12 @@ export default function UserGroupsSettingsTab() {
|
||||
[users]
|
||||
)
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('userGroupsFull')
|
||||
const userGroupsLeft = isUnderPaywall ? 1 - (userGroups?.length ?? 0) : Infinity
|
||||
const shouldDisplayPaywall = isUnderPaywall ? userGroupsLeft <= 0 : false
|
||||
|
||||
const usersByGroup = React.useMemo(() => {
|
||||
const map = new Map<backendModule.UserGroupId, backendModule.User[]>()
|
||||
for (const otherUser of users ?? []) {
|
||||
@ -212,48 +216,71 @@ export default function UserGroupsSettingsTab() {
|
||||
<div className="flex h-3/5 w-settings-main-section max-w-full flex-col gap-settings-subsection lg:h-[unset] lg:min-w">
|
||||
<SettingsSection noFocusArea title={getText('userGroups')} className="overflow-hidden">
|
||||
<HorizontalMenuBar>
|
||||
<UnstyledButton
|
||||
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
||||
onPress={event => {
|
||||
const placeholderId = backendModule.newPlaceholderUserGroupId()
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(
|
||||
<NewUserGroupModal
|
||||
event={position}
|
||||
userGroups={userGroups}
|
||||
onSubmit={groupName => {
|
||||
if (user != null) {
|
||||
const id = placeholderId
|
||||
const { organizationId } = user
|
||||
setUserGroups(oldUserGroups => [
|
||||
...(oldUserGroups ?? []),
|
||||
{ organizationId, id, groupName },
|
||||
])
|
||||
}
|
||||
}}
|
||||
onSuccess={newUserGroup => {
|
||||
setUserGroups(
|
||||
oldUserGroups =>
|
||||
oldUserGroups?.map(userGroup =>
|
||||
userGroup.id !== placeholderId ? userGroup : newUserGroup
|
||||
) ?? null
|
||||
)
|
||||
}}
|
||||
onFailure={() => {
|
||||
setUserGroups(
|
||||
oldUserGroups =>
|
||||
oldUserGroups?.filter(userGroup => userGroup.id !== placeholderId) ?? null
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||
{getText('newUserGroup')}
|
||||
</aria.Text>
|
||||
</UnstyledButton>
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldDisplayPaywall && (
|
||||
<paywallComponents.PaywallDialogButton
|
||||
feature="userGroupsFull"
|
||||
variant="cancel"
|
||||
size="medium"
|
||||
rounded="full"
|
||||
iconPosition="end"
|
||||
tooltip={getText('userGroupsPaywallMessage')}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</paywallComponents.PaywallDialogButton>
|
||||
)}
|
||||
{!shouldDisplayPaywall && (
|
||||
<ariaComponents.Button
|
||||
variant="cancel"
|
||||
rounded="full"
|
||||
size="medium"
|
||||
onPress={event => {
|
||||
const placeholderId = backendModule.newPlaceholderUserGroupId()
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(
|
||||
<NewUserGroupModal
|
||||
event={position}
|
||||
userGroups={userGroups}
|
||||
onSubmit={groupName => {
|
||||
const id = placeholderId
|
||||
const { organizationId } = user
|
||||
setUserGroups(oldUserGroups => [
|
||||
...(oldUserGroups ?? []),
|
||||
{ organizationId, id, groupName },
|
||||
])
|
||||
}}
|
||||
onSuccess={newUserGroup => {
|
||||
setUserGroups(
|
||||
oldUserGroups =>
|
||||
oldUserGroups?.map(userGroup =>
|
||||
userGroup.id !== placeholderId ? userGroup : newUserGroup
|
||||
) ?? null
|
||||
)
|
||||
}}
|
||||
onFailure={() => {
|
||||
setUserGroups(
|
||||
oldUserGroups =>
|
||||
oldUserGroups?.filter(userGroup => userGroup.id !== placeholderId) ??
|
||||
null
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
|
||||
{isUnderPaywall && (
|
||||
<span className="text-xs">
|
||||
{userGroupsLeft <= 0
|
||||
? getText('userGroupsPaywallMessage')
|
||||
: getText('userGroupsLimitMessage', userGroupsLeft)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</HorizontalMenuBar>
|
||||
<div
|
||||
ref={rootRef}
|
||||
@ -318,6 +345,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
|
@ -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 (
|
||||
<div className={PAYWALL_LAYOUT_STYLES({ className })}>
|
||||
<paywallComponents.PaywallScreen feature={feature} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<P extends Record<string, unknown>>(
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Component: React.ComponentType<P>,
|
||||
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 <PaywallSettingsLayout feature={feature} className={className} />
|
||||
} else {
|
||||
return <Component {...componentProps} />
|
||||
}
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ import * as textProvider from '#/providers/TextProvider'
|
||||
import SettingsTab from '#/layouts/Settings/SettingsTab'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import SidebarTabButton from '#/components/styled/SidebarTabButton'
|
||||
|
||||
@ -133,19 +134,22 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
|
||||
>
|
||||
{section.name}
|
||||
</aria.Header>
|
||||
{section.tabs.map(tab => (
|
||||
<SidebarTabButton
|
||||
key={tab.settingsTab}
|
||||
isDisabled={(tab.organizationOnly ?? false) && !isUserInOrganization}
|
||||
id={tab.settingsTab}
|
||||
icon={tab.icon}
|
||||
label={tab.name}
|
||||
active={tab.settingsTab === settingsTab}
|
||||
onPress={() => {
|
||||
setSettingsTab(tab.settingsTab)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<ariaComponents.ButtonGroup gap="xxsmall" direction="column" align="start">
|
||||
{section.tabs.map(tab => (
|
||||
<SidebarTabButton
|
||||
key={tab.settingsTab}
|
||||
isDisabled={(tab.organizationOnly ?? false) && !isUserInOrganization}
|
||||
id={tab.settingsTab}
|
||||
icon={tab.icon}
|
||||
label={tab.name}
|
||||
active={tab.settingsTab === settingsTab}
|
||||
onPress={() => {
|
||||
setSettingsTab(tab.settingsTab)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ariaComponents.ButtonGroup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -6,6 +6,8 @@ import DefaultUserIcon from 'enso-assets/default_user.svg'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as billing from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
@ -16,6 +18,7 @@ import UserMenu from '#/layouts/UserMenu'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
@ -50,20 +53,29 @@ export default function UserBar(props: UserBarProps) {
|
||||
const { setModal, updateModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user?.plan })
|
||||
|
||||
const self =
|
||||
user != null
|
||||
? projectAsset?.permissions?.find(
|
||||
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
|
||||
) ?? null
|
||||
: null
|
||||
|
||||
const shouldShowShareButton =
|
||||
backend.type === backendModule.BackendType.remote &&
|
||||
page === pageSwitcher.Page.editor &&
|
||||
projectAsset != null &&
|
||||
setProjectAsset != null &&
|
||||
self != null
|
||||
|
||||
const shouldShowUpgradeButton = isFeatureUnderPaywall('inviteUser')
|
||||
|
||||
const shouldShowInviteButton =
|
||||
sessionType === authProvider.UserSessionType.full && !shouldShowShareButton
|
||||
sessionType === authProvider.UserSessionType.full &&
|
||||
!shouldShowShareButton &&
|
||||
!shouldShowUpgradeButton
|
||||
|
||||
return (
|
||||
<FocusArea active={!invisible} direction="horizontal">
|
||||
@ -83,6 +95,17 @@ export default function UserBar(props: UserBarProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{shouldShowUpgradeButton && (
|
||||
<paywall.PaywallDialogButton
|
||||
feature={'inviteUser'}
|
||||
rounded="full"
|
||||
size="xsmall"
|
||||
variant="tertiary"
|
||||
>
|
||||
{getText('invite')}
|
||||
</paywall.PaywallDialogButton>
|
||||
)}
|
||||
|
||||
{shouldShowInviteButton && (
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button rounded="full" size="xsmall" variant="tertiary">
|
||||
@ -101,6 +124,7 @@ export default function UserBar(props: UserBarProps) {
|
||||
>
|
||||
{getText('upgrade')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
{shouldShowShareButton && (
|
||||
<UnstyledButton
|
||||
className="text my-auto rounded-full bg-share px-button-x text-inversed"
|
||||
|
@ -4,13 +4,16 @@ import * as React from 'react'
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import isEmail from 'validator/es/lib/isEmail'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as paywallComponents from '#/components/Paywall'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
@ -39,23 +42,46 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
const { onSubmitted, organizationId } = props
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
|
||||
const [inputValue, setInputValue] = React.useState('')
|
||||
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const inputRef = React.useRef<HTMLDivElement>(null)
|
||||
const formRef = React.useRef<HTMLFormElement>(null)
|
||||
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
|
||||
const inviteUserMutation = reactQuery.useMutation({
|
||||
mutationKey: ['inviteUser'],
|
||||
mutationFn: async (params: InviteUsersMutationParams) =>
|
||||
mutationFn: (params: InviteUsersMutationParams) =>
|
||||
backend.inviteUser({ organizationId: params.organizationId, userEmail: params.email }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['listInvitations'] })
|
||||
meta: {
|
||||
invalidates: [['listInvitations']],
|
||||
awaitInvalidates: true,
|
||||
},
|
||||
})
|
||||
|
||||
const [{ data: usersCount }, { data: invitationsCount }] = reactQuery.useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['listInvitations'],
|
||||
queryFn: async () => backend.listInvitations(),
|
||||
select: (invitations: backendModule.Invitation[]) => invitations.length,
|
||||
},
|
||||
{
|
||||
queryKey: ['listUsers'],
|
||||
queryFn: async () => backend.listUsers(),
|
||||
select: (users: backendModule.User[]) => users.length,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('inviteUserFull')
|
||||
const feature = getFeature('inviteUser')
|
||||
|
||||
const seatsLeft = isUnderPaywall
|
||||
? Math.max(feature.meta.maxSeats - (usersCount + invitationsCount), 0)
|
||||
: Infinity
|
||||
|
||||
const getEmailsFromInput = eventCallbackHooks.useEventCallback((value: string) => {
|
||||
return parserUserEmails.parseUserEmails(value)
|
||||
})
|
||||
@ -103,10 +129,14 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
if (trimmedValue === '' || entries.length === 0) {
|
||||
return getText('emailIsRequired')
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
if (!isEmail(entry.email)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return getText('emailIsInvalid')
|
||||
if (entries.length > seatsLeft) {
|
||||
return getText('inviteFormSeatsLeftError', entries.length - seatsLeft)
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
if (!isEmail(entry.email)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return getText('emailIsInvalid')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,13 +186,11 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<aria.Text className="mb-2 text-sm text-primary">
|
||||
{getText('inviteFormDescription')}
|
||||
</aria.Text>
|
||||
<ariaComponents.Text className="mb-2">{getText('inviteFormDescription')}</ariaComponents.Text>
|
||||
|
||||
<ariaComponents.ResizableContentEditableInput
|
||||
ref={inputRef}
|
||||
className="mb-4"
|
||||
className="mb-2"
|
||||
name="email"
|
||||
aria-label={getText('inviteEmailFieldLabel')}
|
||||
placeholder={getText('inviteEmailFieldPlaceholder')}
|
||||
@ -182,15 +210,24 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
{inviteUserMutation.isError && (
|
||||
<ariaComponents.Alert variant="error" className="mb-4">
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
{getText('arbitraryErrorTitle')}. {getText('arbitraryErrorSubtitle')}
|
||||
{getText('arbitraryErrorTitle')}.{' '}
|
||||
{getText('arbitraryErrorSubtitle')}
|
||||
</ariaComponents.Alert>
|
||||
)}
|
||||
|
||||
{isUnderPaywall && (
|
||||
<paywallComponents.PaywallAlert
|
||||
className="mb-4"
|
||||
feature="inviteUserFull"
|
||||
label={getText('inviteFormSeatsLeft', seatsLeft)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ariaComponents.Button
|
||||
type="submit"
|
||||
variant="tertiary"
|
||||
rounded="medium"
|
||||
size="medium"
|
||||
rounded="xlarge"
|
||||
size="large"
|
||||
loading={inviteUserMutation.isPending}
|
||||
fullWidth
|
||||
>
|
||||
|
@ -1,10 +1,11 @@
|
||||
/** @file A modal with inputs for user email and permission level. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import * as toast from 'react-toastify'
|
||||
import isEmail from 'validator/es/lib/isEmail'
|
||||
|
||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
@ -13,12 +14,13 @@ import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import Autocomplete from '#/components/Autocomplete'
|
||||
import Permission from '#/components/dashboard/Permission'
|
||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||
import Modal from '#/components/Modal'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
@ -58,7 +60,12 @@ export default function ManagePermissionsModal<
|
||||
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
|
||||
>(props: ManagePermissionsModalProps<Asset>) {
|
||||
const { item, setItem, self, doRemoveSelf, eventTarget } = props
|
||||
const { user: user } = authProvider.useNonPartialUserSession()
|
||||
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('shareFull')
|
||||
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
@ -98,9 +105,9 @@ export default function ManagePermissionsModal<
|
||||
permissions.every(
|
||||
permission =>
|
||||
permission.permission !== permissionsModule.PermissionAction.own ||
|
||||
(backendModule.isUserPermission(permission) && permission.user.userId === user?.userId)
|
||||
(backendModule.isUserPermission(permission) && permission.user.userId === user.userId)
|
||||
),
|
||||
[user?.userId, permissions, self.permission]
|
||||
[user.userId, permissions, self.permission]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -109,299 +116,300 @@ export default function ManagePermissionsModal<
|
||||
setItem(object.merger({ permissions } as Partial<Asset>))
|
||||
}, [permissions, /* should never change */ setItem])
|
||||
|
||||
if (backend.type === backendModule.BackendType.local || user == null) {
|
||||
// This should never happen - the local backend does not have the "shared with" column,
|
||||
// and `organization` is absent only when offline - in which case the user should only
|
||||
// be able to access the local backend.
|
||||
// This MUST be an error, otherwise the hooks below are considered as conditionally called.
|
||||
throw new Error('Cannot share assets on the local backend.')
|
||||
} else {
|
||||
const listedUsers = asyncEffectHooks.useAsyncEffect(null, () => backend.listUsers(), [])
|
||||
const listedUserGroups = asyncEffectHooks.useAsyncEffect(
|
||||
null,
|
||||
() => backend.listUserGroups(),
|
||||
[]
|
||||
)
|
||||
const canAdd = React.useMemo(
|
||||
() => [
|
||||
...(listedUsers ?? []).filter(
|
||||
listedUser =>
|
||||
!permissionsHoldersNames.has(listedUser.name) &&
|
||||
!emailsOfUsersWithPermission.has(listedUser.email)
|
||||
),
|
||||
...(listedUserGroups ?? []).filter(
|
||||
userGroup => !permissionsHoldersNames.has(userGroup.groupName)
|
||||
),
|
||||
],
|
||||
[emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups]
|
||||
)
|
||||
const willInviteNewUser = React.useMemo(() => {
|
||||
if (usersAndUserGroups.length !== 0 || email == null || email === '') {
|
||||
return false
|
||||
} else {
|
||||
const lowercase = email.toLowerCase()
|
||||
return (
|
||||
lowercase !== '' &&
|
||||
!permissionsHoldersNames.has(lowercase) &&
|
||||
!emailsOfUsersWithPermission.has(lowercase) &&
|
||||
!canAdd.some(
|
||||
userOrGroup =>
|
||||
('name' in userOrGroup && userOrGroup.name.toLowerCase() === lowercase) ||
|
||||
('email' in userOrGroup && userOrGroup.email.toLowerCase() === lowercase) ||
|
||||
('groupName' in userOrGroup && userOrGroup.groupName.toLowerCase() === lowercase)
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [
|
||||
usersAndUserGroups.length,
|
||||
email,
|
||||
emailsOfUsersWithPermission,
|
||||
permissionsHoldersNames,
|
||||
canAdd,
|
||||
])
|
||||
const { data: listedUsers } = reactQuery.useQuery({
|
||||
queryKey: ['listUsers', isUnderPaywall],
|
||||
queryFn: () => backend.listUsers(),
|
||||
enabled: !isUnderPaywall,
|
||||
})
|
||||
|
||||
const doSubmit = async () => {
|
||||
if (willInviteNewUser) {
|
||||
try {
|
||||
setUserAndUserGroups([])
|
||||
setEmail('')
|
||||
if (email != null) {
|
||||
await backend.inviteUser({
|
||||
organizationId: user.organizationId,
|
||||
userEmail: backendModule.EmailAddress(email),
|
||||
})
|
||||
toast.toast.success(getText('inviteSuccess', email))
|
||||
}
|
||||
} catch (error) {
|
||||
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
|
||||
}
|
||||
} else {
|
||||
const { data: listedUserGroups } = reactQuery.useQuery({
|
||||
queryKey: ['listUserGroups'],
|
||||
queryFn: () => backend.listUserGroups(),
|
||||
})
|
||||
|
||||
const canAdd = React.useMemo(
|
||||
() => [
|
||||
...(listedUsers ?? []).filter(
|
||||
listedUser =>
|
||||
!permissionsHoldersNames.has(listedUser.name) &&
|
||||
!emailsOfUsersWithPermission.has(listedUser.email)
|
||||
),
|
||||
...(listedUserGroups ?? []).filter(
|
||||
userGroup => !permissionsHoldersNames.has(userGroup.groupName)
|
||||
),
|
||||
],
|
||||
[emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups]
|
||||
)
|
||||
const willInviteNewUser = React.useMemo(() => {
|
||||
if (usersAndUserGroups.length !== 0 || email == null || email === '') {
|
||||
return false
|
||||
} else {
|
||||
const lowercase = email.toLowerCase()
|
||||
return (
|
||||
lowercase !== '' &&
|
||||
!permissionsHoldersNames.has(lowercase) &&
|
||||
!emailsOfUsersWithPermission.has(lowercase) &&
|
||||
!canAdd.some(
|
||||
userOrGroup =>
|
||||
('name' in userOrGroup && userOrGroup.name.toLowerCase() === lowercase) ||
|
||||
('email' in userOrGroup && userOrGroup.email.toLowerCase() === lowercase) ||
|
||||
('groupName' in userOrGroup && userOrGroup.groupName.toLowerCase() === lowercase)
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [
|
||||
usersAndUserGroups.length,
|
||||
email,
|
||||
emailsOfUsersWithPermission,
|
||||
permissionsHoldersNames,
|
||||
canAdd,
|
||||
])
|
||||
|
||||
const doSubmit = async () => {
|
||||
if (willInviteNewUser) {
|
||||
try {
|
||||
setUserAndUserGroups([])
|
||||
const addedPermissions = usersAndUserGroups.map<backendModule.AssetPermission>(
|
||||
newUserOrUserGroup =>
|
||||
'userId' in newUserOrUserGroup
|
||||
? { user: newUserOrUserGroup, permission: action }
|
||||
: { userGroup: newUserOrUserGroup, permission: action }
|
||||
)
|
||||
const addedUsersIds = new Set(
|
||||
addedPermissions.flatMap(permission =>
|
||||
backendModule.isUserPermission(permission) ? [permission.user.userId] : []
|
||||
)
|
||||
)
|
||||
const addedUserGroupsIds = new Set(
|
||||
addedPermissions.flatMap(permission =>
|
||||
backendModule.isUserGroupPermission(permission) ? [permission.userGroup.id] : []
|
||||
)
|
||||
)
|
||||
const isPermissionNotBeingOverwritten = (permission: backendModule.AssetPermission) =>
|
||||
backendModule.isUserPermission(permission)
|
||||
? !addedUsersIds.has(permission.user.userId)
|
||||
: !addedUserGroupsIds.has(permission.userGroup.id)
|
||||
|
||||
try {
|
||||
setPermissions(oldPermissions =>
|
||||
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort(
|
||||
backendModule.compareAssetPermissions
|
||||
)
|
||||
)
|
||||
await backend.createPermission({
|
||||
actorsIds: addedPermissions.map(permission =>
|
||||
backendModule.isUserPermission(permission)
|
||||
? permission.user.userId
|
||||
: permission.userGroup.id
|
||||
),
|
||||
resourceId: item.id,
|
||||
action: action,
|
||||
setEmail('')
|
||||
if (email != null) {
|
||||
await backend.inviteUser({
|
||||
organizationId: user.organizationId,
|
||||
userEmail: backendModule.EmailAddress(email),
|
||||
})
|
||||
} catch (error) {
|
||||
setPermissions(oldPermissions =>
|
||||
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort(
|
||||
backendModule.compareAssetPermissions
|
||||
)
|
||||
)
|
||||
toastAndLog('setPermissionsError', error)
|
||||
toast.toast.success(getText('inviteSuccess', email))
|
||||
}
|
||||
} catch (error) {
|
||||
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
|
||||
}
|
||||
} else {
|
||||
setUserAndUserGroups([])
|
||||
const addedPermissions = usersAndUserGroups.map<backendModule.AssetPermission>(
|
||||
newUserOrUserGroup =>
|
||||
'userId' in newUserOrUserGroup
|
||||
? { user: newUserOrUserGroup, permission: action }
|
||||
: { userGroup: newUserOrUserGroup, permission: action }
|
||||
)
|
||||
const addedUsersIds = new Set(
|
||||
addedPermissions.flatMap(permission =>
|
||||
backendModule.isUserPermission(permission) ? [permission.user.userId] : []
|
||||
)
|
||||
)
|
||||
const addedUserGroupsIds = new Set(
|
||||
addedPermissions.flatMap(permission =>
|
||||
backendModule.isUserGroupPermission(permission) ? [permission.userGroup.id] : []
|
||||
)
|
||||
)
|
||||
const isPermissionNotBeingOverwritten = (permission: backendModule.AssetPermission) =>
|
||||
backendModule.isUserPermission(permission)
|
||||
? !addedUsersIds.has(permission.user.userId)
|
||||
: !addedUserGroupsIds.has(permission.userGroup.id)
|
||||
|
||||
try {
|
||||
setPermissions(oldPermissions =>
|
||||
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort(
|
||||
backendModule.compareAssetPermissions
|
||||
)
|
||||
)
|
||||
await backend.createPermission({
|
||||
actorsIds: addedPermissions.map(permission =>
|
||||
backendModule.isUserPermission(permission)
|
||||
? permission.user.userId
|
||||
: permission.userGroup.id
|
||||
),
|
||||
resourceId: item.id,
|
||||
action: action,
|
||||
})
|
||||
} catch (error) {
|
||||
setPermissions(oldPermissions =>
|
||||
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort(
|
||||
backendModule.compareAssetPermissions
|
||||
)
|
||||
)
|
||||
toastAndLog('setPermissionsError', error)
|
||||
}
|
||||
}
|
||||
|
||||
const doDelete = async (permissionId: backendModule.UserPermissionIdentifier) => {
|
||||
if (permissionId === self.user.userId) {
|
||||
doRemoveSelf()
|
||||
} else {
|
||||
const oldPermission = permissions.find(
|
||||
permission => backendModule.getAssetPermissionId(permission) === permissionId
|
||||
)
|
||||
try {
|
||||
setPermissions(oldPermissions =>
|
||||
oldPermissions.filter(
|
||||
permission => backendModule.getAssetPermissionId(permission) !== permissionId
|
||||
)
|
||||
)
|
||||
await backend.createPermission({
|
||||
actorsIds: [permissionId],
|
||||
resourceId: item.id,
|
||||
action: null,
|
||||
})
|
||||
} catch (error) {
|
||||
if (oldPermission != null) {
|
||||
setPermissions(oldPermissions =>
|
||||
[...oldPermissions, oldPermission].sort(backendModule.compareAssetPermissions)
|
||||
)
|
||||
}
|
||||
toastAndLog('setPermissionsError', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered={eventTarget == null}
|
||||
className="absolute left top size-full overflow-hidden bg-dim"
|
||||
>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
style={
|
||||
position != null
|
||||
? {
|
||||
left: position.left + window.scrollX,
|
||||
top: position.top + window.scrollY,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
className="sticky w-manage-permissions-modal rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
||||
onClick={mouseEvent => {
|
||||
mouseEvent.stopPropagation()
|
||||
}}
|
||||
onContextMenu={mouseEvent => {
|
||||
mouseEvent.stopPropagation()
|
||||
mouseEvent.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-col gap-modal rounded-default p-modal">
|
||||
<div className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x">
|
||||
<aria.Heading level={2} className="text text-sm font-bold">
|
||||
{getText('invite')}
|
||||
</aria.Heading>
|
||||
{/* Space reserved for other tabs. */}
|
||||
</div>
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<form
|
||||
className="flex gap-input-with-button"
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
void doSubmit()
|
||||
}}
|
||||
{...innerProps}
|
||||
>
|
||||
<div className="flex grow items-center gap-user-permission rounded-full border border-primary/10 px-manage-permissions-modal-input">
|
||||
<PermissionSelector
|
||||
input
|
||||
isDisabled={willInviteNewUser}
|
||||
selfPermission={self.permission}
|
||||
typeSelectorYOffsetPx={TYPE_SELECTOR_Y_OFFSET_PX}
|
||||
action={permissionsModule.PermissionAction.view}
|
||||
assetType={item.type}
|
||||
onChange={setAction}
|
||||
/>
|
||||
<div className="-mx-button-px grow">
|
||||
<Autocomplete
|
||||
multiple
|
||||
autoFocus
|
||||
placeholder={
|
||||
// `listedUsers` will always include the current user.
|
||||
listedUsers?.length !== 1
|
||||
? getText('inviteUserPlaceholder')
|
||||
: getText('inviteFirstUserPlaceholder')
|
||||
}
|
||||
type="text"
|
||||
itemsToString={items =>
|
||||
items.length === 1 && items[0] != null && 'email' in items[0]
|
||||
? items[0].email
|
||||
: getText('xUsersSelected', items.length)
|
||||
}
|
||||
values={usersAndUserGroups}
|
||||
setValues={setUserAndUserGroups}
|
||||
items={canAdd}
|
||||
itemToKey={userOrGroup =>
|
||||
'userId' in userOrGroup ? userOrGroup.userId : userOrGroup.id
|
||||
}
|
||||
itemToString={userOrGroup =>
|
||||
'name' in userOrGroup
|
||||
? `${userOrGroup.name} (${userOrGroup.email})`
|
||||
: userOrGroup.groupName
|
||||
}
|
||||
matches={(userOrGroup, text) =>
|
||||
('email' in userOrGroup &&
|
||||
userOrGroup.email.toLowerCase().includes(text.toLowerCase())) ||
|
||||
('name' in userOrGroup &&
|
||||
userOrGroup.name.toLowerCase().includes(text.toLowerCase())) ||
|
||||
('groupName' in userOrGroup &&
|
||||
userOrGroup.groupName.toLowerCase().includes(text.toLowerCase()))
|
||||
}
|
||||
text={email}
|
||||
setText={setEmail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UnstyledButton
|
||||
isDisabled={
|
||||
willInviteNewUser
|
||||
? email == null || !isEmail(email)
|
||||
: usersAndUserGroups.length === 0 ||
|
||||
(email != null && emailsOfUsersWithPermission.has(email))
|
||||
}
|
||||
className="button bg-invite px-button-x text-tag-text selectable enabled:active"
|
||||
onPress={doSubmit}
|
||||
>
|
||||
<div className="h-text py-modal-invite-button-text-y">
|
||||
{willInviteNewUser ? getText('invite') : getText('share')}
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
</form>
|
||||
)}
|
||||
</FocusArea>
|
||||
<div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input">
|
||||
{editablePermissions.map(permission => (
|
||||
<div
|
||||
key={backendModule.getAssetPermissionName(permission)}
|
||||
className="flex h-row items-center"
|
||||
>
|
||||
<Permission
|
||||
asset={item}
|
||||
self={self}
|
||||
isOnlyOwner={isOnlyOwner}
|
||||
permission={permission}
|
||||
setPermission={newPermission => {
|
||||
const permissionId = backendModule.getAssetPermissionId(newPermission)
|
||||
setPermissions(oldPermissions =>
|
||||
oldPermissions.map(oldPermission =>
|
||||
backendModule.getAssetPermissionId(oldPermission) === permissionId
|
||||
? newPermission
|
||||
: oldPermission
|
||||
)
|
||||
)
|
||||
if (permissionId === self.user.userId) {
|
||||
// This must run only after the permissions have
|
||||
// been updated through `setItem`.
|
||||
setTimeout(() => {
|
||||
unsetModal()
|
||||
}, 0)
|
||||
}
|
||||
}}
|
||||
doDelete={id => {
|
||||
if (id === self.user.userId) {
|
||||
unsetModal()
|
||||
}
|
||||
void doDelete(id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const doDelete = async (permissionId: backendModule.UserPermissionIdentifier) => {
|
||||
if (permissionId === self.user.userId) {
|
||||
doRemoveSelf()
|
||||
} else {
|
||||
const oldPermission = permissions.find(
|
||||
permission => backendModule.getAssetPermissionId(permission) === permissionId
|
||||
)
|
||||
try {
|
||||
setPermissions(oldPermissions =>
|
||||
oldPermissions.filter(
|
||||
permission => backendModule.getAssetPermissionId(permission) !== permissionId
|
||||
)
|
||||
)
|
||||
await backend.createPermission({
|
||||
actorsIds: [permissionId],
|
||||
resourceId: item.id,
|
||||
action: null,
|
||||
})
|
||||
} catch (error) {
|
||||
if (oldPermission != null) {
|
||||
setPermissions(oldPermissions =>
|
||||
[...oldPermissions, oldPermission].sort(backendModule.compareAssetPermissions)
|
||||
)
|
||||
}
|
||||
toastAndLog('setPermissionsError', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered={eventTarget == null}
|
||||
className="absolute left top size-full overflow-hidden bg-dim"
|
||||
>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
style={
|
||||
position != null
|
||||
? {
|
||||
left: position.left + window.scrollX,
|
||||
top: position.top + window.scrollY,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
className="sticky w-manage-permissions-modal rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
||||
onClick={mouseEvent => {
|
||||
mouseEvent.stopPropagation()
|
||||
}}
|
||||
onContextMenu={mouseEvent => {
|
||||
mouseEvent.stopPropagation()
|
||||
mouseEvent.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-col gap-modal rounded-default p-modal">
|
||||
<div className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x">
|
||||
<aria.Heading level={2} className="text text-sm font-bold">
|
||||
{getText('invite')}
|
||||
</aria.Heading>
|
||||
{/* Space reserved for other tabs. */}
|
||||
</div>
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<form
|
||||
className="flex gap-input-with-button"
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
void doSubmit()
|
||||
}}
|
||||
{...innerProps}
|
||||
>
|
||||
<div className="flex grow items-center gap-user-permission rounded-full border border-primary/10 px-manage-permissions-modal-input">
|
||||
<PermissionSelector
|
||||
input
|
||||
isDisabled={willInviteNewUser}
|
||||
selfPermission={self.permission}
|
||||
typeSelectorYOffsetPx={TYPE_SELECTOR_Y_OFFSET_PX}
|
||||
action={permissionsModule.PermissionAction.view}
|
||||
assetType={item.type}
|
||||
onChange={setAction}
|
||||
/>
|
||||
<div className="w-0 grow">
|
||||
<Autocomplete
|
||||
multiple
|
||||
autoFocus
|
||||
placeholder={
|
||||
// `listedUsers` will always include the current user.
|
||||
listedUsers?.length !== 1
|
||||
? getText('inviteUserPlaceholder')
|
||||
: getText('inviteFirstUserPlaceholder')
|
||||
}
|
||||
type="text"
|
||||
itemsToString={items =>
|
||||
items.length === 1 && items[0] != null && 'email' in items[0]
|
||||
? items[0].email
|
||||
: getText('xUsersSelected', items.length)
|
||||
}
|
||||
values={usersAndUserGroups}
|
||||
setValues={setUserAndUserGroups}
|
||||
items={canAdd}
|
||||
itemToKey={userOrGroup =>
|
||||
'userId' in userOrGroup ? userOrGroup.userId : userOrGroup.id
|
||||
}
|
||||
itemToString={userOrGroup =>
|
||||
'name' in userOrGroup
|
||||
? `${userOrGroup.name} (${userOrGroup.email})`
|
||||
: userOrGroup.groupName
|
||||
}
|
||||
matches={(userOrGroup, text) =>
|
||||
('email' in userOrGroup &&
|
||||
userOrGroup.email.toLowerCase().includes(text.toLowerCase())) ||
|
||||
('name' in userOrGroup &&
|
||||
userOrGroup.name.toLowerCase().includes(text.toLowerCase())) ||
|
||||
('groupName' in userOrGroup &&
|
||||
userOrGroup.groupName.toLowerCase().includes(text.toLowerCase()))
|
||||
}
|
||||
text={email}
|
||||
setText={setEmail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ariaComponents.Button
|
||||
rounded="full"
|
||||
size="xsmall"
|
||||
variant="submit"
|
||||
isDisabled={
|
||||
willInviteNewUser
|
||||
? email == null || !isEmail(email)
|
||||
: usersAndUserGroups.length === 0 ||
|
||||
(email != null && emailsOfUsersWithPermission.has(email))
|
||||
}
|
||||
onPress={doSubmit}
|
||||
>
|
||||
{willInviteNewUser ? getText('invite') : getText('share')}
|
||||
</ariaComponents.Button>
|
||||
</form>
|
||||
)}
|
||||
</FocusArea>
|
||||
<div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input">
|
||||
{editablePermissions.map(permission => (
|
||||
<div
|
||||
key={backendModule.getAssetPermissionName(permission)}
|
||||
className="flex h-row items-center"
|
||||
>
|
||||
<Permission
|
||||
asset={item}
|
||||
self={self}
|
||||
isOnlyOwner={isOnlyOwner}
|
||||
permission={permission}
|
||||
setPermission={newPermission => {
|
||||
const permissionId = backendModule.getAssetPermissionId(newPermission)
|
||||
setPermissions(oldPermissions =>
|
||||
oldPermissions.map(oldPermission =>
|
||||
backendModule.getAssetPermissionId(oldPermission) === permissionId
|
||||
? newPermission
|
||||
: oldPermission
|
||||
)
|
||||
)
|
||||
if (permissionId === self.user.userId) {
|
||||
// This must run only after the permissions have
|
||||
// been updated through `setItem`.
|
||||
setTimeout(() => {
|
||||
unsetModal()
|
||||
}, 0)
|
||||
}
|
||||
}}
|
||||
doDelete={id => {
|
||||
if (id === self.user.userId) {
|
||||
unsetModal()
|
||||
}
|
||||
void doDelete(id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isUnderPaywall && (
|
||||
<paywall.PaywallAlert feature="shareFull" label={getText('shareFullPaywallMessage')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
@ -34,7 +34,6 @@ export function SetOrganizationNameModal() {
|
||||
const userPlan =
|
||||
session && 'user' in session && session.user?.plan != null ? session.user.plan : null
|
||||
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
const { data: organizationName } = reactQuery.useSuspenseQuery({
|
||||
queryKey: ['organization', userId],
|
||||
queryFn: () => {
|
||||
@ -51,7 +50,7 @@ export function SetOrganizationNameModal() {
|
||||
const submit = reactQuery.useMutation({
|
||||
mutationKey: ['organization', userId],
|
||||
mutationFn: (name: string) => backend.updateOrganization({ name }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['organization', userId] }),
|
||||
meta: { invalidates: [['organization', userId]], awaitInvalidates: true },
|
||||
})
|
||||
|
||||
const shouldShowModal =
|
||||
|
@ -9,6 +9,7 @@ import type * as stripeJs from '@stripe/stripe-js'
|
||||
|
||||
import OpenInNewTabIcon from 'enso-assets/open.svg'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
import type * as text from '#/text'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
@ -77,6 +78,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
target="_blank"
|
||||
icon={OpenInNewTabIcon}
|
||||
iconPosition="end"
|
||||
size="medium"
|
||||
>
|
||||
{getText('learnMore')}
|
||||
</ariaComponents.Button>
|
||||
@ -89,7 +91,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
|
||||
return (
|
||||
<ariaComponents.DialogTrigger defaultOpen={defaultOpen}>
|
||||
<ariaComponents.Button variant={'outline'} fullWidth size="medium" rounded="full">
|
||||
<ariaComponents.Button variant={'outline'} fullWidth size="large" rounded="full">
|
||||
{getText('subscribe')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
@ -114,6 +116,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
target="_blank"
|
||||
icon={OpenInNewTabIcon}
|
||||
iconPosition="end"
|
||||
size="medium"
|
||||
>
|
||||
{getText('learnMore')}
|
||||
</ariaComponents.Button>
|
||||
@ -129,7 +132,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
|
||||
return (
|
||||
<ariaComponents.DialogTrigger defaultOpen={defaultOpen}>
|
||||
<ariaComponents.Button variant={'submit'} fullWidth size="medium" rounded="full">
|
||||
<ariaComponents.Button variant={'submit'} fullWidth size="large" rounded="full">
|
||||
{getText('subscribe')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
@ -151,6 +154,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
target="_blank"
|
||||
icon={OpenInNewTabIcon}
|
||||
iconPosition="end"
|
||||
size="medium"
|
||||
>
|
||||
{getText('learnMore')}
|
||||
</ariaComponents.Button>
|
||||
@ -166,10 +170,10 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
<ariaComponents.Button
|
||||
fullWidth
|
||||
variant="primary"
|
||||
size="medium"
|
||||
size="large"
|
||||
rounded="full"
|
||||
target="_blank"
|
||||
href="mailto:contact@enso.org?subject=Upgrading%20to%20Organization%20Plan"
|
||||
href={appUtils.getContactSalesURL()}
|
||||
>
|
||||
{getText('contactSales')}
|
||||
</ariaComponents.Button>
|
||||
|
@ -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'
|
||||
@ -899,3 +900,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
|
||||
}
|
||||
|
@ -6,13 +6,75 @@
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
|
||||
declare module '@tanstack/react-query' {
|
||||
/**
|
||||
* Specifies the invalidation behavior of a mutation.
|
||||
*/
|
||||
interface Register {
|
||||
readonly mutationMeta: {
|
||||
/**
|
||||
* List of query keys to invalidate when the mutation succeeds.
|
||||
*/
|
||||
readonly invalidates?: reactQuery.QueryKey[]
|
||||
/**
|
||||
* List of query keys to await invalidation before the mutation is considered successful.
|
||||
*
|
||||
* If `true`, all `invalidates` are awaited.
|
||||
*
|
||||
* If `false`, no invalidations are awaited.
|
||||
*
|
||||
* You can also provide an array of query keys to await.
|
||||
*
|
||||
* Queries that are not listed in invalidates will be ignored.
|
||||
* @default false
|
||||
*/
|
||||
readonly awaitInvalidates?: reactQuery.QueryKey[] | boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new React Query client.
|
||||
*/
|
||||
export function createReactQueryClient() {
|
||||
return new reactQuery.QueryClient({
|
||||
const queryClient: reactQuery.QueryClient = new reactQuery.QueryClient({
|
||||
mutationCache: new reactQuery.MutationCache({
|
||||
onSuccess: (_data, _variables, _context, mutation) => {
|
||||
const shouldAwaitInvalidates = mutation.meta?.awaitInvalidates ?? false
|
||||
const invalidates = mutation.meta?.invalidates ?? []
|
||||
const invalidatesToAwait = (() => {
|
||||
if (Array.isArray(shouldAwaitInvalidates)) {
|
||||
return shouldAwaitInvalidates
|
||||
} else {
|
||||
return shouldAwaitInvalidates ? invalidates : []
|
||||
}
|
||||
})()
|
||||
const invalidatesToIgnore = invalidates.filter(
|
||||
queryKey => !invalidatesToAwait.includes(queryKey)
|
||||
)
|
||||
|
||||
for (const queryKey of invalidatesToIgnore) {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: query => reactQuery.matchQuery({ queryKey }, query),
|
||||
})
|
||||
}
|
||||
|
||||
if (invalidatesToAwait.length > 0) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return Promise.all(
|
||||
invalidatesToAwait.map(queryKey =>
|
||||
queryClient.invalidateQueries({
|
||||
predicate: query => reactQuery.matchQuery({ queryKey }, query),
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
retry: (failureCount, error) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
const statusesToIgnore = [401, 403, 404]
|
||||
@ -28,4 +90,6 @@ export function createReactQueryClient() {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return queryClient
|
||||
}
|
||||
|
@ -200,6 +200,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",
|
||||
@ -340,6 +342,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",
|
||||
@ -443,6 +447,7 @@
|
||||
"editorPageAltText": "Graph Editor",
|
||||
"settingsPageAltText": "Settings",
|
||||
|
||||
"freePlanName": "Free",
|
||||
"soloPlanName": "Solo",
|
||||
"soloPlanSubtitle": "For individuals",
|
||||
"soloPlanPricing": "$60 per user / month",
|
||||
@ -588,6 +593,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",
|
||||
@ -635,5 +642,34 @@
|
||||
"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 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"
|
||||
}
|
||||
|
@ -110,6 +110,13 @@ interface PlaceholderOverrides {
|
||||
readonly logEventBackendError: [string]
|
||||
|
||||
readonly subscribeSuccessSubtitle: [string]
|
||||
|
||||
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}. */
|
||||
|
Loading…
Reference in New Issue
Block a user