diff --git a/package-lock.json b/package-lock.json index 5112f35..c1ac868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,13 @@ "classnames": "2.5.1", "dayjs": "1.11.10", "formik": "2.4.5", + "i18next": "23.11.5", + "i18next-browser-languagedetector": "8.0.0", "lodash": "4.17.21", "qs": "6.11.2", "react": "18.3.1", "react-dom": "18.3.1", + "react-i18next": "14.1.2", "react-router-dom": "6.22.1", "tailwindcss": "3.4.1", "uuid": "9.0.1", @@ -379,7 +382,6 @@ "version": "7.24.5", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -4483,6 +4485,14 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4509,6 +4519,36 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "23.11.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.11.5.tgz", + "integrity": "sha512-41pvpVbW9rhZPk5xjCX2TPJi2861LEig/YRhUkY+1FQ2IQPS0bKUDYnEqY8XPPbB48h1uIwLnP9iiEfuSl20CA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz", + "integrity": "sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6403,6 +6443,27 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" }, + "node_modules/react-i18next": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.2.tgz", + "integrity": "sha512-FSIcJy6oauJbGEXfhUgVeLzvWBhIBIS+/9c6Lj4niwKZyGaGb4V4vUbATXSlsHJDXXB+ociNxqFNiFuV1gmoqg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6483,8 +6544,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/require-directory": { "version": "2.1.1", @@ -7722,6 +7782,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index 05a5d02..67ceb6b 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,13 @@ "classnames": "2.5.1", "dayjs": "1.11.10", "formik": "2.4.5", + "i18next": "23.11.5", + "i18next-browser-languagedetector": "8.0.0", "lodash": "4.17.21", "qs": "6.11.2", "react": "18.3.1", "react-dom": "18.3.1", + "react-i18next": "14.1.2", "react-router-dom": "6.22.1", "tailwindcss": "3.4.1", "uuid": "9.0.1", diff --git a/src/components/Button/LanguageToggle.tsx b/src/components/Button/LanguageToggle.tsx new file mode 100644 index 0000000..564fca2 --- /dev/null +++ b/src/components/Button/LanguageToggle.tsx @@ -0,0 +1,57 @@ +import { PropsWithClassName } from '@leanstacks/react-common'; +import { useTranslation } from 'react-i18next'; + +import { StorageKeys } from 'utils/constants'; +import storage from 'utils/storage'; +import Dropdown from 'components/Dropdown/Dropdown'; +import Icon from 'components/Icon/Icon'; +import DropdownContent from 'components/Dropdown/DropdownContent'; +import DropdownItem from 'components/Dropdown/DropdownItem'; + +/** + * Properties for the `LanguageToggle` component. + * @see {@link PropsWithClassName} + */ +interface LanguageToggleProps extends PropsWithClassName {} + +/** + * The `LanguageToggle` component renders a `Dropdown` which allows users + * to select the language in which they wish to view the application. + * @param {LanguageToggleProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const LanguageToggle = ({ className }: LanguageToggleProps): JSX.Element => { + const { i18n } = useTranslation(); + + /** + * Set the application-wide langague code used for i18n. + * @param {string} lng - A langage code, e.g. `en` or `es`. + */ + const setLanguage = (lng: string) => { + storage.setItem(StorageKeys.Language, lng); + i18n.changeLanguage(lng); + }; + + return ( + } + content={ + + setLanguage('en')} testId="dropdown-item-en"> + English + + setLanguage('fr')} testId="dropdown-item-fr"> + French + + setLanguage('es')} testId="dropdown-item-es"> + Spanish + + + } + className={className} + testId="dropdown-language" + /> + ); +}; + +export default LanguageToggle; diff --git a/src/components/Button/__tests__/LanguageToggle.test.tsx b/src/components/Button/__tests__/LanguageToggle.test.tsx new file mode 100644 index 0000000..ca97bc9 --- /dev/null +++ b/src/components/Button/__tests__/LanguageToggle.test.tsx @@ -0,0 +1,79 @@ +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { StorageKeys } from 'utils/constants'; +import { render, screen } from 'test/test-utils'; +import storage from 'utils/storage'; + +import LanguageToggle from '../LanguageToggle'; + +// mock select functions from react-i18next +const mockChangeLanguage = vi.fn(); +vi.mock('react-i18next', async () => { + const original = await vi.importActual('react-i18next'); + return { + ...original, + useTranslation: () => ({ i18n: { changeLanguage: mockChangeLanguage } }), + }; +}); + +describe('LanguageToggle', () => { + const setItemSpy = vi.spyOn(storage, 'setItem'); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('dropdown-language'); + + // ASSERT + expect(screen.getByTestId('dropdown-language')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('dropdown-language'); + + // ASSERT + expect(screen.getByTestId('dropdown-language').classList).toContain('custom-className'); + }); + + it('should set language to English', async () => { + // ARRANGE + render(); + await screen.findByTestId('dropdown-language'); + + // ACT + await userEvent.click(screen.getByTestId('dropdown-item-en')); + + // ASSERT + expect(mockChangeLanguage).toHaveBeenCalledWith('en'); + expect(setItemSpy).toHaveBeenCalledWith(StorageKeys.Language, 'en'); + }); + + it('should set language to French', async () => { + // ARRANGE + render(); + await screen.findByTestId('dropdown-language'); + + // ACT + await userEvent.click(screen.getByTestId('dropdown-item-fr')); + + // ASSERT + expect(mockChangeLanguage).toHaveBeenCalledWith('fr'); + expect(setItemSpy).toHaveBeenCalledWith(StorageKeys.Language, 'fr'); + }); + + it('should set language to Spanish', async () => { + // ARRANGE + render(); + await screen.findByTestId('dropdown-language'); + + // ACT + await userEvent.click(screen.getByTestId('dropdown-item-es')); + + // ASSERT + expect(mockChangeLanguage).toHaveBeenCalledWith('es'); + expect(setItemSpy).toHaveBeenCalledWith(StorageKeys.Language, 'es'); + }); +}); diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 0000000..a12a3ad --- /dev/null +++ b/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,88 @@ +import { ReactNode, useState } from 'react'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +/** + * Properties for the `Dropdown` component. + * @param {ReactNode} toggle - The content which toggles display of the dropdown content. + * @param {ReactNode} content - The dropdown content. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface DropdownProps extends PropsWithClassName, PropsWithTestId { + toggle: ReactNode; + content: ReactNode; +} + +/** + * The `Dropdown` component controls the display of content which "drops down" + * from the trigger. + * + * Any `ReactNode` may be used for the `toggle` and the `content`; however, + * you may use the `DropdownContent` and `DropdownItem` components to simplify + * creation of a dropdown component content. + * + * *Example:* + * ```jsx + } + content={ + + setLanguage('en')} testId="dropdown-item-en"> + English + + setLanguage('fr')} testId="dropdown-item-fr"> + French + + setLanguage('es')} testId="dropdown-item-es"> + Spanish + + + } + className={className} + testId="dropdown-language" + /> + * ``` + * @param {DropdownProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const Dropdown = ({ + className, + content, + testId = 'dropdown', + toggle, +}: DropdownProps): JSX.Element => { + const [hidden, setHidden] = useState(true); + + return ( +
+
setHidden(true)} + data-testid={`${testId}-backdrop`} + >
+
+
setHidden(!hidden)} + data-testid={`${testId}-toggle`} + > + {toggle} +
+
setHidden(true)} + data-testid={`${testId}-content`} + > + {content} +
+
+
+ ); +}; + +export default Dropdown; diff --git a/src/components/Dropdown/DropdownContent.tsx b/src/components/Dropdown/DropdownContent.tsx new file mode 100644 index 0000000..746abb2 --- /dev/null +++ b/src/components/Dropdown/DropdownContent.tsx @@ -0,0 +1,40 @@ +import { PropsWithChildren } from 'react'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +/** + * Properties for the `DropdownContent` component. + * @see {@link PropsWithChildren} + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface DropdownContentProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId {} + +/** + * The `DropdownContent` component renders a wrapper for the content displayed + * when a `Dropdown` is toggled to be visible. A `Dropdown` accepts any + * `ReactNode` for the `content` attribute; however, the `DropdownContent` + * component provides a styled component wrapper which standardizes the + * appearance of `Dropdown` content. + * @param {DropdownContentProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const DropdownContent = ({ + children, + className, + testId = 'dropdown-content', +}: DropdownContentProps): JSX.Element => { + return ( +
+ {children} +
+ ); +}; + +export default DropdownContent; diff --git a/src/components/Dropdown/DropdownItem.tsx b/src/components/Dropdown/DropdownItem.tsx new file mode 100644 index 0000000..66a0bf8 --- /dev/null +++ b/src/components/Dropdown/DropdownItem.tsx @@ -0,0 +1,45 @@ +import { PropsWithChildren } from 'react'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +/** + * Properties for the `DropdownItem` component. + * @param {function} [onClick] - Optional. A click event handler function. + * @see {@link PropsWithChildren} + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface DropdownItemProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId { + onClick?: () => void; +} + +/** + * The `DropdownItem` component renders a single item or selection within + * the context of a `Dropdown`. A `DropdownItem` is usually contained within + * a `DropdownContent`, but that is not a requirement. + * + * An `onClick` function may be passed which is called in response to a click event. + * @param {DropdownItemProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const DropdownItem = ({ + children, + className, + onClick, + testId = 'dropdown-item', +}: DropdownItemProps): JSX.Element => { + return ( +
onClick?.()} + data-testid={testId} + > + {children} +
+ ); +}; + +export default DropdownItem; diff --git a/src/components/Dropdown/__tests__/Dropdown.test.tsx b/src/components/Dropdown/__tests__/Dropdown.test.tsx new file mode 100644 index 0000000..11f9823 --- /dev/null +++ b/src/components/Dropdown/__tests__/Dropdown.test.tsx @@ -0,0 +1,86 @@ +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import Dropdown from '../Dropdown'; + +describe('Dropdown', () => { + it('should render successfully', async () => { + // ARRANGE + render(} content={
} />); + await screen.findByTestId('dropdown'); + + // ASSERT + expect(screen.getByTestId('dropdown')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(} content={
} testId="custom-testId" />); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(} content={
} className="custom-className" />); + await screen.findByTestId('dropdown'); + + // ASSERT + expect(screen.getByTestId('dropdown').classList).toContain('custom-className'); + }); + + it('should toggle visibility using toggle', async () => { + // ARRANGE + render(} content={
} />); + await screen.findByTestId('dropdown'); + + // ASSERT CONTENT HIDDEN + expect(screen.getByTestId('dropdown-content').classList).toContain('hidden'); + + // ACT + await userEvent.click(screen.getByTestId('dropdown-toggle')); + + // ASSERT CONTENT NOT HIDDEN + expect(screen.getByTestId('dropdown-content').classList).not.toContain('hidden'); + }); + + it('should toggle visibility using backdrop', async () => { + // ARRANGE + render(} content={
} />); + await screen.findByTestId('dropdown'); + + // ACT - SHOW CONTENT + await userEvent.click(screen.getByTestId('dropdown-toggle')); + + // ASSERT CONTENT NOT HIDDEN + expect(screen.getByTestId('dropdown-content').classList).not.toContain('hidden'); + + // ACT - CLICK BACKDROP + await userEvent.click(screen.getByTestId('dropdown-backdrop')); + + // ASSERT CONTENT HIDDEN + expect(screen.getByTestId('dropdown-content').classList).toContain('hidden'); + }); + + it('should toggle visibility using content', async () => { + // ARRANGE + render(} content={
} />); + await screen.findByTestId('dropdown'); + + // ACT - SHOW CONTENT + await userEvent.click(screen.getByTestId('dropdown-toggle')); + + // ASSERT CONTENT NOT HIDDEN + expect(screen.getByTestId('dropdown-content').classList).not.toContain('hidden'); + + // ACT - CLICK CONTENT + await userEvent.click(screen.getByTestId('dropdown-content')); + + // ASSERT CONTENT HIDDEN + expect(screen.getByTestId('dropdown-content').classList).toContain('hidden'); + }); +}); diff --git a/src/components/Dropdown/__tests__/DropdownContent.test.tsx b/src/components/Dropdown/__tests__/DropdownContent.test.tsx new file mode 100644 index 0000000..5e3d42b --- /dev/null +++ b/src/components/Dropdown/__tests__/DropdownContent.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import DropdownContent from '../DropdownContent'; + +describe('DropdownContent', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('dropdown-content'); + + // ASSERT + expect(screen.getByTestId('dropdown-content')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('dropdown-content'); + + // ASSERT + expect(screen.getByTestId('dropdown-content').classList).toContain('custom-className'); + }); +}); diff --git a/src/components/Dropdown/__tests__/DropdownItem.test.tsx b/src/components/Dropdown/__tests__/DropdownItem.test.tsx new file mode 100644 index 0000000..e8118d3 --- /dev/null +++ b/src/components/Dropdown/__tests__/DropdownItem.test.tsx @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; + +import DropdownItem from '../DropdownItem'; + +describe('DropdownItem', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('dropdown-item'); + + // ASSERT + expect(screen.getByTestId('dropdown-item')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('dropdown-item'); + + // ASSERT + expect(screen.getByTestId('dropdown-item').classList).toContain('custom-className'); + }); + + it('should call onClick function', async () => { + // ARRANGE + const mockFn = vi.fn(); + render(); + await screen.findByTestId('dropdown-item'); + + // ACT - CLICK ITEM + await userEvent.click(screen.getByTestId('dropdown-item')); + + // ASSERT + expect(mockFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index 6da989b..dcbf54f 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -1,6 +1,7 @@ import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; import classNames from 'classnames'; import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; import Link from 'components/Link/Link'; @@ -19,6 +20,7 @@ interface FooterProps extends PropsWithClassName, PropsWithTestId {} * @see {@link FooterProps} */ const Footer = ({ className, testId = 'footer' }: FooterProps): JSX.Element => { + const { t } = useTranslation(); const year = dayjs().format('YYYY'); return ( @@ -28,21 +30,21 @@ const Footer = ({ className, testId = 'footer' }: FooterProps): JSX.Element => {
- Privacy + {t('privacy', { ns: 'common' })}
- Terms + {t('terms', { ns: 'common' })}
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 809580e..bd03029 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -6,6 +6,7 @@ import logo from './logo.png'; import ThemeToggle from 'components/Button/ThemeToggle'; import AppMenu from './AppMenu'; import MenuButton from 'components/Menu/MenuButton'; +import LanguageToggle from 'components/Button/LanguageToggle'; /** * Properties for the `Header` component. @@ -33,6 +34,7 @@ const Header = ({ testId = 'header' }: HeaderProps): JSX.Element => {
+
diff --git a/src/main.tsx b/src/main.tsx index 3d7150d..b7eca5a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,12 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import 'utils/i18n'; +import App from './App.tsx'; +import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( , -) +); diff --git a/src/pages/DashboardPage/DashboardPage.tsx b/src/pages/DashboardPage/DashboardPage.tsx index 38537c0..8a9d538 100644 --- a/src/pages/DashboardPage/DashboardPage.tsx +++ b/src/pages/DashboardPage/DashboardPage.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + import { useGetCurrentUser } from 'api/useGetCurrentUser'; import LoaderSkeleton from 'components/Loader/LoaderSkeleton'; import Page from 'components/Page/Page'; @@ -9,6 +11,7 @@ import UserTasksCard from 'pages/UsersPage/components/UserTasksCard'; * @returns {JSX.Element} JSX */ const DashboardPage = (): JSX.Element => { + const { t } = useTranslation(); const { data: user } = useGetCurrentUser(); return ( @@ -18,7 +21,8 @@ const DashboardPage = (): JSX.Element => {
{user ? (

- Welcome {user.name} + {t('welcome', { ns: 'common' })}{' '} + {user.name}

) : ( diff --git a/src/pages/LandingPage/LandingPage.tsx b/src/pages/LandingPage/LandingPage.tsx index 3d04be6..ef4360e 100644 --- a/src/pages/LandingPage/LandingPage.tsx +++ b/src/pages/LandingPage/LandingPage.tsx @@ -1,4 +1,5 @@ import { Navigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { useAuth } from 'hooks/useAuth'; import Page from 'components/Page/Page'; @@ -12,6 +13,7 @@ import Page from 'components/Page/Page'; * @returns {JSX.Element} JSX */ const LandingPage = (): JSX.Element => { + const { t } = useTranslation(); const authContext = useAuth(); if (authContext.isAuthenticated) { @@ -21,9 +23,11 @@ const LandingPage = (): JSX.Element => { return (
-

Let's get started

+

+ {t('letsGetStarted', { ns: 'common' })} +

-
Creating React apps just got a lot simpler
+
{t('creatingReactApps', { ns: 'common' })}
); diff --git a/src/pages/UsersPage/components/UserTasksCard.tsx b/src/pages/UsersPage/components/UserTasksCard.tsx index e6eee0e..c83edb8 100644 --- a/src/pages/UsersPage/components/UserTasksCard.tsx +++ b/src/pages/UsersPage/components/UserTasksCard.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import filter from 'lodash/filter'; import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; import { useGetUserTasks } from '../api/useGetUserTasks'; import Card, { CardProps } from 'components/Card/Card'; @@ -31,20 +32,21 @@ const UserTasksCard = ({ ...props }: UserTasksCardProps): JSX.Element => { const navigate = useNavigate(); + const { t } = useTranslation(); const { data: tasks, error, isLoading } = useGetUserTasks({ userId }); const incompleteTasks = filter(tasks, { completed: false }); const tasksMessage = useMemo(() => { if (error) { - return 'A problem occurred fetching your tasks.'; + return t('task.errors.fetchingList', { ns: 'users' }); } if (incompleteTasks.length === 0) { - return 'You are all caught up!'; + return t('task.allComplete', { ns: 'users' }); } - return `You have ${incompleteTasks.length} tasks to complete.`; - }, [error, incompleteTasks]); + return t('task.toComplete', { ns: 'users', val: incompleteTasks.length }); + }, [error, incompleteTasks, t]); return (
navigate(`/app/users/${userId}/tasks`)} data-testid={testId}> @@ -63,7 +65,7 @@ const UserTasksCard = ({
) : (
-
Tasks
+
{t('task.tasks', { ns: 'users' })}
{tasksMessage}
)} diff --git a/src/pages/UsersPage/components/__tests__/UserTasksCard.test.tsx b/src/pages/UsersPage/components/__tests__/UserTasksCard.test.tsx index d92e856..672ec44 100644 --- a/src/pages/UsersPage/components/__tests__/UserTasksCard.test.tsx +++ b/src/pages/UsersPage/components/__tests__/UserTasksCard.test.tsx @@ -1,12 +1,12 @@ import { render, screen } from 'test/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UseQueryResult } from '@tanstack/react-query'; +import userEvent from '@testing-library/user-event'; import * as UseGetUserTasks from '../../api/useGetUserTasks'; +import { todosFixture } from '__fixtures__/todos'; import UserTasksCard from '../UserTasksCard'; -import { todosFixture } from '__fixtures__/todos'; -import { UseQueryResult } from '@tanstack/react-query'; -import userEvent from '@testing-library/user-event'; // mock select functions from react-router-dom const mockNavigate = vi.fn(); @@ -96,7 +96,7 @@ describe('UserTasksCard', () => { // ASSERT expect(screen.getByTestId('card-user-tasks-message').textContent).toBe( - 'You are all caught up!', + 'All your tasks are complete!', ); }); diff --git a/src/test/test-utils.tsx b/src/test/test-utils.tsx index a3936b0..1b1e547 100644 --- a/src/test/test-utils.tsx +++ b/src/test/test-utils.tsx @@ -6,6 +6,8 @@ import { RenderHookOptions, RenderOptions, } from '@testing-library/react'; + +import 'utils/i18n'; import WithAllProviders from './WithAllProviders'; const customRender = (ui: React.ReactElement, options?: RenderOptions, { route = '/' } = {}) => { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f7d8abf..a81489c 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -14,6 +14,7 @@ export enum QueryKeys { * Keys used for browser local storage. */ export enum StorageKeys { + Language = 'react-starter.language', Settings = 'react-starter.settings', User = 'react-starter.user', UserTokens = 'react-starter.user-tokens', diff --git a/src/utils/i18n/__tests__/i18n.test.ts b/src/utils/i18n/__tests__/i18n.test.ts new file mode 100644 index 0000000..d031303 --- /dev/null +++ b/src/utils/i18n/__tests__/i18n.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; + +import i18n from 'utils/i18n'; + +describe('i18n', () => { + it('should initialize i18n', () => { + // ASSERT + expect(i18n).toBeDefined(); + }); +}); diff --git a/src/utils/i18n/index.ts b/src/utils/i18n/index.ts new file mode 100644 index 0000000..ef16402 --- /dev/null +++ b/src/utils/i18n/index.ts @@ -0,0 +1,41 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import { StorageKeys } from 'utils/constants'; + +// translation resources +import en from './locales/en'; +import es from './locales/es'; +import fr from './locales/fr'; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + // logging + debug: true, + + // languages, namespaces, and resources + supportedLngs: ['en', 'es', 'fr'], + fallbackLng: 'en', + ns: ['common', 'users'], + defaultNS: 'common', + resources: { + en, + es, + fr, + }, + + // translation defaults + interpolation: { + escapeValue: false, + }, + + // plugin - language detector + detection: { + lookupLocalStorage: StorageKeys.Language, + }, + }); + +export default i18n; diff --git a/src/utils/i18n/locales/en/common.json b/src/utils/i18n/locales/en/common.json new file mode 100644 index 0000000..0f1343d --- /dev/null +++ b/src/utils/i18n/locales/en/common.json @@ -0,0 +1,9 @@ +{ + "creatingReactApps": "Creating React apps just got a lot simpler", + "letsGetStarted": "Let's get started", + "privacy": "Privacy", + "privacyPolicy": "Privacy policy", + "terms": "Terms", + "termsAndConditions": "Terms and conditions", + "welcome": "Welcome" +} diff --git a/src/utils/i18n/locales/en/index.ts b/src/utils/i18n/locales/en/index.ts new file mode 100644 index 0000000..d762461 --- /dev/null +++ b/src/utils/i18n/locales/en/index.ts @@ -0,0 +1,4 @@ +import common from './common.json'; +import users from './users.json'; + +export default { common, users }; diff --git a/src/utils/i18n/locales/en/users.json b/src/utils/i18n/locales/en/users.json new file mode 100644 index 0000000..4910b8c --- /dev/null +++ b/src/utils/i18n/locales/en/users.json @@ -0,0 +1,11 @@ +{ + "user": {}, + "task": { + "allComplete": "All your tasks are complete!", + "errors": { + "fetchingList": "A problem occurred fetching your tasks." + }, + "tasks": "tasks", + "toComplete": "You have {{val}} tasks to complete." + } +} diff --git a/src/utils/i18n/locales/es/common.json b/src/utils/i18n/locales/es/common.json new file mode 100644 index 0000000..fe2e9a4 --- /dev/null +++ b/src/utils/i18n/locales/es/common.json @@ -0,0 +1,9 @@ +{ + "creatingReactApps": "Crear aplicaciones React ahora es mucho más sencillo", + "letsGetStarted": "Empecemos", + "privacy": "Privacidad", + "privacyPolicy": "Política de privacidad", + "terms": "Términos", + "termsAndConditions": "Términos y condiciones", + "welcome": "Bienvenido" +} diff --git a/src/utils/i18n/locales/es/index.ts b/src/utils/i18n/locales/es/index.ts new file mode 100644 index 0000000..d762461 --- /dev/null +++ b/src/utils/i18n/locales/es/index.ts @@ -0,0 +1,4 @@ +import common from './common.json'; +import users from './users.json'; + +export default { common, users }; diff --git a/src/utils/i18n/locales/es/users.json b/src/utils/i18n/locales/es/users.json new file mode 100644 index 0000000..578dbf3 --- /dev/null +++ b/src/utils/i18n/locales/es/users.json @@ -0,0 +1,11 @@ +{ + "user": {}, + "task": { + "allComplete": "¡Todas tus tareas están completas!", + "errors": { + "fetchingList": "Ocurrió un problema al recuperar tus tareas." + }, + "tasks": "tareas", + "toComplete": "Tienes {{val}} tarea para completar." + } +} diff --git a/src/utils/i18n/locales/fr/common.json b/src/utils/i18n/locales/fr/common.json new file mode 100644 index 0000000..4c73302 --- /dev/null +++ b/src/utils/i18n/locales/fr/common.json @@ -0,0 +1,9 @@ +{ + "creatingReactApps": "La création d'applications React est devenue beaucoup plus simple", + "letsGetStarted": "Commençons", + "privacy": "Confidentialité", + "privacyPolicy": "Politique de confidentialité", + "terms": "Termes", + "termsAndConditions": "Termes et conditions", + "welcome": "Bienvenue" +} diff --git a/src/utils/i18n/locales/fr/index.ts b/src/utils/i18n/locales/fr/index.ts new file mode 100644 index 0000000..d762461 --- /dev/null +++ b/src/utils/i18n/locales/fr/index.ts @@ -0,0 +1,4 @@ +import common from './common.json'; +import users from './users.json'; + +export default { common, users }; diff --git a/src/utils/i18n/locales/fr/users.json b/src/utils/i18n/locales/fr/users.json new file mode 100644 index 0000000..27e0541 --- /dev/null +++ b/src/utils/i18n/locales/fr/users.json @@ -0,0 +1,11 @@ +{ + "user": {}, + "task": { + "allComplete": "Toutes vos tâches sont terminées!", + "errors": { + "fetchingList": "Un problème est survenu lors de la récupération de vos tâches." + }, + "tasks": "tâches", + "toComplete": "Vous avez {{val}} tâche à accomplir." + } +}