0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(ui): implement preview mode (#852)

* feat(ui): implement preview mode

implement preview mode

* fix(ui): remove unused dependency

remove unused dependency
This commit is contained in:
simeng-li 2022-05-17 14:01:36 +08:00 committed by GitHub
parent 4a7b0abbe3
commit ef19fb3d27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 155 additions and 47 deletions

View file

@ -22,7 +22,7 @@ const Preview = ({ signInExperience }: Props) => {
// TODO: is a placeholder
const config = encodeURIComponent(
JSON.stringify({
...signInExperience,
signInExperience,
language,
mode,
platform,
@ -75,7 +75,7 @@ const Preview = ({ signInExperience }: Props) => {
</TabNavItem>
</TabNav>
<div className={styles.body}>
<iframe src={`/sign-in?config=${config}`} />
<iframe src={`/sign-in?config=${config}&preview=true`} />
</div>
</Card>
);

View file

@ -3,6 +3,7 @@ import { Route, Routes, BrowserRouter, Navigate } from 'react-router-dom';
import AppContent from './components/AppContent';
import usePageContext from './hooks/use-page-context';
import usePreview from './hooks/use-preview';
import initI18n from './i18n/init';
import Callback from './pages/Callback';
import Consent from './pages/Consent';
@ -12,18 +13,24 @@ import Register from './pages/Register';
import SecondarySignIn from './pages/SecondarySignIn';
import SignIn from './pages/SignIn';
import SocialRegister from './pages/SocialRegister';
import getSignInExperienceSettings from './utils/sign-in-experience';
import getSignInExperienceSettings, {
parseSignInExperienceSettings,
} from './utils/sign-in-experience';
import './scss/normalized.scss';
const App = () => {
const { context, Provider } = usePageContext();
const { experienceSettings, setLoading, setExperienceSettings } = context;
const [isPreview, previewSettings] = usePreview();
useEffect(() => {
(async () => {
setLoading(true);
const settings = await getSignInExperienceSettings();
const settings = previewSettings
? parseSignInExperienceSettings(previewSettings.signInExperience)
: await getSignInExperienceSettings();
// Note: i18n must be initialized ahead of global experience settings
await initI18n(settings.languageInfo);
@ -32,7 +39,7 @@ const App = () => {
setLoading(false);
})();
}, [setExperienceSettings, setLoading]);
}, [isPreview, previewSettings, setExperienceSettings, setLoading]);
if (!experienceSettings) {
return null;
@ -40,7 +47,7 @@ const App = () => {
return (
<Provider value={context}>
<AppContent>
<AppContent mode={previewSettings?.mode} platform={previewSettings?.platform}>
<BrowserRouter>
<Routes>
{/* always keep route path with param as the last one */}

View file

@ -1,22 +1,25 @@
import { AppearanceMode } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
import React, { ReactNode, useEffect, useCallback, useContext } from 'react';
import { isMobile } from 'react-device-detect';
import { useDebouncedLoader } from 'use-debounced-loader';
import LoadingLayer from '@/components/LoadingLayer';
import Toast from '@/components/Toast';
import { PageContext } from '@/hooks/use-page-context';
import useTheme from '@/hooks/use-theme';
import { Platform } from '@/types';
import * as styles from './index.module.scss';
export type Props = {
children: ReactNode;
mode?: AppearanceMode;
platform?: Platform;
};
const AppContent = ({ children }: Props) => {
const theme = useTheme();
const { toast, loading, setToast } = useContext(PageContext);
const AppContent = ({ children, mode, platform: platformOverwrite }: Props) => {
const theme = useTheme(mode);
const { toast, loading, platform, setPlatform, setToast } = useContext(PageContext);
const debouncedLoading = useDebouncedLoader(loading);
// Prevent internal eventListener rebind
@ -24,14 +27,21 @@ const AppContent = ({ children }: Props) => {
setToast('');
}, [setToast]);
useEffect(() => {
if (platformOverwrite) {
setPlatform(platformOverwrite);
}
}, [platformOverwrite, setPlatform]);
useEffect(() => {
document.body.classList.remove(conditionalString(styles.light), conditionalString(styles.dark));
document.body.classList.add(conditionalString(styles[theme]));
}, [theme]);
useEffect(() => {
document.body.classList.add(isMobile ? 'mobile' : 'desktop');
}, []);
document.body.classList.remove('desktop', 'mobile');
document.body.classList.add(platform === 'mobile' ? 'mobile' : 'desktop');
}, [platform]);
return (
<main>

View file

@ -1,14 +1,15 @@
import React, { forwardRef, InputHTMLAttributes, Ref } from 'react';
import { isMobile } from 'react-device-detect';
import CheckBox from '@/assets/icons/checkbox-icon.svg';
import RadioButton from '@/assets/icons/radio-button-icon.svg';
import usePlatform from '@/hooks/use-platform';
import * as styles from './index.module.scss';
type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
const Checkbox = ({ disabled, ...rest }: Props, ref: Ref<HTMLInputElement>) => {
const { isMobile } = usePlatform();
const Icon = isMobile ? RadioButton : CheckBox;
return (

View file

@ -1,6 +1,5 @@
import classNames from 'classnames';
import React from 'react';
import { isMobile } from 'react-device-detect';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
@ -43,14 +42,10 @@ const AcModal = ({
</div>
<div className={styles.content}>{children}</div>
<div className={styles.footer}>
<Button
type={isMobile ? 'secondary' : 'outline'}
size={isMobile ? 'large' : 'small'}
onClick={onClose}
>
<Button type="outline" size="small" onClick={onClose}>
{t(cancelText)}
</Button>
<Button size={isMobile ? 'large' : 'small'} onClick={onConfirm ?? onClose}>
<Button size="small" onClick={onConfirm ?? onClose}>
{t(confirmText)}
</Button>
</div>

View file

@ -1,11 +1,14 @@
import React from 'react';
import { isMobile } from 'react-device-detect';
import usePlatform from '@/hooks/use-platform';
import AcModal from './AcModal';
import MobileModal from './MobileModal';
import { ModalProps } from './type';
const ConfirmModal = (props: ModalProps) => {
const { isMobile } = usePlatform();
return isMobile ? <MobileModal {...props} /> : <AcModal {...props} />;
};

View file

@ -1,9 +1,9 @@
import React from 'react';
import { isMobile } from 'react-device-detect';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ArrowPrev from '@/assets/icons/arrow-prev.svg';
import usePlatform from '@/hooks/use-platform';
import * as styles from './index.module.scss';
@ -14,6 +14,7 @@ type Props = {
const NavBar = ({ title }: Props) => {
const navigate = useNavigate();
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { isMobile } = usePlatform();
return (
<div className={styles.navBar}>

View file

@ -1,9 +1,9 @@
import classNames from 'classnames';
import React, { useMemo, useState, useRef } from 'react';
import { isMobile } from 'react-device-detect';
import MoreSocialIcon from '@/assets/icons/more-social-icon.svg';
import SocialIconButton from '@/components/Button/SocialIconButton';
import usePlatform from '@/hooks/use-platform';
import useSocial from '@/hooks/use-social';
import * as styles from './SecondarySocialSignIn.module.scss';
@ -21,6 +21,7 @@ const SecondarySocialSignIn = ({ className }: Props) => {
const isOverSize = socialConnectors.length > defaultSize;
const [showModal, setShowModal] = useState(false);
const moreButtonRef = useRef<HTMLElement>(null);
const { isMobile } = usePlatform();
const displayConnectors = useMemo(() => {
if (isOverSize) {

View file

@ -5,6 +5,7 @@ import { useState, useCallback, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { PageContext } from '@/hooks/use-page-context';
import usePreview from '@/hooks/use-preview';
type UseApi<T extends any[], U> = {
result?: U;
@ -26,6 +27,7 @@ function useApi<Args extends any[], Response>(
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const [error, setError] = useState<RequestErrorBody>();
const [result, setResult] = useState<Response>();
const [isPreview] = usePreview();
const { setLoading, setToast } = useContext(PageContext);
@ -51,6 +53,10 @@ function useApi<Args extends any[], Response>(
const run = useCallback(
async (...args: Args) => {
if (isPreview) {
return;
}
setLoading(true);
// eslint-disable-next-line unicorn/no-useless-undefined
setError(undefined);
@ -66,7 +72,7 @@ function useApi<Args extends any[], Response>(
setLoading(false);
}
},
[api, parseError, setLoading]
[isPreview, api, parseError, setLoading]
);
useEffect(() => {

View file

@ -1,15 +1,18 @@
import { useState, useMemo, createContext } from 'react';
import { isMobile } from 'react-device-detect';
import { SignInExperienceSettings } from '@/types';
import { SignInExperienceSettings, Platform } from '@/types';
type Context = {
toast: string;
loading: boolean;
platform: Platform;
termsAgreement: boolean;
showTermsModal: boolean;
experienceSettings: SignInExperienceSettings | undefined;
setToast: (message: string) => void;
setLoading: (loading: boolean) => void;
setPlatform: (platform: Platform) => void;
setTermsAgreement: (termsAgreement: boolean) => void;
setShowTermsModal: (showTermsModal: boolean) => void;
setExperienceSettings: (settings: SignInExperienceSettings) => void;
@ -22,11 +25,13 @@ const noop = () => {
export const PageContext = createContext<Context>({
toast: '',
loading: false,
platform: isMobile ? 'mobile' : 'web',
termsAgreement: false,
showTermsModal: false,
experienceSettings: undefined,
setToast: noop,
setLoading: noop,
setPlatform: noop,
setTermsAgreement: noop,
setShowTermsModal: noop,
setExperienceSettings: noop,
@ -35,6 +40,7 @@ export const PageContext = createContext<Context>({
const usePageContext = () => {
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState('');
const [platform, setPlatform] = useState<Platform>(isMobile ? 'mobile' : 'web');
const [experienceSettings, setExperienceSettings] = useState<SignInExperienceSettings>();
const [termsAgreement, setTermsAgreement] = useState(false);
const [showTermsModal, setShowTermsModal] = useState(false);
@ -43,16 +49,18 @@ const usePageContext = () => {
() => ({
toast,
loading,
platform,
termsAgreement,
showTermsModal,
experienceSettings,
setLoading,
setToast,
setPlatform,
setTermsAgreement,
setShowTermsModal,
setExperienceSettings,
}),
[experienceSettings, loading, showTermsModal, termsAgreement, toast]
[experienceSettings, loading, platform, showTermsModal, termsAgreement, toast]
);
return {

View file

@ -0,0 +1,11 @@
import { useContext } from 'react';
import { PageContext } from './use-page-context';
const usePlatform = () => {
const { platform } = useContext(PageContext);
return { isMobile: platform === 'mobile', platform };
};
export default usePlatform;

View file

@ -0,0 +1,50 @@
import { Language } from '@logto/phrases';
import { AppearanceMode } from '@logto/schemas';
import { useMemo } from 'react';
import { SignInExperienceSettingsResponse } from '@/types';
import { parseQueryParameters } from '@/utils';
type PreviewConfig = {
signInExperience: SignInExperienceSettingsResponse;
language: Language;
mode: AppearanceMode;
platform: 'web' | 'mobile';
};
const usePreview = (): [boolean, PreviewConfig?] => {
const { preview, config } = parseQueryParameters(window.location.search);
const previewConfig = useMemo(() => {
if (!preview || !config) {
return;
}
try {
const {
signInExperience: { languageInfo, ...rest },
language,
mode,
platform,
} = JSON.parse(decodeURIComponent(config)) as PreviewConfig;
// Overwrite languageInfo
const settings: SignInExperienceSettingsResponse = {
...rest,
languageInfo: {
...languageInfo,
fixedLanguage: language,
autoDetect: false,
},
// TODO: Remove this once preview returns connectors data
socialConnectors: [],
};
return { signInExperience: settings, language, mode, platform };
} catch {}
}, [config, preview]);
return [Boolean(preview), previewConfig];
};
export default usePreview;

View file

@ -1,3 +1,4 @@
import { AppearanceMode } from '@logto/schemas';
import { useState, useEffect, useContext } from 'react';
import { PageContext } from './use-page-context';
@ -7,12 +8,14 @@ export type Theme = 'dark' | 'light';
const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
const getThemeBySystemConfiguration = (): Theme => (darkThemeWatchMedia.matches ? 'dark' : 'light');
export default function useTheme() {
export default function useTheme(mode: AppearanceMode = AppearanceMode.SyncWithSystem): Theme {
const { experienceSettings } = useContext(PageContext);
const [theme, setTheme] = useState<Theme>('light');
const [theme, setTheme] = useState<Theme>(
mode === AppearanceMode.SyncWithSystem ? 'light' : mode
);
useEffect(() => {
if (!experienceSettings?.branding.isDarkModeEnabled) {
if (mode !== AppearanceMode.SyncWithSystem || !experienceSettings?.branding.isDarkModeEnabled) {
return;
}
@ -27,7 +30,7 @@ export default function useTheme() {
return () => {
darkThemeWatchMedia.removeEventListener('change', changeTheme);
};
}, [experienceSettings]);
}, [experienceSettings, mode]);
return theme;
}

View file

@ -1,10 +1,10 @@
import React from 'react';
import { isMobile } from 'react-device-detect';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import NavBar from '@/components/NavBar';
import SocialCreateAccount from '@/containers/SocialCreateAccount';
import usePlatform from '@/hooks/use-platform';
import * as styles from './index.module.scss';
@ -15,6 +15,7 @@ type Parameters = {
const SocialRegister = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { connector } = useParams<Parameters>();
const { isMobile } = usePlatform();
if (!connector) {
return null;

View file

@ -1,4 +1,10 @@
import { Branding, LanguageInfo, TermsOfUse, ConnectorMetadata } from '@logto/schemas';
import {
Branding,
LanguageInfo,
TermsOfUse,
SignInExperience,
ConnectorMetadata,
} from '@logto/schemas';
export type UserFlow = 'sign-in' | 'register';
export type SignInMethod = 'username' | 'email' | 'sms' | 'social';
@ -8,10 +14,16 @@ export enum SearchParameters {
bindWithSocial = 'bw',
}
export type Platform = 'web' | 'mobile';
export interface ConnectorData extends ConnectorMetadata {
id: string;
}
export type SignInExperienceSettingsResponse = SignInExperience & {
socialConnectors: ConnectorData[];
};
export type SignInExperienceSettings = {
branding: Branding;
languageInfo: LanguageInfo;

View file

@ -3,10 +3,10 @@
* TODO: Remove this once we have a better way to get the sign in experience through SSR
*/
import { SignInMethods, SignInExperience } from '@logto/schemas';
import { SignInMethods } from '@logto/schemas';
import { getSignInExperience } from '@/apis/settings';
import { ConnectorData, SignInMethod, SignInExperienceSettings } from '@/types';
import { SignInMethod, SignInExperienceSettingsResponse, SignInExperienceSettings } from '@/types';
const getPrimarySignInMethod = (signInMethods: SignInMethods) => {
for (const [key, value] of Object.entries(signInMethods)) {
@ -27,20 +27,19 @@ const getSecondarySignInMethods = (signInMethods: SignInMethods) =>
return methods;
}, []);
const getSignInExperienceSettings = async <
T extends SignInExperience & { socialConnectors: ConnectorData[] }
>(): Promise<SignInExperienceSettings> => {
const { branding, languageInfo, termsOfUse, signInMethods, socialConnectors } =
await getSignInExperience<T>();
return {
branding,
languageInfo,
termsOfUse,
export const parseSignInExperienceSettings = ({
signInMethods,
...rest
}: SignInExperienceSettingsResponse) => ({
...rest,
primarySignInMethod: getPrimarySignInMethod(signInMethods),
secondarySignInMethods: getSecondarySignInMethods(signInMethods),
socialConnectors,
};
});
const getSignInExperienceSettings = async (): Promise<SignInExperienceSettings> => {
const response = await getSignInExperience<SignInExperienceSettingsResponse>();
return parseSignInExperienceSettings(response);
};
export default getSignInExperienceSettings;