0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-14 23:11:31 -05:00

feat(console): onboarding sie preview (#3296)

This commit is contained in:
Xiao Yijun 2023-03-06 21:26:58 +08:00 committed by GitHub
parent ec65d46826
commit 1efb8097ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 444 additions and 36 deletions

View file

@ -10,6 +10,7 @@ type Props = {
options: Option[];
value: string[];
onChange: (value: string[]) => void;
isNotAllowEmpty?: boolean;
className?: string;
optionClassName?: string;
};
@ -18,12 +19,17 @@ const MultiCardSelector = ({
options,
value: selectedValues,
onChange,
isNotAllowEmpty = false,
className,
optionClassName,
}: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const onToggle = (value: string) => {
if (selectedValues.includes(value) && selectedValues.length === 1 && isNotAllowEmpty) {
return;
}
onChange(
selectedValues.includes(value)
? selectedValues.filter((selected) => selected !== value)

View file

@ -1,11 +1,11 @@
import { SignInIdentifier } from '@logto/schemas';
import type { OnboardingSieConfig } from '../types';
import type { OnboardSieConfig } from '../types';
import { Authentication } from '../types';
export const reservationLink = 'https://calendly.com/logto/30min';
export const defaultOnboardingSieConfig: OnboardingSieConfig = {
export const defaultOnboardingSieConfig: OnboardSieConfig = {
color: '#5D34F2',
identifier: SignInIdentifier.Email,
authentications: [Authentication.Password],

View file

@ -12,7 +12,6 @@ import * as pageLayout from '@/cloud/scss/layout.module.scss';
import Button from '@/components/Button';
import Divider from '@/components/Divider';
import OverlayScrollbar from '@/components/OverlayScrollbar';
import { getBasename } from '@/consts';
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
import { OnboardPage } from '../../types';
@ -28,7 +27,7 @@ const Congrats = () => {
const enterAdminConsole = async () => {
await update({ hasOnboard: true });
navigate(getBasename());
navigate('/');
};
const handleBack = () => {

View file

@ -0,0 +1,34 @@
@use '@/scss/underscore' as _;
.tab {
display: flex;
align-items: center;
border-radius: 6px;
font: var(--font-label-2);
padding: _.unit(1) _.unit(2);
user-select: none;
cursor: pointer;
.icon {
color: var(--color-primary);
margin-right: _.unit(2);
> svg {
display: block;
}
}
&.selected {
color: var(--color-layer-1);
background-color: var(--color-inverse-primary);
.icon {
color: var(--color-static-white);
opacity: 70%;
}
}
&:not(.selected):hover {
background-color: var(--color-hover-variant);
}
}

View file

@ -0,0 +1,40 @@
import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { PreviewPlatform } from '@/components/SignInExperiencePreview/types';
import { onKeyDownHandler } from '@/utils/a11y';
import * as styles from './PlatformTab.module.scss';
type Props = {
isSelected: boolean;
icon: ReactNode;
title: AdminConsoleKey;
tab: PreviewPlatform;
onClick: (tab: PreviewPlatform) => void;
};
const PlatformTab = ({ isSelected, icon, title, tab, onClick }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div
role="tab"
tabIndex={0}
className={classNames(styles.tab, isSelected && styles.selected)}
onClick={() => {
onClick(tab);
}}
onKeyDown={onKeyDownHandler(() => {
onClick(tab);
})}
>
<span className={styles.icon}>{icon}</span>
{t(title)}
</div>
);
};
export default PlatformTab;

View file

@ -0,0 +1,12 @@
@use '@/scss/underscore' as _;
.container {
border-radius: 8px;
background-color: var(--color-layer-1);
display: flex;
align-items: center;
padding: _.unit(1);
gap: 12px;
border: 1px solid var(--color-surface-5);
}

View file

@ -0,0 +1,32 @@
import Native from '@/assets/images/connector-platform-icon-native.svg';
import Web from '@/assets/images/connector-platform-icon-web.svg';
import { PreviewPlatform } from '@/components/SignInExperiencePreview/types';
import PlatformTab from './PlatformTab';
import * as styles from './index.module.scss';
type Props = {
currentTab: PreviewPlatform;
onSelect: (tab: PreviewPlatform) => void;
};
const PlatformTabs = ({ currentTab, onSelect }: Props) => (
<div className={styles.container}>
<PlatformTab
icon={<Native />}
title="cloud.sie.preview.mobile_tab"
tab={PreviewPlatform.MobileWeb}
isSelected={currentTab === PreviewPlatform.MobileWeb}
onClick={onSelect}
/>
<PlatformTab
icon={<Web />}
title="cloud.sie.preview.web_tab"
tab={PreviewPlatform.DesktopWeb}
isSelected={currentTab === PreviewPlatform.DesktopWeb}
onClick={onSelect}
/>
</div>
);
export default PlatformTabs;

View file

@ -0,0 +1,40 @@
@use '@/scss/underscore' as _;
.container {
border-radius: 16px;
background-color: var(--color-neutral-variant-90);
padding: _.unit(6);
display: flex;
flex-direction: column;
}
.topBar {
display: flex;
align-items: center;
justify-content: space-between;
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
.button {
border: 1px solid var(--color-surface-5);
margin-left: _.unit(2);
}
.themeButton {
padding: _.unit(2);
}
.themeIcon {
color: var(--color-primary);
}
.livePreviewIcon {
width: 20px;
height: 20px;
color: var(--color-primary);
}
}
}

View file

@ -0,0 +1,54 @@
import type { SignInExperience } from '@logto/schemas';
import { AppearanceMode } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useState } from 'react';
import LivePreviewButton from '@/components/LivePreviewButton';
import SignInExperiencePreview from '@/components/SignInExperiencePreview';
import { PreviewPlatform } from '@/components/SignInExperiencePreview/types';
import ToggleThemeButton from '@/components/ToggleThemeButton';
import PlatformTabs from '../PlatformTabs';
import * as styles from './index.module.scss';
type Props = {
signInExperience?: SignInExperience;
isLivePreviewDisabled?: boolean;
className?: string;
};
const Preview = ({ signInExperience, isLivePreviewDisabled = false, className }: Props) => {
const [currentTab, setCurrentTab] = useState(PreviewPlatform.MobileWeb);
const [mode, setMode] = useState<Omit<AppearanceMode, AppearanceMode.SyncWithSystem>>(
AppearanceMode.LightMode
);
return (
<div className={classNames(styles.container, className)}>
<div className={styles.topBar}>
<PlatformTabs currentTab={currentTab} onSelect={setCurrentTab} />
<div className={styles.actions}>
<ToggleThemeButton
className={classNames(styles.button, styles.themeButton)}
iconClassName={styles.themeIcon}
value={mode}
onToggle={setMode}
/>
<LivePreviewButton
isDisabled={isLivePreviewDisabled}
className={styles.button}
iconClassName={conditional(!isLivePreviewDisabled && styles.livePreviewIcon)}
/>
</div>
</div>
<SignInExperiencePreview
platform={currentTab}
mode={mode}
signInExperience={signInExperience}
/>
</div>
);
};
export default Preview;

View file

@ -2,14 +2,22 @@
.content {
display: flex;
flex: 1;
overflow-y: hidden;
padding: 0 _.unit(17);
.config {
.configWrapper {
flex: 1;
overflow: auto;
margin-right: _.unit(4);
padding: 0 _.unit(4) _.unit(6) 0;
}
.config {
background-color: var(--color-layer-1);
border-radius: 8px;
padding: _.unit(12);
margin-right: _.unit(8);
min-width: 430px;
.title {
margin-top: _.unit(6);
@ -51,5 +59,14 @@
.preview {
flex: 1;
margin-bottom: _.unit(6);
min-height: 580px;
}
}
.continueActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: _.unit(4);
}

View file

@ -1,6 +1,10 @@
import type { SignInExperience as SignInExperienceType } from '@logto/schemas';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import Bulb from '@/assets/images/bulb.svg';
import Tools from '@/assets/images/tools.svg';
@ -8,24 +12,50 @@ import ActionBar from '@/cloud/components/ActionBar';
import { CardSelector, MultiCardSelector } from '@/cloud/components/CardSelector';
import { defaultOnboardingSieConfig } from '@/cloud/constants';
import * as pageLayout from '@/cloud/scss/layout.module.scss';
import type { OnboardingSieConfig } from '@/cloud/types';
import type { OnboardSieConfig } from '@/cloud/types';
import { OnboardPage } from '@/cloud/types';
import { getOnboardPagePathname } from '@/cloud/utils';
import Button from '@/components/Button';
import ColorPicker from '@/components/ColorPicker';
import FormField from '@/components/FormField';
import OverlayScrollbar from '@/components/OverlayScrollbar';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import Preview from './components/Preview';
import * as styles from './index.module.scss';
import { authenticationOptions, identifierOptions } from './options';
import { parser } from './utils';
const SignInExperience = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const { data: signInExperience, mutate } = useSWR<SignInExperienceType, RequestError>(
'api/sign-in-exp'
);
const api = useApi();
const { reset, control, handleSubmit } = useForm<OnboardingSieConfig>({
defaultValues: defaultOnboardingSieConfig,
});
const {
reset,
control,
watch,
handleSubmit,
formState: { isSubmitting, isDirty },
} = useForm<OnboardSieConfig>({ defaultValues: defaultOnboardingSieConfig });
useEffect(() => {
if (signInExperience) {
reset(parser.signInExperienceToOnboardSieConfig(signInExperience));
}
}, [reset, signInExperience]);
const onboardingSieConfig = watch();
const previewSieConfig = useMemo(() => {
if (signInExperience) {
return parser.onboardSieConfigToSignInExperience(onboardingSieConfig, signInExperience);
}
}, [onboardingSieConfig, signInExperience]);
const handleInspireMe = () => {
// TODO @xiaoyijun
@ -34,14 +64,28 @@ const SignInExperience = () => {
};
const onSubmit = handleSubmit(async (formData) => {
// TODO @xiaoyijun convert to sign in experience config data and update
console.log(formData);
if (!signInExperience) {
return;
}
const updatedData = await api
.patch('api/sign-in-exp', {
json: parser.onboardSieConfigToSignInExperience(formData, signInExperience),
})
.json<SignInExperienceType>();
void mutate(updatedData);
});
const handleBack = () => {
navigate(getOnboardPagePathname(OnboardPage.AboutUser));
};
const handleSave = async () => {
await onSubmit();
toast.success(t('general.saved'));
};
const handleNext = async () => {
await onSubmit();
navigate(getOnboardPagePathname(OnboardPage.Congrats));
@ -49,8 +93,11 @@ const SignInExperience = () => {
return (
<div className={pageLayout.page}>
<OverlayScrollbar className={pageLayout.contentContainer}>
<div className={styles.content}>
<div className={styles.content}>
<OverlayScrollbar
options={{ scrollbars: { autoHide: 'scroll', autoHideDelay: 500 } }}
className={styles.configWrapper}
>
<div className={styles.config}>
<Tools />
<div className={styles.title}>{t('cloud.sie.title')}</div>
@ -102,9 +149,10 @@ const SignInExperience = () => {
<Controller
name="authentications"
control={control}
defaultValue={[]}
defaultValue={defaultOnboardingSieConfig.authentications}
render={({ field: { value, onChange } }) => (
<MultiCardSelector
isNotAllowEmpty
className={styles.authnSelector}
value={value}
options={authenticationOptions}
@ -121,12 +169,30 @@ const SignInExperience = () => {
TBD
</FormField>
</div>
<div className={styles.preview}>Preview(TBD)</div>
</div>
</OverlayScrollbar>
</OverlayScrollbar>
<Preview
className={styles.preview}
signInExperience={previewSieConfig}
isLivePreviewDisabled={isDirty}
/>
</div>
<ActionBar step={3}>
<Button type="primary" title="cloud.sie.finish_and_done" onClick={handleNext} />
<Button title="general.back" onClick={handleBack} />
<div className={styles.continueActions}>
<Button
type="outline"
title="general.save"
disabled={isSubmitting}
onClick={handleSave}
/>
<Button
type="primary"
title="cloud.sie.finish_and_done"
disabled={isSubmitting}
onClick={handleNext}
/>
</div>
<Button title="general.back" disabled={isSubmitting} onClick={handleBack} />
</ActionBar>
</div>
);

View file

@ -0,0 +1,78 @@
import type { SignInExperience } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import type { OnboardSieConfig } from '@/cloud/types';
import { Authentication } from '@/cloud/types';
const signInExperienceToOnboardSieConfig = (
signInExperience: SignInExperience
): OnboardSieConfig => {
const {
color: { primaryColor },
signIn: { methods: signInMethods },
signUp: { identifiers: signUpIdentifiers },
} = signInExperience;
const identifier =
signInMethods.find(({ identifier }) => signUpIdentifiers.includes(identifier))?.identifier ??
SignInIdentifier.Username;
const authentications = signInMethods.reduce<Authentication[]>((result, method) => {
const { password, verificationCode } = method;
return [
...result,
...(password ? [Authentication.Password] : []),
...(verificationCode ? [Authentication.VerificationCode] : []),
];
}, []);
return {
color: primaryColor,
identifier,
authentications,
};
};
const onboardSieConfigToSignInExperience = (
config: OnboardSieConfig,
basedConfig: SignInExperience
): SignInExperience => {
const { color: onboardConfigColor, identifier, authentications } = config;
const { color: baseColorConfig } = basedConfig;
const isPasswordSetup = authentications.includes(Authentication.Password);
const isVerificationCodeSetup =
authentications.includes(Authentication.VerificationCode) &&
identifier !== SignInIdentifier.Username;
const signInExperience = {
...basedConfig,
color: {
...baseColorConfig,
primaryColor: onboardConfigColor,
},
signUp: {
identifiers: [identifier],
password: isPasswordSetup,
verify: isVerificationCodeSetup,
},
signIn: {
methods: [
{
identifier,
password: isPasswordSetup,
verificationCode: isVerificationCodeSetup,
isPasswordPrimary: isPasswordSetup,
},
],
},
};
return signInExperience;
};
export const parser = {
signInExperienceToOnboardSieConfig,
onboardSieConfigToSignInExperience,
};

View file

@ -72,7 +72,7 @@ export enum Authentication {
VerificationCode = 'verificationCode',
}
export type OnboardingSieConfig = {
export type OnboardSieConfig = {
color: string;
identifier: SignInIdentifier;
authentications: Authentication[];

View file

@ -15,9 +15,11 @@ import * as styles from './index.module.scss';
type Props = {
size?: ButtonProps['size'];
isDisabled: boolean;
className?: string;
iconClassName?: string;
};
const LivePreviewButton = ({ size = 'medium', isDisabled }: Props) => {
const LivePreviewButton = ({ size = 'medium', isDisabled, className, iconClassName }: Props) => {
const { configs, updateConfigs } = useConfigs();
const { userEndpoint } = useContext(AppEndpointsContext);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -27,9 +29,12 @@ const LivePreviewButton = ({ size = 'medium', isDisabled }: Props) => {
<Button
size={size}
disabled={isDisabled}
className={className}
title="sign_in_exp.preview.live_preview"
trailingIcon={
<ExternalLinkIcon className={classNames(styles.icon, isDisabled && styles.disabled)} />
<ExternalLinkIcon
className={classNames(styles.icon, iconClassName, isDisabled && styles.disabled)}
/>
}
onClick={() => {
if (!configs?.livePreviewChecked) {

View file

@ -219,17 +219,6 @@
}
.compact:not(.disabled) {
&:focus {
color: var(--color-text-link);
border-color: var(--color-primary);
.content {
.icon {
color: var(--color-primary);
}
}
}
&:hover {
color: var(--color-text-link);
border-color: var(--color-primary);

View file

@ -19,11 +19,11 @@ import { PreviewPlatform } from './types';
type Props = {
platform: PreviewPlatform;
mode: Omit<AppearanceMode, AppearanceMode.SyncWithSystem>;
language: LanguageTag;
language?: LanguageTag;
signInExperience?: SignInExperience;
};
const SignInExperiencePreview = ({ platform, mode, language, signInExperience }: Props) => {
const SignInExperiencePreview = ({ platform, mode, language = 'en', signInExperience }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { customPhrases } = useUiLanguages();
@ -89,6 +89,10 @@ const SignInExperiencePreview = ({ platform, mode, language, signInExperience }:
};
}, [postPreviewMessage]);
if (!userEndpoint) {
return null;
}
return (
<div
className={classNames(

View file

@ -90,6 +90,10 @@ const cloud = {
},
social_field: 'Social sign in', // UNTRANSLATED
finish_and_done: 'Finish and done', // UNTRANSLATED
preview: {
mobile_tab: 'Mobile', // UNTRANSLATED
web_tab: 'Web', // UNTRANSLATED
},
},
};

View file

@ -90,6 +90,10 @@ const cloud = {
},
social_field: 'Social sign in',
finish_and_done: 'Finish and done',
preview: {
mobile_tab: 'Mobile',
web_tab: 'Web',
},
},
};

View file

@ -90,6 +90,10 @@ const cloud = {
},
social_field: 'Social sign in', // UNTRANSLATED
finish_and_done: 'Finish and done', // UNTRANSLATED
preview: {
mobile_tab: 'Mobile', // UNTRANSLATED
web_tab: 'Web', // UNTRANSLATED
},
},
};

View file

@ -90,6 +90,10 @@ const cloud = {
},
social_field: 'Social sign in', // UNTRANSLATED
finish_and_done: 'Finish and done', // UNTRANSLATED
preview: {
mobile_tab: 'Mobile', // UNTRANSLATED
web_tab: 'Web', // UNTRANSLATED
},
},
};

View file

@ -90,6 +90,10 @@ const cloud = {
},
social_field: 'Social sign in', // UNTRANSLATED
finish_and_done: 'Finish and done', // UNTRANSLATED
preview: {
mobile_tab: 'Mobile', // UNTRANSLATED
web_tab: 'Web', // UNTRANSLATED
},
},
};

View file

@ -90,6 +90,10 @@ const cloud = {
},
social_field: 'Social sign in', // UNTRANSLATED
finish_and_done: 'Finish and done', // UNTRANSLATED
preview: {
mobile_tab: 'Mobile', // UNTRANSLATED
web_tab: 'Web', // UNTRANSLATED
},
},
};

View file

@ -90,6 +90,10 @@ const cloud = {
},
social_field: 'Social sign in', // UNTRANSLATED
finish_and_done: 'Finish and done', // UNTRANSLATED
preview: {
mobile_tab: 'Mobile', // UNTRANSLATED
web_tab: 'Web', // UNTRANSLATED
},
},
};

View file

@ -90,6 +90,10 @@ const cloud = {
},
social_field: 'Social sign in', // UNTRANSLATED
finish_and_done: 'Finish and done', // UNTRANSLATED
preview: {
mobile_tab: 'Mobile', // UNTRANSLATED
web_tab: 'Web', // UNTRANSLATED
},
},
};