0
Fork 0
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:
Charles Zhao 2024-07-22 11:50:47 +08:00 committed by GitHub
parent ead7c84973
commit def21c7bca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 62 additions and 6 deletions

View file

@ -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"

View file

@ -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

View file

@ -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 {

View file

@ -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>

View file

@ -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);

View file

@ -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
)} )}
> >

View file

@ -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);
}

View file

@ -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>

View file

@ -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);