diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index f13bfb535..8dcbee90d 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -8,6 +8,9 @@ const translation = { error: 'Username or password is invalid.', username: 'Username', password: 'Password', + terms_of_use: 'Terms of Use', + terms_agreement_prefix: 'I agree with ', + continue_with: 'Continue With', }, register: { create_account: 'Create an Account', diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index f5f1f3a5b..8e2507dd3 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -10,6 +10,9 @@ const translation = { error: '用户名或密码错误。', username: '用户名', password: '密码', + terms_of_use: '用户协议', + terms_agreement_prefix: '登录即表明您已经同意', + continue_with: '更多', }, register: { create_account: '创建新账户', diff --git a/packages/ui/src/assets/icons/radio-button.svg b/packages/ui/src/assets/icons/radio-button.svg new file mode 100644 index 000000000..324930ca5 --- /dev/null +++ b/packages/ui/src/assets/icons/radio-button.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/src/components/AppContent/index.module.scss b/packages/ui/src/components/AppContent/index.module.scss index c0cfe8fc0..0fae2cefb 100644 --- a/packages/ui/src/components/AppContent/index.module.scss +++ b/packages/ui/src/components/AppContent/index.module.scss @@ -74,15 +74,20 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif; --color-control-focus: #{$color-primary-tint-60}; --color-control-action: #{$color-neutral-70}; --color-control-action-focus: #{$color-primary-tint-70}; + --color-checkbox-border: #{$color-neutral-30}; + --color-divider: #dbdbdb; /* Font Color */ --color-font-primary: #{$color-neutral-100}; --color-font-secondary: #444; --color-font-placeholder: #aaa; + --color-font-divider: #bbb; --color-font-button-text: #{$color-neutral-0}; --color-font-button-text-active: #{rgba($color-neutral-0, 0.4)}; --color-font-secondary-active: #{$color-neutral-70}; --color-font-secondary-disabled: #{rgba($color-neutral-100, 0.4)}; + --color-font-link: #{$color-primary}; + --color-font-link-secondary: #{$color-neutral-70}; /* ===== Legacy Styling ===== */ --color-heading: #333; @@ -98,15 +103,15 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif; .mobile { --font-title: 600 32px/40px #{$font-family}; - --font-heading-2: 400 18px/22px #{$font-family}; + --font-heading-2: 500 18px/22px #{$font-family}; --font-heading-2-bold: 600 18px/22px #{$font-family}; --font-control: 500 18px/20px #{$font-family}; --font-button-text: 600 20px/24px #{$font-family}; - + --font-body: 400 16px/20px #{$font-family}; + --font-body-bold: 600 16px/20px #{$font-family}; + --font-body-small: 400 14px/18px #{$font-family}; /* ===== Legacy Styling ===== */ --font-headline: 600 40px/56px #{$font-family}; --font-heading-1: 600 28px/39px #{$font-family}; --font-heading-3: 600 16px/22.4px #{$font-family}; - --font-body: 400 12px/16px #{$font-family}; - --font-body-bold: 500 12px/16px #{$font-family}; } diff --git a/packages/ui/src/components/Divider/index.module.scss b/packages/ui/src/components/Divider/index.module.scss new file mode 100644 index 000000000..b7270e8f8 --- /dev/null +++ b/packages/ui/src/components/Divider/index.module.scss @@ -0,0 +1,24 @@ +@use '@/scss/underscore' as _; + + +.divider { + @include _.flex-row; + font: var(--font-body); + color: var(--color-font-divider); + margin: _.unit(4) 0; + width: 100%; + + .line { + flex: 1; + height: 1px; + background: var(--color-divider); + + &:first-child { + margin-right: _.unit(4); + } + + &:last-child { + margin-left: _.unit(4); + } + } +} diff --git a/packages/ui/src/components/Divider/index.test.tsx b/packages/ui/src/components/Divider/index.test.tsx new file mode 100644 index 000000000..30d69919f --- /dev/null +++ b/packages/ui/src/components/Divider/index.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import Divider from '.'; + +describe('Divider', () => { + const { t } = useTranslation(); + + it('render with content', () => { + const { queryByText } = render(); + expect(queryByText(t('sign_in.continue_with'))).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/components/Divider/index.tsx b/packages/ui/src/components/Divider/index.tsx new file mode 100644 index 000000000..15c5fad23 --- /dev/null +++ b/packages/ui/src/components/Divider/index.tsx @@ -0,0 +1,25 @@ +import { I18nKey } from '@logto/phrases'; +import classNames from 'classnames'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import * as styles from './index.module.scss'; + +type Props = { + className?: string; + label?: I18nKey; +}; + +const Divider = ({ className, label }: Props) => { + const { t } = useTranslation(); + + return ( +
+ + {label && t(label)} + +
+ ); +}; + +export default Divider; diff --git a/packages/ui/src/components/ErrorMessage/index.module.scss b/packages/ui/src/components/ErrorMessage/index.module.scss new file mode 100644 index 000000000..5d1f1c60b --- /dev/null +++ b/packages/ui/src/components/ErrorMessage/index.module.scss @@ -0,0 +1,6 @@ +@use '@/scss/underscore' as _; + +.error { + font: var(--font-body-small); + color: var(--color-error); +} diff --git a/packages/ui/src/components/ErrorMessage/index.tsx b/packages/ui/src/components/ErrorMessage/index.tsx new file mode 100644 index 000000000..8d930f78c --- /dev/null +++ b/packages/ui/src/components/ErrorMessage/index.tsx @@ -0,0 +1,24 @@ +import { LogtoErrorCode, LogtoErrorI18nKey } from '@logto/phrases'; +import classNames from 'classnames'; +import React, { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import * as styles from './index.module.scss'; + +export type Props = { + errorCode?: LogtoErrorCode; + className?: string; + children?: ReactNode; +}; + +const ErrorMessage = ({ errorCode, className, children }: Props) => { + const { i18n } = useTranslation(); + + return ( +
+ {children ?? (errorCode ? i18n.t(`errors:${errorCode}`) : ``)} +
+ ); +}; + +export default ErrorMessage; diff --git a/packages/ui/src/components/Icons/RadioButtonIcon.tsx b/packages/ui/src/components/Icons/RadioButtonIcon.tsx new file mode 100644 index 000000000..603cfd5a5 --- /dev/null +++ b/packages/ui/src/components/Icons/RadioButtonIcon.tsx @@ -0,0 +1,16 @@ +import React, { SVGProps } from 'react'; + +import RadioButton from '@/assets/icons/radio-button.svg'; + +const RadioButtonIcon = ({ + checked, + ...props +}: SVGProps & { checked?: boolean }) => { + return ( + + + + ); +}; + +export default RadioButtonIcon; diff --git a/packages/ui/src/components/MessageBox/index.module.scss b/packages/ui/src/components/MessageBox/index.module.scss deleted file mode 100644 index c6586256b..000000000 --- a/packages/ui/src/components/MessageBox/index.module.scss +++ /dev/null @@ -1,13 +0,0 @@ -@use '@/scss/underscore' as _; - -.messageBox { - font: var(--font-body-bold); - padding: _.unit(2) _.unit(5); - border-radius: _.unit(); - - &.error { - color: var(--color-error); - background: var(--color-error-background); - border: 1px solid var(--color-error-border); - } -} diff --git a/packages/ui/src/components/MessageBox/index.tsx b/packages/ui/src/components/MessageBox/index.tsx deleted file mode 100644 index 698a96ea6..000000000 --- a/packages/ui/src/components/MessageBox/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import classNames from 'classnames'; -import React, { ReactNode } from 'react'; - -import * as styles from './index.module.scss'; - -export type Props = { - className?: string; - children: ReactNode; -}; - -const MessageBox = ({ className, children }: Props) => { - return
{children}
; -}; - -export default MessageBox; diff --git a/packages/ui/src/components/TermsOfUse/index.module.scss b/packages/ui/src/components/TermsOfUse/index.module.scss new file mode 100644 index 000000000..9595343d3 --- /dev/null +++ b/packages/ui/src/components/TermsOfUse/index.module.scss @@ -0,0 +1,22 @@ +@use '@/scss/underscore' as _; + +.terms { + @include _.flex-row; + + input[type='checkbox'] { + appearance: none; + position: absolute; + margin: 0; + width: 0; + height: 0; + } +} + +.radioButton { + margin-right: _.unit(2); + transform: scale(0.8); +} + +.content { + @include _.text-hint; +} diff --git a/packages/ui/src/components/TermsOfUse/index.test.tsx b/packages/ui/src/components/TermsOfUse/index.test.tsx new file mode 100644 index 000000000..069a9a712 --- /dev/null +++ b/packages/ui/src/components/TermsOfUse/index.test.tsx @@ -0,0 +1,47 @@ +import { TermsOfUse as TermsOfUseType } from '@logto/schemas'; +import { render, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import TermsOfUse from '.'; + +describe('Terms of Use', () => { + const onChange = jest.fn(); + const termsOfUse: TermsOfUseType = { + enabled: true, + contentUrl: 'http://logto.dev/', + }; + const { t } = useTranslation(); + const prefix = t('sign_in.terms_agreement_prefix'); + + beforeEach(() => { + onChange.mockClear(); + }); + + it('render Terms of User checkbox', () => { + const { getByText, container } = render( + + ); + + const element = getByText(prefix); + + fireEvent.click(element); + + expect(onChange).toBeCalledWith(true); + + const linkElement = container.querySelector('a'); + expect(linkElement).not.toBeNull(); + + if (linkElement) { + expect(linkElement.href).toEqual(termsOfUse.contentUrl); + } + }); + + it('render null with disabled terms', () => { + const { container } = render( + + ); + + expect(container.children).toHaveLength(0); + }); +}); diff --git a/packages/ui/src/components/TermsOfUse/index.tsx b/packages/ui/src/components/TermsOfUse/index.tsx new file mode 100644 index 000000000..60106687d --- /dev/null +++ b/packages/ui/src/components/TermsOfUse/index.tsx @@ -0,0 +1,52 @@ +import { TermsOfUse as TermsOfUseType } from '@logto/schemas'; +import classNames from 'classnames'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import RadioButtonIcon from '@/components/Icons/RadioButtonIcon'; +import TextLink from '@/components/TextLink'; + +import * as styles from './index.module.scss'; + +type Props = { + name: string; + className?: string; + termsOfUse: TermsOfUseType; + isChecked?: boolean; + onChange: (checked: boolean) => void; +}; + +const TermsOfUse = ({ name, className, termsOfUse, isChecked, onChange }: Props) => { + const { t } = useTranslation(); + + if (!termsOfUse.enabled || !termsOfUse.contentUrl) { + return null; + } + + const prefix = t('sign_in.terms_agreement_prefix'); + + return ( +
{ + onChange(!isChecked); + }} + > + + +
+ {prefix} + { + event.stopPropagation(); + }} + /> +
+
+ ); +}; + +export default TermsOfUse; diff --git a/packages/ui/src/components/TextLink/index.module.scss b/packages/ui/src/components/TextLink/index.module.scss index 0795288b6..565024e2e 100644 --- a/packages/ui/src/components/TextLink/index.module.scss +++ b/packages/ui/src/components/TextLink/index.module.scss @@ -1,12 +1,18 @@ @use '@/scss/underscore' as _; .link { - color: var(--color-button-background); - font: var(--font-body-bold); transition: var(--transition-default-control); cursor: pointer; + -webkit-tap-highlight-color: transparent; - &:hover { - color: var(--color-button-background-hover); + &.primary { + color: var(--color-font-link); + font: var(--font-body-bold); + text-decoration: none; + } + + &.secondary { + color: var(--color-font-link-secondary); + font: var(--font-body-small); } } diff --git a/packages/ui/src/components/TextLink/index.test.tsx b/packages/ui/src/components/TextLink/index.test.tsx new file mode 100644 index 000000000..922cee4ff --- /dev/null +++ b/packages/ui/src/components/TextLink/index.test.tsx @@ -0,0 +1,18 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import TextLink from '.'; + +describe('TextLink', () => { + it('render with children', () => { + const { queryByText } = render(foo); + expect(queryByText('foo')).not.toBeNull(); + }); + + it('render with i18nKey', () => { + const { queryByText } = render(); + const { t } = useTranslation(); + expect(queryByText(t('sign_in.action'))).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/components/TextLink/index.tsx b/packages/ui/src/components/TextLink/index.tsx index 9808dac18..33b228be8 100644 --- a/packages/ui/src/components/TextLink/index.tsx +++ b/packages/ui/src/components/TextLink/index.tsx @@ -1,18 +1,25 @@ +import { I18nKey } from '@logto/phrases'; import classNames from 'classnames'; -import React, { ReactChild } from 'react'; +import React, { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import * as styles from './index.module.scss'; export type Props = { className?: string; - children: ReactChild; + children?: ReactNode; + text?: I18nKey; href: string; + type?: 'primary' | 'secondary'; + onClick?: React.MouseEventHandler; }; -const TextLink = ({ className, children, href }: Props) => { +const TextLink = ({ className, children, text, href, type = 'primary', onClick }: Props) => { + const { t } = useTranslation(); + return ( - - {children} + + {children ?? (text ? t(text) : '')} ); }; diff --git a/packages/ui/src/pages/Register/index.module.scss b/packages/ui/src/pages/Register/index.module.scss index 661702d4a..f46f49b5e 100644 --- a/packages/ui/src/pages/Register/index.module.scss +++ b/packages/ui/src/pages/Register/index.module.scss @@ -16,8 +16,7 @@ } .title { - font: var(--font-heading-1); - color: var(--color-heading); + @include _.title; margin-bottom: _.unit(9); } diff --git a/packages/ui/src/pages/Register/index.tsx b/packages/ui/src/pages/Register/index.tsx index c8a1f9a63..a77757458 100644 --- a/packages/ui/src/pages/Register/index.tsx +++ b/packages/ui/src/pages/Register/index.tsx @@ -1,20 +1,19 @@ -import { LogtoErrorI18nKey } from '@logto/phrases'; import classNames from 'classnames'; import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { register } from '@/apis/register'; import Button from '@/components/Button'; +import ErrorMessage from '@/components/ErrorMessage'; import Input from '@/components/Input'; import PasswordInput from '@/components/Input/PasswordInput'; -import MessageBox from '@/components/MessageBox'; import TextLink from '@/components/TextLink'; import useApi from '@/hooks/use-api'; import * as styles from './index.module.scss'; const Register: FC = () => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -52,11 +51,7 @@ const Register: FC = () => { value={password} onChange={setPassword} // TODO: password validation /> - {error && ( - - {i18n.t(`errors:${error.code}`)} - - )} + {error && } diff --git a/packages/ui/src/pages/SignIn/index.module.scss b/packages/ui/src/pages/SignIn/index.module.scss index d93f73ae9..4a88e0a00 100644 --- a/packages/ui/src/pages/SignIn/index.module.scss +++ b/packages/ui/src/pages/SignIn/index.module.scss @@ -16,8 +16,7 @@ } .title { - font: var(--font-heading-1); - color: var(--color-heading); + @include _.title; margin-bottom: _.unit(9); } diff --git a/packages/ui/src/pages/SignIn/index.tsx b/packages/ui/src/pages/SignIn/index.tsx index 8fb2a5d39..72bfb5c08 100644 --- a/packages/ui/src/pages/SignIn/index.tsx +++ b/packages/ui/src/pages/SignIn/index.tsx @@ -1,13 +1,12 @@ -import { LogtoErrorI18nKey } from '@logto/phrases'; import classNames from 'classnames'; import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { signInBasic } from '@/apis/sign-in'; import Button from '@/components/Button'; +import ErrorMessage from '@/components/ErrorMessage'; import Input from '@/components/Input'; import PasswordInput from '@/components/Input/PasswordInput'; -import MessageBox from '@/components/MessageBox'; import TextLink from '@/components/TextLink'; import useApi from '@/hooks/use-api'; @@ -15,7 +14,7 @@ import * as styles from './index.module.scss'; const SignIn: FC = () => { // TODO: Consider creating cross page data modal - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -57,17 +56,15 @@ const SignIn: FC = () => { className={styles.inputField} onChange={setPassword} /> - {error && ( - - {i18n.t(`errors:${error.code}`)} - - )} + {error && } - - {t('register.create_account')} - + ); diff --git a/packages/ui/src/scss/_underscore.scss b/packages/ui/src/scss/_underscore.scss index d84efcb45..1e3340c8c 100644 --- a/packages/ui/src/scss/_underscore.scss +++ b/packages/ui/src/scss/_underscore.scss @@ -20,6 +20,16 @@ object-position: center; } +@mixin text-hint { + font: var(--font-body-small); + color: var(--color-font-link-secondary); +} + +@mixin title { + font: var(--font-title); + color: var(--color-font-primary); +} + @function border($color: transparent, $width: 1) { @return #{$width}px solid #{$color}; }