mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console,phrases): add bring your UI feature paywall (#6275)
feat(console,phrases): add bring your ui feature paywall
This commit is contained in:
parent
ead7c84973
commit
def21c7bca
9 changed files with 62 additions and 6 deletions
|
@ -15,11 +15,13 @@ import * as styles from './index.module.scss';
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly value?: CustomUiAssets;
|
readonly value?: CustomUiAssets;
|
||||||
readonly onChange: (value: CustomUiAssets) => void;
|
readonly onChange: (value: CustomUiAssets) => void;
|
||||||
|
// eslint-disable-next-line react/boolean-prop-naming
|
||||||
|
readonly disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowedMimeTypes: AllowedUploadMimeType[] = ['application/zip'];
|
const allowedMimeTypes: AllowedUploadMimeType[] = ['application/zip'];
|
||||||
|
|
||||||
function CustomUiAssetsUploader({ value, onChange }: Props) {
|
function CustomUiAssetsUploader({ value, onChange, disabled }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const [file, setFile] = useState<File>();
|
const [file, setFile] = useState<File>();
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
@ -48,6 +50,7 @@ function CustomUiAssetsUploader({ value, onChange }: Props) {
|
||||||
if (showUploader) {
|
if (showUploader) {
|
||||||
return (
|
return (
|
||||||
<FileUploader<{ customUiAssetId: string }>
|
<FileUploader<{ customUiAssetId: string }>
|
||||||
|
disabled={disabled}
|
||||||
allowedMimeTypes={allowedMimeTypes}
|
allowedMimeTypes={allowedMimeTypes}
|
||||||
maxSize={maxUploadFileSize}
|
maxSize={maxUploadFileSize}
|
||||||
uploadUrl="api/sign-in-exp/default/custom-ui-assets"
|
uploadUrl="api/sign-in-exp/default/custom-ui-assets"
|
||||||
|
|
|
@ -8,7 +8,7 @@ import * as styles from './index.module.scss';
|
||||||
|
|
||||||
export { default as BetaTag } from './BetaTag';
|
export { default as BetaTag } from './BetaTag';
|
||||||
|
|
||||||
type Props = {
|
export type Props = {
|
||||||
/**
|
/**
|
||||||
* Whether the tag should be visible. It should be `true` if the tenant's subscription
|
* Whether the tag should be visible. It should be `true` if the tenant's subscription
|
||||||
* plan has NO access to the feature (paywall), but it will always be visible for dev
|
* plan has NO access to the feature (paywall), but it will always be visible for dev
|
||||||
|
|
|
@ -34,6 +34,10 @@
|
||||||
font: var(--font-body-2);
|
font: var(--font-body-2);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.featureTag {
|
||||||
|
margin-left: _.unit(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { ReactElement, ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import Tip from '@/assets/icons/tip.svg';
|
import Tip from '@/assets/icons/tip.svg';
|
||||||
|
import FeatureTag, { type Props as FeatureTagProps } from '@/components/FeatureTag';
|
||||||
|
|
||||||
import type DangerousRaw from '../DangerousRaw';
|
import type DangerousRaw from '../DangerousRaw';
|
||||||
import DynamicT from '../DynamicT';
|
import DynamicT from '../DynamicT';
|
||||||
|
@ -25,6 +26,7 @@ export type Props = {
|
||||||
readonly headlineSpacing?: 'default' | 'large';
|
readonly headlineSpacing?: 'default' | 'large';
|
||||||
readonly headlineClassName?: string;
|
readonly headlineClassName?: string;
|
||||||
readonly tip?: ToggleTipProps['content'];
|
readonly tip?: ToggleTipProps['content'];
|
||||||
|
readonly featureTag?: FeatureTagProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
function FormField({
|
function FormField({
|
||||||
|
@ -37,6 +39,7 @@ function FormField({
|
||||||
className,
|
className,
|
||||||
headlineSpacing = 'default',
|
headlineSpacing = 'default',
|
||||||
tip,
|
tip,
|
||||||
|
featureTag,
|
||||||
headlineClassName,
|
headlineClassName,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
|
@ -63,6 +66,7 @@ function FormField({
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ToggleTip>
|
</ToggleTip>
|
||||||
)}
|
)}
|
||||||
|
{featureTag && <FeatureTag {...featureTag} className={styles.featureTag} />}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
{isRequired && <div className={styles.required}>{t('general.required')}</div>}
|
{isRequired && <div className={styles.required}>{t('general.required')}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -33,7 +33,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--color-disabled);
|
||||||
|
color: var(--color-disabled);
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
.icon {
|
||||||
|
color: var(--color-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.disabled):hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
|
|
||||||
|
@ -44,7 +56,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.dragActive {
|
&:not(.disabled).dragActive {
|
||||||
cursor: copy;
|
cursor: copy;
|
||||||
background-color: var(--color-hover-variant);
|
background-color: var(--color-hover-variant);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
|
|
|
@ -15,6 +15,8 @@ import { Ring } from '../../Spinner';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
export type Props<T extends Record<string, unknown> = UserAssets> = {
|
export type Props<T extends Record<string, unknown> = UserAssets> = {
|
||||||
|
// eslint-disable-next-line react/boolean-prop-naming
|
||||||
|
readonly disabled?: boolean;
|
||||||
readonly maxSize: number; // In bytes
|
readonly maxSize: number; // In bytes
|
||||||
readonly allowedMimeTypes: AllowedUploadMimeType[];
|
readonly allowedMimeTypes: AllowedUploadMimeType[];
|
||||||
readonly actionDescription?: string;
|
readonly actionDescription?: string;
|
||||||
|
@ -33,6 +35,7 @@ export type Props<T extends Record<string, unknown> = UserAssets> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function FileUploader<T extends Record<string, unknown> = UserAssets>({
|
function FileUploader<T extends Record<string, unknown> = UserAssets>({
|
||||||
|
disabled,
|
||||||
maxSize,
|
maxSize,
|
||||||
allowedMimeTypes,
|
allowedMimeTypes,
|
||||||
actionDescription,
|
actionDescription,
|
||||||
|
@ -121,7 +124,7 @@ function FileUploader<T extends Record<string, unknown> = UserAssets>({
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
disabled: isUploading,
|
disabled: isUploading || disabled,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
accept: Object.fromEntries(allowedMimeTypes.map((mimeType) => [mimeType, []])),
|
accept: Object.fromEntries(allowedMimeTypes.map((mimeType) => [mimeType, []])),
|
||||||
});
|
});
|
||||||
|
@ -133,6 +136,7 @@ function FileUploader<T extends Record<string, unknown> = UserAssets>({
|
||||||
styles.uploader,
|
styles.uploader,
|
||||||
Boolean(uploadError) && styles.uploaderError,
|
Boolean(uploadError) && styles.uploaderError,
|
||||||
isDragActive && styles.dragActive,
|
isDragActive && styles.dragActive,
|
||||||
|
disabled && styles.disabled,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
.customCssCodeEditor {
|
.customCssCodeEditor {
|
||||||
max-height: calc(100vh - 260px);
|
max-height: calc(100vh - 260px);
|
||||||
min-height: 132px; // min-height to show three lines of code
|
min-height: 132px; // min-height to show three lines of code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upsell {
|
||||||
|
margin-top: _.unit(3);
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
|
import { ReservedPlanId } from '@logto/schemas';
|
||||||
|
import { useContext } from 'react';
|
||||||
import { Controller, useFormContext } from 'react-hook-form';
|
import { Controller, useFormContext } from 'react-hook-form';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import CustomUiAssetsUploader from '@/components/CustomUiAssetsUploader';
|
import CustomUiAssetsUploader from '@/components/CustomUiAssetsUploader';
|
||||||
|
import InlineUpsell from '@/components/InlineUpsell';
|
||||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||||
|
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import Card from '@/ds-components/Card';
|
import Card from '@/ds-components/Card';
|
||||||
import CodeEditor from '@/ds-components/CodeEditor';
|
import CodeEditor from '@/ds-components/CodeEditor';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
|
@ -18,6 +22,8 @@ function CustomUiForm() {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { getDocumentationUrl } = useDocumentationUrl();
|
const { getDocumentationUrl } = useDocumentationUrl();
|
||||||
const { control } = useFormContext<SignInExperienceForm>();
|
const { control } = useFormContext<SignInExperienceForm>();
|
||||||
|
const { currentPlan } = useContext(SubscriptionDataContext);
|
||||||
|
const isBringYourUiEnabled = currentPlan.quota.bringYourUiEnabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
@ -79,14 +85,29 @@ function CustomUiForm() {
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
descriptionPosition="top"
|
descriptionPosition="top"
|
||||||
|
featureTag={{
|
||||||
|
isVisible: !isBringYourUiEnabled,
|
||||||
|
plan: ReservedPlanId.Pro,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
name="customUiAssets"
|
name="customUiAssets"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<CustomUiAssetsUploader value={value} onChange={onChange} />
|
<CustomUiAssetsUploader
|
||||||
|
disabled={!isBringYourUiEnabled}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{!isBringYourUiEnabled && (
|
||||||
|
<InlineUpsell
|
||||||
|
className={brandingStyles.upsell}
|
||||||
|
for="bring_your_ui"
|
||||||
|
actionButtonText="upsell.view_plans"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
@ -65,6 +65,8 @@ const paywall = {
|
||||||
description:
|
description:
|
||||||
"Upgrade to a paid plan for custom JWT functionality and premium benefits. Don't hesitate to <a>contact us</a> if you have any questions.",
|
"Upgrade to a paid plan for custom JWT functionality and premium benefits. Don't hesitate to <a>contact us</a> if you have any questions.",
|
||||||
},
|
},
|
||||||
|
bring_your_ui:
|
||||||
|
'Upgrade to a paid plan for bring your custom UI functionality and premium benefits.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(paywall);
|
export default Object.freeze(paywall);
|
||||||
|
|
Loading…
Reference in a new issue