diff --git a/README.md b/README.md index 39c9a51fb72..3927e53641e 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,122 @@ -# React Product Catalog - -Implement the catalog with a shopping cart and favorites page according to one of the next designs: - -- [Original](https://www.figma.com/file/T5ttF21UnT6RRmCQQaZc6L/Phone-catalog-(V2)-Original) -- [Original Dark](https://www.figma.com/design/WMdJ24eHk4EkSr25mrt7Y2/Phone-catalog--V2--Original-Dark) -- [Rounded Blue](https://www.figma.com/file/FRxncC4lfyhs6og1L6FGEU/Phone-catalog-(V2)-Rounded-Style-2?node-id=0%3A1) -- [Rounded Purple](https://www.figma.com/file/xMK2Dy0mfBbJJSNctmOuLW/Phone-catalog-(V2)-Rounded-Style-1?node-id=0%3A1) -- [Rounded Orange](https://www.figma.com/file/7JTa0q8n3dTSAyMNaA0u8o/Phone-catalog-(V2)-Rounded-Style-3?node-id=0%3A1) - -You may also implement color theme switching! - -## If you work in a team - -Follow the [Work in a team guideline](https://github.com/mate-academy/react_task-guideline/blob/master/team-flow.md#how-to-work-in-a-team) - -## Project Setup from scratch - -Follow the [Instruction](https://github.com/mate-academy/react_phone-catalog/blob/master/setup.md) to setup your project, add Eslint, Prettier, Husky and enable auto deploy. - -## Data - -Use the data from `/public/api` and images from `/public/img` folders. You can reorganize them the way you like. - -## App - -1. Put components into the `src/components` folder. - - Each component should be a folder with `index.ts`, `ComponentName.tsx`, `ComponentName.module.scss` files. - - Use CSS modules. - - Keep `.module.scss` files together with their components. -2. Advanced project structure: - - `src/modules` folder. Inside per page modules `HomePage`, `CartPage`, etc., and `shared` folder with shared content between modules. - - Inside each module its own `components` folder with the structure described above. And optionally other files/folders: `hooks`, `constants`, and so on. -3. Add the sticky header with a logo, navigation, favorites, and cart. -4. The footer with the link to the GitHub repo and `Back to top` button. - - The content should be limited to the same width as the page content; - - `Back to top` button should scroll to the top smoothly; -5. Add `NotFoundPage` containing text `Page not found` for all the unknown URLs. -6. All changes the hover effects should be smooth. -7. Scale all image links by 10% on hover. -8. Implement all form elements and icons according to the UI Kit. - -## Home page - -Implement Home page at available at `/`. - -1. `

Product Catalog

` should be visually hidden. -2. `PicturesSlider`: - - Find your own images to personalize the App; - - Change pictures automatically every 5 seconds; - - The next buttons should show the first image after the last one; - - Dashes at the bottom should allow choosing an exact picture. -3. `ProductsSlider` for the `Hot prices` block: - - The products with a discount starting from the biggest absolute value; - - `<` and `>` buttons should scroll products. -4. `Shop by category` block with links to `/phones`, `/tablets`, and `/accessories`. -5. Add Brand new block using ProductsSlider with products that are the newest according to the year field. - -## Product pages - -There should be 3 separate pages `/phones`, `/tablets`, and `/accessories`. - -1. Each page loads the data of the required `type`. -2. Add an `h1` with `Phones/Tablets/Accessories page` (choose required). -3. Add `ProductsList` component showing all the `products`. -4. Implement a `Loader` to show it while waiting for the data from the server. -5. In case of a loading error show the something went wrong message with a reload button. -6. If there are no products available show the `There are no phones/tablets/accessories yet` message (choose required). -7. Add a ` + classNames(styles.dropdown__control, { + [styles['dropdown__control--is-focused']]: state.isFocused, + [styles['dropdown__control--menu-is-open']]: state.menuIsOpen, + }), + singleValue: () => styles.dropdown__single_value, + option: state => + classNames(styles.dropdownOption, { + [styles['dropdown__option--is-focused']]: state.isFocused, + [styles['dropdown__option--is-selected']]: state.isSelected, + }), + indicatorSeparator: () => styles.dropdown__indicator_separator, + menu: () => styles.dropdown__menu, + }} + options={options} + value={value} + onChange={onChange} + isSearchable={false} + components={{ + SingleValue, + DropdownIndicator, + Option, + }} + /> + + + ); +}; diff --git a/src/modules/shared/molecules/Dropdown/index.ts b/src/modules/shared/molecules/Dropdown/index.ts new file mode 100644 index 00000000000..2f29bad4e67 --- /dev/null +++ b/src/modules/shared/molecules/Dropdown/index.ts @@ -0,0 +1 @@ +export * from './Dropdown'; diff --git a/src/modules/shared/molecules/Heading/Heading.module.scss b/src/modules/shared/molecules/Heading/Heading.module.scss new file mode 100644 index 00000000000..bcd1c855724 --- /dev/null +++ b/src/modules/shared/molecules/Heading/Heading.module.scss @@ -0,0 +1,5 @@ +.heading { + display: flex; + flex-direction: column; + gap: var(--field-gap, 8px); +} diff --git a/src/modules/shared/molecules/Heading/Heading.tsx b/src/modules/shared/molecules/Heading/Heading.tsx new file mode 100644 index 00000000000..97c7a90083f --- /dev/null +++ b/src/modules/shared/molecules/Heading/Heading.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styles from './Heading.module.scss'; +import { Typography } from '../../atoms/Typography'; + +type Props = { + title: string; + subtitle?: string; + title_tag?: 'h1' | 'h2' | 'h3' | 'h4'; +}; + +export const Heading: React.FC = ({ + title, + subtitle, + title_tag = 'h1', +}) => ( +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+); diff --git a/src/modules/shared/molecules/Heading/index.ts b/src/modules/shared/molecules/Heading/index.ts new file mode 100644 index 00000000000..6406e7b07f4 --- /dev/null +++ b/src/modules/shared/molecules/Heading/index.ts @@ -0,0 +1 @@ +export * from './Heading'; diff --git a/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.module.scss b/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.module.scss new file mode 100644 index 00000000000..f3dd8bca247 --- /dev/null +++ b/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.module.scss @@ -0,0 +1,31 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.lang_switcher { + display: flex; + + align-items: center; + + // padding: 4px; + + &__option:hover &__language { + color: var(--buttons-text-color-primary); + transition: color var(--animation-duration, 0.3s) ease; + } + + &__option { + padding: 4px; + + background-color: transparent; + + &--active { + color: var(--buttons-text-color-primary); + transition: color var(--animation-duration, 0.3s); + } + + @include underline-animate; + + &--active::after { + transform: scaleX(1); + } + } +} diff --git a/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.tsx b/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.tsx new file mode 100644 index 00000000000..6058e65938e --- /dev/null +++ b/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.tsx @@ -0,0 +1,50 @@ +import classNames from 'classnames'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { Language } from '../../../../enums/Language'; +import { setLanguage } from '../../../../features/i18nSlice'; +import { Button } from '../../atoms/Button'; +import { Typography } from '../../atoms/Typography'; +import styles from './LanguageSwitcher.module.scss'; + +const langMap = [Language.EN, Language.UA]; + +type Props = { + className?: string; +}; + +export const LanguageSwitcher: React.FC = ({ className }) => { + const { i18n } = useTranslation(); + const dispatch = useDispatch(); + + const handleLanguageChange = (lang: Language) => { + dispatch(setLanguage(lang)); + }; + + return ( +
+ {langMap.map((lang, index, arr) => ( + + + {index < arr.length - 1 && ( + / + )} + + ))} +
+ ); +}; diff --git a/src/modules/shared/molecules/LanguageSwitcher/index.ts b/src/modules/shared/molecules/LanguageSwitcher/index.ts new file mode 100644 index 00000000000..de458ccf133 --- /dev/null +++ b/src/modules/shared/molecules/LanguageSwitcher/index.ts @@ -0,0 +1 @@ +export * from './LanguageSwitcher'; diff --git a/src/modules/shared/molecules/LogoLink/LogoLink.module.scss b/src/modules/shared/molecules/LogoLink/LogoLink.module.scss new file mode 100644 index 00000000000..fbfc7175354 --- /dev/null +++ b/src/modules/shared/molecules/LogoLink/LogoLink.module.scss @@ -0,0 +1,24 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.logo { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + box-sizing: border-box; + + @include hover(transform, scale(1.1)); + @include focus-visible; +} + +.logoImage { + width: 64px; + + color: var(--buttons-text-color-primary); + + @include on-small-desktop { + width: 80px; + object-fit: contain; + } +} diff --git a/src/modules/shared/molecules/LogoLink/LogoLink.tsx b/src/modules/shared/molecules/LogoLink/LogoLink.tsx new file mode 100644 index 00000000000..1d8fcc782a2 --- /dev/null +++ b/src/modules/shared/molecules/LogoLink/LogoLink.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import styles from './LogoLink.module.scss'; +import { NavLink } from 'react-router-dom'; +import { HeaderLogo } from '../../../../assets/icons/header-logo-icon'; +import classNames from 'classnames'; + +type Props = { + className?: string; +}; + +export const LogoLink: React.FC = ({ className }) => { + return ( + + + + ); +}; diff --git a/src/modules/shared/molecules/LogoLink/index.ts b/src/modules/shared/molecules/LogoLink/index.ts new file mode 100644 index 00000000000..60120a68d30 --- /dev/null +++ b/src/modules/shared/molecules/LogoLink/index.ts @@ -0,0 +1 @@ +export * from './LogoLink'; diff --git a/src/modules/shared/molecules/PageLoader/PageLoader.module.scss b/src/modules/shared/molecules/PageLoader/PageLoader.module.scss new file mode 100644 index 00000000000..ff563d0d9e7 --- /dev/null +++ b/src/modules/shared/molecules/PageLoader/PageLoader.module.scss @@ -0,0 +1,29 @@ +.content { + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + + overflow: hidden; +} + +.astronaut { + animation: fly-up 2.5s ease-in-out forwards; + width: min(100%, 480px); + aspect-ratio: 1; +} + +@keyframes fly-up { + 0% { + transform: translateY(100vh); + opacity: 0; + } + 50% { + transform: translateY(0); + opacity: 1; + } + 100% { + transform: translateY(-100vh); + opacity: 0; + } +} diff --git a/src/modules/shared/molecules/PageLoader/PageLoader.tsx b/src/modules/shared/molecules/PageLoader/PageLoader.tsx new file mode 100644 index 00000000000..143d8dd65b8 --- /dev/null +++ b/src/modules/shared/molecules/PageLoader/PageLoader.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import styles from './PageLoader.module.scss'; + +export const PageLoader: React.FC = () => ( +
+ Loading... +
+); diff --git a/src/modules/shared/molecules/PageLoader/index.ts b/src/modules/shared/molecules/PageLoader/index.ts new file mode 100644 index 00000000000..2428f77a9de --- /dev/null +++ b/src/modules/shared/molecules/PageLoader/index.ts @@ -0,0 +1 @@ +export * from './PageLoader'; diff --git a/src/modules/shared/molecules/PageMessage/PageMessage.module.scss b/src/modules/shared/molecules/PageMessage/PageMessage.module.scss new file mode 100644 index 00000000000..5c8153331dd --- /dev/null +++ b/src/modules/shared/molecules/PageMessage/PageMessage.module.scss @@ -0,0 +1,31 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.content { + height: 100%; + flex-grow: 1; + overflow: hidden; + row-gap: 12px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + &__image { + width: 50%; + + @include on-tablet { + width: 25%; + } + + &__img { + width: 100%; + aspect-ratio: 1; + object-fit: contain; + } + } + &__message { + display: flex; + flex-direction: column; + text-align: center; + } +} diff --git a/src/modules/shared/molecules/PageMessage/PageMessage.tsx b/src/modules/shared/molecules/PageMessage/PageMessage.tsx new file mode 100644 index 00000000000..311cfa8c348 --- /dev/null +++ b/src/modules/shared/molecules/PageMessage/PageMessage.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import styles from './PageMessage.module.scss'; +import { Typography } from '../../atoms/Typography'; + +type Props = { + title: string; + subtitle?: string; + imgSrc?: string; +}; + +export const PageMessage: React.FC = ({ title, subtitle, imgSrc }) => ( +
+ {imgSrc && ( +
+ +
+ )} + + + {title} + + + {subtitle && ( + + {subtitle} + + )} +
+); diff --git a/src/modules/shared/molecules/PageMessage/index.ts b/src/modules/shared/molecules/PageMessage/index.ts new file mode 100644 index 00000000000..8d7f792567a --- /dev/null +++ b/src/modules/shared/molecules/PageMessage/index.ts @@ -0,0 +1 @@ +export * from './PageMessage'; diff --git a/src/modules/shared/molecules/ProductControls/ProductControls.module.scss b/src/modules/shared/molecules/ProductControls/ProductControls.module.scss new file mode 100644 index 00000000000..546afe59cf7 --- /dev/null +++ b/src/modules/shared/molecules/ProductControls/ProductControls.module.scss @@ -0,0 +1,45 @@ +@use './../../styles/utils/mixins.scss' as *; + +.product_controls { + display: flex; + gap: 8px; + width: 100%; +} + +.button { + background-color: var(--surface2-color); + + &__text { + &--active { + color: var(--buttons-text-color-primary-active); + } + } + + &--primary { + flex-grow: 1; + + background-color: var(--buttons-background-active); + + &:not(:disabled):hover { + background-color: var(--buttons-background-active-hover); + } + + &--active { + background-color: var(--surface2-color); + } + } + + &--favourite { + flex-shrink: 0; + + &:hover { + background-color: var(--icons-color); + } + + &--active { + background-color: transparent; + + border: 1px solid var(--elements-color); + } + } +} diff --git a/src/modules/shared/molecules/ProductControls/ProductControls.tsx b/src/modules/shared/molecules/ProductControls/ProductControls.tsx new file mode 100644 index 00000000000..58011dac772 --- /dev/null +++ b/src/modules/shared/molecules/ProductControls/ProductControls.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import styles from './ProductControls.module.scss'; +import { Button } from '../../atoms/Button'; +import classNames from 'classnames'; +import { Typography } from '../../atoms/Typography'; +import { IconButton } from '../../atoms/IconButton'; + +interface Props { + onAddToCart: (event: React.MouseEvent) => void; + onToggleFavourite: (event: React.MouseEvent) => void; + isFavourite: boolean; + isInCart: boolean; + size?: 'medium' | 'large'; + cartButtonText: string; + icon: React.ReactNode; +} + +export const ProductControls: React.FC = ({ + onAddToCart, + onToggleFavourite, + isInCart, + isFavourite, + size = 'medium', + cartButtonText, + icon, +}) => { + return ( +
+ + + {icon} + +
+ ); +}; diff --git a/src/modules/shared/molecules/ProductControls/index.ts b/src/modules/shared/molecules/ProductControls/index.ts new file mode 100644 index 00000000000..275f4b5e25a --- /dev/null +++ b/src/modules/shared/molecules/ProductControls/index.ts @@ -0,0 +1 @@ +export * from './ProductControls'; diff --git a/src/modules/shared/molecules/ProductPrice/ProductPrice.module.scss b/src/modules/shared/molecules/ProductPrice/ProductPrice.module.scss new file mode 100644 index 00000000000..b8c0fcce45a --- /dev/null +++ b/src/modules/shared/molecules/ProductPrice/ProductPrice.module.scss @@ -0,0 +1,34 @@ +.price { + display: flex; + gap: var(--field-gap, 8px); + align-items: center; + + &__value { + &::before { + content: '$'; + } + + isolation: isolate; + + &--strikethrough { + position: relative; + + font-weight: 600; + + &::after { + content: ''; + + position: absolute; + left: 0; + top: 50%; + + height: 0.1rem; + width: 100%; + + mix-blend-mode: difference; + + background-color: var(--main-text-color-secondary); + } + } + } +} diff --git a/src/modules/shared/molecules/ProductPrice/ProductPrice.tsx b/src/modules/shared/molecules/ProductPrice/ProductPrice.tsx new file mode 100644 index 00000000000..eeb76367032 --- /dev/null +++ b/src/modules/shared/molecules/ProductPrice/ProductPrice.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import styles from './ProductPrice.module.scss'; +import { Typography } from '../../atoms/Typography'; +import classNames from 'classnames'; + +type Props = { + fullPrice: number; + price: number; + big?: boolean; +}; + +export const ProductPrice: React.FC = ({ fullPrice, price, big }) => { + const isOnDiscount = price < fullPrice; + + return ( +
+ + {isOnDiscount ? price : fullPrice} + + {isOnDiscount && ( + + {fullPrice} + + )} +
+ ); +}; diff --git a/src/modules/shared/molecules/ProductPrice/index.ts b/src/modules/shared/molecules/ProductPrice/index.ts new file mode 100644 index 00000000000..061c1b420cf --- /dev/null +++ b/src/modules/shared/molecules/ProductPrice/index.ts @@ -0,0 +1 @@ +export * from './ProductPrice'; diff --git a/src/modules/shared/molecules/ProductSpec/ProductSpec.module.scss b/src/modules/shared/molecules/ProductSpec/ProductSpec.module.scss new file mode 100644 index 00000000000..5ee928ee584 --- /dev/null +++ b/src/modules/shared/molecules/ProductSpec/ProductSpec.module.scss @@ -0,0 +1,5 @@ +.field { + display: flex; + justify-content: space-between; + gap: var(--field-gap, 8px); +} diff --git a/src/modules/shared/molecules/ProductSpec/ProductSpec.tsx b/src/modules/shared/molecules/ProductSpec/ProductSpec.tsx new file mode 100644 index 00000000000..96e81ecbb2d --- /dev/null +++ b/src/modules/shared/molecules/ProductSpec/ProductSpec.tsx @@ -0,0 +1,26 @@ +import React, { memo } from 'react'; +import styles from './ProductSpec.module.scss'; +import { Typography } from '../../atoms/Typography'; + +type Props = { + label: string; + value: string; +}; + +const ProductSpecComponent: React.FC = ({ label, value }) => ( +
+ + {label} + + + + {value} + +
+); + +export const ProductSpec = memo(ProductSpecComponent); diff --git a/src/modules/shared/molecules/ProductSpec/index.ts b/src/modules/shared/molecules/ProductSpec/index.ts new file mode 100644 index 00000000000..e8c6612f969 --- /dev/null +++ b/src/modules/shared/molecules/ProductSpec/index.ts @@ -0,0 +1 @@ +export * from './ProductSpec'; diff --git a/src/modules/shared/molecules/SearchField/SearchField.module.scss b/src/modules/shared/molecules/SearchField/SearchField.module.scss new file mode 100644 index 00000000000..0a2e9322449 --- /dev/null +++ b/src/modules/shared/molecules/SearchField/SearchField.module.scss @@ -0,0 +1,40 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.search { + display: flex; + align-items: center; + gap: 4px; + height: 100%; + min-width: 80px; + max-width: 180px; + flex-grow: 0; +} + +.input { + display: block; + width: 100%; + box-sizing: border-box; + background-color: var(--main-background-color); + border: 1px solid var(--icons-color); + border-radius: 10px; + color: var(--main-text-color-primary); + align-items: center; + padding-inline: 8px; + + &:focus, + &:active { + outline: 1px solid var(--main-text-color-primary); + } + + &::placeholder { + font-weight: 400; + font-size: 10px; + color: var(--main-text-color-secondary); + + @include on-tablet { + font-size: 12px; + } + } + + @include focus-visible; +} diff --git a/src/modules/shared/molecules/SearchField/SearchField.tsx b/src/modules/shared/molecules/SearchField/SearchField.tsx new file mode 100644 index 00000000000..80ca4b7cf78 --- /dev/null +++ b/src/modules/shared/molecules/SearchField/SearchField.tsx @@ -0,0 +1,66 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Icon } from '../../atoms/Icon'; +import { SearchIcon } from '../../../../assets/icons/search-icon'; +import styles from './SearchField.module.scss'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { SearchParam } from '../../../../enums/SearchFields'; +import { DefaultValues } from '../../../../enums/DefaultValues'; +import { getSearchWith } from '../../../../helpers/searchHelper'; +import debounce from 'lodash.debounce'; +import classNames from 'classnames'; + +export const SearchField: React.FC = () => { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const initialQuery = + searchParams.get(SearchParam.Query) || DefaultValues.Query; + const [query, setQuery] = useState(initialQuery); + + const updateQuery = useCallback( + (value: string) => { + const updatedSearch = getSearchWith(searchParams, { + [SearchParam.Query]: value || null, + [SearchParam.Page]: null, + }); + + navigate({ search: updatedSearch }); + }, + [searchParams, navigate], + ); + + const debouncedUpdateQuery = useMemo( + () => debounce(updateQuery, 300), + [updateQuery], + ); + + useEffect(() => { + return () => { + debouncedUpdateQuery.cancel(); + }; + }, [debouncedUpdateQuery]); + + const handleChange = (event: React.ChangeEvent) => { + setQuery(event.target.value); + debouncedUpdateQuery(event.target.value); + }; + + return ( +
+ + + + + +
+ ); +}; diff --git a/src/modules/shared/molecules/SearchField/index.ts b/src/modules/shared/molecules/SearchField/index.ts new file mode 100644 index 00000000000..9f5ef334723 --- /dev/null +++ b/src/modules/shared/molecules/SearchField/index.ts @@ -0,0 +1 @@ +export * from './SearchField'; diff --git a/src/modules/shared/organisms/PageStateWrapper/PageStateWrapper.tsx b/src/modules/shared/organisms/PageStateWrapper/PageStateWrapper.tsx new file mode 100644 index 00000000000..fe8ac82cc59 --- /dev/null +++ b/src/modules/shared/organisms/PageStateWrapper/PageStateWrapper.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { PageLoader } from '../../molecules/PageLoader'; +import { RetryErrorMessage } from '../RetryErrorMessage'; + +type AsyncState = { + loading: boolean; + error?: boolean; +}; + +type PageStateWrapperProps = { + asyncStates: AsyncState[]; + fallback?: React.ReactNode; + errorComponent?: React.ReactNode; + children: React.ReactNode; +}; + +export const PageStateWrapper: React.FC = ({ + asyncStates, + fallback = , + errorComponent = , + children, +}) => { + const isLoading = asyncStates.some(state => state.loading); + const hasError = asyncStates.some(state => state.error); + + if (isLoading) { + return fallback; + } + + if (hasError) { + return errorComponent; + } + + return <>{children}; +}; diff --git a/src/modules/shared/organisms/PageStateWrapper/index.ts b/src/modules/shared/organisms/PageStateWrapper/index.ts new file mode 100644 index 00000000000..7b5706a9f6c --- /dev/null +++ b/src/modules/shared/organisms/PageStateWrapper/index.ts @@ -0,0 +1 @@ +export * from './PageStateWrapper'; diff --git a/src/modules/shared/organisms/ProductCard/ProductCard.module.scss b/src/modules/shared/organisms/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..e1c93cc1e3d --- /dev/null +++ b/src/modules/shared/organisms/ProductCard/ProductCard.module.scss @@ -0,0 +1,51 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.card { + height: 100%; + display: flex; + flex-direction: column; + + background-color: var(--surface1-color); + + @include hover(transform, scale(1.1)); + @include focus-visible; + + &__content { + flex: 1; + padding: 32px; + display: flex; + flex-direction: column; + gap: var(--field-gap, 8px); + } + + &__photo { + display: block; + width: 100%; + height: auto; + object-fit: contain; + flex-shrink: 1; + + aspect-ratio: 1/1; + } + + &__description { + margin-top: auto; + display: flex; + flex-direction: column; + gap: var(--field-gap, 8px); + } + + &__title { + padding-top: 16px; + } + + &__specs { + display: flex; + flex-direction: column; + + justify-content: space-between; + + gap: 8px; + padding-block: 8px; + } +} diff --git a/src/modules/shared/organisms/ProductCard/ProductCard.tsx b/src/modules/shared/organisms/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..6b44725f4aa --- /dev/null +++ b/src/modules/shared/organisms/ProductCard/ProductCard.tsx @@ -0,0 +1,115 @@ +import React, { memo } from 'react'; +import styles from './ProductCard.module.scss'; +import { Product } from '../../../../types/Product'; +import { useAppDispatch, useAppSelector } from '../../../../hooks/hooks'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { toggleFavourite } from '../../../../features/favouritesSlice'; +import { add, remove } from '../../../../features/cartSlice'; +import { Typography } from '../../atoms/Typography'; +import { ProductPrice } from '../../molecules/ProductPrice'; +import { Divider } from '../../atoms/Divider'; +import { ProductSpec } from '../../molecules/ProductSpec'; +import { ProductControls } from '../../molecules/ProductControls'; +import { Icon } from '../../atoms/Icon'; +import { HeartFilledIcon } from '../../../../assets/icons/heart-filled-icon'; +import { HeartIcon } from '../../../../assets/icons/heart-icon'; +import { showToast } from '../../../NotificationToast'; + +type Props = { + product: Product; +}; + +const ProductCardComponent: React.FC = ({ product }) => { + const dispatch = useAppDispatch(); + const { favourites } = useAppSelector(state => state.favourites); + const { cartItems } = useAppSelector(state => state.cart); + const { t } = useTranslation(); + + const isInFavourites = favourites.some(fav => fav.itemId === product.itemId); + const isInCart = cartItems.some( + cartItem => cartItem.itemId === product.itemId, + ); + + const handleToggle = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + dispatch(toggleFavourite(product)); + showToast({ + description: t( + `notification.${isInFavourites ? 'remove' : 'add'}.favourites`, + { name: product.name }, + ), + }); + }; + + const addToCart = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (isInCart) { + dispatch(remove(product)); + showToast({ + description: t('notification.remove.cart', { + name: product.name, + }), + }); + } else { + dispatch(add(product)); + showToast({ + description: t('notification.add.cart', { + name: product.name, + }), + }); + } + }; + + return ( + +
+ {`${product.name} +
+ + {product.name} + + + +
+ + + +
+ + {isInFavourites ? : } + + } + /> +
+
+ + ); +}; + +export const ProductCard = memo(ProductCardComponent); diff --git a/src/modules/shared/organisms/ProductCard/index.ts b/src/modules/shared/organisms/ProductCard/index.ts new file mode 100644 index 00000000000..7ce031c3820 --- /dev/null +++ b/src/modules/shared/organisms/ProductCard/index.ts @@ -0,0 +1 @@ +export * from './ProductCard'; diff --git a/src/modules/shared/organisms/ProductList/ProductList.module.scss b/src/modules/shared/organisms/ProductList/ProductList.module.scss new file mode 100644 index 00000000000..34c75947976 --- /dev/null +++ b/src/modules/shared/organisms/ProductList/ProductList.module.scss @@ -0,0 +1,15 @@ +@use './../../styles/utils/mixins.scss' as *; + +.container { + row-gap: 40px; + + @include page-grid; + + &__item { + grid-column: span 4; + + @include on-tablet { + grid-column: span 6; + } + } +} diff --git a/src/modules/shared/organisms/ProductList/ProductList.tsx b/src/modules/shared/organisms/ProductList/ProductList.tsx new file mode 100644 index 00000000000..caf64ba6fb1 --- /dev/null +++ b/src/modules/shared/organisms/ProductList/ProductList.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import styles from './ProductList.module.scss'; +import { Product } from '../../../../types/Product'; +import { ProductCard } from '../ProductCard'; + +type Props = { + list: Product[]; +}; + +export const ProductList: React.FC = ({ list }) => { + return ( +
+ {list.map(product => ( +
+ +
+ ))} +
+ ); +}; diff --git a/src/modules/shared/organisms/ProductList/index.ts b/src/modules/shared/organisms/ProductList/index.ts new file mode 100644 index 00000000000..c71910ae056 --- /dev/null +++ b/src/modules/shared/organisms/ProductList/index.ts @@ -0,0 +1 @@ +export * from './ProductList'; diff --git a/src/modules/shared/organisms/ProductSlider/ProductSlider.module.scss b/src/modules/shared/organisms/ProductSlider/ProductSlider.module.scss new file mode 100644 index 00000000000..52bcb033070 --- /dev/null +++ b/src/modules/shared/organisms/ProductSlider/ProductSlider.module.scss @@ -0,0 +1,42 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.slider { + display: flex; + flex-direction: column; + gap: 24px; + + &__heading { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__button { + display: flex; + gap: 16px; + } + + &__controls { + display: flex; + gap: 16px; + } +} + +.swiper__slide { + height: auto; + + @include span-columns(3, 4); + + @include on-tablet { + @include span-columns(5, 12); + } + + @include on-small-desktop { + @include span-columns(6, 24); + } +} + +.margin_compensation { + padding: 24px 16px; + margin: -24px -16px; +} diff --git a/src/modules/shared/organisms/ProductSlider/ProductSlider.tsx b/src/modules/shared/organisms/ProductSlider/ProductSlider.tsx new file mode 100644 index 00000000000..50467ab770d --- /dev/null +++ b/src/modules/shared/organisms/ProductSlider/ProductSlider.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { Navigation, Pagination, Autoplay } from 'swiper/modules'; +import { SwiperSlide, Swiper } from 'swiper/react'; +import { Product } from '../../../../types/Product'; +import { ArrowButton } from '../../atoms/ArrowButton'; +import { Typography } from '../../atoms/Typography'; +import { ProductCard } from '../ProductCard'; + +import styles from './ProductSlider.module.scss'; + +type Props = { + title: string; + productsList: Product[]; + id: number; + className?: string; + infinite?: boolean; +}; + +export const ProductSlider: React.FC = ({ + title, + productsList, + id, + className, + infinite = false, +}) => { + const [isBeginning, setIsBeginning] = useState(true); + const [isEnd, setIsEnd] = useState(false); + + return ( +
+
+ + {title} + +
+ + +
+
+
+ { + setIsBeginning(swiper.isBeginning); + setIsEnd(swiper.isEnd); + }} + loop={infinite} + spaceBetween={16} + navigation={{ + nextEl: `[data-swiper-next="${id}"]`, + prevEl: `[data-swiper-prev="${id}"]`, + }} + slidesPerView="auto" + className={classNames( + styles.swiper, + styles.margin_compensation, + className, + )} + > + {productsList.map(product => ( + + + + ))} + +
+
+ ); +}; diff --git a/src/modules/shared/organisms/ProductSlider/index.ts b/src/modules/shared/organisms/ProductSlider/index.ts new file mode 100644 index 00000000000..53492382705 --- /dev/null +++ b/src/modules/shared/organisms/ProductSlider/index.ts @@ -0,0 +1 @@ +export * from './ProductSlider'; diff --git a/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.module.scss b/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.module.scss new file mode 100644 index 00000000000..902a17f6285 --- /dev/null +++ b/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.module.scss @@ -0,0 +1,54 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.error { + padding-top: 20px; + box-sizing: border-box; + min-height: calc(100vh - 64px - 64px - 96px - 96px); + flex-grow: 1; + overflow: hidden; + gap: 12px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + &__image { + max-width: 268px; + aspect-ratio: 1; + display: block; + flex-shrink: 1; + + object-fit: contain; + } + + &__message { + display: flex; + justify-content: center; + align-items: center; + + margin: 0 auto; + + color: var(--purple-accent-color); + + &__text { + text-wrap: balance; + text-align: center; + } + } + + &__button { + width: 260px; + + background-color: var(--buttons-background-active); + + cursor: pointer; + + &:hover { + background-color: var(--buttons-background-active-hover); + } + + &__text { + color: var(--buttons-text-color-primary-active); + } + } +} diff --git a/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.tsx b/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.tsx new file mode 100644 index 00000000000..c43b885d1b6 --- /dev/null +++ b/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import styles from './RetryErrorMessage.module.scss'; + +import { useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAppDispatch } from '../../../../hooks/hooks'; +import { init as initProducts } from '../../../../features/productsSlice'; +import { init as initFavourites } from '../../../../features/favouritesSlice'; +import { init as initCart } from '../../../../features/cartSlice'; +import { init as initPhones } from '../../../../features/phonesSlice'; +import { init as initTablets } from '../../../../features/tabletsSlice'; +import { init as initAccessories } from '../../../../features/accessoriesSlice'; +import { Typography } from '../../atoms/Typography'; +import { Button } from '../../atoms/Button'; + +export const RetryErrorMessage = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const { t } = useTranslation(); + + const dispatchData = async () => { + dispatch(initProducts()); + dispatch(initFavourites()); + dispatch(initCart()); + dispatch(initPhones()); + dispatch(initTablets()); + dispatch(initAccessories()); + navigate(`${pathname}`); + }; + + return ( +
+ +
+ + {t('error.unknown')} + +
+ +
+ ); +}; diff --git a/src/modules/shared/organisms/RetryErrorMessage/index.ts b/src/modules/shared/organisms/RetryErrorMessage/index.ts new file mode 100644 index 00000000000..188a3e8d7f1 --- /dev/null +++ b/src/modules/shared/organisms/RetryErrorMessage/index.ts @@ -0,0 +1 @@ +export * from './RetryErrorMessage'; diff --git a/src/modules/shared/styles/buttons/_index.scss b/src/modules/shared/styles/buttons/_index.scss new file mode 100644 index 00000000000..70b268be02c --- /dev/null +++ b/src/modules/shared/styles/buttons/_index.scss @@ -0,0 +1,14 @@ +@mixin control-button { + background-color: var(--surface2-color); + color: var(--icons-color); + + &:hover { + background-color: var(--icons-color); + } + + &:disabled { + background-color: transparent; + border: 1px solid var(--elements-color); + color: var(--icons-color); + } +} diff --git a/src/modules/shared/styles/utils/mixins.scss b/src/modules/shared/styles/utils/mixins.scss new file mode 100644 index 00000000000..988460b4a1b --- /dev/null +++ b/src/modules/shared/styles/utils/mixins.scss @@ -0,0 +1,136 @@ +@mixin on-tablet { + @media (min-width: 640px) { + @content; + } +} + +@mixin on-small-desktop { + @media (min-width: 1200px) { + @content; + } +} + +@mixin on-large-desktop { + @media (min-width: 1440px) { + @content; + } +} + +@mixin hover($property, $value) { + transition: #{$property} var(--animation-duration, 0.3s); + &:hover { + #{$property}: $value; + cursor: pointer; + } +} + +@mixin outline-selection($important: false) { + outline: 2px solid var(--a11y-focus-color); + outline-offset: 2px; + + @if $important { + outline: 2px solid var(--a11y-focus-color) !important; + outline-offset: 2px !important; + } +} + +@mixin focus-visible($important: false) { + &:focus-visible { + @include outline-selection($important); + } +} + +@mixin page-grid { + --columns: 4; + + display: grid; + column-gap: 16px; + grid-template-columns: repeat(var(--columns), 1fr); + + @include on-tablet { + --columns: 12; + } + + @include on-small-desktop { + --columns: 24; + } +} + +@mixin content-padding-inline { + padding-inline: 16px; + + @include on-tablet { + padding-inline: 24px; + } + + @include on-small-desktop { + padding-inline: 32px; + } + + @include on-large-desktop { + padding-inline: 152px; + } +} + +@mixin span-columns($count, $total, $gap: 16px) { + $gap-count: $total - 1; + $total-gap: $gap-count * $gap; + + width: calc( + (100% - #{$total-gap}) / #{$total} * #{$count} + (#{$count - 1} * #{$gap}) + ); +} + +@mixin margin-compensation( + $top: 0, + $right: $top, + $bottom: $top, + $left: $right +) { + margin: -#{$top} -#{$right} -#{$bottom} -#{$left}; + padding: $top $right $bottom $left; +} + +@mixin underline-animate( + $color: var(--buttons-text-color-primary), + $height: 3px, + $duration: var(--animation-duration, 0.3s) +) { + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: $height; + background-color: $color; + z-index: 1; + transform: scaleX(0); + transform-origin: right; + transition: transform $duration ease-in-out; + } + + &:hover::after { + transform: scaleX(1); + transform-origin: left; + } +} + +@mixin flex-column($gap: null, $align: null, $justify: null) { + display: flex; + flex-direction: column; + + @if $gap { + gap: $gap; + } + + @if $align { + align-items: $align; + } + + @if $justify { + justify-content: $justify; + } +} diff --git a/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.module.scss b/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.module.scss new file mode 100644 index 00000000000..160f65af76e --- /dev/null +++ b/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.module.scss @@ -0,0 +1,13 @@ +@keyframes fade-in { + to { + opacity: 1; + } +} + +.content { + display: flex; + flex-direction: column; + gap: 40px; + opacity: 0; + animation: fade-in var(--animation-duration, 0.3s) ease forwards; +} diff --git a/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.tsx b/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.tsx new file mode 100644 index 00000000000..dc3fe47631a --- /dev/null +++ b/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import styles from './CategoryTemplate.module.scss'; +import { Heading } from '../../molecules/Heading'; +import { ProductList } from '../../organisms/ProductList'; +import { Product } from '../../../../types/Product'; +import { useTranslation } from 'react-i18next'; + +import { Breadcrumbs } from '../../../Breadcrumbs'; +import { CatalogControls } from './components/organisms/CatalogControls'; +import { CatalogPagination } from './components/organisms/CatalogPagination'; +import { PageMessage } from '../../molecules/PageMessage'; + +type Props = { + category: string; + paginatedProducts: Product[]; + filteredProducts: Product[]; + totalPages: number; + currentPage: number; +}; +export const CategoryTemplate: React.FC = ({ + category, + paginatedProducts, + filteredProducts, + totalPages, + currentPage, +}) => { + const { t } = useTranslation(); + + return ( +
+ +
+
+ +
+ + + + {filteredProducts.length === 0 ? ( + + ) : ( + <> + + + + )} +
+
+ ); +}; diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.module.scss b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.module.scss new file mode 100644 index 00000000000..74cef0ef74c --- /dev/null +++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.module.scss @@ -0,0 +1,21 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.controls { + @include page-grid; + + &__sort { + grid-column: span 2; + + @include on-tablet { + grid-column: span 4; + } + } + + &__quantity { + grid-column: span 2; + + @include on-tablet { + grid-column: span 3; + } + } +} diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.tsx b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.tsx new file mode 100644 index 00000000000..e57064aaf56 --- /dev/null +++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.tsx @@ -0,0 +1,82 @@ +/* eslint-disable max-len */ +import React from 'react'; +import styles from './CatalogControls.module.scss'; + +import { useTranslation } from 'react-i18next'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { DefaultValues } from '../../../../../../../enums/DefaultValues'; +import { ItemPerPage } from '../../../../../../../enums/ItemsPerPage'; +import { SearchParam } from '../../../../../../../enums/SearchFields'; +import { SortBy } from '../../../../../../../enums/SortBy'; +import { enumToDropdownOptions } from '../../../../../../../helpers/enumToOptions'; +import { getSearchWith } from '../../../../../../../helpers/searchHelper'; +import { Dropdown } from '../../../../../molecules/Dropdown'; +import { DropdownOption } from '../../../../../../../types/DropdownOption'; + +export const CatalogControls: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + const optionsSort: DropdownOption[] = enumToDropdownOptions( + SortBy, + t, + 'catalog.dropdown.sortBy.options', + ); + const optionsProdPerPage: DropdownOption[] = enumToDropdownOptions( + ItemPerPage, + t, + 'catalog.dropdown.sortBy.options', + ); + + const currentSortBy = searchParams.get(SearchParam.Sort); + const currentPerPage = searchParams.get(SearchParam.PerPage); + + const selectedSortBy = optionsSort.find( + option => option.value === (currentSortBy || DefaultValues.Sort), + )!; + const selectedPerPage = optionsProdPerPage.find( + option => option.value === (currentPerPage || DefaultValues.PerPage), + )!; + + const handleSortByChange = (newValue: DropdownOption | null) => { + const updatedSearch = getSearchWith(searchParams, { + [SearchParam.Sort]: + (newValue?.value !== DefaultValues.Sort && newValue?.value) || null, + [SearchParam.Page]: null, + }); + + navigate({ search: updatedSearch }); + }; + + const handlePerPageChange = (newValue: DropdownOption | null) => { + const updatedSearch = getSearchWith(searchParams, { + [SearchParam.PerPage]: + (newValue?.value !== DefaultValues.PerPage && newValue?.value) || null, + [SearchParam.Page]: null, + }); + + navigate({ search: updatedSearch }); + }; + + return ( +
+
+ +
+
+ +
+
+ ); +}; diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/index.ts b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/index.ts new file mode 100644 index 00000000000..47d28e224c6 --- /dev/null +++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/index.ts @@ -0,0 +1 @@ +export * from './CatalogControls'; diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.module.scss b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.module.scss new file mode 100644 index 00000000000..f2424662d39 --- /dev/null +++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.module.scss @@ -0,0 +1,37 @@ +$gap: 8px; + +.container { + display: flex; + gap: var(--field-gap, $gap); + + justify-content: center; + + &__page { + --bgcolor: var(--surface1-color); + + background-color: var(--bgcolor); + + &:hover { + --bgcolor: var(--elements-color); + } + + &--active { + --bgcolor: var(--purple-accent-color); + + &:hover { + --bgcolor: var(--purple-hover-color); + } + } + + &--active &-text { + color: var(--buttons-text-color-primary-active); + } + } + + &__pages { + display: flex; + gap: var(--field-gap, $gap); + + justify-content: space-between; + } +} diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.tsx b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.tsx new file mode 100644 index 00000000000..185a30751f7 --- /dev/null +++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import styles from './CatalogPagination.module.scss'; + +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import classNames from 'classnames'; +import { SearchParam } from '../../../../../../../enums/SearchFields'; +import { getPageRange } from '../../../../../../../helpers/getPaginationPages'; +import { getSearchWith } from '../../../../../../../helpers/searchHelper'; +import { ArrowButton } from '../../../../../atoms/ArrowButton'; +import { IconButton } from '../../../../../atoms/IconButton'; +import { Typography } from '../../../../../atoms/Typography'; + +type Props = { + currentPage: number; + totalPages: number; +}; + +export const CatalogPagination: React.FC = ({ + currentPage, + totalPages, +}) => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + if (totalPages <= 1) { + return null; + } + + const handlePageClick = (newPage: number) => { + if (newPage === currentPage) { + return; + } + + const scrollY = window.scrollY; + + const updatedSearch = getSearchWith(searchParams, { + [SearchParam.Page]: newPage.toString(), + }); + + navigate({ search: updatedSearch }); + + setTimeout(() => { + window.scrollTo(0, scrollY); + }, 0); + }; + + const range = getPageRange(currentPage, totalPages, 4); + + return ( +
+ handlePageClick(currentPage - 1)} + disabled={currentPage === 1} + className={styles.container__arrow} + direction="left" + /> +
+ {range.map(page => ( + handlePageClick(page)} + className={classNames(styles.container__page, { + [styles['container__page--active']]: page === currentPage, + })} + > + + {page} + + + ))} +
+ handlePageClick(currentPage + 1)} + className={styles.container__arrow} + disabled={currentPage === totalPages} + direction="right" + /> +
+ ); +}; diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/index.ts b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/index.ts new file mode 100644 index 00000000000..58fead74de9 --- /dev/null +++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/index.ts @@ -0,0 +1 @@ +export * from './CatalogPagination'; diff --git a/src/modules/shared/templates/CategoryTemplate/index.ts b/src/modules/shared/templates/CategoryTemplate/index.ts new file mode 100644 index 00000000000..a0ad2577d4c --- /dev/null +++ b/src/modules/shared/templates/CategoryTemplate/index.ts @@ -0,0 +1 @@ +export * from './CategoryTemplate'; diff --git a/src/modules/shared/templates/MainLayout/Footer/Footer.module.scss b/src/modules/shared/templates/MainLayout/Footer/Footer.module.scss new file mode 100644 index 00000000000..c3016c1cef6 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Footer/Footer.module.scss @@ -0,0 +1,94 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.footer { + box-sizing: border-box; + padding-block: 32px; + + @include content-padding-inline; + + background-color: var(--main-background-color); + border-top: 1px solid var(--elements-color); + + display: flex; + + align-items: center; + justify-content: center; + + &__content { + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: column; + row-gap: 32px; + + height: 100%; + width: min(1200px, 100%); + + @include on-tablet { + flex-direction: row; + height: 96px; + } + } + + &__logo { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 32px; + color: var(--main-text-color-primary); + } + + &__img { + display: block; + width: 89px; + height: 32px; + } + + &__nav { + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 16px; + width: 100%; + + @include on-tablet { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + } + + &__link { + width: fit-content; + } + + &__item { + flex-grow: 1; + width: 100%; + min-height: 50%; + + display: flex; + align-items: center; + + &:first-child { + justify-content: flex-start; + } + + &:last-child { + justify-content: center; + + @include on-tablet { + justify-content: flex-end; + } + } + } + + &__button { + cursor: pointer; + display: flex; + align-items: center; + gap: 16px; + } +} diff --git a/src/modules/shared/templates/MainLayout/Footer/Footer.tsx b/src/modules/shared/templates/MainLayout/Footer/Footer.tsx new file mode 100644 index 00000000000..34b681e1c12 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Footer/Footer.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import styles from './Footer.module.scss'; +import { LogoLink } from '../../../molecules/LogoLink'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { FOOTER_LINKS } from './constants'; +import { NavLink } from '../../../atoms/NavLink'; +import { Typography } from '../../../atoms/Typography'; +import { ArrowButton } from '../../../atoms/ArrowButton'; + +type Props = { + className?: string; +}; + +export const Footer: React.FC = ({ className }) => { + const { t } = useTranslation(); + + return ( +
+
+
+ +
+ +
+ +
+ +
+ + {t('buttons.actions.toTop')} + window.scrollTo({ top: 0, behavior: 'smooth' })} + /> + +
+
+
+ ); +}; diff --git a/src/modules/shared/templates/MainLayout/Footer/constants.ts b/src/modules/shared/templates/MainLayout/Footer/constants.ts new file mode 100644 index 00000000000..86a5d3e3df1 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Footer/constants.ts @@ -0,0 +1,24 @@ +/* eslint-disable max-len */ +export const FOOTER_LINKS = [ + { + label: 'GitHub', + path: 'https://github.com/berezandiana/react_phone-catalog/tree/develop', + external: true, + target: '_blank', + rel: 'noopener noreferrer', + }, + { + label: 'Contacts', + path: 'https://github.com/berezandiana', + external: true, + target: '_blank', + rel: 'noopener noreferrer', + }, + { + label: 'Rights', + path: 'https://github.com/berezandiana/react_phone-catalog/blob/develop/LICENSE', + external: true, + target: '_blank', + rel: 'noopener noreferrer', + }, +]; diff --git a/src/modules/shared/templates/MainLayout/Footer/index.ts b/src/modules/shared/templates/MainLayout/Footer/index.ts new file mode 100644 index 00000000000..ddcc5a9cd18 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/modules/shared/templates/MainLayout/Header/Header.module.scss b/src/modules/shared/templates/MainLayout/Header/Header.module.scss new file mode 100644 index 00000000000..90b0e8336ff --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Header/Header.module.scss @@ -0,0 +1,133 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.header { + box-sizing: border-box; + display: flex; + justify-content: center; + height: 48px; + background-color: var(--main-background-color); + border-bottom: 1px solid var(--elements-color); + position: sticky; + top: 0; + z-index: 10; + + // overflow: hidden; + + @include on-small-desktop { + height: 64px; + } + + &__logo { + padding-inline: 16px; + + @include on-tablet { + padding-inline: 24px; + } + } + + &__content { + display: flex; + justify-content: space-between; + gap: 16px; + height: 100%; + width: min(100%, var(--maxwidth-header-footer, 100%)); + } + + &__icon { + aspect-ratio: 1; + + height: 100%; + + background-color: unset; + + &--theme { + @include underline-animate; + } + } + + & &__hideable { + display: none; + + height: 100%; + + @include on-tablet { + display: flex; + } + } + + & &__menu { + @include on-tablet { + display: none; + } + } + + &__icons { + height: 100%; + display: flex; + + & > :not(:first-child) { + border-left: 1px solid var(--elements-color); + } + } + + & &__language { + display: none; + + @include on-tablet { + display: flex; + } + } +} + +.nav { + display: none; + + flex-grow: 1; + + height: 100%; + + @include on-tablet { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: flex-start; + } +} + +.list { + display: none; + + @include on-tablet { + display: flex; + gap: 32px; + margin: 0; + padding: 0; + list-style: none; + } + + @include on-small-desktop { + gap: 64px; + } +} + +.item { + display: none; + + @include on-tablet { + display: flex; + align-items: center; + height: 48px; + } + + @include on-small-desktop { + height: 64px; + } +} + +.link { + display: none; + + @include on-tablet { + display: flex; + } +} diff --git a/src/modules/shared/templates/MainLayout/Header/Header.tsx b/src/modules/shared/templates/MainLayout/Header/Header.tsx new file mode 100644 index 00000000000..77a160e4fd6 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Header/Header.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState } from 'react'; +import styles from './Header.module.scss'; +import classNames from 'classnames'; + +import { useTranslation } from 'react-i18next'; +import { HeartIcon } from '../../../../../assets/icons/heart-icon'; +import { Icon } from '../../../atoms/Icon'; +import { MenuIcon } from '../../../../../assets/icons/menu-icon'; +import { ShoppingBagIcon } from '../../../../../assets/icons/shopping-bag-icon'; +import { SideMenu } from '../SideMenu'; +import { NAV_LINKS } from '../../../../../constants/navigation'; +import { HTMLDataAttr } from '../../../../../enums/htmlDataAttribs'; +import { selectTotalItems } from '../../../../../features/cartSlice'; +import { setElementDataAttr } from '../../../../../helpers/setHtmlDataAttr'; +import { useAppSelector } from '../../../../../hooks/hooks'; +import { NavLink as TextNavLink } from '../../../atoms/NavLink'; +import { IconButton } from '../../../atoms/IconButton'; +import { BadgeCounter } from '../../../atoms/BadgeCounter'; +import { LanguageSwitcher } from '../../../molecules/LanguageSwitcher'; +import { ThemeToggleButton } from './components/ThemeToggleButton'; +import { LogoLink } from '../../../molecules/LogoLink'; +import { SettingsMenu } from './components/molecules/SettingsMenu'; + +type Props = { + className?: string; +}; + +export const Header: React.FC = ({ className = '' }) => { + const { t } = useTranslation(); + + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const { favourites } = useAppSelector(state => state.favourites); + const { cartItems } = useAppSelector(state => state.cart); + const totalItems = useAppSelector(selectTotalItems); + + const toggleMenu = () => setIsMenuOpen(prev => !prev); + const closeMenu = () => setIsMenuOpen(false); + + useEffect(() => { + setElementDataAttr('body', HTMLDataAttr.Menu, isMenuOpen); + }, [isMenuOpen]); + + return ( +
+
+ + + +
+ + + + + + + + + {favourites.length > 0 && ( + {favourites.length} + )} + + + + + + {cartItems.length > 0 && ( + {totalItems} + )} + + + + + + + + +
+ + +
+
+ ); +}; diff --git a/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.module.scss b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.module.scss new file mode 100644 index 00000000000..1efaf5ed358 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.module.scss @@ -0,0 +1,7 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.icon { + background-color: transparent; + + @include underline-animate; +} diff --git a/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.tsx b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.tsx new file mode 100644 index 00000000000..a8989ae6b98 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import styles from './ThemeToggleButton.module.scss'; +import { useDispatch } from 'react-redux'; +import { Icon } from '../../../../../atoms/Icon'; +import { MoonIcon } from '../../../../../../../assets/icons/moon-icon'; +import { SunIcon } from '../../../../../../../assets/icons/sun-icon'; +import { Theme } from '../../../../../../../enums/Theme'; +import { setTheme } from '../../../../../../../features/themeSlice'; +import { useAppSelector } from '../../../../../../../hooks/hooks'; +import { IconButton } from '../../../../../atoms/IconButton'; +import classNames from 'classnames'; + +type Props = { + className?: string; +}; + +export const ThemeToggleButton: React.FC = ({ className }) => { + const { theme } = useAppSelector(state => state.theme); + const dispatch = useDispatch(); + + const toggleTheme = () => { + dispatch(setTheme(theme === Theme.Dark ? Theme.Light : Theme.Dark)); + }; + + return ( + + + {theme === Theme.Dark ? : } + + + ); +}; diff --git a/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/index.ts b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/index.ts new file mode 100644 index 00000000000..e4fc821bef9 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/index.ts @@ -0,0 +1 @@ +export * from './ThemeToggleButton'; diff --git a/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.module.scss b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.module.scss new file mode 100644 index 00000000000..3b80809d270 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.module.scss @@ -0,0 +1,29 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.icon { + background-color: transparent; + + margin-left: -24px; + + @include underline-animate; + + &--active::after { + transform: scaleX(1); + transform-origin: left; + } +} + +.settingsmenu { + position: fixed; + z-index: 20; + + display: flex; + flex-direction: column; + + gap: 8px; + + padding: 8px; + + background-color: var(--surface1-color); + border: 1px solid var(--elements-color); +} diff --git a/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.tsx b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.tsx new file mode 100644 index 00000000000..509ce23299c --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef, useState } from 'react'; +import styles from './SettingsMenu.module.scss'; +import classNames from 'classnames'; +import { createPortal } from 'react-dom'; +import { Button } from '../../../../../../atoms/Button'; +import { Icon } from '../../../../../../atoms/Icon'; +import { GearIcon } from '../../../../../../../../assets/icons/gear-icon'; + +type Props = { + className?: string; + children: React.ReactNode; +}; + +export const SettingsMenu: React.FC = ({ className, children }) => { + const [isOpen, setIsOpen] = useState(false); + const buttonRef = useRef(null); + const [popupStyles, setPopupStyles] = useState({}); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + buttonRef.current && + !buttonRef.current.contains(event.target as Node) && + !document + .getElementById('settings-popup') + ?.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + useEffect(() => { + const updatePopupPosition = () => { + if (isOpen && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + + setPopupStyles({ + top: rect.bottom + 5, + left: rect.left, + }); + } + }; + + updatePopupPosition(); + + window.addEventListener('resize', updatePopupPosition); + + return () => window.removeEventListener('resize', updatePopupPosition); + }, [isOpen]); + + return ( + <> + + + {isOpen && + createPortal( +
+ {children} +
, + document.body, + )} + + ); +}; diff --git a/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/index.ts b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/index.ts new file mode 100644 index 00000000000..b41c178064c --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/index.ts @@ -0,0 +1 @@ +export * from './SettingsMenu'; diff --git a/src/modules/shared/templates/MainLayout/Header/index.tsx b/src/modules/shared/templates/MainLayout/Header/index.tsx new file mode 100644 index 00000000000..266dec8a1bc --- /dev/null +++ b/src/modules/shared/templates/MainLayout/Header/index.tsx @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/modules/shared/templates/MainLayout/MainLayout.module.scss b/src/modules/shared/templates/MainLayout/MainLayout.module.scss new file mode 100644 index 00000000000..61dd654b110 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/MainLayout.module.scss @@ -0,0 +1,30 @@ +@use '@shared/styles/utils/mixins' as *; + +.main { + flex: 1; + + &__content { + margin-inline: auto; + + max-width: 1200px; + + @include content-padding-inline; + + padding-top: 24px; + padding-bottom: 56px; + + @include on-tablet { + padding-bottom: 64px; + } + + @include on-small-desktop { + padding-bottom: 80px; + } + } +} + +.container { + display: flex; + flex-direction: column; + min-height: 100vh; +} diff --git a/src/modules/shared/templates/MainLayout/MainLayout.tsx b/src/modules/shared/templates/MainLayout/MainLayout.tsx new file mode 100644 index 00000000000..24a61fd6e4d --- /dev/null +++ b/src/modules/shared/templates/MainLayout/MainLayout.tsx @@ -0,0 +1,56 @@ +import React, { useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; +import { Header } from './Header'; + +import styles from './MainLayout.module.scss'; +import { Footer } from './Footer'; +import { HTMLDataAttr } from '../../../../enums/htmlDataAttribs'; +import { setElementDataAttr } from '../../../../helpers/setHtmlDataAttr'; +import { useAppDispatch, useAppSelector } from '../../../../hooks/hooks'; +import { init as initProducts } from '../../../../features/productsSlice'; +import { init as initFavourites } from '../../../../features/favouritesSlice'; +import { init as initCart } from '../../../../features/cartSlice'; +import { init as initPhones } from '../../../../features/phonesSlice'; +import { init as initTablets } from '../../../../features/tabletsSlice'; +import { init as initAccessories } from '../../../../features/accessoriesSlice'; +import { PageStateWrapper } from '../../organisms/PageStateWrapper'; +import { RetryErrorMessage } from '../../organisms/RetryErrorMessage'; +import { Toaster } from 'sonner'; + +export const MainLayout: React.FC = () => { + const dispatch = useAppDispatch(); + const { theme } = useAppSelector(state => state.theme); + const { loading, error } = useAppSelector(state => state.products); + + useEffect(() => { + dispatch(initProducts()); + dispatch(initFavourites()); + dispatch(initCart()); + dispatch(initPhones()); + dispatch(initTablets()); + dispatch(initAccessories()); + }, [dispatch]); + + useEffect(() => { + setElementDataAttr('html', HTMLDataAttr.Theme, theme); + }, [theme]); + + return ( + +
+
+ {error ? ( + + ) : ( +
+
+ +
+
+ )} +
+
+ +
+ ); +}; diff --git a/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.module.scss b/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.module.scss new file mode 100644 index 00000000000..81abb9c44c7 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.module.scss @@ -0,0 +1,212 @@ +@use '@shared/styles/utils/mixins.scss' as *; + +.menu { + position: fixed; + top: 0; + right: 0; + left: 0; + height: 100vh; + background-color: var(--main-background-color); + z-index: 1; + + transition: all var(--animation-duration, 0.3s) ease-in-out; + opacity: 0; + transform: translateX(100%); + pointer-events: none; + + @include on-tablet { + display: none; + } +} + +.menuOpen { + opacity: 1; + transform: translateX(0); + pointer-events: all; +} + +.top { + box-sizing: border-box; + display: flex; + justify-content: space-between; + width: 100%; + height: 48px; + padding: 0; + border-bottom: 1px solid var(--elements-color); + gap: 16px; +} + +.logo { + display: flex; + align-items: center; + justify-content: center; + width: 96px; + height: 48px; + + @include hover(transform, scale(1.1)); +} + +.logoImage { + width: 64px; + height: 22px; + padding: 13px 16px; + color: var(--main-text-color-primary); +} + +.close { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + cursor: pointer; + aspect-ratio: 1; + border-left: 1px solid var(--elements-color); + background-color: var(--main-background-color); + height: 100%; +} + +.content { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.list { + width: 288px; + list-style: none; + gap: 16px; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 24px; + margin-bottom: 16px; +} + +.link { + font-size: 12px; + text-transform: uppercase; + font-weight: 800; + text-decoration: none; + line-height: 11px; + display: flex; + justify-content: center; + align-items: center; + color: var(--secondary-color); + height: 27px; + position: relative; + + &:hover { + color: var(--main-text-color-primary); + } +} + +.activeLink { + color: var(--main-text-color-primary); + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2px; + background-color: var(--main-text-color-primary); + transition: opacity var(--animation-duration, 0.3s) ease; + } + + &:focus::after, + &:active::after { + opacity: 1; + } +} + +.langSwitcher { + display: flex; + justify-content: center; + align-items: center; + padding: 4px; + font-weight: 400; + color: var(--main-text-color-secondary); +} + +.langOption { + font-size: 12px; + text-transform: uppercase; + font-weight: 400; + line-height: 11px; + color: var(--main-text-color-secondary); + background-color: var(--main-background-color); + padding: 4px; + cursor: pointer; + + &--active { + color: var(--main-text-color-primary); + text-decoration: underline; + font-weight: 800; + } +} + +.bottom { + box-sizing: border-box; + display: flex; + width: 100%; + height: 64px; + border-top: 1px solid var(--elements-color); + flex-direction: row; + position: fixed; + bottom: 0; +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + height: 100%; + position: relative; + &:first-child { + border-right: 1px solid var(--elements-color); + } + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2px; + opacity: 0; + background-color: var(--buttons-text-color-primary); + transition: opacity var(--animation-duration, 0.3s) ease; + } + + &:focus::after, + &:active::after { + opacity: 1; + } +} + +.counter { + position: absolute; + top: -6px; + right: -6px; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + width: 14px; + height: 14px; + background-color: var(--red-accent-color); + border-radius: 50%; + border: 1px solid var(--main-background-color); + + font-weight: 600; + font-size: 9px; + line-height: 100%; + letter-spacing: 0%; + text-align: center; +} diff --git a/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.tsx b/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.tsx new file mode 100644 index 00000000000..b1e5a3fbabe --- /dev/null +++ b/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.tsx @@ -0,0 +1,87 @@ +import classNames from 'classnames'; +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import styles from './SideMenu.module.scss'; +import { NAV_LINKS } from '../../../../../constants/navigation'; +import { Icon } from '../../../atoms/Icon'; +import { HeartIcon } from '../../../../../assets/icons/heart-icon'; +import { ShoppingBagIcon } from '../../../../../assets/icons/shopping-bag-icon'; +import { CloseIcon } from '../../../../../assets/icons/close-icon'; +import { useAppSelector } from '../../../../../hooks/hooks'; +import { selectTotalItems } from '../../../../../features/cartSlice'; +import { useTranslation } from 'react-i18next'; +import { LanguageSwitcher } from '../../../molecules/LanguageSwitcher'; +import { HeaderLogo } from '../../../../../assets/icons/header-logo-icon'; + +type Props = { + onClose: () => void; + isOpen: boolean; +}; + +export const SideMenu: React.FC = ({ onClose, isOpen }: Props) => { + const getMenuNavLinkClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.link, { [styles.activeLink]: isActive }); + + const { favourites } = useAppSelector(state => state.favourites); + const { cartItems } = useAppSelector(state => state.cart); + const totalItems = useAppSelector(selectTotalItems); + + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/modules/shared/templates/MainLayout/SideMenu/index.tsx b/src/modules/shared/templates/MainLayout/SideMenu/index.tsx new file mode 100644 index 00000000000..6b65bcfbfe8 --- /dev/null +++ b/src/modules/shared/templates/MainLayout/SideMenu/index.tsx @@ -0,0 +1 @@ +export * from './SideMenu'; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 00000000000..d4068169bcd --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export * from './store'; diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 00000000000..0a48ba32b03 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,27 @@ +import { combineSlices, configureStore } from '@reduxjs/toolkit'; +import { productsSlice } from '../features/productsSlice'; +import { favouritesSlice } from '../features/favouritesSlice'; +import { cartSlice } from '../features/cartSlice'; +import { phonesSlice } from '../features/phonesSlice'; +import { tabletsSlice } from '../features/tabletsSlice'; +import { accessoriesSlice } from '../features/accessoriesSlice'; +import { i18nSlice } from '../features/i18nSlice'; +import { themeSlice } from '../features/themeSlice'; + +const rootReducer = combineSlices({ + products: productsSlice.reducer, + favourites: favouritesSlice.reducer, + cart: cartSlice.reducer, + phones: phonesSlice.reducer, + tablets: tabletsSlice.reducer, + accessories: accessoriesSlice.reducer, + i18n: i18nSlice.reducer, + theme: themeSlice.reducer, +}); + +export const store = configureStore({ + reducer: rootReducer, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/styles/index.scss b/src/styles/index.scss new file mode 100644 index 00000000000..06dab1d04fc --- /dev/null +++ b/src/styles/index.scss @@ -0,0 +1,60 @@ +@use './utils/font.scss'; +@use 'normalize-scss' as *; +@use './themes/light'; +@use './themes/dark'; +@use './themes/variables'; +@use './utils/normalize.scss'; +@include normalize; + +:root { + font-family: Mont, system-ui, Ubuntu, sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.page { + line-height: 1.5; + + &__body { + min-height: 100vh; + min-width: 320px; + + justify-content: center; + + background-color: var(--main-background-color); + + &[data-modal-open='true'], + // &[data-settings-open='true'], + &[data-menu-open='true'] { + overflow: hidden; + } + } +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + background-color: var(--main-background-color); +} + +input { + background-color: var(--surface1-color); + color: var(--main-text-color-primary); +} + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +textarea:-webkit-autofill { + -webkit-box-shadow: 0 0 0 1000px var(--surface1-color) inset; + -webkit-text-fill-color: var(--main-text-color-primary) !important; + caret-color: var(--main-text-color-primary); + background-color: var(--surface1-color) !important; + transition: background-color 5000s ease-in-out 0s; +} diff --git a/src/styles/themes/_dark.scss b/src/styles/themes/_dark.scss new file mode 100644 index 00000000000..200e00aef1a --- /dev/null +++ b/src/styles/themes/_dark.scss @@ -0,0 +1,23 @@ +:root { + --icons-color: #4a4d58; + --secondary-color: #75767f; + --white-accent-color: #f1f2f9; + --purple-accent-color: #905bff; + --purple-hover-color: #a378ff; + --green-accent-color: #27ae60; + --red-accent-color: #eb5757; + --main-background-color: #0f1121; + --buttons-text-color-primary: #f1f2f9; + --buttons-text-color-primary-active: var(--white-accent-color); + --buttons-text-color-secondary: #75767f; + --main-text-color-primary: #f1f2f9; + --main-text-color-secondary: #75767f; + --surface1-color: #161827; + --surface2-color: #323542; + --elements-color: #3b3e4a; + --buttons-background-active: var(--purple-accent-color); + --buttons-background-active-hover: var(--purple-hover-color); + --success-color: var(--green-accent-color); + --error-color: var(--red-accent-color); + --a11y-focus-color: var(--purple-hover-color); +} \ No newline at end of file diff --git a/src/styles/themes/_light.scss b/src/styles/themes/_light.scss new file mode 100644 index 00000000000..f9d106762ac --- /dev/null +++ b/src/styles/themes/_light.scss @@ -0,0 +1,23 @@ +:root[data-theme='light'] { + --icons-color: #6b6f7a; + --secondary-color: #9a9ba3; + --white-accent-color: #f1f2f9; + --purple-accent-color: #905bff; + --purple-hover-color: #a378ff; + --green-accent-color: #27ae60; + --red-accent-color: #eb5757; + --main-background-color: #f7f8fc; + --buttons-text-color-primary: #2c2d33; + --buttons-text-color-primary-active: var(--white-accent-color); + --buttons-text-color-secondary: #6b6f7a; + --main-text-color-primary: #2c2d33; + --main-text-color-secondary: #6b6f7a; + --surface1-color: #fff; + --surface2-color: #e2e6e9; + --elements-color: #d9dbe0; + --buttons-background-active: var(--purple-accent-color); + --buttons-background-active-hover: var(--purple-hover-color); + --success-color: var(--green-accent-color); + --error-color: var(--red-accent-color); + --a11y-focus-color: var(--purple-hover-color); +} \ No newline at end of file diff --git a/src/styles/themes/_variables.scss b/src/styles/themes/_variables.scss new file mode 100644 index 00000000000..d2cd5124a57 --- /dev/null +++ b/src/styles/themes/_variables.scss @@ -0,0 +1,4 @@ +:root { + --animation-duration: 0.3s; + --maxwidth-header-footer: 1490px; +} diff --git a/src/styles/utils/font.scss b/src/styles/utils/font.scss new file mode 100644 index 00000000000..65c118081f9 --- /dev/null +++ b/src/styles/utils/font.scss @@ -0,0 +1,23 @@ +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Bold.ttf') format('truetype'); + font-weight: 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} diff --git a/src/styles/utils/normalize.scss b/src/styles/utils/normalize.scss new file mode 100644 index 00000000000..d2a15b7df73 --- /dev/null +++ b/src/styles/utils/normalize.scss @@ -0,0 +1,30 @@ +a { + text-decoration: none; + color: white; +} + +button { + border-radius: 0; + border: 0; + padding: 0; +} + +h1, +h2, +h3, +h4 { + margin: 0; +} + +p { + margin: 0; +} + +img { + display: block; +} + +li { + text-decoration: none; + list-style: none; +} diff --git a/src/types/DropdownOption.ts b/src/types/DropdownOption.ts new file mode 100644 index 00000000000..1f20b42c161 --- /dev/null +++ b/src/types/DropdownOption.ts @@ -0,0 +1,4 @@ +export type DropdownOption = { + value: string; + label: string; +}; diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 00000000000..e5ae479782a --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,16 @@ +type Category = 'phones' | 'tablets' | 'accessories'; + +export interface Product { + id: number; + category: Category; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +} diff --git a/src/types/ProductDetails.ts b/src/types/ProductDetails.ts new file mode 100644 index 00000000000..ffbb9c5bebd --- /dev/null +++ b/src/types/ProductDetails.ts @@ -0,0 +1,36 @@ +import { Category } from '../enums/Category'; + +type Specification = string; +type ImagePath = string; + +export type ProductDetails = { + id: string; + namespaceId: string; + + name: string; + category: Category; + + priceRegular: number; + priceDiscount: number; + + capacityAvailable: string[]; + capacity: string; + + colorsAvailable: string[]; + color: string; + + images: ImagePath[]; + + description: { + title: string; + text: string[]; + }[]; + + screen: Specification; + resolution: Specification; + processor: Specification; + ram: Specification; + cell: Specification[]; + zoom: Specification; + camera: Specification; +}; diff --git a/src/types/react-i18next.d.ts b/src/types/react-i18next.d.ts new file mode 100644 index 00000000000..37aa85c8d0a --- /dev/null +++ b/src/types/react-i18next.d.ts @@ -0,0 +1,9 @@ +/* eslint-disable import/extensions */ +import 'react-i18next'; +import { TranslationKey } from '../enums/i18n/Keys'; + +declare module 'react-i18next' { + interface DefaultResources { + translation: Record; + } +} diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..91d674fd6ce 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,20 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; -// https://vitejs.dev/config/ export default defineConfig({ + base: '/react_phone-catalog/', plugins: [react()], -}) + resolve: { + alias: { + '@shared': path.resolve(__dirname, 'src/modules/shared'), + }, + }, + css: { + preprocessorOptions: { + scss: { + additionalData: '', + }, + }, + }, +});