diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b51149cf57..b11f948c00 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,7 +5,7 @@ module.exports = { }, extends: [ 'plugin:react/recommended', - "plugin:react-hooks/recommended", + 'plugin:react-hooks/recommended', 'airbnb-typescript', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended', @@ -18,7 +18,7 @@ module.exports = { 'rules': { 'react/jsx-filename-extension': ['off'], } - } + }, ], parser: '@typescript-eslint/parser', parserOptions: { @@ -29,6 +29,7 @@ module.exports = { project: './tsconfig.json', sourceType: 'module', }, + plugins: [ 'jsx-a11y', 'import', @@ -38,6 +39,7 @@ module.exports = { ], rules: { // JS + 'import/no-extraneous-dependencies': 'off', 'semi': 'off', '@typescript-eslint/semi': ['error', 'always'], 'prefer-const': 2, diff --git a/.stylelintrc.js b/.stylelintrc.js index f3a4e74272..8b8c13e209 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,4 +1,7 @@ module.exports = { - extends: "@mate-academy/stylelint-config", - rules: {} + extends: '@mate-academy/stylelint-config', + rules: { + 'media-feature-range-notation': null, + 'no-descending-specificity': null, + } }; diff --git a/README.md b/README.md deleted file mode 100644 index 98eb0bd1c0..0000000000 --- a/README.md +++ /dev/null @@ -1,143 +0,0 @@ -# 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/file/BUusqCIMAWALqfBahnyIiH/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 ` + + + {query && ( +
+ Close +
+ )} + + + ); +}; diff --git a/src/components/Search/index.ts b/src/components/Search/index.ts new file mode 100644 index 0000000000..addd53308b --- /dev/null +++ b/src/components/Search/index.ts @@ -0,0 +1 @@ +export * from './Search'; diff --git a/src/components/SortByDropdown/SortByDropdown.module.scss b/src/components/SortByDropdown/SortByDropdown.module.scss new file mode 100644 index 0000000000..a5ef1ab295 --- /dev/null +++ b/src/components/SortByDropdown/SortByDropdown.module.scss @@ -0,0 +1,98 @@ +@import '../../styles/main'; + +.sortByDropdown { + height: 59px; + width: 136px; + + @include on-tablet { + width: 176px; + } + + &__label { + margin-bottom: 8px; + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 12px; + color: var(--header-text-color); + } + + &__toggle { + width: 100%; + height: 40px; + margin-top: 4px; + display: flex; + justify-content: space-between; + align-items: center; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 21px; + padding-inline: 12px; + border: 1px solid var(--slider-btn-border); + color: var(--header-text-color-active); + background-color: var(--dropdown-bg); + cursor: pointer; + z-index: 200; + + &:focus { + border-color: var(--border-focus); + } + + &:hover { + border-color: var(--border-focus); + } + + &__toggleIcon { + width: 16px; + height: 16px; + transition: transform 0.3s ease; + + &--active { + transform: rotate(90deg); + } + } + } + + &__list { + display: none; + background-color: var(--c-background); + + &--active { + width: 136px; + position: absolute; + margin-top: 5px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + border: 1px solid var(--bg-hover-active); + z-index: 200; + + @include on-tablet { + width: 176px; + } + } + } + + &__item { + height: 40px; + padding-left: 12px; + position: relative; + display: flex; + align-items: center; + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + color: var(--back-top); + cursor: pointer; + transition: background-color 0.3s ease; + + + &:hover, + &:focus { + background-color: var(--bg-focus-list); + color: var(--header-text-color-active); + } + } +} diff --git a/src/components/SortByDropdown/SortByDropdown.tsx b/src/components/SortByDropdown/SortByDropdown.tsx new file mode 100644 index 0000000000..adf719e411 --- /dev/null +++ b/src/components/SortByDropdown/SortByDropdown.tsx @@ -0,0 +1,129 @@ +import arrowRightLight from '../../images/icon-right-light-theme.svg'; +import arrowRightDark from '../../images/icon-right-dark-theme.svg'; +import { useAppSelector } from '../../hooks/hooks'; +import { useTranslation } from 'react-i18next'; +import { useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import i18next from 'i18next'; +import classNames from 'classnames'; +import styles from './SortByDropdown.module.scss'; + +export const SortByDropdown = () => { + const { theme } = useAppSelector(state => state.theme); + const { t } = useTranslation(); + const [isDropdownActive, setIsDropdownActive] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + const dropdownContainerRef = useRef(null); + + const sortOptions = [ + t('sortByDropdown.option.newest'), + t('sortByDropdown.option.alphabetically'), + t('sortByDropdown.option.cheapest'), + ]; + + const handleOutsideClick = (event: MouseEvent) => { + if ( + dropdownContainerRef.current && + !dropdownContainerRef.current.contains(event.target as Node) + ) { + setIsDropdownActive(false); + } + }; + + const handleOptionSelect = (option: string) => { + const params = new URLSearchParams(searchParams); + + params.set('sort', option); + setSearchParams(params); + setIsDropdownActive(false); + }; + + const toggleDropdown = () => { + setIsDropdownActive(prevState => !prevState); + }; + + const translateSortOption = (sort: string | null) => { + const params = new URLSearchParams(searchParams); + + if (sort === 'Найновіші' && i18next.language === 'en') { + params.set('sort', 'Newest'); + setSearchParams(params); + } else if (sort === 'Newest' && i18next.language === 'uk') { + params.set('sort', 'Найновіші'); + setSearchParams(params); + } + + if (sort === 'Алфавітом' && i18next.language === 'en') { + params.set('sort', 'Alphabetically'); + setSearchParams(params); + } else if (sort === 'Alphabetically' && i18next.language === 'uk') { + params.set('sort', 'Алфавітом'); + setSearchParams(params); + } + + if (sort === 'Найдешевші' && i18next.language === 'en') { + params.set('sort', 'Cheapest'); + setSearchParams(params); + } else if (sort === 'Cheapest' && i18next.language === 'uk') { + params.set('sort', 'Найдешевші'); + setSearchParams(params); + } + + return sort; + }; + + useEffect(() => { + if (isDropdownActive) { + document.addEventListener('mousedown', handleOutsideClick); + } else { + document.removeEventListener('mousedown', handleOutsideClick); + } + + return () => { + document.removeEventListener('mousedown', handleOutsideClick); + }; + }, [isDropdownActive]); + + return ( +
+ + + + + +
+ ); +}; diff --git a/src/components/SortByDropdown/index.ts b/src/components/SortByDropdown/index.ts new file mode 100644 index 0000000000..77f9fa435d --- /dev/null +++ b/src/components/SortByDropdown/index.ts @@ -0,0 +1 @@ +export * from './SortByDropdown'; diff --git a/src/constants/productColors.ts b/src/constants/productColors.ts new file mode 100644 index 0000000000..d76d7ee3cd --- /dev/null +++ b/src/constants/productColors.ts @@ -0,0 +1,57 @@ +export type ProductColors = { + black: string; + blue: string; + coral: string; + gold: string; + graphite: string; + green: string; + midnight: string; + midnightgreen: string; + pink: string; + purple: string; + red: string; + rosegold: string; + sierrablue: string; + silver: string; + skyblue: string; + spaceblack: string; + spacegray: string; + starlight: string; + white: string; + yellow: string; + + 'space gray'?: string; + 'rose gold'?: string; + 'sky blue'?: string; + 'midnight green'?: string; + 'space black'?: string; +}; + +export const productColors: ProductColors = { + black: '#3C4042', + blue: '#CED5D9', + coral: '#FF6E5A', + gold: '#F4E8CE', + graphite: '#54524F', + green: '#576856', + midnight: '#232A31', + midnightgreen: '#394C38', + pink: '#FADDD7', + purple: '#594F63', + red: '#FC0324', + rosegold: '#F7E8DD', + silver: '#F1F2ED', + skyblue: '#276787', + spaceblack: '#403E3D', + 'space gray': '#6E6E73', + starlight: '#FAF6F2', + white: '#F6F2EF', + yellow: '#FFE681', + + 'rose gold': '#F7E8DD', + 'sky blue': '#276787', + 'midnight green': '#394C38', + spacegray: '#535150', + sierrablue: '#A7C1D9', + 'space black': '#403E3D', +}; diff --git a/src/features/cartSlice.ts b/src/features/cartSlice.ts new file mode 100644 index 0000000000..dfc1d7fb6a --- /dev/null +++ b/src/features/cartSlice.ts @@ -0,0 +1,88 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { UpdatedProduct } from '../types/UpdatedProduct'; +import { Product } from '../types/Product'; + +type Cart = { + cartProducts: UpdatedProduct[]; +}; + +const initialState: Cart = { + cartProducts: JSON.parse(localStorage.getItem('cart') || '[]'), +}; + +const syncCartWithLocalStorage = (items: UpdatedProduct[]) => { + localStorage.setItem('cart', JSON.stringify(items)); +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState, + reducers: { + addItemToCart: (state, action: PayloadAction) => { + const { id } = action.payload; + + const existingItem = state.cartProducts.find(item => item.id === id); + + if (existingItem) { + existingItem.quantity = (existingItem.quantity || 1) + 1; + } else { + state.cartProducts.push({ ...action.payload, quantity: 1 }); + } + + syncCartWithLocalStorage(state.cartProducts); + }, + + removeItemFromCart: (state, action: PayloadAction) => { + // eslint-disable-next-line no-param-reassign + state.cartProducts = state.cartProducts.filter( + item => item.id !== action.payload, + ); + syncCartWithLocalStorage(state.cartProducts); + }, + + clearCart: state => { + // eslint-disable-next-line no-param-reassign + state.cartProducts = []; + syncCartWithLocalStorage(state.cartProducts); + }, + + incrementItemQuantity: (state, action: PayloadAction) => { + const targetItem = state.cartProducts.find( + item => item.id === action.payload, + ); + + if (targetItem) { + targetItem.quantity += 1; + syncCartWithLocalStorage(state.cartProducts); + } + }, + + decrementItemQuantity: (state, action: PayloadAction) => { + const targetItem = state.cartProducts.find( + item => item.id === action.payload, + ); + + if (targetItem) { + if (targetItem.quantity > 1) { + targetItem.quantity -= 1; + } else { + // eslint-disable-next-line no-param-reassign + state.cartProducts = state.cartProducts.filter( + item => item.id !== action.payload, + ); + } + + syncCartWithLocalStorage(state.cartProducts); + } + }, + }, +}); + +export default cartSlice.reducer; +export const { + addItemToCart, + removeItemFromCart, + clearCart, + incrementItemQuantity, + decrementItemQuantity, +} = cartSlice.actions; diff --git a/src/features/favoritesSlice.ts b/src/features/favoritesSlice.ts new file mode 100644 index 0000000000..40509762dd --- /dev/null +++ b/src/features/favoritesSlice.ts @@ -0,0 +1,37 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { Product } from '../types/Product'; + +type Favorites = { + favoriteProducts: Product[]; +}; + +const initialState: Favorites = { + favoriteProducts: JSON.parse( + localStorage.getItem('favorites') || '[]', + ).filter(product => product?.id != null && product.id !== ''), +}; + +const syncFavoritesWithLocalStorage = (favorites: Product[]) => { + localStorage.setItem('favorites', JSON.stringify(favorites)); +}; + +const favoritesSlice = createSlice({ + name: 'favorites', + initialState, + reducers: { + addFavorite: (state, action: PayloadAction) => { + state.favoriteProducts.push(action.payload); + syncFavoritesWithLocalStorage(state.favoriteProducts); + }, + removeFavorite: (state, action: PayloadAction) => { + // eslint-disable-next-line no-param-reassign + state.favoriteProducts = state.favoriteProducts.filter( + item => item.id !== action.payload.id, + ); + syncFavoritesWithLocalStorage(state.favoriteProducts); + }, + }, +}); + +export default favoritesSlice.reducer; +export const { addFavorite, removeFavorite } = favoritesSlice.actions; diff --git a/src/features/productsSlice.ts b/src/features/productsSlice.ts new file mode 100644 index 0000000000..6fe577412d --- /dev/null +++ b/src/features/productsSlice.ts @@ -0,0 +1,24 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { Product } from '../types/Product'; + +type ProductsState = { + products: Product[]; +}; + +const initialState: ProductsState = { + products: [], +}; + +const productsSlice = createSlice({ + name: 'products', + initialState, + reducers: { + setProducts: (state, action: PayloadAction) => { + // eslint-disable-next-line no-param-reassign + state.products = action.payload; + }, + }, +}); + +export default productsSlice.reducer; +export const { setProducts } = productsSlice.actions; diff --git a/src/features/themeSlice.ts b/src/features/themeSlice.ts new file mode 100644 index 0000000000..8025717523 --- /dev/null +++ b/src/features/themeSlice.ts @@ -0,0 +1,33 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { createSlice } from '@reduxjs/toolkit'; + +type ThemeState = { + theme: string; +}; + +const initialState: ThemeState = { + theme: localStorage.getItem('theme') || 'light', +}; + +const themeSlice = createSlice({ + name: 'theme', + initialState, + reducers: { + toggleTheme: state => { + if (state.theme === 'light') { + // eslint-disable-next-line no-param-reassign + state.theme = 'dark'; + } else if (state.theme === 'dark') { + // eslint-disable-next-line no-param-reassign + state.theme = 'light'; + } + + localStorage.setItem('theme', state.theme); + document.body.classList.remove('light', 'dark'); + document.body.classList.add(state.theme); + }, + }, +}); + +export default themeSlice.reducer; +export const { toggleTheme } = themeSlice.actions; diff --git a/src/helpers/httpClient.ts b/src/helpers/httpClient.ts new file mode 100644 index 0000000000..e715c29274 --- /dev/null +++ b/src/helpers/httpClient.ts @@ -0,0 +1,13 @@ +const BASE_URL = 'https://anna-agerone.github.io/react_phone-catalog/api'; + +// const BASE_URL = './api/'; + +export function getData(url: string): Promise { + return fetch(BASE_URL + url).then(response => { + if (!response.ok) { + throw new Error(`${response.status} ${response.text}`); + } + + return response.json(); + }); +} diff --git a/src/hooks/hooks.ts b/src/hooks/hooks.ts new file mode 100644 index 0000000000..e0f87e58ac --- /dev/null +++ b/src/hooks/hooks.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { TypedUseSelectorHook, useSelector } from 'react-redux'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { useDispatch } from 'react-redux'; +import { RootState } from '../app/store'; +import { AppDispatch } from '../app/store'; + +export const useAppSelector: TypedUseSelectorHook = useSelector; +export const useAppDispatch: () => AppDispatch = useDispatch; diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 0000000000..0219dc43ea --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,23 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import en from './translations/en'; +import uk from './translations/uk'; +import { LanguageType } from '../types/Language'; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + [LanguageType.EN]: { translation: en }, + [LanguageType.UK]: { translation: uk }, + }, + fallbackLng: LanguageType.EN, + interpolation: { + escapeValue: false, + }, + }); + +export default i18n; diff --git a/src/i18n/translations/en.js b/src/i18n/translations/en.js new file mode 100644 index 0000000000..77f0884fd2 --- /dev/null +++ b/src/i18n/translations/en.js @@ -0,0 +1,182 @@ +const en = { + welcomeMessage: 'Welcome to React and react-i18next', + header: { + navigation: { + home: 'HOME', + phones: 'PHONES', + tablets: 'TABLETS', + accessories: 'ACCESSORIES', + }, + languageSwitcher: 'УКР', + themeSwitcher: { + light: 'dark', + dark: 'light', + }, + }, + + search: { + placeholder: 'Search in {{category}}', + categories: { + phones: 'phones', + tablets: 'tablets', + accessories: 'accessories', + }, + }, + + footer: { + contacts: 'CONTACTS', + rights: 'RIGHTS', + backToTop: 'Back to top', + notificationAlert: + // eslint-disable-next-line max-len + 'You are about to leave this page and visit the GitHub profile of the project creator. Do you wish to continue?', + rightsAlert: 'This is a mock implementation. Full feature coming soon!', + }, + + homePage: { + title: 'Welcome to Nice Gadgets store!', + brandNewModels: 'Brand new models', + hotPrices: 'Hot prices', + categories: { + mainTitle: 'Shop by category', + phonesTitle: 'Mobile phones', + tabletsTitle: 'Tablets', + accessoriesTitle: 'Accessories', + count_one: '{{count}} model', + count_few: '{{count}} models', + count_many: '{{count}} models', + count_other: '{{count}} models', + shopLatest: 'Shop the latest {{category}}', + performanceAndStyle: 'Performance and style in your hands!', + }, + }, + + breadCrumbs: { + phones: 'Phones', + tablets: 'Tablets', + accessories: 'Accessories', + favorites: 'Favorites', + }, + + sortByDropdown: { + title: 'Sort by', + option: { + newest: 'Newest', + alphabetically: 'Alphabetically', + cheapest: 'Cheapest', + }, + placeholder: 'Select an option', + }, + + itemsPerPageDropdown: { + title: 'Items on page', + all: 'All', + }, + + phonesPage: { + title: 'Mobile phones', + count_one: '{{count}} model', + count_other: '{{count}} models', + }, + + tabletsPage: { + title: 'Tablets', + count_one: '{{count}} model', + count_other: '{{count}} models', + }, + + accessoriesPage: { + title: 'Accessories', + count_one: '{{count}} model', + count_other: '{{count}} models', + }, + + favoritesPage: { + title: 'Favorites', + count_one: '{{count}} model', + count_other: '{{count}} models', + empty: + 'You don’t have any favorites yet. \nExplore and add your top picks!', + }, + + productDetailsPage: { + suggestionsTitle: 'You may also like', + screen: 'Screen', + processor: 'Processor', + resolution: 'Resolution', + capacity: 'Capacity', + ram: 'RAM', + about: 'About', + techSpecs: 'Tech specs', + builtInMemory: 'Built in memory', + camera: 'Camera', + zoom: 'Zoom', + cell: 'Cell', + }, + + colorSelection: { + title: 'Available colors', + }, + + capacitySelection: { + title: 'Select capacity', + }, + + cartPage: { + title: 'Cart', + totalFor: 'Total for {{count}} {{items}}', + items: { + one: 'item', + few: 'items', + many: 'items', + other: 'items', + }, + emptyCart: 'Your cart is empty', + checkout: 'Checkout', + movingText: 'Thank you for choosing us!', + }, + + modal: { + title: 'Checkout is not implemented yet.', + message: 'Do you want to clear the cart?', + confirmBtn: 'Confirm', + cancelBtn: 'Cancel', + }, + + notFoundPage: { + message: 'Oops!', + title: 'Page Not Found', + backHome: 'Go back to Home', + }, + + productNotFoundPage: { + phones: 'No phones found. Please try again!', + tablets: 'No tablets found. Please try again!', + accessories: 'No accessories found. Please try again!', + titleOutOfStock: 'Back Soon: Product Out of Stock', + }, + + buttonBack: { + back: 'Back', + }, + + productCard: { + specs: { + screen: 'Screen', + capacity: 'Capacity', + ram: 'RAM', + }, + + button: { + add: 'Add to cart', + added: 'Added to cart', + }, + + toast: { + added: '{{name}} has been added to the cart!', + removed: '{{name}} has been removed from the cart!', + }, + }, +}; + +export default en; diff --git a/src/i18n/translations/uk.js b/src/i18n/translations/uk.js new file mode 100644 index 0000000000..69496a274b --- /dev/null +++ b/src/i18n/translations/uk.js @@ -0,0 +1,193 @@ +const uk = { + welcomeMessage: 'Ласкаво просимо до React та react-i18next', + header: { + navigation: { + home: 'ГОЛОВНА', + phones: 'ТЕЛЕФОНИ', + tablets: 'ПЛАНШЕТИ', + accessories: 'АКСЕСУАРИ', + }, + languageSwitcher: 'EN', + themeSwitcher: { + light: 'темна', + dark: 'світла', + }, + }, + + search: { + placeholder: 'Пошук серед {{category}}', + categories: { + phones: 'телефонів', + tablets: 'планшетів', + accessories: 'аксесуарів', + }, + }, + + footer: { + contacts: 'КОНТАКТИ', + rights: 'ПРАВА', + backToTop: 'На початок', + notificationAlert: + // eslint-disable-next-line max-len + 'Ви збираєтесь покинути цю сторінку і перейти на GitHub профіль розробника цього проєкту. Бажаєте продовжити?', + rightsAlert: + // eslint-disable-next-line max-len + 'Це макет реалізації. Повна функціональність буде доступна найближчим часом!', + }, + + homePage: { + title: 'Ласкаво просимо до магазину "Nice Gadgets"!', + brandNewModels: 'Нові моделі', + hotPrices: 'Гарячі ціни', + categories: { + mainTitle: 'Купуйте за категоріями', + phonesTitle: 'Мобільні телефони', + tabletsTitle: 'Планшети', + accessoriesTitle: 'Аксесуари', + count_one: '{{count}} модель', + count_few: '{{count}} моделі', + count_many: '{{count}} моделей', + count_other: '{{count}} моделей', + shopLatest: 'Купуйте новітні {{category}}', + performanceAndStyle: 'Потужність і стиль у ваших руках!', + }, + }, + + breadCrumbs: { + phones: 'Мобільні телефони', + tablets: 'Планшети', + accessories: 'Аксесуари', + favorites: 'Обрані', + }, + + sortByDropdown: { + title: 'Сортувати за', + option: { + newest: 'Найновіші', + alphabetically: 'Алфавітом', + cheapest: 'Найдешевші', + }, + placeholder: 'Оберіть опцію', + }, + + itemsPerPageDropdown: { + title: 'Кількість на сторінці', + all: 'Всі', + }, + + phonesPage: { + title: 'Мобільні телефони', + count_one: '{{count}} модель', + count_few: '{{count}} моделі', + count_many: '{{count}} моделей', + count_other: '{{count}} моделей', + }, + + tabletsPage: { + title: 'Планшети', + count_one: '{{count}} модель', + count_few: '{{count}} моделі', + count_many: '{{count}} моделей', + count_other: '{{count}} моделей', + }, + + accessoriesPage: { + title: 'Аксесуари', + count_one: '{{count}} модель', + count_few: '{{count}} моделі', + count_many: '{{count}} моделей', + count_other: '{{count}} моделей', + }, + + favoritesPage: { + title: 'Обрані', + count_one: '{{count}} модель', + count_few: '{{count}} моделі', + count_many: '{{count}} моделей', + count_other: '{{count}} моделей', + empty: + // eslint-disable-next-line max-len + 'У вас ще немає обраного. \nДосліджуйте та додавайте свої улюблені варіанти!', + }, + + productDetailsPage: { + suggestionsTitle: 'Вам також може сподобатися', + screen: 'Екран', + processor: 'Процесор', + resolution: 'Роздільна здатність', + capacity: 'Ємність', + ram: 'ОЗП', + about: 'Про продукт', + techSpecs: 'Технічні характеристики', + builtInMemory: "Вбудована пам'ять", + camera: 'Камера', + zoom: 'Зум', + cell: 'Мережа', + }, + + colorSelection: { + title: 'Доступні кольори', + }, + + capacitySelection: { + title: 'Виберіть ємність', + }, + + cartPage: { + title: 'Кошик', + totalFor: 'Загальна вартість для {{count}} {{items}}', + items: { + one: 'товарy', + few: 'товарів', + many: 'товарів', + other: 'товарів', + }, + emptyCart: 'Ваш кошик порожній', + checkout: 'Оплата', + movingText: 'Дякуємо, що обрали нас!', + }, + + modal: { + title: 'Оформлення замовлення ще не реалізовано.', + message: 'Ви хочете очистити кошик?', + confirmBtn: 'Підтвердити', + cancelBtn: 'Скасувати', + }, + + notFoundPage: { + message: 'Упс!', + title: 'Сторінку не знайдено', + backHome: 'Повернутися на головну сторінку', + }, + + productNotFoundPage: { + phones: 'Телефони не знайдено.\nБудь ласка, спробуйте ще раз!', + tablets: 'Планшети не знайдено.\nБудь ласка, спробуйте ще раз!', + accessories: 'Аксесуари не знайдено.\nБудь ласка, спробуйте ще раз!', + titleOutOfStock: 'Незабаром у продажу: товар відсутній на складі', + }, + + buttonBack: { + back: 'Назад', + }, + + productCard: { + specs: { + screen: 'Екран', + capacity: 'Ємність', + ram: 'ОЗП', + }, + + button: { + add: 'Додати до кошика', + added: 'Додано до кошика', + }, + + toast: { + added: '{{name}} було додано до кошика!', + removed: '{{name}} було видалено з кошика!', + }, + }, +}; + +export default uk; diff --git a/src/images/accessories-category.png b/src/images/accessories-category.png new file mode 100644 index 0000000000..0158d84c66 Binary files /dev/null and b/src/images/accessories-category.png differ diff --git a/public/img/banner-accessories.png b/src/images/banner-accessories.png similarity index 100% rename from public/img/banner-accessories.png rename to src/images/banner-accessories.png diff --git a/public/img/banner-phones.png b/src/images/banner-phones.png similarity index 100% rename from public/img/banner-phones.png rename to src/images/banner-phones.png diff --git a/public/img/banner-tablets.png b/src/images/banner-tablets.png similarity index 100% rename from public/img/banner-tablets.png rename to src/images/banner-tablets.png diff --git a/src/images/burger-menu-dark-theme.svg b/src/images/burger-menu-dark-theme.svg new file mode 100644 index 0000000000..c8c52c08a9 --- /dev/null +++ b/src/images/burger-menu-dark-theme.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/images/burger-menu-light-theme.svg b/src/images/burger-menu-light-theme.svg new file mode 100644 index 0000000000..2c535f4586 --- /dev/null +++ b/src/images/burger-menu-light-theme.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/images/cart-dark-theme.svg b/src/images/cart-dark-theme.svg new file mode 100644 index 0000000000..425ee63976 --- /dev/null +++ b/src/images/cart-dark-theme.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/cart-is-empty.png b/src/images/cart-is-empty.png similarity index 100% rename from public/img/cart-is-empty.png rename to src/images/cart-is-empty.png diff --git a/src/images/cart-light-theme.svg b/src/images/cart-light-theme.svg new file mode 100644 index 0000000000..6030970f2e --- /dev/null +++ b/src/images/cart-light-theme.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/images/favicon.svg b/src/images/favicon.svg new file mode 100644 index 0000000000..c6b8617056 --- /dev/null +++ b/src/images/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/images/favorites-dark-theme.svg b/src/images/favorites-dark-theme.svg new file mode 100644 index 0000000000..8fb5abef51 --- /dev/null +++ b/src/images/favorites-dark-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/favorites-light-theme.svg b/src/images/favorites-light-theme.svg new file mode 100644 index 0000000000..ca57cfedd8 --- /dev/null +++ b/src/images/favorites-light-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/footer-arrowUp-dark-theme.svg b/src/images/footer-arrowUp-dark-theme.svg new file mode 100644 index 0000000000..0d2745b7c1 --- /dev/null +++ b/src/images/footer-arrowUp-dark-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/footer-arrowUp-light-theme.svg b/src/images/footer-arrowUp-light-theme.svg new file mode 100644 index 0000000000..0da5241741 --- /dev/null +++ b/src/images/footer-arrowUp-light-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/footer-logo-dark-theme.svg b/src/images/footer-logo-dark-theme.svg new file mode 100644 index 0000000000..d59f941639 --- /dev/null +++ b/src/images/footer-logo-dark-theme.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/images/footer-logo-light-theme.svg b/src/images/footer-logo-light-theme.svg new file mode 100644 index 0000000000..0a2d076bef --- /dev/null +++ b/src/images/footer-logo-light-theme.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/images/icon-close-dark-theme.svg b/src/images/icon-close-dark-theme.svg new file mode 100644 index 0000000000..925e5fce49 --- /dev/null +++ b/src/images/icon-close-dark-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-close-light-theme.svg b/src/images/icon-close-light-theme.svg new file mode 100644 index 0000000000..78d418ab46 --- /dev/null +++ b/src/images/icon-close-light-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-filled-heart-fav-red.svg b/src/images/icon-filled-heart-fav-red.svg new file mode 100644 index 0000000000..be5c1fc994 --- /dev/null +++ b/src/images/icon-filled-heart-fav-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-heart-dark-theme.svg b/src/images/icon-heart-dark-theme.svg new file mode 100644 index 0000000000..35c86cc33b --- /dev/null +++ b/src/images/icon-heart-dark-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-heart-light-theme.svg b/src/images/icon-heart-light-theme.svg new file mode 100644 index 0000000000..a90209fa54 --- /dev/null +++ b/src/images/icon-heart-light-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-home-dark-theme.svg b/src/images/icon-home-dark-theme.svg new file mode 100644 index 0000000000..e16ca7d794 --- /dev/null +++ b/src/images/icon-home-dark-theme.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/images/icon-home-light-theme.svg b/src/images/icon-home-light-theme.svg new file mode 100644 index 0000000000..474476cb02 --- /dev/null +++ b/src/images/icon-home-light-theme.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/images/icon-left-dark-theme.svg b/src/images/icon-left-dark-theme.svg new file mode 100644 index 0000000000..e2016da355 --- /dev/null +++ b/src/images/icon-left-dark-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-left-light-theme.svg b/src/images/icon-left-light-theme.svg new file mode 100644 index 0000000000..32c91f685f --- /dev/null +++ b/src/images/icon-left-light-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-minus-dark-theme.svg b/src/images/icon-minus-dark-theme.svg new file mode 100644 index 0000000000..7ca53e577a --- /dev/null +++ b/src/images/icon-minus-dark-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-minus-light-theme.svg b/src/images/icon-minus-light-theme.svg new file mode 100644 index 0000000000..97c41038ac --- /dev/null +++ b/src/images/icon-minus-light-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-moon.svg b/src/images/icon-moon.svg new file mode 100644 index 0000000000..8041dbf83a --- /dev/null +++ b/src/images/icon-moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/images/icon-plus-dark-theme.svg b/src/images/icon-plus-dark-theme.svg new file mode 100644 index 0000000000..aa791a47ad --- /dev/null +++ b/src/images/icon-plus-dark-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-plus-light-theme.svg b/src/images/icon-plus-light-theme.svg new file mode 100644 index 0000000000..ab3c34061b --- /dev/null +++ b/src/images/icon-plus-light-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-right-dark-theme.svg b/src/images/icon-right-dark-theme.svg new file mode 100644 index 0000000000..efe2edfd36 --- /dev/null +++ b/src/images/icon-right-dark-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-right-light-theme.svg b/src/images/icon-right-light-theme.svg new file mode 100644 index 0000000000..b4f4687671 --- /dev/null +++ b/src/images/icon-right-light-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-search-dark-theme.svg b/src/images/icon-search-dark-theme.svg new file mode 100644 index 0000000000..56a317c46f --- /dev/null +++ b/src/images/icon-search-dark-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-search-light-theme.svg b/src/images/icon-search-light-theme.svg new file mode 100644 index 0000000000..801f11a548 --- /dev/null +++ b/src/images/icon-search-light-theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/icon-sun.svg b/src/images/icon-sun.svg new file mode 100644 index 0000000000..aefc5da19a --- /dev/null +++ b/src/images/icon-sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/images/logo-dark-theme.svg b/src/images/logo-dark-theme.svg new file mode 100644 index 0000000000..3cc037d318 --- /dev/null +++ b/src/images/logo-dark-theme.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/images/logo-light-theme.svg b/src/images/logo-light-theme.svg new file mode 100644 index 0000000000..6c9de93f01 --- /dev/null +++ b/src/images/logo-light-theme.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/images/page-not-found.png b/src/images/page-not-found.png new file mode 100644 index 0000000000..b39a562f7a Binary files /dev/null and b/src/images/page-not-found.png differ diff --git a/src/images/phones-category.png b/src/images/phones-category.png new file mode 100644 index 0000000000..bfd46779d9 Binary files /dev/null and b/src/images/phones-category.png differ diff --git a/src/images/product-not-found.png b/src/images/product-not-found.png new file mode 100644 index 0000000000..aa335c9f77 Binary files /dev/null and b/src/images/product-not-found.png differ diff --git a/src/images/tablets-category.png b/src/images/tablets-category.png new file mode 100644 index 0000000000..175ca600e5 Binary files /dev/null and b/src/images/tablets-category.png differ diff --git a/src/index.tsx b/src/index.tsx index 50470f1508..6d981a67b4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,10 @@ import { createRoot } from 'react-dom/client'; -import { App } from './App'; +import { Root } from './Root'; +import { Provider } from 'react-redux'; +import store from './app/store'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + , +); diff --git a/src/modules/AccessoriesPage/AccessoriesPage.module.scss b/src/modules/AccessoriesPage/AccessoriesPage.module.scss new file mode 100644 index 0000000000..980629d462 --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.module.scss @@ -0,0 +1,48 @@ +@import '../../styles/main'; + +.accessoriesPage { + width: 100%; + height: 100%; + + .container { + @include padding-content-inline-responsive; + } + + .title { + font-family: Mont, sans-serif; + font-size: 48px; + font-weight: 800; + line-height: 56px; + color: var(--primary); + margin-top: 24px; + + @include on-tablet { + margin-top: 40px; + } + } + + .count { + margin-top: 8px; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: var(--header-text-color); + + } + + .dropdownContainer { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + flex-direction: row; + gap: 16px; + margin-top: 32px; + + @include on-tablet { + justify-content: left; + margin-top: 40px; + } + } +} diff --git a/src/modules/AccessoriesPage/AccessoriesPage.tsx b/src/modules/AccessoriesPage/AccessoriesPage.tsx new file mode 100644 index 0000000000..60d8e28bbb --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.tsx @@ -0,0 +1,116 @@ +import { useTranslation } from 'react-i18next'; +import { BreadCrumbs } from '../../components/BreadCrumbs'; +import styles from './AccessoriesPage.module.scss'; +import { useAppSelector } from '../../hooks/hooks'; +import { useDispatch } from 'react-redux'; +import { useSearchParams } from 'react-router-dom'; +import { getNewProducts } from '../../services/getNewProducts'; +import { useEffect, useState } from 'react'; +import { setProducts } from '../../features/productsSlice'; +import { Categories } from '../../types/Categories'; +import { SortByDropdown } from '../../components/SortByDropdown'; +import { ItemsPerPageDropdown } from '../../components/ItemsPerPageDropdown'; +import { Product } from '../../types/Product'; +import { ProductGallery } from '../../components/ProductGallery'; +import { getItemsPerPage } from '../../services/getItemsPerPage'; +import { LoaderProductCard } from '../../components/LoaderProductCard'; +import { Pagination } from '../../components/Pagination/Pagination'; +import { ProductNotFoundPage } from '../ProductNotFoundPage'; + +export const AccessoriesPage = () => { + const [isLoading, setIsLoading] = useState(true); + const { t } = useTranslation(); + const { products } = useAppSelector(state => state.products); + const dispatch = useDispatch(); + const [searchParams] = useSearchParams(); + const sort = searchParams.get('sort'); + const query = searchParams.get('search'); + + const type = Categories.Accessories; + + const filteredProducts = products.filter(product => { + if (query) { + return ( + product.category === type && + product.name.toLowerCase().includes(query.toLowerCase()) + ); + } else { + return product.category === type; + } + }); + + const sortProducts = (item: Product[], sortType: string) => { + switch (sortType) { + case `${t('sortByDropdown.option.newest')}`: + return item.sort((a, b) => b.year - a.year); + + case `${t('sortByDropdown.option.alphabetically')}`: + return item.sort((a, b) => a.name.localeCompare(b.name)); + + case `${t('sortByDropdown.option.cheapest')}`: + return item.sort((a, b) => a.price - b.price); + + default: + return item; + } + }; + + const sortedProducts = sortProducts(filteredProducts, sort as string); + const page = searchParams.get('page') || 1; + const perPage = searchParams.get('perPage'); + + const itemsPerPage = getItemsPerPage(perPage, page, filteredProducts); + + useEffect(() => { + setIsLoading(true); + + getNewProducts() + .then(resolve => { + const newProducts = resolve.map(item => ({ ...item, quantity: 1 })); + + dispatch(setProducts(newProducts)); + }) + .catch( + () => + // eslint-disable-next-line max-len + 'Oops! Something went wrong while loading data. Please try again later.', + ) + .finally(() => { + setTimeout(() => { + setIsLoading(false); + }, 500); + }); + }, [dispatch, searchParams]); + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, [page]); + + if (!itemsPerPage.length && !isLoading) { + return ; + } + + return ( +
+
+ +

{t('accessoriesPage.title')}

+

+ {t('accessoriesPage.count', { count: filteredProducts.length })} +

+ +
+ + +
+ {isLoading ? ( + + ) : ( + + )} + + {perPage && } +
+
+ ); +}; diff --git a/src/modules/AccessoriesPage/index.ts b/src/modules/AccessoriesPage/index.ts new file mode 100644 index 0000000000..486474aa0b --- /dev/null +++ b/src/modules/AccessoriesPage/index.ts @@ -0,0 +1 @@ +export * from './AccessoriesPage'; diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 0000000000..d5effeffbd --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,223 @@ +@import '../../styles/main'; + +.cartPage { + height: 100%; + width: 100%; + margin-top: 24px; + position: relative; + + @include padding-content-inline-responsive; + + @include on-tablet { + margin-top: 40px; + } + + .container { + display: flex; + + .customBackButton { + color: var(--back-btn-cart); + font-size: 12px; + transition: color 0.3s ease; + + &:hover { + color: var(--back-btn-cart-hover); + } + + .customIcon { + opacity: 1; + transition: filter 0.3s ease; + + &:hover { + filter: invert(29%) sepia(57%) saturate(548%) hue-rotate(270deg) brightness(93%) contrast(92%); + } + } + } + } + + .cartTitle { + font-family: Mont, sans-serif; + font-size: 32px; + font-weight: 800; + color: var(--header-text-color-active); + margin-top: 24px; + + @include on-tablet { + font-size: 48px; + margin-top: 16px; + } + } + + .content { + @include on-desktop { + display: flex; + flex-direction: row; + gap: 16px; + width: 100%; + } + + .loader { + position: absolute; + left: 45%; + padding-top: 40px; + } + + .list { + display: flex; + flex-direction: column; + margin-top: 32px; + gap: 16px; + width: 100%; + + @include on-desktop { + width: 752px; + } + + .cartItem { + cursor: pointer; + display: flex; + width: 100%; + } + } + + .totalCheckoutBlock { + margin-top: 32px; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + height: 190px; + padding-inline: 24px; + padding-block: 24px; + border: 1px solid var(--checkout-border); + + @include on-tablet { + height: 206px; + } + + @include on-desktop { + width: 368px; + } + + .containerBtm { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + + .totalPrice { + display: flex; + align-items: center; + font-family: Mont, sans-serif; + font-size: 32px; + font-weight: 800; + color: var(--header-text-color-active); + } + + .totalCount { + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + color: var(--header-text-color); + } + + .line { + height: 1px; + width: 100%; + background-color: var(--checkout-border); + display: flex; + margin-bottom: 16px; + margin-top: 16px; + + @include on-desktop { + margin-bottom: 24px; + margin-top: 25px; + } + } + } + + .checkoutBtn { + height: 48px; + background-color: var(--pg-selected); + color: var(--checkout-btn-color); + width: 100%; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 700; + transition: box-shadow 0.3s ease, background-color 0.3s ease; + + &:hover { + background-color: var(--btn-bg-hover); + box-shadow: var(--btn-hover-shadow); + } + } + + + } + + .emptyContent { + padding-top: 7px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + text-align: center; + height: 100%; + width: 100%; + + .emptyCartMessage { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 21px; + line-height: 43px; + color: var(--header-text-color); + text-align: center; + + @include on-tablet { + font-size: 32px; + } + } + + .emptyImg { + width: 100%; + height: auto; + max-width: 400px; + display: block; + } + } + } +} + +.containerLine { + margin-top: 20px; + position: relative; + width: 100%; + height: 50px; + overflow: hidden; +} + +.movingText { + position: absolute; + top: 50%; + left: 100%; + transform: translateY(-50%); + white-space: nowrap; + color: var(--header-text-color-active); + font-family: Mont, sans-serif; + font-size: 21px; + font-weight: 800; + padding: 8px; + border: 1px solid var(--checkout-border); + border-radius: 10px; + animation: scroll 15s linear infinite; +} + +@keyframes scroll { + 0% { + left: 100%; + } + 100% { + left: -100%; + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 0000000000..9a5cae4d92 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,144 @@ +import { useTranslation } from 'react-i18next'; +import { GoBackButton } from '../../components/GoBackButton'; +import styles from './CartPage.module.scss'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../hooks/hooks'; +import { CartItem } from '../../components/CartItem'; +import { useEffect, useState } from 'react'; +import emptyCartImg from '../../images/cart-is-empty.png'; +import { Modal } from '../../components/Modal'; +import { clearCart } from '../../features/cartSlice'; +import { Loader } from '../../components/Loader'; + +export const CartPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { cartProducts } = useAppSelector(state => state.cart); + const dispatch = useAppDispatch(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setIsLoading(false); + }, 2000); + + return () => clearTimeout(timer); + }, []); + + const totalPrice = cartProducts.reduce( + (total, product) => total + product.price * product.quantity, + 0, + ); + const totalCount = cartProducts.reduce( + (count, product) => count + product.quantity, + 0, + ); + + const [isModalOpen, setIsModalOpen] = useState(false); + const handleCheckoutClick = () => { + setIsModalOpen(true); + }; + + const handleConfirm = () => { + dispatch(clearCart()); + setIsModalOpen(false); + }; + + const handleCancel = () => { + setIsModalOpen(false); + }; + + const getItemForm = (count: number) => { + if (count === 1) { + return 'one'; + } + + if (count >= 2 && count <= 4) { + return 'few'; + } + + if (count > 4) { + return 'many'; + } + + return 'other'; + }; + + useEffect(() => { + window.scrollTo({ top: 0 }); + }, []); + + return ( +
+
+ navigate(-1)} + /> +
+ +

{t('cartPage.title')}

+ +
+ {isLoading ? ( +
+ +
+ ) : cartProducts.length > 0 ? ( + <> +
    + {cartProducts.map(product => ( + + + + ))} +
+
+
+

{`$${totalPrice}`}

+

+ {t('cartPage.totalFor', { + count: totalCount, + items: t(`cartPage.items.${getItemForm(totalCount)}`), + })} +

+ +
+ +
+ + ) : ( +
+

{t('cartPage.emptyCart')}

+ Empty Cart +
+ )} +
+ + + +
+
{t('cartPage.movingText')} ❤️
+
+
+ ); +}; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 0000000000..90c010237a --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export * from './CartPage'; diff --git a/src/modules/FavoritesPage/FavoritePage.module.scss b/src/modules/FavoritesPage/FavoritePage.module.scss new file mode 100644 index 0000000000..01f9638abf --- /dev/null +++ b/src/modules/FavoritesPage/FavoritePage.module.scss @@ -0,0 +1,74 @@ +@import '../../styles/main'; + +.favoritesPage { + width: 100%; + height: 100%; + + .container { + height: 100%; + + @include padding-content-inline-responsive; + } + + .title { + font-family: Mont, sans-serif; + font-size: 32px; + font-weight: 800; + line-height: 56px; + color: var(--primary); + margin-top: 24px; + + @include on-tablet { + margin-top: 40px; + font-size: 48px; + } + } + + .count { + margin-top: 8px; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: var(--header-text-color); + + } + + .empty { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + width: 100%; + height: 100%; + + .img { + width: 100%; + height: auto; + max-width: 400px; + display: block; + } + + .emptyTitle { + padding-top: 7px; + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 21px; + line-height: 43px; + padding-bottom: 1rem; + color: var(--header-text-color); + text-align: center; + white-space: pre-line; + + @include on-tablet { + font-size: 32px;; + } + } + } + } + + .loader { + padding-top: 40px; + + } + diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 0000000000..f515d99cf0 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from 'react-i18next'; +import { BreadCrumbs } from '../../components/BreadCrumbs'; +import { useAppSelector } from '../../hooks/hooks'; +import styles from './FavoritePage.module.scss'; +import { ProductGallery } from '../../components/ProductGallery'; +import { useEffect, useState } from 'react'; +import { Loader } from '../../components/Loader'; +import emptyFavoritesImg from '../../images/product-not-found.png'; +import { useNavigate } from 'react-router-dom'; +import { GoBackButton } from '../../components/GoBackButton/GoBackButton'; + +export const FavoritesPage = () => { + const { favoriteProducts } = useAppSelector(state => state.favorites); + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setIsLoading(false); + }, 2000); + + return () => clearTimeout(timer); + }, []); + + const handleGoBack = () => { + if (window.history.length > 1) { + navigate(-1); + } else { + navigate('/'); + } + }; + + return ( +
+
+ +

{t('favoritesPage.title')}

+ + {isLoading ? ( +
+ +
+ ) : ( +

+ {t('favoritesPage.count', { count: favoriteProducts.length })} +

+ )} + + {isLoading ? null : favoriteProducts.length > 0 ? ( + + ) : ( +
+

{t('favoritesPage.empty')}

+ Empty Favorites + + +
+ )} +
+
+ ); +}; diff --git a/src/modules/FavoritesPage/index.ts b/src/modules/FavoritesPage/index.ts new file mode 100644 index 0000000000..b3a884b188 --- /dev/null +++ b/src/modules/FavoritesPage/index.ts @@ -0,0 +1 @@ +export * from './FavoritesPage'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 0000000000..6b02192155 --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,66 @@ +@import '../../styles/main'; + +.homePage { + position: relative; + + &__title { + @include padding-content-inline-responsive; + + &__text { + font-family: Mont, sans-serif; + font-size: 32px; + font-weight: 800; + line-height: 41px; + color: var(--primary); + padding-block: 24px; + + @include on-tablet { + font-size: 48px; + font-weight: 800; + line-height: 56px; + padding-block: 32px; + } + + @include on-desktop { + padding-block: 56px; + } + } + } + + .content { + display: flex; + flex-direction: column; + row-gap: 56px; + + @include on-tablet { + row-gap: 64px; + } + + .picturesSlider { + padding-inline: 0; + + @include on-tablet { + @include padding-content-inline-responsive; + } + } + + .phonesSlider { + padding-left: 16px; + + @include on-tablet { + padding-inline: 24px; + } + + @include on-desktop { + padding-inline: 152px; + } + } + } + +} + +.loader { +position: absolute; +left: 45%; +top: 25%; +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 0000000000..f963c9e084 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from 'react-i18next'; +import styles from '../../modules/HomePage/HomePage.module.scss'; +import PicturesSlider from '../../components/PicturesSlider/PicturesSlider'; +import { PhonesSlider } from '../../components/PhonesSlider/PhonesSlider'; +import { useEffect, useState } from 'react'; +import { getNewProducts } from '../../services/getNewProducts'; +import { useAppDispatch, useAppSelector } from '../../hooks/hooks'; +import { setProducts } from '../../features/productsSlice'; +import { Loader } from '../../components/Loader'; +import { CategoriesSection } from '../../components/CategoriesSection'; + +export const HomePage = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [isLoading, setIsLoading] = useState(false); + const { products } = useAppSelector(state => state.products); + + useEffect(() => { + setIsLoading(true); + + getNewProducts() + .then(resolve => { + const newProducts = resolve.map(item => ({ ...item, quantity: 1 })); + + dispatch(setProducts(newProducts)); + }) + .catch( + () => + // eslint-disable-next-line max-len + 'Oops! Something went wrong while loading data. Please try again later.', + ) + .finally(() => { + setTimeout(() => { + setIsLoading(false); + }, 500); + }); + }, [dispatch]); + + const brandNewModels = [...products] + .filter(prod => prod.year === 2022) + .sort((prod1, prod2) => prod1.year - prod2.year); + + const discountedModels = [...products] + .sort((a, b) => b.fullPrice - b.price - (a.fullPrice - a.price)) + .filter(prod => prod.fullPrice - prod.price > 80); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

{t('homePage.title')}

+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 0000000000..11e53da674 --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export * from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 0000000000..27013e4a40 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,60 @@ +@import '../../styles/main'; + +.notFoundPage { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--c-background); + text-align: center; + + @include padding-content-inline-responsive; +} + +.title { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 32px; + padding-bottom: 1rem; + color: var(--primary); + + @include on-tablet { + font-size: 48px; + } +} + +.message { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 1.5rem; + padding-top: 60px; + color: var(--primary); +} + + .image { + max-width: 400px; + align-items: center; + } + + .back { + display: flex; + justify-content: center; + align-items: center; + + .backIcon { + opacity: 0.5; + } + + .link { + font-size: 1rem; + color: var(--header-text-color); + text-decoration: none; + + &:hover { + color: var(--header-text-color-active); + } + } + } + diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 0000000000..18e07f13c6 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,32 @@ +import styles from './NotFoundPage.module.scss'; +import NotFoundPageImg from '../../images/page-not-found.png'; +import { useTranslation } from 'react-i18next'; +import { GoBackButton } from '../../components/GoBackButton'; +import { useNavigate } from 'react-router-dom'; + +export const NotFoundPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const onGoBackHome = () => { + navigate('/'); + }; + + return ( +
+

{t('notFoundPage.message')}

+ Page Not Found 404 +

{t('notFoundPage.title')}

+ + +
+ ); +}; diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 0000000000..6197aa75aa --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export * from './NotFoundPage'; diff --git a/src/modules/PhonesPage/PhonesPage.module.scss b/src/modules/PhonesPage/PhonesPage.module.scss new file mode 100644 index 0000000000..b66657e152 --- /dev/null +++ b/src/modules/PhonesPage/PhonesPage.module.scss @@ -0,0 +1,49 @@ +@import '../../styles/main'; + +.phonesPage { + width: 100%; + height: 100%; + + .container { + @include padding-content-inline-responsive; + } + + .title { + font-family: Mont, sans-serif; + font-size: 48px; + font-weight: 800; + line-height: 56px; + color: var(--primary); + margin-top: 24px; + + @include on-tablet { + margin-top: 40px; + } + } + + .count { + margin-top: 8px; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: var(--header-text-color); + + } + + .dropdownContainer { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; + gap: 16px; + margin-top: 32px; + + @include on-tablet { + justify-content: left; + margin-top: 40px; + } + } +} diff --git a/src/modules/PhonesPage/PhonesPage.tsx b/src/modules/PhonesPage/PhonesPage.tsx new file mode 100644 index 0000000000..0fc0b37ef1 --- /dev/null +++ b/src/modules/PhonesPage/PhonesPage.tsx @@ -0,0 +1,116 @@ +import { useTranslation } from 'react-i18next'; +import { BreadCrumbs } from '../../components/BreadCrumbs'; +import styles from './PhonesPage.module.scss'; +import { useAppSelector } from '../../hooks/hooks'; +import { useDispatch } from 'react-redux'; +import { useSearchParams } from 'react-router-dom'; +import { getNewProducts } from '../../services/getNewProducts'; +import { useEffect, useState } from 'react'; +import { setProducts } from '../../features/productsSlice'; +import { Categories } from '../../types/Categories'; +import { SortByDropdown } from '../../components/SortByDropdown'; +import { ItemsPerPageDropdown } from '../../components/ItemsPerPageDropdown'; +import { Product } from '../../types/Product'; +import { ProductGallery } from '../../components/ProductGallery'; +import { getItemsPerPage } from '../../services/getItemsPerPage'; +import { LoaderProductCard } from '../../components/LoaderProductCard'; +import { Pagination } from '../../components/Pagination/Pagination'; +import { ProductNotFoundPage } from '../ProductNotFoundPage'; + +export const PhonesPage = () => { + const [isLoading, setIsLoading] = useState(true); + const { t } = useTranslation(); + const { products } = useAppSelector(state => state.products); + const dispatch = useDispatch(); + const [searchParams] = useSearchParams(); + const sort = searchParams.get('sort'); + const query = searchParams.get('search'); + + const type = Categories.Phones; + + const filteredProducts = products.filter(product => { + if (query) { + return ( + product.category === type && + product.name.toLowerCase().includes(query.toLowerCase()) + ); + } else { + return product.category === type; + } + }); + + const sortProducts = (item: Product[], sortType: string) => { + switch (sortType) { + case `${t('sortByDropdown.option.newest')}`: + return item.sort((a, b) => b.year - a.year); + + case `${t('sortByDropdown.option.alphabetically')}`: + return item.sort((a, b) => a.name.localeCompare(b.name)); + + case `${t('sortByDropdown.option.cheapest')}`: + return item.sort((a, b) => a.price - b.price); + + default: + return item; + } + }; + + const sortedProducts = sortProducts(filteredProducts, sort as string); + const page = Number(searchParams.get('page')) || 1; + const perPage = searchParams.get('perPage'); + + const itemsPerPage = getItemsPerPage(perPage, page, filteredProducts); + + useEffect(() => { + setIsLoading(true); + + getNewProducts() + .then(resolve => { + const newProducts = resolve.map(item => ({ ...item, quantity: 1 })); + + dispatch(setProducts(newProducts)); + }) + .catch( + () => + // eslint-disable-next-line max-len + 'Oops! Something went wrong while loading data. Please try again later.', + ) + .finally(() => { + setTimeout(() => { + setIsLoading(false); + }, 500); + }); + }, [dispatch, searchParams]); + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, [page]); + + if (!itemsPerPage.length && !isLoading) { + return ; + } + + return ( +
+
+ +

{t('phonesPage.title')}

+

+ {t('phonesPage.count', { count: filteredProducts.length })} +

+ +
+ + +
+ {isLoading ? ( + + ) : ( + + )} + + {perPage && } +
+
+ ); +}; diff --git a/src/modules/PhonesPage/index.ts b/src/modules/PhonesPage/index.ts new file mode 100644 index 0000000000..380be65cc7 --- /dev/null +++ b/src/modules/PhonesPage/index.ts @@ -0,0 +1 @@ +export * from './PhonesPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 0000000000..174ee27b06 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,59 @@ +@import '../../styles/main'; + +.productDetailsPage { + position: relative; + + @include padding-content-inline-responsive; + + +.goBackBtn { + display: flex; + align-items: flex-start; + padding-top: 24px; + + @include on-tablet { + padding-top: 40px; + } + + .customBackButton { + color: var(--back-btn-cart); + font-size: 12px; + transition: color 0.2s ease; + + &:hover { + color: var(--back-btn-cart-hover); + } + + .customIcon { + opacity: 1; + transition: filter 0.2s ease; + + &:hover { + filter: invert(29%) sepia(57%) saturate(548%) hue-rotate(270deg) brightness(93%) contrast(92%); + + } + } + } +} + + .productTitle { + font-family: Mont, sans-serif; + font-size: 22px; + font-weight: 800; + color: var(--header-text-color-active); + padding-top: 16px; + padding-bottom: 32px; + + @include on-tablet { + font-size: 32px; + padding-bottom: 40px; +} + } + +} + +.mainLoader { + position: absolute; +left: 45%; +top: 25%; +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 0000000000..d9059e78b4 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; +import { BreadCrumbs } from '../../components/BreadCrumbs'; +import { PhonesSlider } from '../../components/PhonesSlider/PhonesSlider'; +import { getNewProducts } from '../../services/getNewProducts'; +import { useAppDispatch, useAppSelector } from '../../hooks/hooks'; +import { setProducts } from '../../features/productsSlice'; +import { getProductsDetails } from '../../services/getProductDetails'; +import { ProductDetails } from '../../types/ProductDetails'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { ProductDetailsCard } from '../../components/ProductDetailsCard'; +import { Loader } from '../../components/Loader'; +import styles from './ProductDetailsPage.module.scss'; +import { useTranslation } from 'react-i18next'; +import { GoBackButton } from '../../components/GoBackButton'; +import { Product } from '../../types/Product'; + +export const ProductDetailsPage = () => { + const [isLoading, setIsLoading] = useState(false); + const [details, setDetails] = useState([]); + const { products } = useAppSelector(state => state.products); + const { t } = useTranslation(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const slash = true; + const { productId = '' } = useParams(); + const { pathname } = useLocation(); + const newId = productId.slice(1); + + const selectedProduct: ProductDetails | undefined = details.find( + item => item.id === newId, + ); + + const path = pathname.slice(1); + const category = path.split('/').slice(0, 1).join(); + + useEffect(() => { + setIsLoading(true); + + getNewProducts() + .then(response => { + dispatch(setProducts(response)); + }) + .catch( + () => + // eslint-disable-next-line max-len + 'Oops! Something went wrong while loading data. Please try again later.', + ) + .finally(() => { + setIsLoading(false); + }); + }, [dispatch]); + + useEffect(() => { + getProductsDetails(category).then(setDetails); + }, [category, newId]); + + useEffect(() => { + window.scrollTo({ top: 0 }); + }, [newId]); + + const getRandomProducts = (productsRandom: Product[]) => { + const shuffled = [...productsRandom].sort(() => 0.5 - Math.random()); + + return shuffled; + }; + + const randomProducts = getRandomProducts(products); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ navigate(-1)} + /> +
+

{selectedProduct?.name}

+ + +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 0000000000..6615089e5e --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductDetailsPage'; diff --git a/src/modules/ProductNotFoundPage/ProductNotFoundPage.module.scss b/src/modules/ProductNotFoundPage/ProductNotFoundPage.module.scss new file mode 100644 index 0000000000..9e55a2939e --- /dev/null +++ b/src/modules/ProductNotFoundPage/ProductNotFoundPage.module.scss @@ -0,0 +1,58 @@ +@import '../../styles/main'; + +.productNotFoundPage { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--c-background); + text-align: center; + + @include padding-content-inline-responsive; +} + +.title { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 32px; + padding-bottom: 1rem; + color: var(--primary); + white-space: pre-line; +} + +.message { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 1.5rem; + padding-top: 60px; + color: var(--primary); +} + + .image { + max-width: 400px; + width: 100%; + height: 100%; + } + + .back { + padding-top: 50px; + display: flex; + justify-content: center; + align-items: center; + + .backIcon { + opacity: 0.5; + } + + .link { + font-size: 1rem; + color: var(--header-text-color); + text-decoration: none; + + &:hover { + color: var(--header-text-color-active); + } + } + } diff --git a/src/modules/ProductNotFoundPage/ProductNotFoundPage.tsx b/src/modules/ProductNotFoundPage/ProductNotFoundPage.tsx new file mode 100644 index 0000000000..d704a8efda --- /dev/null +++ b/src/modules/ProductNotFoundPage/ProductNotFoundPage.tsx @@ -0,0 +1,39 @@ +import styles from './ProductNotFoundPage.module.scss'; +import ProductNotFoundImg from '../../images/product-not-found.png'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { GoBackButton } from '../../components/GoBackButton'; +import { useNavigate } from 'react-router-dom'; + +type Props = { + title?: string; +}; + +export const ProductNotFoundPage: React.FC = React.memo(({ title }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const onGoBackHome = () => { + navigate('/'); + }; + + return ( +
+

{t('notFoundPage.message')}

+ Product Not Found 404 +

{title}

+ + +
+ ); +}); + +ProductNotFoundPage.displayName = 'ProductNotFoundPage'; diff --git a/src/modules/ProductNotFoundPage/index.ts b/src/modules/ProductNotFoundPage/index.ts new file mode 100644 index 0000000000..d15aa85a32 --- /dev/null +++ b/src/modules/ProductNotFoundPage/index.ts @@ -0,0 +1 @@ +export * from './ProductNotFoundPage'; diff --git a/src/modules/TabletsPage/TabletsPage.module.scss b/src/modules/TabletsPage/TabletsPage.module.scss new file mode 100644 index 0000000000..dea4997f96 --- /dev/null +++ b/src/modules/TabletsPage/TabletsPage.module.scss @@ -0,0 +1,48 @@ +@import '../../styles/main'; + +.tabletsPage { + width: 100%; + height: 100%; + + .container { + @include padding-content-inline-responsive; + } + + .title { + font-family: Mont, sans-serif; + font-size: 48px; + font-weight: 800; + line-height: 56px; + color: var(--primary); + margin-top: 24px; + + @include on-tablet { + margin-top: 40px; + } + } + + .count { + margin-top: 8px; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: var(--header-text-color); + + } + + .dropdownContainer { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + flex-direction: row; + gap: 16px; + margin-top: 32px; + + @include on-tablet { + justify-content: left; + margin-top: 40px; + } + } +} diff --git a/src/modules/TabletsPage/TabletsPage.tsx b/src/modules/TabletsPage/TabletsPage.tsx new file mode 100644 index 0000000000..1b0bdd5ea9 --- /dev/null +++ b/src/modules/TabletsPage/TabletsPage.tsx @@ -0,0 +1,116 @@ +import { useTranslation } from 'react-i18next'; +import { BreadCrumbs } from '../../components/BreadCrumbs'; +import styles from './TabletsPage.module.scss'; +import { useAppSelector } from '../../hooks/hooks'; +import { useDispatch } from 'react-redux'; +import { useSearchParams } from 'react-router-dom'; +import { getNewProducts } from '../../services/getNewProducts'; +import { useEffect, useState } from 'react'; +import { setProducts } from '../../features/productsSlice'; +import { Categories } from '../../types/Categories'; +import { SortByDropdown } from '../../components/SortByDropdown'; +import { ItemsPerPageDropdown } from '../../components/ItemsPerPageDropdown'; +import { Product } from '../../types/Product'; +import { ProductGallery } from '../../components/ProductGallery'; +import { getItemsPerPage } from '../../services/getItemsPerPage'; +import { LoaderProductCard } from '../../components/LoaderProductCard'; +import { Pagination } from '../../components/Pagination/Pagination'; +import { ProductNotFoundPage } from '../ProductNotFoundPage'; + +export const TabletsPage = () => { + const [isLoading, setIsLoading] = useState(true); + const { t } = useTranslation(); + const { products } = useAppSelector(state => state.products); + const dispatch = useDispatch(); + const [searchParams] = useSearchParams(); + const sort = searchParams.get('sort'); + const query = searchParams.get('search'); + + const type = Categories.Tablets; + + const filteredProducts = products.filter(product => { + if (query) { + return ( + product.category === type && + product.name.toLowerCase().includes(query.toLowerCase()) + ); + } else { + return product.category === type; + } + }); + + const sortProducts = (item: Product[], sortType: string) => { + switch (sortType) { + case `${t('sortByDropdown.option.newest')}`: + return item.sort((a, b) => b.year - a.year); + + case `${t('sortByDropdown.option.alphabetically')}`: + return item.sort((a, b) => a.name.localeCompare(b.name)); + + case `${t('sortByDropdown.option.cheapest')}`: + return item.sort((a, b) => a.price - b.price); + + default: + return item; + } + }; + + const sortedProducts = sortProducts(filteredProducts, sort as string); + const page = searchParams.get('page') || 1; + const perPage = searchParams.get('perPage'); + + const itemsPerPage = getItemsPerPage(perPage, page, filteredProducts); + + useEffect(() => { + setIsLoading(true); + + getNewProducts() + .then(resolve => { + const newProducts = resolve.map(item => ({ ...item, quantity: 1 })); + + dispatch(setProducts(newProducts)); + }) + .catch( + () => + // eslint-disable-next-line max-len + 'Oops! Something went wrong while loading data. Please try again later.', + ) + .finally(() => { + setTimeout(() => { + setIsLoading(false); + }, 500); + }); + }, [dispatch, searchParams]); + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, [page]); + + if (!itemsPerPage.length && !isLoading) { + return ; + } + + return ( +
+
+ +

{t('tabletsPage.title')}

+

+ {t('tabletsPage.count', { count: filteredProducts.length })} +

+ +
+ + +
+ {isLoading ? ( + + ) : ( + + )} + + {perPage && } +
+
+ ); +}; diff --git a/src/modules/TabletsPage/index.ts b/src/modules/TabletsPage/index.ts new file mode 100644 index 0000000000..6988826db6 --- /dev/null +++ b/src/modules/TabletsPage/index.ts @@ -0,0 +1 @@ +export * from './TabletsPage'; diff --git a/src/services/getItemsPerPage.tsx b/src/services/getItemsPerPage.tsx new file mode 100644 index 0000000000..e60627e996 --- /dev/null +++ b/src/services/getItemsPerPage.tsx @@ -0,0 +1,25 @@ +import { Product } from '../types/Product'; + +export const getItemsPerPage = ( + perPage: string | null, + currPage: number | string, + products: Product[], +): Product[] => { + let paginatedProducts = [...products]; + + switch (perPage) { + case '4': + case '8': + case '16': + paginatedProducts = paginatedProducts.splice( + +perPage * (+currPage - 1), + +perPage, + ); + break; + + default: + break; + } + + return paginatedProducts; +}; diff --git a/src/services/getNewProducts.ts b/src/services/getNewProducts.ts new file mode 100644 index 0000000000..e5533594e0 --- /dev/null +++ b/src/services/getNewProducts.ts @@ -0,0 +1,6 @@ +import { Product } from '../types/Product'; +import { getData } from '../helpers/httpClient'; + +export function getNewProducts(): Promise { + return getData('/products.json'); +} diff --git a/src/services/getProductDetails.ts b/src/services/getProductDetails.ts new file mode 100644 index 0000000000..1654351f5c --- /dev/null +++ b/src/services/getProductDetails.ts @@ -0,0 +1,8 @@ +import { getData } from '../helpers/httpClient'; +import { ProductDetails } from '../types/ProductDetails'; + +export function getProductsDetails( + category: string, +): Promise { + return getData(`/${category}.json`); +} diff --git a/src/styles/main.scss b/src/styles/main.scss new file mode 100644 index 0000000000..9e79a343c8 --- /dev/null +++ b/src/styles/main.scss @@ -0,0 +1,6 @@ +@import '../styles/theme'; +@import '../utils/fonts'; +@import '../utils/reset'; +@import '../utils/variables'; +@import '../utils/mixins'; + diff --git a/src/styles/theme.scss b/src/styles/theme.scss new file mode 100644 index 0000000000..387a097cf5 --- /dev/null +++ b/src/styles/theme.scss @@ -0,0 +1,205 @@ +:root { + --c-background: #fff; + --header-text-color: #89939a; + --element-color: #e2e6e9; + --bg-hover-active: #e2e6e9; + --primary: #313237; + --footer-background: #fff; + --footer-border: #e2e6e9; + --footer-btn-bg-color: #fff; + --footer-border-btn: #b4bdc3; + --footer-text-color: #89939a; + --footer-link-hover: #313237; + --back-top: #89939a; + --footer-btn-hover: #313237; + --slider-btn-border: #b4bdc3; + --dropdown-bg: #fff; + --slider-bg: #fff; + --slider-border:#323542; + --bg-focus-list: #FAFBFC; + --card-bg-color: #fff; + --card-border: #E2E6E9; + --card-hover-border: #E2E6E9; + --box-shadow-hover: 0px 2px 16px 0px #0000001A; + --btn-add-cart-bg: #313237; + --fav-icon-border: #B4BDC3; + --fav-bg: #fff; + --btn-text-cart: #27AE60; + --btn-bg-hover: #313237; + --btn-pg-hover-border: #313237; + --btn-pg-border: #B4BDC3; + --pg-item: #000; + --pg-selected: #313237; + --pg-item-selected: #fff; + --fav-icon-count-border: #fff; + --fav-text-count: #fff; + --loader-color: #ffe135; + --back-btn-cart: #89939A; + --back-btn-cart-hover: #313237; + --checkout-btn-color: #fff; + --quantity-color: #000; + --border-cart-item: #E2E6E9; + --checkout-border: #E2E6E9; + --cart-btn-hover-border: #313237; + --overlay-bg-light: rgb(0 0 0 / 40%); + --overlay-bg-dark: rgb(255 255 255 / 10%); + --modal-bg-light: #fff; + --modal-bg-dark: #1e1e1e; + --modal-title-light: #333; + --modal-title-dark: #f0f0f0; + --modal-message-light: #666; + --modal-message-dark: #ccc; + --button-cancel-bg-light: #f0f0f0; + --button-cancel-bg-dark: #444; + --button-cancel-hover-light: #e0e0e0; + --button-cancel-hover-dark: #555; + --button-cancel-text-light: #555; + --button-cancel-text-dark: #eee; + --button-confirm-bg-light: #007bff; + --button-confirm-bg-dark: #4e8eff; + --button-confirm-hover-light: #0056b3; + --button-confirm-hover-dark: #3b6bcc; + --button-confirm-text-light: white; + --button-confirm-text-dark: white; + --modal-shadow-light: 0 4px 15px rgb(0 0 0 / 10%); + --modal-shadow-dark: 0 4px 15px rgb(0 0 0 / 30%); + --item-border: #C4C4C4; + --capacity-default-border: #B4BDC3; + --capacity-active-text: #FFF; + +}; + +.light { + --c-background: #fff; + --header-text-color-active: #313237; + --header-text-color: #89939a; + --element-color: #e2e6e9; + --bg-hover-active: #e2e6e9; + --primary: #313237; + --footer-background: #fff; + --footer-border: #e2e6e9; + --footer-btn-bg-color: #fff; + --footer-border-btn: #b4bdc3; + --footer-text-color: #89939a; + --footer-link-hover: #313237; + --back-top: #89939a; + --slider-btn-border: #b4bdc3; + --slider-border: #b4bdc3; + --slider-bg: #fff; + --dropdown-bg: #fff; + --border-focus: #313237; + --bg-focus-list: #FAFBFC; + --card-bg-color: #fff; + --card-border: #E2E6E9; + --card-hover-border: #E2E6E9; + --box-shadow-hover: 0px 2px 16px 0px #0000001A; + --btn-add-cart-bg: #313237; + --fav-icon-border: #B4BDC3; + --fav-bg: #fff; + --fav-border-hover: #313237; + --cart-added: #ffff; + --cart-added-border: #E2E6E9; + --btn-text-cart: #27AE60; + --btn-bg-hover: #313237; + --btn-hover-shadow: 0px 3px 13px 0px #17203166; + --btn-pg-border: #B4BDC3; + --btn-pg-hover-border: #313237; + --pg-item: #000; + --pg-item-border: #E2E6E9; + --pg-selected: #313237; + --pg-item-selected: #fff; + --border-pg-selected: #313237; + --fav-icon-count-border: #fff; + --fav-text-count: #fff; + --loader-color: #f5c26b; + --back-btn-cart: #89939A; + --back-btn-cart-hover: #313237; + --checkout-btn-color: #fff; + --quantity-color: #000; + --border-cart-item: #E2E6E9; + --checkout-border: #E2E6E9; + --cart-btn-hover-border: #313237; + --overlay-bg: var(--overlay-bg-light); + --modal-bg: var(--modal-bg-light); + --modal-title: var(--modal-title-light); + --modal-message: var(--modal-message-light); + --button-cancel-bg: var(--button-cancel-bg-light); + --button-cancel-hover: var(--button-cancel-hover-light); + --button-cancel-text: var(--button-cancel-text-light); + --button-confirm-bg: #313237; + --button-confirm-hover: #414247; + --button-confirm-text: var(--button-confirm-text-light); + --modal-shadow: var(--modal-shadow-light); + --item-border: #C4C4C4; + --capacity-default-border: #B4BDC3; + --capacity-active-text: #FFF; +}; + +.dark { + --c-background: #0f1121; + --header-text-color-active: #f1f2f9; + --header-text-color: #75767f; + --element-color: #323542; + --bg-hover-active: #3b3e4a; + --primary: #f1f2f9; + --footer-background: #0f1121; + --footer-border: #3b3e4a; + --footer-btn-bg-color: #323542; + --footer-border-btn: #0f1121; + --footer-text-color: #f1f2f9; + --footer-link-hover: #89939a; + --back-top: #75767f; + --footer-button-bg-hover: #4a4d58; + --slider-btn-border: #323542; + --slider-bg: #323542; + --slider-border: #323542; + --dropdown-bg: #323542; + --border-focus: #905BFF; + --bg-focus-list: #323542; + --card-bg-color: #161827; + --card-border: #161827; + --card-hover-border: #323542; + --box-shadow-hover: 0px 2px 15px 0px #0000001A; + --btn-add-cart-bg: #905BFF; + --btn-bg-hover: #A378FF; + --fav-bg: #323542; + --fav-icon-border: #323542; + --fav-hover: #4A4D58; + --fav-active: #161827; + --btn-text-cart: #F1F2F9; + --btn-pg-border: #323542; + --btn-pg-bg-hover: #4A4D58; + --btn-pg-hover-border:#3B3E4A; + --pg-item: #F1F2F9; + --pg-bg-color: #161827; + --pg-bg-hover: #3B3E4A; + --pg-selected: #905BFF; + --pg-item-selected: #F1F2F9; + --fav-icon-count-border: #fff; + --fav-text-count: #F1F2F9; + --loader-color: #ff9800; + --back-btn-cart: #F1F2F9; + --back-btn-cart-hover: #905BFF; + --checkout-btn-color: #F1F2F9; + --quantity-color: #F1F2F9; + --border-cart-item: #161827; + --checkout-border: #3B3E4A; + --cart-btn-hover-border: #4A4D58; + --cart-added: #323542; + --overlay-bg: var(--overlay-bg-dark); + --modal-bg: var(--modal-bg-dark); + --modal-title: var(--modal-title-dark); + --modal-message: var(--modal-message-dark); + --button-cancel-bg: var(--button-cancel-bg-dark); + --button-cancel-hover: var(--button-cancel-hover-dark); + --button-cancel-text: var(--button-cancel-text-dark); + --button-confirm-bg: #905BFF; + --button-confirm-hover: #A378FF; + --button-confirm-text: var(--button-confirm-text-dark); + --modal-shadow: var(--modal-shadow-dark); + --item-border: #3B3E4A; + --capacity-default-border: #4A4D58; + --capacity-active-text: #0F1121; + + +}; diff --git a/src/types/Categories.ts b/src/types/Categories.ts new file mode 100644 index 0000000000..6e1033f878 --- /dev/null +++ b/src/types/Categories.ts @@ -0,0 +1,5 @@ +export enum Categories { + Phones = 'phones', + Tablets = 'tablets', + Accessories = 'accessories', +} diff --git a/src/types/Language.ts b/src/types/Language.ts new file mode 100644 index 0000000000..5f9a1c729c --- /dev/null +++ b/src/types/Language.ts @@ -0,0 +1,4 @@ +export enum LanguageType { + EN = 'en', + UK = 'uk', +} diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 0000000000..d14c6804cf --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,17 @@ +import { Categories } from './Categories'; + +export interface Product { + id: number; + namespaceId: string; + category: Categories; + 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 0000000000..e299f637b3 --- /dev/null +++ b/src/types/ProductDetails.ts @@ -0,0 +1,28 @@ +import { ProductColors } from '../constants/productColors'; + +export type ProductDesription = { + title: string; + text: string[]; +}; + +export interface ProductDetails { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: (keyof ProductColors)[]; + color: keyof ProductColors; + images: string[]; + description: ProductDesription[]; + screen: string; + resolution: string; + processor: string; + ram: string; + cell: string[]; + camera: string; + zoom: string; +} diff --git a/src/types/UpdatedProduct.ts b/src/types/UpdatedProduct.ts new file mode 100644 index 0000000000..c4bb3adae8 --- /dev/null +++ b/src/types/UpdatedProduct.ts @@ -0,0 +1,17 @@ +import { Categories } from './Categories'; + +export type UpdatedProduct = { + id: number; + category: Categories; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; + quantity: number; +}; diff --git a/src/utils/_fonts.scss b/src/utils/_fonts.scss new file mode 100644 index 0000000000..9e98fd7c7b --- /dev/null +++ b/src/utils/_fonts.scss @@ -0,0 +1,26 @@ +// fonts.scss + +@font-face { + font-family: Mont; + src: local('Mont Regular'), url('/fonts/Mont-Regular.otf') format('opentype'); + font-weight: 500; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: local('Mont SemiBold'), url('/fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: local('Mont Bold'), url('/fonts/Mont-Bold.otf') format('opentype'); + font-weight: 800; + font-style: normal; +} + +$mont-regular: mont, sans-serif; +$mont-semiBold: mont, sans-serif; +$mont-bold: mont, sans-serif; diff --git a/src/utils/_reset.scss b/src/utils/_reset.scss new file mode 100644 index 0000000000..8fdf79a3dd --- /dev/null +++ b/src/utils/_reset.scss @@ -0,0 +1,31 @@ +iframe { + display: none; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + + html { + width: 100%; + min-width: 320px; + min-height: 100vh; + scroll-behavior: smooth; +} + +ul, +li { + list-style: none; +} + +a { + text-decoration: none; +} + +button { + border-style: none; + cursor: pointer; +} + diff --git a/src/utils/_variables.scss b/src/utils/_variables.scss new file mode 100644 index 0000000000..dda89b0ff3 --- /dev/null +++ b/src/utils/_variables.scss @@ -0,0 +1,3 @@ +$black: #000; +$white: #fff; +$tomato-red: #EB5757; diff --git a/src/utils/mixins.scss b/src/utils/mixins.scss new file mode 100644 index 0000000000..7867508adf --- /dev/null +++ b/src/utils/mixins.scss @@ -0,0 +1,50 @@ +@mixin on-tablet { + @media (width >= 640px) { + @content; + } +} + +@mixin on-desktop { + @media (width >= 1200px) { + @content; + } +} + +@mixin page-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + + @include on-tablet { + grid-template-columns: repeat(12, 1fr); + } + + @include on-desktop { + grid-template-columns: repeat(24, 1fr); + } +} + +@mixin padding-content-inline-responsive() { + padding-inline: 16px; + + @media (width >= 640px) { + padding-inline: 24px; + } + + @media (width >= 1200px) { + padding-inline: 152px; + } +} + + +@mixin padding-inline-responsive() { + padding-inline: 32px; + + @media (width >= 640px) { + padding-inline: 24px; + } + + @media (width >= 1200px) { + padding-inline: 152px; + } +}