mirror of
https://github.com/logto-io/logto.git
synced 2025-03-03 22:15:32 -05:00
feat(ui): add inline notification (#2432)
This commit is contained in:
parent
1be139dc46
commit
ec2492700d
21 changed files with 176 additions and 142 deletions
|
@ -83,6 +83,7 @@ const translation = {
|
|||
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
|
||||
link_email_or_phone_description:
|
||||
'For added security, please link your email or phone with the account.', // UNTRANSLATED
|
||||
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: 'Benutzername oder Passwort ist falsch',
|
||||
|
|
|
@ -79,6 +79,7 @@ const translation = {
|
|||
link_phone_description: 'For added security, please link your phone with the account.',
|
||||
link_email_or_phone_description:
|
||||
'For added security, please link your email or phone with the account.',
|
||||
continue_with_more_information: 'For added security, please complete below account details.',
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: 'Username and password do not match',
|
||||
|
|
|
@ -83,6 +83,7 @@ const translation = {
|
|||
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
|
||||
link_email_or_phone_description:
|
||||
'For added security, please link your email or phone with the account.', // UNTRANSLATED
|
||||
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas",
|
||||
|
|
|
@ -79,6 +79,7 @@ const translation = {
|
|||
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
|
||||
link_email_or_phone_description:
|
||||
'For added security, please link your email or phone with the account.', // UNTRANSLATED
|
||||
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.',
|
||||
|
|
|
@ -79,6 +79,7 @@ const translation = {
|
|||
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
|
||||
link_email_or_phone_description:
|
||||
'For added security, please link your email or phone with the account.', // UNTRANSLATED
|
||||
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: 'O Utilizador e a password não correspondem',
|
||||
|
|
|
@ -80,6 +80,7 @@ const translation = {
|
|||
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
|
||||
link_email_or_phone_description:
|
||||
'For added security, please link your email or phone with the account.', // UNTRANSLATED
|
||||
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.',
|
||||
|
|
|
@ -75,6 +75,7 @@ const translation = {
|
|||
link_email_description: '绑定邮箱以保障您的账号安全',
|
||||
link_phone_description: '绑定手机号以保障您的账号安全',
|
||||
link_email_or_phone_description: '绑定邮箱或手机号以保障您的账号安全',
|
||||
continue_with_more_information: '为保障您的账号安全,需要您补充以下信息。',
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: '用户名和密码不匹配',
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.notification {
|
||||
padding: _.unit(3) _.unit(4);
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-type-primary);
|
||||
background: var(--color-neutral-variant-90);
|
||||
border: _.border(var(--color-neutral-variant-80));
|
||||
box-shadow: var(--color-shadow-1);
|
||||
border-radius: var(--radius);
|
||||
@include _.flex_row;
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: _.unit(3);
|
||||
color: var(--color-neutral-variant-60);
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
margin-right: _.unit(4);
|
||||
}
|
||||
|
||||
.link {
|
||||
max-width: 20%;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import classNames from 'classnames';
|
||||
import type { CSSProperties, ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import InfoIcon from '@/assets/icons/info-icon.svg';
|
||||
import TextLink from '@/components/TextLink';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
/* eslint-disable react/require-default-props */
|
||||
type Props = {
|
||||
className?: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
/* eslint-enable react/require-default-props */
|
||||
|
||||
const AppNotification = forwardRef(
|
||||
({ className, message, style, onClose }: Props, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
return (
|
||||
<div ref={ref} className={classNames(styles.notification, className)} style={style}>
|
||||
<InfoIcon className={styles.icon} />
|
||||
<div className={styles.message}>{message}</div>
|
||||
<TextLink text="action.got_it" className={styles.link} onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default AppNotification;
|
|
@ -0,0 +1,10 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.notification {
|
||||
padding: _.unit(3) _.unit(4);
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-type-primary);
|
||||
margin: 0 auto _.unit(2);
|
||||
background: var(--color-alert-99);
|
||||
@include _.flex_row;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import classNames from 'classnames';
|
||||
import type { TFuncKey } from 'react-i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
message: TFuncKey;
|
||||
};
|
||||
|
||||
const InlineNotification = ({ className, message }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <div className={classNames(styles.notification, className)}>{t(message)}</div>;
|
||||
};
|
||||
|
||||
export default InlineNotification;
|
|
@ -1,55 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.notification {
|
||||
padding: _.unit(3) _.unit(4);
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-type-primary);
|
||||
margin: 0 auto _.unit(2);
|
||||
@include _.flex_row;
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: _.unit(3);
|
||||
}
|
||||
|
||||
&.alert {
|
||||
background: var(--color-alert-99);
|
||||
|
||||
.icon {
|
||||
color: var(--color-alert-70);
|
||||
}
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: var(--color-neutral-variant-90);
|
||||
|
||||
.icon {
|
||||
color: var(--color-neutral-variant-60);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
margin-right: _.unit(4);
|
||||
}
|
||||
|
||||
.link {
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.notification {
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--color-shadow-1);
|
||||
|
||||
&.alert {
|
||||
border: _.border(var(--color-alert-70));
|
||||
}
|
||||
|
||||
&.info {
|
||||
border: _.border(var(--color-neutral-variant-80));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,25 +1,2 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
import InfoIcon from '@/assets/icons/info-icon.svg';
|
||||
|
||||
import TextLink from '../TextLink';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
type?: 'info' | 'alert';
|
||||
};
|
||||
|
||||
const Notification = ({ className, message, onClose, type = 'info' }: Props) => {
|
||||
return (
|
||||
<div className={classNames(styles.notification, styles[type], className)}>
|
||||
<InfoIcon className={styles.icon} />
|
||||
<div className={styles.message}>{message}</div>
|
||||
<TextLink text="action.got_it" className={styles.link} onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notification;
|
||||
export { default as AppNotification } from './AppNotification';
|
||||
export { default as InlineNotification } from './InlineNotification';
|
||||
|
|
|
@ -26,6 +26,10 @@
|
|||
.title {
|
||||
@include _.title;
|
||||
}
|
||||
|
||||
.notification {
|
||||
margin: 0 _.unit(-5) _.unit(6);
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
|
@ -40,4 +44,11 @@
|
|||
.title {
|
||||
@include _.title_desktop;
|
||||
}
|
||||
|
||||
.notification {
|
||||
@include _.full-width;
|
||||
margin-top: _.unit(6);
|
||||
box-shadow: var(--color-shadow-1);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,9 @@ import { useTranslation } from 'react-i18next';
|
|||
import type { TFuncKey } from 'react-i18next';
|
||||
|
||||
import NavBar from '@/components/NavBar';
|
||||
import usePlatform from '@/hooks/use-platform';
|
||||
|
||||
import { InlineNotification } from '../Notification';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -10,6 +12,7 @@ type Props = {
|
|||
description?: TFuncKey;
|
||||
titleProps?: Record<string, unknown>;
|
||||
descriptionProps?: Record<string, unknown>;
|
||||
notification?: TFuncKey;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
|
@ -18,13 +21,18 @@ const SecondaryPageWrapper = ({
|
|||
description,
|
||||
titleProps,
|
||||
descriptionProps,
|
||||
notification,
|
||||
children,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = usePlatform();
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<NavBar />
|
||||
{isMobile && notification && (
|
||||
<InlineNotification message={notification} className={styles.notification} />
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
{title && <div className={styles.title}>{t(title, titleProps)}</div>}
|
||||
|
@ -35,6 +43,9 @@ const SecondaryPageWrapper = ({
|
|||
|
||||
{children}
|
||||
</div>
|
||||
{!isMobile && notification && (
|
||||
<InlineNotification message={notification} className={styles.notification} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -85,5 +85,6 @@ body {
|
|||
border-radius: 16px;
|
||||
background: var(--color-bg-float);
|
||||
box-shadow: var(--color-shadow-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,18 +15,8 @@
|
|||
|
||||
:global(body.desktop) {
|
||||
.appNotification {
|
||||
top: _.unit(-6);
|
||||
left: 50%;
|
||||
transform: translate(-50%, -100%);
|
||||
transform: translate(-50%);
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 820px) {
|
||||
:global(body.desktop) {
|
||||
.appNotification {
|
||||
top: _.unit(6);
|
||||
transform: translate(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
|
||||
import Notification from './index';
|
||||
|
||||
describe('Notification', () => {
|
||||
it('render Notification', () => {
|
||||
const notification = 'text notification';
|
||||
|
||||
const { queryByText, getByText } = renderWithPageContext(
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
notification,
|
||||
}}
|
||||
>
|
||||
<Notification />
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
expect(queryByText(notification)).not.toBeNull();
|
||||
|
||||
const closeButton = getByText('action.got_it');
|
||||
fireEvent.click(closeButton);
|
||||
expect(queryByText(notification)).toBeNull();
|
||||
});
|
||||
});
|
|
@ -1,34 +1,66 @@
|
|||
import { useCallback, useContext } from 'react';
|
||||
import { useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import Notification from '@/components/Notification';
|
||||
import { AppNotification as Notification } from '@/components/Notification';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import usePlatform from '@/hooks/use-platform';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const AppNotification = () => {
|
||||
const { experienceSettings, setExperienceSettings } = useContext(PageContext);
|
||||
const notification = experienceSettings?.notification;
|
||||
const { isMobile } = usePlatform();
|
||||
const { experienceSettings } = useContext(PageContext);
|
||||
const [notification, setNotification] = useState<string>();
|
||||
const [topOffset, setTopOffset] = useState<number>();
|
||||
const eleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (experienceSettings?.notification) {
|
||||
setNotification(experienceSettings.notification);
|
||||
}
|
||||
}, [experienceSettings]);
|
||||
|
||||
const adjustNotificationPosition = useCallback(() => {
|
||||
const mainEleOffsetTop = document.querySelector('main')?.offsetTop;
|
||||
const elementHeight = eleRef.current?.offsetHeight;
|
||||
|
||||
if (mainEleOffsetTop !== undefined && elementHeight) {
|
||||
const topSpace = mainEleOffsetTop - elementHeight - 24;
|
||||
setTopOffset(Math.max(32, topSpace));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notification || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
adjustNotificationPosition();
|
||||
|
||||
window.addEventListener('resize', adjustNotificationPosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', adjustNotificationPosition);
|
||||
};
|
||||
}, [adjustNotificationPosition, isMobile, notification]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
// Clear notification
|
||||
setExperienceSettings((settings) => {
|
||||
if (!settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...settings,
|
||||
notification: undefined,
|
||||
};
|
||||
});
|
||||
}, [setExperienceSettings]);
|
||||
setNotification('');
|
||||
}, []);
|
||||
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Notification className={styles.appNotification} message={notification} onClose={onClose} />
|
||||
return createPortal(
|
||||
<Notification
|
||||
ref={eleRef}
|
||||
className={styles.appNotification}
|
||||
message={notification}
|
||||
style={isMobile ? undefined : { top: topOffset }}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -4,10 +4,10 @@ import type { ReactNode } from 'react';
|
|||
import { useContext } from 'react';
|
||||
|
||||
import BrandingHeader from '@/components/BrandingHeader';
|
||||
import AppNotification from '@/containers/AppNotification';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import { getLogoUrl } from '@/utils/logo';
|
||||
|
||||
import AppNotification from '../AppNotification';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -34,9 +34,9 @@ const LandingPageContainer = ({ children, className }: Props) => {
|
|||
logo={getLogoUrl({ theme, logoUrl, darkLogoUrl })}
|
||||
/>
|
||||
{children}
|
||||
<AppNotification />
|
||||
</div>
|
||||
{platform === 'web' && <div className={styles.placeholderBottom} />}
|
||||
<AppNotification />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,7 +7,10 @@ const SetPassword = () => {
|
|||
const { setPassword } = useSetPassword();
|
||||
|
||||
return (
|
||||
<SecondaryPageWrapper title="description.set_password">
|
||||
<SecondaryPageWrapper
|
||||
title="description.set_password"
|
||||
notification="description.continue_with_more_information"
|
||||
>
|
||||
<SetPasswordForm autoFocus onSubmit={setPassword} />
|
||||
</SecondaryPageWrapper>
|
||||
);
|
||||
|
|
Loading…
Add table
Reference in a new issue