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:
parent
4a7b0abbe3
commit
ef19fb3d27
16 changed files with 155 additions and 47 deletions
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 */}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
11
packages/ui/src/hooks/use-platform.ts
Normal file
11
packages/ui/src/hooks/use-platform.ts
Normal 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;
|
50
packages/ui/src/hooks/use-preview.ts
Normal file
50
packages/ui/src/hooks/use-preview.ts
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue