diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b51149cf57d..533d68f7529 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -90,7 +90,7 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-unused-vars': ['error'], - '@typescript-eslint/indent': ['error', 2], + '@typescript-eslint/indent': "off", '@typescript-eslint/ban-types': ['error', { extendDefaults: true, types: { diff --git a/.github/deploy.yml b/.github/deploy.yml new file mode 100644 index 00000000000..4fb2df52e2e --- /dev/null +++ b/.github/deploy.yml @@ -0,0 +1,23 @@ +name: Github Page Deploy Workflow + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: "12.x" + - run: npm ci + - run: npm run build + - name: Deploy + uses: crazy-max/ghaction-github-pages@v1 + with: + target_branch: gh-pages + build_dir: build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 3e1213ef5f7..3305446f46d 100644 --- a/README.md +++ b/README.md @@ -141,3 +141,4 @@ Show `input:search` in the header when a page contains a `ProductList` to search 1. Save the `Search` value in the URL as a `?query=value` to apply on page load. 2. Show `There are no phones/tablets/accessories/products matching the query` instead of `ProductList` when needed. 3. Add `debounce` to the search field. +- Replace `` with your Github username in the [DEMO LINK](https://maximtsyrulnyk.github.io/react_phone-catalog/ ) and add it to the PR description. diff --git a/index.html b/index.html index 095fb3a4537..094744396b4 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ Vite + React + TS +
diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..48fa71d2b69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -40,6 +40,7 @@ "eslint-plugin-react": "^7.34.4", "eslint-plugin-react-hooks": "^4.6.2", "gh-pages": "^6.1.1", + "husky": "^9.1.7", "mochawesome": "^7.1.3", "mochawesome-merge": "^4.3.0", "mochawesome-report-generator": "^6.2.0", @@ -1184,10 +1185,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -5907,6 +5909,22 @@ "node": ">=8.12.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index ae251685c8b..9e052f0a9c7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -36,6 +36,7 @@ "eslint-plugin-react": "^7.34.4", "eslint-plugin-react-hooks": "^4.6.2", "gh-pages": "^6.1.1", + "husky": "^9.1.7", "mochawesome": "^7.1.3", "mochawesome-merge": "^4.3.0", "mochawesome-report-generator": "^6.2.0", @@ -79,5 +80,20 @@ "_comment": "Add `cypressComponents: true` to enable component tests", "cypress": true } + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "*.ts": [ + "eslint --fix", + "prettier --write" + ], + "*.tsx": [ + "eslint --fix", + "prettier --write" + ] } } diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 00000000000..6d6b888d3b3 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/img/banner-1.jpg b/public/img/banner-1.jpg new file mode 100644 index 00000000000..f839ca47e7a Binary files /dev/null and b/public/img/banner-1.jpg differ diff --git a/public/img/banner-2.jpg b/public/img/banner-2.jpg new file mode 100644 index 00000000000..573b2bdbaaa Binary files /dev/null and b/public/img/banner-2.jpg differ diff --git a/public/img/banner-3.jpg b/public/img/banner-3.jpg new file mode 100644 index 00000000000..828d970d3bd Binary files /dev/null and b/public/img/banner-3.jpg differ diff --git a/public/img/banner-4.jpg b/public/img/banner-4.jpg new file mode 100644 index 00000000000..b6cb53c845e Binary files /dev/null and b/public/img/banner-4.jpg differ diff --git a/src/App.scss b/src/App.scss index 71bc413aade..f116d9a7451 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,19 @@ -// not empty +@import './styles/reset'; +@import './styles/fonts'; +@import './styles/variables'; +@import './styles/mixins'; +@import './styles/typography'; + + +.App { + display: flex; + flex-direction: column; +} + +html, +body { + background-color: var(--page-bg); + color: var(--text-main); + + transition: background-color 0.3s ease, color 0.3s ease; +} diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..53332fa2ab7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,71 @@ +import { HashRouter, Route, Routes } from 'react-router-dom'; import './App.scss'; +import { HomePage } from './modules/HomePage'; +import { NotFoundPage } from './modules/NotFoundPage'; +import { Layout } from './components/Layout'; +import { ThemeProvider } from './context/ThemeContext'; +import { CartPage } from './modules/CartPage'; +import { CartProvider } from './context/CartContext'; +import { FavouritesProvider } from './context/FavoritesContext'; +import { FavouritesPage } from './modules/FavouritesPage'; +import { CategoryPage } from './modules/CategoryPage'; +import { CategoriesType, PathType } from './types/Types'; +import { ProductDetailsPage } from './modules/ProductDetailsPage'; export const App = () => (
-

Product Catalog

+ + + + + + }> + } /> + + + } + > + + } + > + + } + > + + } + > + }> + } + /> + + }> + + + + + +
); diff --git a/src/api/fetchClient.ts b/src/api/fetchClient.ts new file mode 100644 index 00000000000..50a4c5901e7 --- /dev/null +++ b/src/api/fetchClient.ts @@ -0,0 +1,11 @@ +const BASE_URL = 'api'; + +export async function getData(url: string): Promise { + const response = await fetch(`${BASE_URL}${url}`); + + if (!response.ok) { + throw new Error(`Error fetching data: ${response.statusText}`); + } + + return response.json(); +} diff --git a/src/api/products.ts b/src/api/products.ts new file mode 100644 index 00000000000..27a2e70f82a --- /dev/null +++ b/src/api/products.ts @@ -0,0 +1,67 @@ +import { + CatalogProducts, + CategoriesType, + PathType, + Product, +} from '../types/Types'; +import { getData } from './fetchClient'; + +export const getProducts = () => { + return getData(`${PathType.PRODUCTS}.json`); +}; + +export const getPhones = async () => { + const products = await getProducts(); + + return products.filter(product => product.category === CategoriesType.PHONES); +}; + +export const getTablets = async () => { + const products = await getProducts(); + + return products.filter( + product => product.category === CategoriesType.TABLETS, + ); +}; + +export const getAccessories = async () => { + const products = await getProducts(); + + return products.filter( + product => product.category === CategoriesType.ACCESSORIES, + ); +}; + +export const getProductById = async (category: string, itemId: string) => { + let path = ''; + + switch (category) { + case CategoriesType.PHONES: + path = PathType.PHONES; + break; + case CategoriesType.TABLETS: + path = PathType.TABLETS; + break; + case CategoriesType.ACCESSORIES: + path = PathType.ACCESSORIES; + break; + default: + throw new Error('Unknown category'); + } + + const products = await getData(`${path}.json`); + + const product = products.find(item => item.id === itemId); + + if (!product) { + throw new Error('Product not found'); + } + + return product; +}; + +export const getSuggestedProducts = async () => { + const products = await getProducts(); + + return [...products].sort(() => Math.random() - 0.5).slice(0, 12); +}; diff --git a/src/assets/logo/logo-d.svg b/src/assets/logo/logo-d.svg new file mode 100644 index 00000000000..bef8c383ebd --- /dev/null +++ b/src/assets/logo/logo-d.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/logo/logo-l.svg b/src/assets/logo/logo-l.svg new file mode 100644 index 00000000000..90f3b4811fc --- /dev/null +++ b/src/assets/logo/logo-l.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Breadcrumbs/Breadcrumbs.module.scss b/src/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 00000000000..82f12554df4 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,63 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.breadcrumbs { + margin-top: 24px; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 8px; + grid-column: 1 / -1; + + @include respond-to('tablet') { + margin-bottom: 40px; + } + + &__home { + display: flex; + align-items: center; + flex-shrink: 0; + color: var(--text-main); + + @include hover-transition(color); + + @include hover { + color: var(--icon-color); + } + } + + &__link { + @include small-text; + + font-weight: 600; + color: var(--text-main); + text-decoration: none; + + @include hover-transition(color); + + @include hover { + color: var(--text-main); + } + + &::first-letter { + text-transform: uppercase; + } + } + + &__separator { + display: flex; + align-items: center; + flex-shrink: 0; + color: var(--icon-color); + } + + &__link, + &__current { + @include small-text; + + font-weight: 600; + color: var(--text-secondary); + line-height: 1; + } +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..e1264a95911 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowRightIcon } from '../ui/ArrowRightIcon'; +import { HomeIcon } from '../ui/HomeIcon'; +import styles from './Breadcrumbs.module.scss'; + +interface BreadcrumbsProps { + category?: string | undefined; + productName?: string | undefined; +} + +const categoryLabels: Record = { + phones: 'Phones', + tablets: 'Tablets', + accessories: 'Accessories', + favorites: 'Favourites', +}; + +export const Breadcrumbs: React.FC = ({ + category, + productName, +}) => { + return ( +
+ + + + + {category && ( + <> + + + + + {categoryLabels[category] || category} + + + )} + + {productName && ( + <> + + + + {productName} + + )} +
+ ); +}; diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts new file mode 100644 index 00000000000..28140a257ff --- /dev/null +++ b/src/components/Breadcrumbs/index.ts @@ -0,0 +1 @@ +export { Breadcrumbs } from './Breadcrumbs'; diff --git a/src/components/BurgerMenu/BurgerMenu.module.scss b/src/components/BurgerMenu/BurgerMenu.module.scss new file mode 100644 index 00000000000..ee8edac1845 --- /dev/null +++ b/src/components/BurgerMenu/BurgerMenu.module.scss @@ -0,0 +1,122 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.burger { + position: fixed; + inset: 48px 0 0; + background-color: var(--page-bg); + z-index: 100; + display: flex; + flex-direction: column; + + transform: translateX(100%); + opacity: 0; + visibility: hidden; + + transition: + transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.4s linear, + visibility 0.4s; + + &--open { + transform: translateX(0); + opacity: 1; + visibility: visible; + } + + @include respond-to('tablet') { + display: none; + } + + &__content { + flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + padding-top: 24px; + } + + &__nav { + width: 100%; + } + + &__list { + flex-direction: column; + align-items: center; + gap: 16px; + height: auto; + } + + &__link { + display: inline-block; + height: auto; + padding-bottom: 8px; + } + + &__footer { + display: flex; + height: 64px; + border-top: 1px solid var(--border); + margin-top: auto; + } + + &__action { + position: relative; + flex: 1; + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + height: 100%; + color: var(--btn-text); + + &:first-child { + border-right: 1px solid var(--border); + } + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 3px; + background-color: var(--accent); + + transform: scaleX(0); + + transform-origin: left; + + @include hover-transition; + } + + &.active { + color: var(--btn-text); + + &::after { + transform: scaleX(1); + } + } + } + + &__iconContainer { + position: relative; + } + + &__counter { + box-sizing: border-box; + position: absolute; + top: -4px; + right: -8px; + width: 14px; + height: 14px; + + background: $cl-red; + border-radius: 50%; + + font-size: 9px; + text-align: center; + color: $lt-white; + } +} diff --git a/src/components/BurgerMenu/BurgerMenu.tsx b/src/components/BurgerMenu/BurgerMenu.tsx new file mode 100644 index 00000000000..3f8087ae96c --- /dev/null +++ b/src/components/BurgerMenu/BurgerMenu.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import { Nav } from '../Nav'; +import { CartIcon } from '../ui/CartIcon'; +import { FavouriteIcon } from '../ui/FavouriteIcon'; +import { NavLink } from 'react-router-dom'; +import { PathType } from '../../types/Types'; +import { useCart } from '../../context/CartContext'; +import { useFavourites } from '../../context/FavoritesContext'; +import styles from './BurgerMenu.module.scss'; + +interface BurgerMenuProps { + onClose: () => void; + isOpen: boolean; +} + +export const BurgerMenu: React.FC = ({ onClose, isOpen }) => { + const { favourites } = useFavourites(); + const { cartItems } = useCart(); + + const cartItemsCount = cartItems.reduce( + (total, item) => total + item.quantity, + 0, + ); + + const favouritesCount = favourites.length; + + const getActiveLinkClass = ({ isActive }: { isActive: boolean }) => + [styles.burger__action, isActive ? styles.active : ''] + .filter(Boolean) + .join(' '); + + return ( +
+
+
+
+ +
+ + {favouritesCount > 0 && ( + {favouritesCount} + )} +
+
+ +
+ + {cartItemsCount > 0 && ( + {cartItemsCount} + )} +
+
+
+
+ ); +}; diff --git a/src/components/BurgerMenu/index.ts b/src/components/BurgerMenu/index.ts new file mode 100644 index 00000000000..484c71bfd03 --- /dev/null +++ b/src/components/BurgerMenu/index.ts @@ -0,0 +1 @@ +export { BurgerMenu } from './BurgerMenu'; diff --git a/src/components/Catalog/Catalog.module.scss b/src/components/Catalog/Catalog.module.scss new file mode 100644 index 00000000000..20248c95ea2 --- /dev/null +++ b/src/components/Catalog/Catalog.module.scss @@ -0,0 +1,104 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.catalog { + margin-top: 24px; + row-gap: 24px; + + @include page-container; + @include page-grid; + + &__header, + &__controls, + &__list, + &__loader, + &__error, + &__pagination, + &__empty { + grid-column: 1 / -1; + } + + &__header { + margin-bottom: 8px; + } + + &__title { + margin-bottom: 4px; + } + + &__count { + color: var(--text-secondary); + + @include body-text; + } + + &__controls { + grid-column: 1 / -1; + margin-bottom: 24px; + gap: 16px; + + @include page-grid; + } + + &__dropdown { + grid-column: span 2; + + @include respond-to('tablet') { + &:first-child { + grid-column: span 4; + } + &:last-child { + grid-column: span 3; + } + } + + @include respond-to('desktop') { + &:first-child { + grid-column: span 4; + } + &:last-child { + grid-column: span 3; + } + } + } + + &__pagination, + &__loader, + &__error { + display: flex; + justify-content: center; + margin-top: 24px; + + @include respond-to('desktop') { + margin-top: 40px; + } + } + + &__emptyContent { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + @include respond-to('tablet') { + flex-direction: row; + justify-content: space-around; + text-align: left; + } + } + + &__emptyText { + color: var(--text-main); + + @include h2; + } + + &__emptyImage { + width: 100%; + max-width: 300px; + height: auto; + object-fit: contain; + } +} diff --git a/src/components/Catalog/Catalog.tsx b/src/components/Catalog/Catalog.tsx new file mode 100644 index 00000000000..eb39ff1980b --- /dev/null +++ b/src/components/Catalog/Catalog.tsx @@ -0,0 +1,150 @@ +import React, { useMemo } from 'react'; + +import { CatalogProducts, PerPageType } from '../../types/Types'; +import { Loader } from '../Loader'; +import { ProductsList } from '../ProductsList'; +import { Pagination } from '../Pagination'; +import { useCatalogParams } from '../../hooks/useCatalogParams'; +import { getPaginatedProducts, getSortedProducts } from '../../utils/helpers'; +import { Dropdown } from '../ui/Dropdown'; +import { PER_PAGE_OPTIONS, SORT_OPTIONS } from '../../constants'; +import { Breadcrumbs } from '../Breadcrumbs'; +import noProductsMatching from '../../../public/img/product-not-found.png'; +import styles from './Catalog.module.scss'; + +const EMPTY_MESSAGES: Record = { + 'Mobile phones': 'There are no phones yet', + Tablets: 'There are no tablets yet', + Accessories: 'There are no accessories yet', +}; + +interface CatalogProps { + title: string; + products: CatalogProducts[]; + errorMessage: string; + isLoading: boolean; + onReload: () => void; + category?: string; +} + +export const Catalog: React.FC = ({ + title, + products, + isLoading, + errorMessage, + onReload, + category, +}) => { + const { + sort, + page, + perPage, + query, + handleSortChange, + handlePerPageChange, + handlePageChange, + } = useCatalogParams(); + + const filteredProducts = useMemo(() => { + if (!query) { + return products; + } + + const lowerCaseQuery = query.toLowerCase(); + + return products.filter(product => + product.name.toLowerCase().includes(lowerCaseQuery), + ); + }, [products, query]); + + const totalPages = + perPage === PerPageType.ALL + ? 0 + : Math.ceil(filteredProducts.length / Number(perPage)); + + const visibleProducts = useMemo(() => { + const sortedProducts = getSortedProducts(filteredProducts, sort); + + return getPaginatedProducts(sortedProducts, page, perPage); + }, [sort, page, perPage, filteredProducts]); + + const emptyStateMessage = + EMPTY_MESSAGES[title] || 'There are no products yet'; + const noResultsMessage = `There are no ${title.toLowerCase()} matching ${query}`; + + return ( +
+ {isLoading && } + + {errorMessage && ( +
+ {errorMessage}{' '} + +
+ )} + + {!isLoading && !errorMessage && ( + <> +
+ {category && } +

{title}

+ + {filteredProducts.length} models + +
+ + {products.length === 0 ? ( +
+

{emptyStateMessage}

+
+ ) : filteredProducts.length === 0 ? ( +
+

{noResultsMessage}

+ No products matching +
+ ) : ( + <> +
+ + + +
+ +
+ +
+ + {totalPages > 1 && ( +
+ +
+ )} + + )} + + )} +
+ ); +}; diff --git a/src/components/Catalog/index.ts b/src/components/Catalog/index.ts new file mode 100644 index 00000000000..508d78151d5 --- /dev/null +++ b/src/components/Catalog/index.ts @@ -0,0 +1 @@ +export { Catalog } from './Catalog'; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..c108a7692ae --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,118 @@ +@import '../../styles/variables'; +@import '../../styles/typography'; +@import '../../styles/mixins'; + +.footer { + width: 100%; + padding: 32px; + border-top: 1px solid var(--border); + background-color: var(--page-bg); + + &__content { + display: flex; + flex-direction: column; + gap: 32px; + + @include page-container; + + @include respond-to('tablet') { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + } + + &__logo { + width: 89px; + height: 32px; + display: block; + } + + &__list { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + padding: 0; + + @include respond-to('tablet') { + flex-direction: row; + align-items: center; + gap: 13.5px; + } + + @include respond-to('desktop') { + gap: 107px; + } + } + + &__link { + display: block; + color: var(--text-secondary); + text-decoration: none; + + @include uppercase-label; + @include hover-transition(color); + + @include hover { + color: var(--text-main); + } + } + + &__backText { + color: var(--text-secondary); + + @include small-text; + @include hover-transition(color); + + @include hover { + color: var(--text-main); + } + } + + &__backIcon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + box-sizing: border-box; + padding: 8px; + + @include hover-transition(border-color); + + &:hover { + border-color: var(--text-main); + } + } + + &__backButton { + background: transparent; + border: none; + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + width: fit-content; + margin: 0 auto; + padding: 0; + gap: 16px; + height: auto; + + @include respond-to('tablet') { + margin: 0; + } + + &:hover { + .footer__backIcon { + border-color: var(--text-main); + } + .footer__backText { + color: var(--text-main); + } + } + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..b64b9978bb7 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Logo } from '../Logo'; +import { ArrowUpIcon } from '../ui/ArrowUpIcon'; +import styles from './Footer.module.scss'; +import { + CONTACTS_ORIGIN_REPO, + GIT_HUB_REPO, + RIGHTS_PATH, +} from '../../constants'; + +export const Footer: React.FC = () => { + const handleBackToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 00000000000..65e2506faf5 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export { Footer } from './Footer'; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..519d3cf419b --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,170 @@ +@import '../../styles/variables'; +@import '../../styles/typography'; +@import '../../styles/mixins'; + +.header { + width: 100%; + position: sticky; + top: 0; + z-index: 100; + + &__top { + display: flex; + justify-content: space-between; + align-items: center; + height: 48px; + width: 100%; + + background-color: var(--page-bg); + border-bottom: 1px solid var(--border); + + @include respond-to('desktop') { + height: 64px; + } + } + + &__logo { + display: block; + width: 64px; + height: 22px; + } + + &__link { + display: flex; + align-items: center; + height: 100%; + padding: 0 16px; + text-decoration: none; + flex-shrink: 0; + + @include hover-image-scale; + + @include respond-to('desktop') { + padding-left: 32px; + } + } + + &__nav { + display: none; + + @include respond-to('tablet') { + display: flex; + margin-right: auto; + padding-left: 16px; + } + + @include respond-to('desktop') { + padding-left: 24px; + } + } + + &__actions { + display: none; + flex-shrink: 0; + + @include respond-to('tablet') { + display: flex; + height: 100%; + } + } + + &__iconLink { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 48px; + height: 100%; + + border-left: 1px solid var(--border); + color: var(--text-main); + box-sizing: border-box; + + @include respond-to('desktop') { + width: 64px; + } + + &::after { + content: ''; + position: absolute; + bottom: 0; + width: 100%; + height: 3px; + background-color: var(--accent); + + transform: scaleX(0); + transform-origin: left; + + @include hover-transition; + } + + &.active { + color: var(--text-main); + + &::after { + transform: scaleX(1); + } + } + } + + &__icon { + @include hover-image-scale; + } + + &__menu { + width: 48px; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + + background: transparent; + border: none; + border-left: 1px solid var(--border); + color: var(--text-main); + cursor: pointer; + padding: 0; + + @include respond-to('tablet') { + display: none; + } + } + + &__themeToggleMobile { + width: 48px; + height: 100%; + border-left: 1px solid var(--border); + box-sizing: border-box; + margin-left: auto; + + @include respond-to('desktop') { + width: 64px; + } + } + + &__top &__themeToggleMobile { + @include respond-to('tablet') { + display: none; + } + } + + &__iconContainer { + position: relative; + } + + &__counter { + box-sizing: border-box; + position: absolute; + top: -4px; + right: -8px; + width: 14px; + height: 14px; + + background: $cl-red; + border-radius: 50%; + + font-size: 9px; + text-align: center; + color: $lt-white; + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..d5ed645153d --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; +import { Link, NavLink } from 'react-router-dom'; +import { useLockBodyScroll } from '../../hooks/useLockBodyScroll'; +import { useMenuCloseOnResize } from '../../hooks/useMenuCloseOnResize'; +import { Logo } from '../Logo'; +import { Nav } from '../Nav'; +import { FavouriteIcon } from '../ui/FavouriteIcon'; +import { CartIcon } from '../ui/CartIcon'; +import { BurgerMenu } from '../BurgerMenu'; +import { CloseIcon } from '../ui/CloseIcon'; +import { MenuIcon } from '../ui/MenuIcon'; +import { ThemeToggler } from '../ui/ThemeToggler'; +import { PathType } from '../../types/Types'; +import { Search } from '../Search'; +import { useFavourites } from '../../context/FavoritesContext'; +import { useCart } from '../../context/CartContext'; +import styles from './Header.module.scss'; + +export const Header: React.FC = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const toggleMenu = () => setIsMenuOpen(prev => !prev); + const closeMenu = () => setIsMenuOpen(false); + const { favourites } = useFavourites(); + const { cartItems } = useCart(); + + const cartItemsCount = cartItems.reduce( + (total, item) => total + item.quantity, + 0, + ); + + const favouritesCount = favourites.length; + + const getIconLinkClass = ({ isActive }: { isActive: boolean }) => + [styles.header__iconLink, isActive ? styles.active : ''] + .filter(Boolean) + .join(' '); + + useLockBodyScroll(isMenuOpen); + useMenuCloseOnResize(isMenuOpen, closeMenu); + + return ( + <> +
+
+ + + + +
+
+ + + ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 00000000000..29429dc97e8 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/src/components/HotPrices/HotPrices.tsx b/src/components/HotPrices/HotPrices.tsx new file mode 100644 index 00000000000..9613fecd237 --- /dev/null +++ b/src/components/HotPrices/HotPrices.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { ProductsSlider } from '../ProductsSlider'; +import { CatalogProducts } from '../../types/Types'; + +interface HotPricesProps { + title: string; + products: CatalogProducts[]; +} + +export const HotPrices: React.FC = ({ title, products }) => { + return ; +}; diff --git a/src/components/HotPrices/index.ts b/src/components/HotPrices/index.ts new file mode 100644 index 00000000000..60baab0ee1c --- /dev/null +++ b/src/components/HotPrices/index.ts @@ -0,0 +1 @@ +export { HotPrices } from './HotPrices'; diff --git a/src/components/Layout/Layout.module.scss b/src/components/Layout/Layout.module.scss new file mode 100644 index 00000000000..a7b8cf6e2dc --- /dev/null +++ b/src/components/Layout/Layout.module.scss @@ -0,0 +1,11 @@ +.main { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.layout { + display: flex; + flex-direction: column; + min-height: 100vh; +} diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx new file mode 100644 index 00000000000..e71e0c9be03 --- /dev/null +++ b/src/components/Layout/Layout.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { Header } from '../Header'; +import { Footer } from '../Footer'; + +import styles from './Layout.module.scss'; + +export const Layout: React.FC = () => { + return ( +
+
+
+ +
+
+
+ ); +}; diff --git a/src/components/Layout/index.ts b/src/components/Layout/index.ts new file mode 100644 index 00000000000..9fc685e2aab --- /dev/null +++ b/src/components/Layout/index.ts @@ -0,0 +1 @@ +export { Layout } from './Layout'; diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 00000000000..8a8802c3815 --- /dev/null +++ b/src/components/Loader/Loader.module.scss @@ -0,0 +1,26 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.loader { + margin: 20px; + width: 48px; + height: 48px; + + border: 5px solid var(--border-light, #f3f3f3); + border-top: 5px solid var(--accent, #3498db); + border-radius: 50%; + + display: inline-block; + box-sizing: border-box; + animation: spin 3s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx new file mode 100644 index 00000000000..b7209714c58 --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import styles from './Loader.module.scss'; + +export const Loader: React.FC = () => { + return ( +
+ ); +}; diff --git a/src/components/Loader/index.ts b/src/components/Loader/index.ts new file mode 100644 index 00000000000..d7027885251 --- /dev/null +++ b/src/components/Loader/index.ts @@ -0,0 +1 @@ +export { Loader } from './Loader'; diff --git a/src/components/Logo/Logo.tsx b/src/components/Logo/Logo.tsx new file mode 100644 index 00000000000..858cada0aab --- /dev/null +++ b/src/components/Logo/Logo.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import logoLight from '../../assets/logo/logo-l.svg'; +import logoDark from '../../assets/logo/logo-d.svg'; +import { useTheme } from '../../context/ThemeContext'; + +interface LogoProps { + className: string; +} + +export const Logo: React.FC = ({ className }) => { + const { theme } = useTheme(); + + return ( + Logo + ); +}; diff --git a/src/components/Logo/index.ts b/src/components/Logo/index.ts new file mode 100644 index 00000000000..33af5053383 --- /dev/null +++ b/src/components/Logo/index.ts @@ -0,0 +1 @@ +export { Logo } from './Logo'; diff --git a/src/components/Nav/Nav.module.scss b/src/components/Nav/Nav.module.scss new file mode 100644 index 00000000000..88f462d082f --- /dev/null +++ b/src/components/Nav/Nav.module.scss @@ -0,0 +1,61 @@ +@import '../../styles/variables'; +@import '../../styles/typography'; +@import '../../styles/mixins'; + +.nav { + &__list { + display: flex; + align-items: center; + gap: 32px; + height: 100%; + padding: 0; + + @include respond-to('desktop') { + gap: 64px; + } + } + + &__link { + @include uppercase-label; + @include hover-transition(color); + + position: relative; + display: flex; + align-items: center; + height: 48px; + color: var(--text-secondary); + text-decoration: none; + box-sizing: border-box; + + @include respond-to('desktop') { + height: 64px; + } + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 3px; + background-color: var(--accent); + + transform: scaleX(0); + transform-origin: left; + + @include hover-transition; + } + + &.active { + color: var(--text-main); + + &::after { + transform: scaleX(1); + } + } + + @include hover { + color: var(--text-main); + } + } +} diff --git a/src/components/Nav/Nav.tsx b/src/components/Nav/Nav.tsx new file mode 100644 index 00000000000..f1441051238 --- /dev/null +++ b/src/components/Nav/Nav.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import { PathType } from '../../types/Types'; +import styles from './Nav.module.scss'; + +interface NavProps { + className?: string; + listClassName?: string; + linkClassName?: string; + onClick?: () => void; +} + +export const Nav: React.FC = ({ + className = '', + listClassName = '', + linkClassName = '', + onClick, +}) => { + const getActiveLinkClass = ({ isActive }: { isActive: boolean }) => + [styles.nav__link, linkClassName, isActive ? styles.active : ''] + .filter(Boolean) + .join(' '); + + return ( + + ); +}; diff --git a/src/components/Nav/index.ts b/src/components/Nav/index.ts new file mode 100644 index 00000000000..d21bdc25f6c --- /dev/null +++ b/src/components/Nav/index.ts @@ -0,0 +1 @@ +export { Nav } from './Nav'; diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss new file mode 100644 index 00000000000..a1db4e7af99 --- /dev/null +++ b/src/components/Pagination/Pagination.module.scss @@ -0,0 +1,63 @@ +/* src/components/Pagination/Pagination.module.scss */ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.pagination { + display: flex; + justify-content: center; + + &__list { + display: flex; + gap: 8px; + padding-bottom: 64px; + margin: 0; + + @include respond-to('desktop') { + padding-bottom: 80px; + } + } + + &__item { + &:first-child { + margin-right: 8px; + } + + &:last-child { + margin-left: 8px; + } + + button { + @include body-text; + + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--border); + color: var(--accent); + background-color: transparent; + cursor: pointer; + + @include hover-transition; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + @include hover { + &:not(:disabled) { + border-color: var(--accent); + } + } + + &.isActive { + background-color: var(--btn-bg); + color: var(--page-bg); + border-color: var(--accent); + } + } + } +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 00000000000..5fda24f7742 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { ArrowLeftIcon } from '../ui/ArrowLeftIcon'; +import { ArrowRightIcon } from '../ui/ArrowRightIcon'; +import styles from './Pagination.module.scss'; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export const Pagination: React.FC = ({ + currentPage, + totalPages, + onPageChange, +}) => { + const getVisiblePages = () => { + const visibleRange = 4; + let start = Math.max(1, currentPage - Math.floor(visibleRange / 2)); + let end = start + visibleRange - 1; + + if (end > totalPages) { + end = totalPages; + start = Math.max(1, end - visibleRange + 1); + } + + return Array.from({ length: end - start + 1 }, (_, index) => start + index); + }; + + const pages = getVisiblePages(); + + return ( + + ); +}; diff --git a/src/components/Pagination/index.ts b/src/components/Pagination/index.ts new file mode 100644 index 00000000000..0a1fd4dad6c --- /dev/null +++ b/src/components/Pagination/index.ts @@ -0,0 +1 @@ +export { Pagination } from './Pagination'; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..3771fb2a31e --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,194 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.productCard { + display: flex; + flex-direction: column; + grid-column: span 3; + box-sizing: border-box; + height: 100%; + padding: 32px; + + background-color: var(--card-bg); + border: 1px solid var(--border); + + @include hover-transition(box-shadow); + + @include respond-to('tablet') { + grid-column: span 5; + } + + @include respond-to('desktop') { + grid-column: span 6; + } + + @include hover { + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.1); + } + + &__imageLink { + display: flex; + align-items: center; + justify-content: center; + height: 196px; + margin-bottom: 24px; + overflow: hidden; + + @include hover-image-scale; + } + + &__image { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + &__title { + height: 42px; + margin-bottom: 8px; + + &Link { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + + color: var(--text-main); + font-weight: 600; + text-decoration: none; + + @include body-text; + @include hover-transition(color); + + @include hover { + color: var(--text-secondary); + } + } + } + + &__priceContainer { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + &__price { + color: var(--text-main); + font-family: Mont, sans-serif; + font-size: 22px; + font-weight: 800; + line-height: 31px; + } + + &__fullPrice { + color: var(--text-secondary); + font-family: Mont, sans-serif; + font-size: 22px; + font-weight: 500; + line-height: 28px; + text-decoration: line-through; + } + + &__divider { + width: 100%; + height: 1px; + margin: 0 0 8px; + + background-color: var(--border); + border: none; + } + + &__specsContainer { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 8px; + margin-bottom: 16px; + padding: 8px 0; + } + + &__specs { + display: flex; + align-items: center; + justify-content: space-between; + + &Name { + color: var(--text-secondary); + + @include small-text; + } + + &Value { + color: var(--text-main); + text-align: right; + + @include small-text; + } + } + + &__actions { + display: flex; + gap: 8px; + margin-top: auto; + } + + &__actionAddButton { + flex: 1; + height: 40px; + + background-color: var(--btn-bg); + color: var(--btn-text); + border: 1px solid var(--btn-bg); + cursor: pointer; + + @include button-text; + @include hover-transition(all); + + &--active { + background-color: var(--page-bg); + color: $cl-green; + border-color: var(--border); + cursor: default; + } + + + + &:not(&--active) { + @include hover { + filter: brightness(1.2); + box-shadow: 0 3px 13px 0 rgba(23, 32, 49, 0.4); + } + } + } + + &__actionFavouriteIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + + background: transparent; + border: 1px solid var(--icon-color); + cursor: pointer; + + @include hover-transition(all); + + @include hover { + border-color: var(--text-main); + } + } + + &__icon { + display: block; + fill: none; + color: var(--text-main); + + @include hover-image-scale; + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..28c8b39d70d --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { FavouriteIcon } from '../ui/FavouriteIcon'; +import { CatalogProducts } from '../../types/Types'; +import { HeartFillIcon } from '../ui/HeartFillIcon'; +import { useCart } from '../../context/CartContext'; +import { useFavourites } from '../../context/FavoritesContext'; +import classNames from 'classnames'; +import styles from './ProductCard.module.scss'; + +interface ProductCardProps { + product: CatalogProducts; +} + +export const ProductCard: React.FC = ({ product }) => { + const { addToCart, isInCart } = useCart(); + const { toggleFavourite, isFavourite } = useFavourites(); + + const { name, price, fullPrice, screen, capacity, ram, image } = product; + + const isActiveFavourite = isFavourite(product.id); + const isAdded = isInCart(product.id); + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handleFavouriteClick = () => { + toggleFavourite(product); + scrollToTop(); + }; + + const handleCartClick = () => { + if (!isAdded) { + addToCart(product); + } + + scrollToTop(); + }; + + const productLink = `/product/${product.itemId}`; + + const specs = [ + { name: 'Screen', value: screen }, + { name: 'Capacity', value: capacity }, + { name: 'RAM', value: ram }, + ]; + + return ( +
+ + {name} + +

+ + {name} + +

+
+ ${price} + {fullPrice !== price && ( + ${fullPrice} + )} +
+
+
+ {specs.map(spec => ( +
+ {spec.name} + {spec.value} +
+ ))} +
+
+ + +
+
+ ); +}; diff --git a/src/components/ProductCard/index.ts b/src/components/ProductCard/index.ts new file mode 100644 index 00000000000..c4f2778191c --- /dev/null +++ b/src/components/ProductCard/index.ts @@ -0,0 +1 @@ +export { ProductCard } from './ProductCard'; diff --git a/src/components/ProductsList/ProductsList.module.scss b/src/components/ProductsList/ProductsList.module.scss new file mode 100644 index 00000000000..b77ef78a194 --- /dev/null +++ b/src/components/ProductsList/ProductsList.module.scss @@ -0,0 +1,29 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.list { + gap: 16px; + + row-gap: 40px; + padding: 0; + margin: 0; + + @include page-grid; +} + +.item { + grid-column: span 4; + + @include respond-to('tablet') { + grid-column: span 6; + } + + @include respond-to('tablet-md') { + grid-column: span 4; + } + + @include respond-to('desktop') { + grid-column: span 6; + } +} diff --git a/src/components/ProductsList/ProductsList.tsx b/src/components/ProductsList/ProductsList.tsx new file mode 100644 index 00000000000..abe366e1f49 --- /dev/null +++ b/src/components/ProductsList/ProductsList.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { CatalogProducts } from '../../types/Types'; +import { ProductCard } from '../ProductCard'; +import styles from './ProductsList.module.scss'; + +interface ProductsListProps { + products: CatalogProducts[]; +} + +export const ProductsList: React.FC = ({ products }) => { + return ( +
    + {products.map(product => { + return ( +
  • + +
  • + ); + })} +
+ ); +}; diff --git a/src/components/ProductsList/index.ts b/src/components/ProductsList/index.ts new file mode 100644 index 00000000000..ae9d590cdbd --- /dev/null +++ b/src/components/ProductsList/index.ts @@ -0,0 +1 @@ +export { ProductsList } from './ProductsList'; diff --git a/src/components/ProductsSlider/ProductsSlider.module.scss b/src/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 00000000000..f7b1affb0d7 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,85 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.slider { + width: 100%; + margin: 65px 0; + + overflow: visible; + + @include respond-to('tablet') { + margin: 64px 0; + } + + @include respond-to('desktop') { + margin: 80px 0; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + &__title { + color: var(--text-main); + + @include h2; + } + + &__buttons { + display: flex; + gap: 16px; + } + + &__button { + width: 32px; + height: 32px; + border: 1px solid var(--border); + background: transparent; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-main); + + @include hover-transition(border-color); + + @include hover { + border-color: var(--text-main); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + border-color: var(--border); + } + } + + &__list { + display: flex; + gap: 16px; + overflow: auto visible; + scroll-behavior: smooth; + + &::-webkit-scrollbar { + display: none; + } + } + + &__item { + flex-shrink: 0; + + width: 212px; + + @include respond-to('tablet') { + width: 237px; + } + + @include respond-to('desktop') { + width: 272px; + } + } +} diff --git a/src/components/ProductsSlider/ProductsSlider.tsx b/src/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..72a0a12d201 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { ArrowRightIcon } from '../ui/ArrowRightIcon'; +import { ArrowLeftIcon } from '../ui/ArrowLeftIcon'; +import { ProductCard } from '../ProductCard'; +import { CatalogProducts } from '../../types/Types'; +import styles from './ProductsSlider.module.scss'; +import { useSlider } from '../../hooks/useSlider'; + +interface ProductSliderProps { + title: string; + products: CatalogProducts[]; +} + +const MAX_VISIBLE_PRODUCTS = 12; + +export const ProductsSlider: React.FC = ({ + title, + products, +}) => { + const visibleProducts = products.slice(0, MAX_VISIBLE_PRODUCTS); + + const { + listRef, + canScrollLeft, + canScrollRight, + scroll, + checkScrollPosition, + } = useSlider(visibleProducts); + + return ( +
+
+

{title}

+
+ + +
+
+
+ {visibleProducts.map(product => ( +
+ +
+ ))} +
+
+ ); +}; diff --git a/src/components/ProductsSlider/index.ts b/src/components/ProductsSlider/index.ts new file mode 100644 index 00000000000..0a5bb986628 --- /dev/null +++ b/src/components/ProductsSlider/index.ts @@ -0,0 +1 @@ +export { ProductsSlider } from './ProductsSlider'; diff --git a/src/components/Search/Search.module.scss b/src/components/Search/Search.module.scss new file mode 100644 index 00000000000..7d0e490a1a7 --- /dev/null +++ b/src/components/Search/Search.module.scss @@ -0,0 +1,83 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.search { + position: relative; + display: flex; + align-items: center; + width: auto; + min-width: 0; + flex: 1; + max-width: none; + margin: 0 8px; + padding-inline: 4px; + + @include respond-to('desktop') { + width: 180px; + flex: 0 0 auto; + max-width: 200px; + } + + @include respond-to('tablet') { + margin: 0 12px; + } + + @include respond-to('desktop') { + margin: 0 16px; + } + + &__input { + @include body-text; + + width: 100%; + height: 40px; + padding-left: 12px; + padding-right: 40px; + + background-color: var(--card-bg); + border: 1px solid var(--border); + color: var(--text-main); + outline: none; + + @include hover-transition(border-color); + + &::placeholder { + color: var(--text-secondary); + text-transform: capitalize; + font-size: 12px; + line-height: 1.2; + + @include respond-to('tablet') { + font-size: 14px; + } + } + + &:focus, + &:hover { + border-color: var(--text-main); + } + } + + &__button { + position: absolute; + right: 16px; + display: flex; + align-items: center; + justify-content: center; + + padding: 0; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-secondary); + + @include hover-transition(color, transform); + + @include hover { + color: var(--text-main); + } + + @include hover-image-scale; + } +} diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx new file mode 100644 index 00000000000..575833135ac --- /dev/null +++ b/src/components/Search/Search.tsx @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useCatalogParams } from '../../hooks/useCatalogParams'; +import { useLocation } from 'react-router-dom'; +import { useDebounce } from '../../hooks/useDebounce'; +import { SearchIcon } from '../ui/SearchIcon'; +import { CloseIcon } from '../ui/CloseIcon'; +import { PathType } from '../../types/Types'; +import styles from './Search.module.scss'; + +const SEARCHABLE_PATHS = [ + PathType.PHONES, + PathType.TABLETS, + PathType.ACCESSORIES, + PathType.FAVOURITES, +]; + +export const Search: React.FC = () => { + const { query, handleQueryChange } = useCatalogParams(); + const [localQuery, setLocalQuery] = useState(query); + const debouncedQuery = useDebounce(localQuery, 500); + const location = useLocation(); + + useEffect(() => { + setLocalQuery(query); + }, [query]); + + useEffect(() => { + if (debouncedQuery !== query) { + handleQueryChange(debouncedQuery); + } + }, [debouncedQuery, handleQueryChange, query]); + + const showSearch = SEARCHABLE_PATHS.includes(location.pathname as PathType); + + const pageTitle = useMemo(() => { + const pathname = location.pathname.replace('/', ''); + + if (!pathname) { + return ''; + } + + return pathname.charAt(0).toUpperCase() + pathname.slice(1); + }, [location.pathname]); + + if (!showSearch) { + return null; + } + + return ( +
+ setLocalQuery(event.target.value)} + aria-label={`Search in ${pageTitle}`} + /> + {localQuery ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/components/Search/index.ts b/src/components/Search/index.ts new file mode 100644 index 00000000000..6860ea7e14c --- /dev/null +++ b/src/components/Search/index.ts @@ -0,0 +1 @@ +export { Search } from './Search'; diff --git a/src/components/ShopByCategory/ShopByCategory.module.scss b/src/components/ShopByCategory/ShopByCategory.module.scss new file mode 100644 index 00000000000..9fccb945b56 --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.module.scss @@ -0,0 +1,105 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.category { + margin-bottom: 56px; + + @include page-container; + + @include respond-to('desktop') { + margin-bottom: 80px; + } + + &__title { + color: var(--text-main); + margin-bottom: 24px; + + @include h2; + } + + &__items { + gap: 32px; + + @include page-grid; + } + + &__item { + grid-column: span 4; + + @include respond-to('tablet') { + grid-column: span 4; + } + + @include respond-to('desktop') { + grid-column: span 8; + } + } + + &__imageLink { + display: block; + position: relative; + aspect-ratio: 1 / 1; + overflow: hidden; + + &--phones { + background-color: #6d6474; + } + + &--tablets { + background-color: #8d8d92; + } + + &--accessories { + background-color: #973d5f; + } + } + + &__image { + position: absolute; + object-fit: contain; + + @include hover-image-scale; + } + + &__imageLink--phones &__image { + scale: 1.1; + top: 5%; + left: 13%; + width: 105%; + height: 124%; + } + + &__imageLink--tablets &__image { + scale: 1.5; + left: 30%; + width: 150%; + height: 160%; + } + + &__imageLink--accessories &__image { + scale: 2; + top: 20%; + left: 55%; + width: 95%; + height: 75%; + } + + &__info { + margin-top: 24px; + } + + &__name { + color: var(--text-main); + margin-bottom: 4px; + + @include h4; + } + + &__quantity { + color: var(--text-secondary); + display: block; + + @include body-text; + } +} diff --git a/src/components/ShopByCategory/ShopByCategory.tsx b/src/components/ShopByCategory/ShopByCategory.tsx new file mode 100644 index 00000000000..b67ec3c7980 --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import { CategoriesType, Category, PathType } from '../../types/Types'; +import { useCategoryCounts } from '../../hooks/useCategoryCounts'; +import styles from './ShopByCategory.module.scss'; + +export const ShopByCategory: React.FC = () => { + const counts = useCategoryCounts(); + + const categories: Category[] = [ + { + name: 'Mobile phones', + src: 'img/category-phones.png', + alt: 'Phones category', + link: PathType.PHONES, + quantity: counts[CategoriesType.PHONES], + type: CategoriesType.PHONES, + }, + { + name: 'Tablets', + src: 'img/category-tablets.png', + alt: 'Tablets category', + link: PathType.TABLETS, + quantity: counts[CategoriesType.TABLETS], + type: CategoriesType.TABLETS, + }, + { + name: 'Accessories', + src: 'img/category-accessories.png', + alt: 'Accessories category', + link: PathType.ACCESSORIES, + quantity: counts[CategoriesType.ACCESSORIES], + type: CategoriesType.ACCESSORIES, + }, + ]; + + return ( +
+

Shop by category

+
+
    + {categories.map(category => ( +
  • + + {category.alt} + + +
    +

    {category.name}

    + + {category.quantity} models + +
    +
  • + ))} +
+
+
+ ); +}; diff --git a/src/components/ShopByCategory/index.ts b/src/components/ShopByCategory/index.ts new file mode 100644 index 00000000000..767e814b1f2 --- /dev/null +++ b/src/components/ShopByCategory/index.ts @@ -0,0 +1 @@ +export { ShopByCategory } from './ShopByCategory'; diff --git a/src/components/ui/ArrowDownIcon/ArrowDownIcon.tsx b/src/components/ui/ArrowDownIcon/ArrowDownIcon.tsx new file mode 100644 index 00000000000..8f544f7cec6 --- /dev/null +++ b/src/components/ui/ArrowDownIcon/ArrowDownIcon.tsx @@ -0,0 +1,20 @@ +/* eslint-disable max-len */ +import React from 'react'; + +export const ArrowDownIcon: React.FC = () => ( + + + +); diff --git a/src/components/ui/ArrowDownIcon/index.ts b/src/components/ui/ArrowDownIcon/index.ts new file mode 100644 index 00000000000..f6361737995 --- /dev/null +++ b/src/components/ui/ArrowDownIcon/index.ts @@ -0,0 +1 @@ +export { ArrowDownIcon } from './ArrowDownIcon'; diff --git a/src/components/ui/ArrowLeftIcon/ArrowLeftIcon.tsx b/src/components/ui/ArrowLeftIcon/ArrowLeftIcon.tsx new file mode 100644 index 00000000000..3474b88f252 --- /dev/null +++ b/src/components/ui/ArrowLeftIcon/ArrowLeftIcon.tsx @@ -0,0 +1,19 @@ +/* eslint-disable max-len */ +import React from 'react'; + +export const ArrowLeftIcon: React.FC = () => ( + + + +); diff --git a/src/components/ui/ArrowLeftIcon/index.ts b/src/components/ui/ArrowLeftIcon/index.ts new file mode 100644 index 00000000000..67e553fe6fe --- /dev/null +++ b/src/components/ui/ArrowLeftIcon/index.ts @@ -0,0 +1 @@ +export { ArrowLeftIcon } from './ArrowLeftIcon'; diff --git a/src/components/ui/ArrowRightIcon/ArrowRightIcon.tsx b/src/components/ui/ArrowRightIcon/ArrowRightIcon.tsx new file mode 100644 index 00000000000..09ba19ba047 --- /dev/null +++ b/src/components/ui/ArrowRightIcon/ArrowRightIcon.tsx @@ -0,0 +1,19 @@ +/* eslint-disable max-len */ +import React from 'react'; + +export const ArrowRightIcon: React.FC = () => ( + + + +); diff --git a/src/components/ui/ArrowRightIcon/index.ts b/src/components/ui/ArrowRightIcon/index.ts new file mode 100644 index 00000000000..7f16765097d --- /dev/null +++ b/src/components/ui/ArrowRightIcon/index.ts @@ -0,0 +1 @@ +export { ArrowRightIcon } from './ArrowRightIcon'; diff --git a/src/components/ui/ArrowUpIcon/ArrowUpIcon.tsx b/src/components/ui/ArrowUpIcon/ArrowUpIcon.tsx new file mode 100644 index 00000000000..050c31a1042 --- /dev/null +++ b/src/components/ui/ArrowUpIcon/ArrowUpIcon.tsx @@ -0,0 +1,19 @@ +/* eslint-disable max-len */ +import React from 'react'; + +export const ArrowUpIcon: React.FC = () => ( + + + +); diff --git a/src/components/ui/ArrowUpIcon/index.ts b/src/components/ui/ArrowUpIcon/index.ts new file mode 100644 index 00000000000..f819766c6be --- /dev/null +++ b/src/components/ui/ArrowUpIcon/index.ts @@ -0,0 +1 @@ +export { ArrowUpIcon } from './ArrowUpIcon'; diff --git a/src/components/ui/CartIcon/CartIcon.tsx b/src/components/ui/CartIcon/CartIcon.tsx new file mode 100644 index 00000000000..a8e7e4d08d6 --- /dev/null +++ b/src/components/ui/CartIcon/CartIcon.tsx @@ -0,0 +1,39 @@ +/* eslint-disable max-len */ + +import React from 'react'; + +interface CartProps { + className: string; +} + +export const CartIcon: React.FC = ({ className }) => { + return ( + + + + + + ); +}; diff --git a/src/components/ui/CartIcon/index.ts b/src/components/ui/CartIcon/index.ts new file mode 100644 index 00000000000..50b77f8901b --- /dev/null +++ b/src/components/ui/CartIcon/index.ts @@ -0,0 +1 @@ +export { CartIcon } from './CartIcon'; diff --git a/src/components/ui/CloseIcon/CloseIcon.tsx b/src/components/ui/CloseIcon/CloseIcon.tsx new file mode 100644 index 00000000000..a0dcaa017cd --- /dev/null +++ b/src/components/ui/CloseIcon/CloseIcon.tsx @@ -0,0 +1,27 @@ +/* eslint-disable max-len */ + +import React from 'react'; + +interface CloseIconProps { + className?: string; +} + +export const CloseIcon: React.FC = ({ className }) => { + return ( + + + + ); +}; diff --git a/src/components/ui/CloseIcon/index.ts b/src/components/ui/CloseIcon/index.ts new file mode 100644 index 00000000000..af2ed99b509 --- /dev/null +++ b/src/components/ui/CloseIcon/index.ts @@ -0,0 +1 @@ +export { CloseIcon } from './CloseIcon'; diff --git a/src/components/ui/Dropdown/Dropdown.module.scss b/src/components/ui/Dropdown/Dropdown.module.scss new file mode 100644 index 00000000000..63f0871d49e --- /dev/null +++ b/src/components/ui/Dropdown/Dropdown.module.scss @@ -0,0 +1,111 @@ +@import '../../../styles/variables'; +@import '../../../styles/mixins'; +@import '../../../styles/typography'; + +.container { + display: flex; + flex-direction: column; + gap: 4px; + position: relative; + + &__label { + @include small-text; + + color: var(--text-secondary); + } + + &__dropdown { + position: relative; + width: 100%; + } + + &__trigger { + width: 100%; + height: 40px; + display: flex; + justify-content: space-between; + align-items: center; + + background-color: var(--page-bg); + border: 1px solid var(--border); + cursor: pointer; + + @include hover-transition(border-color); + + @include hover { + border-color: var(--text-main); + } + + &:focus { + border-color: var(--accent); + } + } + + &__value { + @include button-text; + + color: var(--text-main); + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + flex-grow: 1; + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + } + + &__list { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 10; + + background-color: var(--page-bg); + border: 1px solid var(--border); + + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: + opacity 0.3s ease, + transform 0.3s ease, + visibility 0.3s; + } + + &__listVisible { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + + &__item { + height: 32px; + display: flex; + align-items: center; + padding-left: 12px; + + @include body-text; + + color: var(--text-secondary); + cursor: pointer; + + @include hover-transition(all, 0.3s); + + @include hover { + background-color: var(--hover-bg); + color: var(--text-main); + } + } + + &__itemActive { + background-color: var(--hover-bg); + color: var(--text-main); + } +} diff --git a/src/components/ui/Dropdown/Dropdown.tsx b/src/components/ui/Dropdown/Dropdown.tsx new file mode 100644 index 00000000000..dbbc47102b0 --- /dev/null +++ b/src/components/ui/Dropdown/Dropdown.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useRef, useState } from 'react'; +import styles from './Dropdown.module.scss'; +import { ArrowDownIcon } from '../ArrowDownIcon'; +import { ArrowUpIcon } from '../ArrowUpIcon'; +import { useClickOutside } from '../../../hooks/useClickOutside'; + +interface DropdownOption { + label: string; + value: string; +} + +interface DropdownProps { + options: DropdownOption[]; + value: string; + onChange: (value: string) => void; + label?: string; + className?: string; +} + +export const Dropdown: React.FC = ({ + options, + value, + onChange, + label, + className = '', +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const closeDropdown = useCallback(() => { + setIsOpen(false); + }, []); + + useClickOutside(dropdownRef, closeDropdown); + + const selectedOption = + options.find(option => option.value === value) || options[0]; + + const handleSelect = (optionValue: string) => { + onChange(optionValue); + setIsOpen(false); + }; + + return ( +
+ {label && {label}} + +
+ + +
    + {options.map(option => ( +
  • handleSelect(option.value)} + role="option" + aria-selected={option.value === value} + > + {option.label} +
  • + ))} +
+
+
+ ); +}; diff --git a/src/components/ui/Dropdown/index.ts b/src/components/ui/Dropdown/index.ts new file mode 100644 index 00000000000..c0ad316fc26 --- /dev/null +++ b/src/components/ui/Dropdown/index.ts @@ -0,0 +1 @@ +export { Dropdown } from './Dropdown'; diff --git a/src/components/ui/FavouriteIcon/FavouriteIcon.tsx b/src/components/ui/FavouriteIcon/FavouriteIcon.tsx new file mode 100644 index 00000000000..c336d65e53e --- /dev/null +++ b/src/components/ui/FavouriteIcon/FavouriteIcon.tsx @@ -0,0 +1,27 @@ +/* eslint-disable max-len */ + +import React from 'react'; + +interface FavouriteIconProps { + className: string; +} + +export const FavouriteIcon: React.FC = ({ className }) => { + return ( + + + + ); +}; diff --git a/src/components/ui/FavouriteIcon/index.ts b/src/components/ui/FavouriteIcon/index.ts new file mode 100644 index 00000000000..a20b2959b65 --- /dev/null +++ b/src/components/ui/FavouriteIcon/index.ts @@ -0,0 +1 @@ +export { FavouriteIcon } from './FavouriteIcon'; diff --git a/src/components/ui/HeartFillIcon/HeartFillIcon.tsx b/src/components/ui/HeartFillIcon/HeartFillIcon.tsx new file mode 100644 index 00000000000..fa64ee5a70c --- /dev/null +++ b/src/components/ui/HeartFillIcon/HeartFillIcon.tsx @@ -0,0 +1,26 @@ +/* eslint-disable max-len */ +import React from 'react'; + +interface HeartFillIconProps { + className: string; +} + +export const HeartFillIcon: React.FC = ({ className }) => { + return ( + + + + ); +}; diff --git a/src/components/ui/HeartFillIcon/index.ts b/src/components/ui/HeartFillIcon/index.ts new file mode 100644 index 00000000000..9da69621d7a --- /dev/null +++ b/src/components/ui/HeartFillIcon/index.ts @@ -0,0 +1 @@ +export { HeartFillIcon } from './HeartFillIcon'; diff --git a/src/components/ui/HomeIcon/HomeIcon.tsx b/src/components/ui/HomeIcon/HomeIcon.tsx new file mode 100644 index 00000000000..cb56db9d62d --- /dev/null +++ b/src/components/ui/HomeIcon/HomeIcon.tsx @@ -0,0 +1,27 @@ +/* eslint-disable max-len */ +import React from 'react'; + +export const HomeIcon: React.FC = () => { + return ( + + + + + ); +}; diff --git a/src/components/ui/HomeIcon/index.ts b/src/components/ui/HomeIcon/index.ts new file mode 100644 index 00000000000..263c596e96b --- /dev/null +++ b/src/components/ui/HomeIcon/index.ts @@ -0,0 +1 @@ +export { HomeIcon } from './HomeIcon'; diff --git a/src/components/ui/MenuIcon/MenuIcon.tsx b/src/components/ui/MenuIcon/MenuIcon.tsx new file mode 100644 index 00000000000..c08468b06ed --- /dev/null +++ b/src/components/ui/MenuIcon/MenuIcon.tsx @@ -0,0 +1,33 @@ +/* eslint-disable max-len */ + +import React from 'react'; + +interface MenuIconProps { + className?: string; +} + +export const MenuIcon: React.FC = ({ className }) => { + return ( + + + + + + ); +}; diff --git a/src/components/ui/MenuIcon/index.ts b/src/components/ui/MenuIcon/index.ts new file mode 100644 index 00000000000..063031272d2 --- /dev/null +++ b/src/components/ui/MenuIcon/index.ts @@ -0,0 +1 @@ +export { MenuIcon } from './MenuIcon'; diff --git a/src/components/ui/MinusIcon/MinusIcon.tsx b/src/components/ui/MinusIcon/MinusIcon.tsx new file mode 100644 index 00000000000..5111cbb3f09 --- /dev/null +++ b/src/components/ui/MinusIcon/MinusIcon.tsx @@ -0,0 +1,21 @@ +/* eslint-disable max-len */ +import React from 'react'; + +export const MinusIcon: React.FC = () => { + return ( + + + + ); +}; diff --git a/src/components/ui/MinusIcon/index.ts b/src/components/ui/MinusIcon/index.ts new file mode 100644 index 00000000000..1b167c72a95 --- /dev/null +++ b/src/components/ui/MinusIcon/index.ts @@ -0,0 +1 @@ +export { MinusIcon } from './MinusIcon'; diff --git a/src/components/ui/PlusIcon/PlusIcon.tsx b/src/components/ui/PlusIcon/PlusIcon.tsx new file mode 100644 index 00000000000..6c1e590dc63 --- /dev/null +++ b/src/components/ui/PlusIcon/PlusIcon.tsx @@ -0,0 +1,21 @@ +/* eslint-disable max-len */ +import React from 'react'; + +export const PlusIcon: React.FC = () => { + return ( + + + + ); +}; diff --git a/src/components/ui/PlusIcon/index.ts b/src/components/ui/PlusIcon/index.ts new file mode 100644 index 00000000000..6e8bba2e53d --- /dev/null +++ b/src/components/ui/PlusIcon/index.ts @@ -0,0 +1 @@ +export { PlusIcon } from './PlusIcon'; diff --git a/src/components/ui/SearchIcon/SearchIcon.tsx b/src/components/ui/SearchIcon/SearchIcon.tsx new file mode 100644 index 00000000000..fbcb79f89c9 --- /dev/null +++ b/src/components/ui/SearchIcon/SearchIcon.tsx @@ -0,0 +1,21 @@ +/* eslint-disable max-len */ +import React from 'react'; + +export const SearchIcon: React.FC = () => { + return ( + + + + ); +}; diff --git a/src/components/ui/SearchIcon/index.ts b/src/components/ui/SearchIcon/index.ts new file mode 100644 index 00000000000..18ac949b052 --- /dev/null +++ b/src/components/ui/SearchIcon/index.ts @@ -0,0 +1 @@ +export { SearchIcon } from './SearchIcon'; diff --git a/src/components/ui/ThemeToggler/ThemeToggler.module.scss b/src/components/ui/ThemeToggler/ThemeToggler.module.scss new file mode 100644 index 00000000000..c785e8b77be --- /dev/null +++ b/src/components/ui/ThemeToggler/ThemeToggler.module.scss @@ -0,0 +1,33 @@ +@import '../../../styles/variables'; +@import '../../../styles/mixins'; + +.toggler { + display: flex; + justify-content: center; + align-items: center; + width: 48px; + height: 100%; + padding: 0; + + background: transparent; + border: none; + border-left: 1px solid var(--border); + color: var(--text-main); + cursor: pointer; + + box-sizing: border-box; + + @include hover-transition(color); + + @include hover { + color: var(--accent); + } + + @include respond-to('desktop') { + width: 64px; + } + + &__icon { + @include hover-image-scale; + } +} diff --git a/src/components/ui/ThemeToggler/ThemeToggler.tsx b/src/components/ui/ThemeToggler/ThemeToggler.tsx new file mode 100644 index 00000000000..e86102969e4 --- /dev/null +++ b/src/components/ui/ThemeToggler/ThemeToggler.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { useTheme } from '../../../context/ThemeContext'; + +import styles from './ThemeToggler.module.scss'; +import classNames from 'classnames'; + +interface ThemeTogglerProps { + className?: string; +} + +export const ThemeToggler: React.FC = ({ className }) => { + const { theme, toggleTheme } = useTheme(); + const isDark = theme === 'dark'; + + return ( + + ); +}; diff --git a/src/components/ui/ThemeToggler/index.ts b/src/components/ui/ThemeToggler/index.ts new file mode 100644 index 00000000000..ca8ff5ec265 --- /dev/null +++ b/src/components/ui/ThemeToggler/index.ts @@ -0,0 +1 @@ +export { ThemeToggler } from './ThemeToggler'; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 00000000000..cb9b72e3e75 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,23 @@ +import { PerPageType, SortType } from '../types/Types'; + +export const SORT_OPTIONS = [ + { label: 'Newest', value: SortType.AGE }, + { label: 'Alphabetically', value: SortType.TITLE }, + { label: 'Cheapest', value: SortType.PRICE }, +]; + +export const PER_PAGE_OPTIONS = [ + { label: '4', value: PerPageType.FOUR }, + { label: '8', value: PerPageType.EIGHT }, + { label: '16', value: PerPageType.SIXTEEN }, + { label: 'All', value: PerPageType.ALL }, +]; + +export const GIT_HUB_REPO = + 'https://github.com/maximtsyrulnyk/react_phone-catalog'; + +export const CONTACTS_ORIGIN_REPO = + 'https://github.com/maximtsyrulnyk/react_phone-catalog'; + +export const RIGHTS_PATH = + 'https://github.com/maximtsyrulnyk/react_phone-catalog'; diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx new file mode 100644 index 00000000000..48184184ac9 --- /dev/null +++ b/src/context/CartContext.tsx @@ -0,0 +1,131 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + ReactNode, +} from 'react'; +import { CatalogProducts } from '../types/Types'; + +export interface CartItemInterface { + id: string | number; + quantity: number; + product: CatalogProducts; +} + +interface CartContextType { + cartItems: CartItemInterface[]; + addToCart: (product: CatalogProducts) => void; + removeFromCart: (productId: CatalogProducts['id']) => void; + updateQuantity: (productId: CatalogProducts['id'], quantity: number) => void; + isInCart: (productId: CatalogProducts['id']) => boolean; + clearCart: () => void; +} + +export const CartContext = createContext( + undefined, +); + +interface CartProviderProps { + children: ReactNode; +} + +export const CartProvider = ({ children }: CartProviderProps) => { + const [cartItems, setCartItems] = useState(() => { + const savedcart = localStorage.getItem('cart'); + + if (savedcart) { + try { + return JSON.parse(savedcart); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error parsing cart products:', error); + + return []; + } + } + + return []; + }); + + useEffect(() => { + localStorage.setItem('cart', JSON.stringify(cartItems)); + }, [cartItems]); + + const addToCart = useCallback((product: CatalogProducts) => { + setCartItems(previousCartItems => { + const isAlreadyInCart = previousCartItems.some( + item => item.id === product.id, + ); + + if (isAlreadyInCart) { + return previousCartItems; + } + + return [ + ...previousCartItems, + { id: product.id, quantity: 1, product: product }, + ]; + }); + }, []); + + const removeFromCart = useCallback((productId: CartItemInterface['id']) => { + setCartItems(previousCartItems => + previousCartItems.filter(item => item.id !== productId), + ); + }, []); + + const updateQuantity = useCallback( + (productId: CartItemInterface['id'], newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + + return; + } + + setCartItems(previousCartItems => + previousCartItems.map(item => + item.id === productId ? { ...item, quantity: newQuantity } : item, + ), + ); + }, + [removeFromCart], + ); + + const isInCart = useCallback( + (productId: CartItemInterface['id']) => { + return cartItems.some(item => item.id === productId); + }, + [cartItems], + ); + + const clearCart = useCallback(() => { + setCartItems([]); + }, []); + + const value = useMemo( + () => ({ + cartItems, + addToCart, + removeFromCart, + updateQuantity, + isInCart, + clearCart, + }), + [cartItems, addToCart, removeFromCart, updateQuantity, isInCart, clearCart], + ); + + return {children}; +}; + +export const useCart = () => { + const context = useContext(CartContext); + + if (!context) { + throw new Error('useCart must be used within a CartProvider'); + } + + return context; +}; diff --git a/src/context/FavoritesContext.tsx b/src/context/FavoritesContext.tsx new file mode 100644 index 00000000000..707c6951bed --- /dev/null +++ b/src/context/FavoritesContext.tsx @@ -0,0 +1,95 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + ReactNode, +} from 'react'; +import { CatalogProducts } from '../types/Types'; + +interface FavouriteContextInterface { + favourites: CatalogProducts[]; + toggleFavourite: (product: CatalogProducts) => void; + isFavourite: (productId: CatalogProducts['id']) => boolean; +} + +export const FavouritesContext = createContext< + FavouriteContextInterface | undefined +>(undefined); + +interface FavouritesProviderProps { + children: ReactNode; +} + +export const FavouritesProvider = ({ children }: FavouritesProviderProps) => { + const [favourites, setFavourites] = useState(() => { + const savedFavourites = localStorage.getItem('favourites'); + + if (savedFavourites) { + try { + return JSON.parse(savedFavourites); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error parsing favourite products:', error); + + return []; + } + } + + return []; + }); + + useEffect(() => { + localStorage.setItem('favourites', JSON.stringify(favourites)); + }, [favourites]); + + const toggleFavourite = useCallback((product: CatalogProducts) => { + setFavourites(previousFavourites => { + const isAlreadyFavourite = previousFavourites.some( + item => item.id === product.id, + ); + + if (isAlreadyFavourite) { + return previousFavourites.filter(item => item.id !== product.id); + } + + return [...previousFavourites, product]; + }); + }, []); + + const isFavourite = useCallback( + (productId: CatalogProducts['id']) => { + return favourites.some(item => item.id === productId); + }, + [favourites], + ); + + const value = useMemo( + () => ({ + favourites, + toggleFavourite, + isFavourite, + }), + [favourites, toggleFavourite, isFavourite], + ); + + return ( + + {children} + + ); +}; + +export const useFavourites = () => { + const context = useContext(FavouritesContext); + + if (!context) { + throw new Error( + 'useFavourites should be used internally FavouritesProvider', + ); + } + + return context; +}; diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx new file mode 100644 index 00000000000..e3e22b66776 --- /dev/null +++ b/src/context/ThemeContext.tsx @@ -0,0 +1,50 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [theme, setTheme] = useState(() => { + const savedTheme = localStorage.getItem('theme') || 'light'; + + if (savedTheme === 'light' || savedTheme === 'dark') { + return savedTheme; + } + + return 'light'; + }); + + const toggleTheme = () => { + setTheme(previousTheme => (previousTheme === 'light' ? 'dark' : 'light')); + }; + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + }, [theme]); + + return ( + + {children} + + ); +}; + +//--- Hook for convenient use in components ---// +export const useTheme = () => { + const context = useContext(ThemeContext); + + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + + return context; +}; diff --git a/src/hooks/useCatalogParams.ts b/src/hooks/useCatalogParams.ts new file mode 100644 index 00000000000..38231f456d5 --- /dev/null +++ b/src/hooks/useCatalogParams.ts @@ -0,0 +1,101 @@ +import { useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { PerPageType, SortType } from '../types/Types'; + +export const useCatalogParams = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const sortParam = searchParams.get('sort'); + const pageParam = searchParams.get('page'); + const perPageParam = searchParams.get('perPage'); + const queryParam = searchParams.get('query') || ''; + + const sort = + sortParam === SortType.AGE || + sortParam === SortType.TITLE || + sortParam === SortType.PRICE + ? sortParam + : SortType.AGE; + + const page = Number(pageParam) || 1; + const perPage = perPageParam + ? perPageParam === PerPageType.ALL + ? PerPageType.ALL + : Number(perPageParam) + : PerPageType.ALL; + + const handleSortChange = useCallback( + (newSort: string) => { + const newParams = new URLSearchParams(searchParams); + + if (newSort === SortType.AGE) { + newParams.delete('sort'); + } else { + newParams.set('sort', newSort); + } + + newParams.delete('page'); + setSearchParams(newParams); + }, + [searchParams, setSearchParams], + ); + + const handlePerPageChange = useCallback( + (newPerPage: string) => { + const newParams = new URLSearchParams(searchParams); + + if (newPerPage === PerPageType.ALL) { + newParams.delete('perPage'); + } else { + newParams.set('perPage', newPerPage); + } + + newParams.delete('page'); + setSearchParams(newParams); + }, + [searchParams, setSearchParams], + ); + + const handlePageChange = useCallback( + (newPage: number) => { + const newParams = new URLSearchParams(searchParams); + + if (newPage === 1) { + newParams.delete('page'); + } else { + newParams.set('page', newPage.toString()); + } + + setSearchParams(newParams); + }, + [searchParams, setSearchParams], + ); + + const handleQueryChange = useCallback( + (newQuery: string) => { + const newParams = new URLSearchParams(searchParams); + + if (newQuery.trim()) { + newParams.set('query', newQuery); + } else { + newParams.delete('query'); + } + + newParams.delete('page'); + setSearchParams(newParams); + }, + + [searchParams, setSearchParams], + ); + + return { + sort, + page, + perPage, + query: queryParam, + handleSortChange, + handlePerPageChange, + handlePageChange, + handleQueryChange, + }; +}; diff --git a/src/hooks/useCategoryCounts.ts b/src/hooks/useCategoryCounts.ts new file mode 100644 index 00000000000..e74e2870fc2 --- /dev/null +++ b/src/hooks/useCategoryCounts.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; +import { CategoriesType } from '../types/Types'; +import { getProducts } from '../api/products'; + +export const useCategoryCounts = () => { + const [counts, setCounts] = useState({ + [CategoriesType.PHONES]: 0, + [CategoriesType.TABLETS]: 0, + [CategoriesType.ACCESSORIES]: 0, + }); + + useEffect(() => { + const fetchCounts = async () => { + try { + const products = await getProducts(); + + const newCounts = { + [CategoriesType.PHONES]: 0, + [CategoriesType.TABLETS]: 0, + [CategoriesType.ACCESSORIES]: 0, + }; + + products.forEach(product => { + if (product.category === CategoriesType.PHONES) { + newCounts[CategoriesType.PHONES]++; + } else if (product.category === CategoriesType.TABLETS) { + newCounts[CategoriesType.TABLETS]++; + } else if (product.category === CategoriesType.ACCESSORIES) { + newCounts[CategoriesType.ACCESSORIES]++; + } + }); + + setCounts(newCounts); + } catch (error) { + throw new Error('Failed to fetch products for categories'); + } + }; + + fetchCounts(); + }, []); + + return counts; +}; diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts new file mode 100644 index 00000000000..d886d904651 --- /dev/null +++ b/src/hooks/useClickOutside.ts @@ -0,0 +1,26 @@ +import { useEffect, RefObject } from 'react'; + +export const useClickOutside = ( + ref: RefObject, + callback: () => void, +) => { + useEffect(() => { + const handleClick = (event: MouseEvent) => { + const { target } = event; + + if ( + ref.current && + target instanceof Node && + !ref.current.contains(target) + ) { + callback(); + } + }; + + document.addEventListener('mousedown', handleClick); + + return () => { + document.removeEventListener('mousedown', handleClick); + }; + }, [ref, callback]); +}; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000000..fdbe1be69b3 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export const useDebounce = (value: T, delay: number = 500): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/src/hooks/useLockBodyScroll.ts b/src/hooks/useLockBodyScroll.ts new file mode 100644 index 00000000000..a0f05ccf289 --- /dev/null +++ b/src/hooks/useLockBodyScroll.ts @@ -0,0 +1,11 @@ +import { useEffect } from 'react'; + +export const useLockBodyScroll = (isLocked: boolean) => { + useEffect(() => { + document.body.style.overflow = isLocked ? 'hidden' : ''; + + return () => { + document.body.style.overflow = ''; + }; + }, [isLocked]); +}; diff --git a/src/hooks/useMenuCloseOnResize.ts b/src/hooks/useMenuCloseOnResize.ts new file mode 100644 index 00000000000..4ac873d1150 --- /dev/null +++ b/src/hooks/useMenuCloseOnResize.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; + +export const useMenuCloseOnResize = ( + isOpen: boolean, + onClose: () => void, + breakpoint = 640, +) => { + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= breakpoint && isOpen) { + onClose(); + } + }; + + window.addEventListener('resize', handleResize); + + return () => window.removeEventListener('resize', handleResize); + }, [isOpen, onClose, breakpoint]); +}; diff --git a/src/hooks/useProductDetails.ts b/src/hooks/useProductDetails.ts new file mode 100644 index 00000000000..617769c6d39 --- /dev/null +++ b/src/hooks/useProductDetails.ts @@ -0,0 +1,72 @@ +import { useState, useCallback, useEffect } from 'react'; +import { CatalogProducts, Product } from '../types/Types'; +import { + getProductById, + getProducts, + getSuggestedProducts, +} from '../api/products'; + +export const useProductDetails = (productId: string | undefined) => { + const [product, setProduct] = useState(null); + const [catalogProduct, setCatalogProduct] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [selectedImage, setSelectedImage] = useState(''); + const [suggestedProducts, setSuggestedProducts] = useState( + [], + ); + + const fetchProducts = useCallback(async () => { + if (!productId) { + return; + } + + setIsLoading(true); + setErrorMessage(''); + + try { + const allProducts = await getProducts(); + + const match = allProducts.find(prod => prod.itemId === productId); + + if (!match) { + throw new Error('Product not found in catalog'); + } + + setCatalogProduct(match); + + const data = await getProductById(match.category, productId); + + if (!data) { + throw new Error('No product information found'); + } + + setProduct(data); + setSelectedImage(data.images[0] || ''); + + const suggested = await getSuggestedProducts(); + + setSuggestedProducts(suggested); + } catch (error) { + setErrorMessage('Product was not found.'); + } finally { + setIsLoading(false); + } + }, [productId]); + + useEffect(() => { + fetchProducts(); + }, [fetchProducts]); + + return { + product, + catalogProduct, + isLoading, + errorMessage, + selectedImage, + setSelectedImage, + suggestedProducts, + }; +}; diff --git a/src/hooks/useSlider.ts b/src/hooks/useSlider.ts new file mode 100644 index 00000000000..aa5bf8e1d47 --- /dev/null +++ b/src/hooks/useSlider.ts @@ -0,0 +1,71 @@ +import { useState, useRef, useCallback, useEffect, RefObject } from 'react'; + +const SCROLL_STEPS = { mobile: 228, tablet: 253, desktop: 288 }; + +interface UseSliderReturn { + listRef: RefObject; + canScrollLeft: boolean; + canScrollRight: boolean; + scroll: (direction: 'left' | 'right') => void; + checkScrollPosition: () => void; +} + +export const useSlider = (dependency: T): UseSliderReturn => { + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + const listRef = useRef(null); + + const checkScrollPosition = useCallback(() => { + if (listRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = listRef.current; + + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(Math.ceil(scrollLeft + clientWidth) < scrollWidth - 1); + } + }, []); + + useEffect(() => { + const timer = setTimeout(checkScrollPosition, 50); + + window.addEventListener('resize', checkScrollPosition); + + return () => { + clearTimeout(timer); + window.removeEventListener('resize', checkScrollPosition); + }; + }, [checkScrollPosition, dependency]); + + const getScrollStep = useCallback(() => { + if (window.innerWidth < 640) { + return SCROLL_STEPS.mobile; + } + + if (window.innerWidth < 1200) { + return SCROLL_STEPS.tablet; + } + + return SCROLL_STEPS.desktop; + }, []); + + const scroll = useCallback( + (direction: 'left' | 'right') => { + if (!listRef.current) { + return; + } + + listRef.current.scrollBy({ + left: direction === 'left' ? -getScrollStep() : getScrollStep(), + behavior: 'smooth', + }); + }, + [getScrollStep], + ); + + return { + listRef, + canScrollLeft, + canScrollRight, + scroll, + checkScrollPosition, + }; +}; diff --git a/src/hooks/useSwipe.ts b/src/hooks/useSwipe.ts new file mode 100644 index 00000000000..1d62dc485f3 --- /dev/null +++ b/src/hooks/useSwipe.ts @@ -0,0 +1,47 @@ +import { useState } from 'react'; + +interface UseSwipeProps { + onSwipedLeft: () => void; + onSwipedRight: () => void; +} + +export const useSwipe = ({ onSwipedLeft, onSwipedRight }: UseSwipeProps) => { + const [touchStart, setTouchStart] = useState(null); + const [touchEnd, setTouchEnd] = useState(null); + + const minSwipeDistance = 50; + + const onTouchStart = (event: React.TouchEvent) => { + setTouchEnd(null); + + setTouchStart(event.targetTouches[0].clientX); + }; + + const onTouchMove = (event: React.TouchEvent) => { + setTouchEnd(event.targetTouches[0].clientX); + }; + + const onTouchEnd = () => { + if (touchStart === null || touchEnd === null) { + return; + } + + const distance = touchStart - touchEnd; + const isLeftSwipe = distance > minSwipeDistance; + const isRightSwipe = distance < -minSwipeDistance; + + if (isLeftSwipe) { + onSwipedLeft(); + } + + if (isRightSwipe) { + onSwipedRight(); + } + }; + + return { + onTouchStart, + onTouchMove, + onTouchEnd, + }; +}; diff --git a/src/modules/CartPage/CartItem/CartItem.module.scss b/src/modules/CartPage/CartItem/CartItem.module.scss new file mode 100644 index 00000000000..a1e4043ddc3 --- /dev/null +++ b/src/modules/CartPage/CartItem/CartItem.module.scss @@ -0,0 +1,132 @@ +@import '../../../styles/variables'; +@import '../../../styles/typography'; +@import '../../../styles/mixins'; + +.cart { + &__item { + display: flex; + flex-direction: column; + gap: 16px; + box-sizing: border-box; + padding: 16px; + background-color: var(--card-bg); + border: 1px solid var(--border); + + @include respond-to('tablet') { + flex-direction: row; + align-items: center; + padding: 24px; + } + } + + &__info { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + width: 100%; + + @include respond-to('tablet') { + gap: 24px; + flex-grow: 1; + width: auto; + } + } + &__itemImage { + flex-shrink: 0; + width: 80px; + height: 80px; + object-fit: contain; + + @include hover-image-scale; + } + + &__buttonDelete { + display: flex; + width: 16px; + height: 16px; + padding: 0; + color: var(--icon-color); + background: transparent; + border: none; + cursor: pointer; + + @include hover-transition(color); + + @include hover { + color: var(--text-main); + } + } + + &__itemTitle { + color: var(--text-main); + + @include body-text; + @include hover-transition(color); + + @include hover { + color: var(--text-secondary); + } + } + + &__actions { + display: flex; + justify-content: space-between; + gap: 24px; + } + + &__quantityControl { + display: flex; + align-items: center; + } + + &__buttonMinus { + color: var(--icon-color); + } + + &__buttonPlus { + color: var(--text-main); + } + + &__buttonMinus, + &__buttonPlus { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: 1px solid var(--border); + cursor: pointer; + + @include hover-transition(border-color); + + @include hover { + border-color: var(--text-main); + } + + &:disabled { + color: var(--border); + cursor: not-allowed; + border-color: var(--border); + } + } + + &__quantity { + display: inline-block; + width: 40px; + text-align: center; + color: var(--text-main); + + @include body-text; + } + + &__price { + color: var(--text-main); + + width: 100px; + text-align: right; + + @include h3; + } +} diff --git a/src/modules/CartPage/CartItem/CartItem.tsx b/src/modules/CartPage/CartItem/CartItem.tsx new file mode 100644 index 00000000000..40213defa95 --- /dev/null +++ b/src/modules/CartPage/CartItem/CartItem.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { MinusIcon } from '../../../components/ui/MinusIcon'; +import { PlusIcon } from '../../../components/ui/PlusIcon'; +import { CloseIcon } from '../../../components/ui/CloseIcon'; +import { CartItemInterface, useCart } from '../../../context/CartContext'; +import { Link } from 'react-router-dom'; +import { PathType } from '../../../types/Types'; +import styles from './CartItem.module.scss'; + +interface CartItemProps { + item: CartItemInterface; +} + +export const CartItem: React.FC = ({ item }) => { + const { updateQuantity, removeFromCart } = useCart(); + + const handleIncrease = () => + updateQuantity(item.product.id, item.quantity + 1); + + const handleDecrease = () => + updateQuantity(item.product.id, item.quantity - 1); + + const handleRemove = () => removeFromCart(item.product.id); + + const handleTotalPrice = () => item.product.price * item.quantity; + + return ( +
+
+ + + {item.product.name} + + + {item.product.name} + +
+
+
+ + {item.quantity} + +
+ ${handleTotalPrice()} +
+
+ ); +}; diff --git a/src/modules/CartPage/CartItem/index.ts b/src/modules/CartPage/CartItem/index.ts new file mode 100644 index 00000000000..186b364ebdf --- /dev/null +++ b/src/modules/CartPage/CartItem/index.ts @@ -0,0 +1 @@ +export { CartItem } from './CartItem'; diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..8e2263b99d4 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,103 @@ +@import '../../styles/variables'; +@import '../../styles/typography'; +@import '../../styles/mixins'; +@import '../../styles/fonts'; + +.cart { + padding-bottom: 64px; + + @include page-container; + @include page-grid; + + @include respond-to('desktop') { + padding-bottom: 80px; + } + + &__back { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 24px; + margin-bottom: 16px; + padding: 0; + line-height: 1; + grid-column: 1 / -1; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + + @include hover-transition(color); + @include small-text; + + @include hover { + color: var(--text-main); + } + } + + &__title { + margin: 24px 0 32px; + color: var(--text-main); + grid-column: 1 / -1; + + @include h1; + + @include respond-to('tablet') { + margin-top: 16px; + } + } + + &__emptyContent { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + @include respond-to('tablet') { + flex-direction: row; + justify-content: space-around; + text-align: left; + } + } + + &__emptyText { + color: var(--text-main); + + @include h2; + } + + &__emptyImage { + width: 100%; + max-width: 280px; + height: auto; + object-fit: contain; + } + + &__list { + display: flex; + flex-direction: column; + gap: 16px; + grid-column: span 4; + + @include respond-to('tablet') { + grid-column: span 12; + } + + @include respond-to('desktop') { + grid-column: span 16; + } + } + + &__summary { + grid-column: span 4; + + @include respond-to('tablet') { + grid-column: span 12; + } + + @include respond-to('desktop') { + grid-column: span 8; + } + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..f7f42c42e73 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { CartItem } from './CartItem'; +import { CartTotal } from './CartTotal'; +import { useCart } from '../../context/CartContext'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeftIcon } from '../../components/ui/ArrowLeftIcon'; +import cartIsEmpty from '../../../public/img/cart-is-empty.png'; +import styles from './CartPage.module.scss'; + +export const CartPage: React.FC = () => { + const { cartItems } = useCart(); + const navigate = useNavigate(); + + return ( +
+ +

Cart

+ + {cartItems.length === 0 ? ( +
+

Your cart is empty

+ Cart is empty +
+ ) : ( + <> +
+ {cartItems.map(item => ( + + ))} +
+
+ +
+ + )} +
+ ); +}; diff --git a/src/modules/CartPage/CartTotal/CartTotal.module.scss b/src/modules/CartPage/CartTotal/CartTotal.module.scss new file mode 100644 index 00000000000..3647cca7ce0 --- /dev/null +++ b/src/modules/CartPage/CartTotal/CartTotal.module.scss @@ -0,0 +1,70 @@ +@import '../../../styles/variables'; +@import '../../../styles/typography'; +@import '../../../styles/mixins'; + +.cart { + &__total { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; + padding: 24px; + background-color: var(--card-bg); + border: 1px solid var(--border); + } + + &__totalInfo { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + } + + &__totalPrice { + color: var(--text-main); + text-align: center; + + @include h2; + } + + &__totalQuantity { + color: var(--text-secondary); + text-align: center; + font-weight: 600; + + @include body-text; + } + + &__totalDivider { + width: 100%; + height: 1px; + margin: 16px 0; + + background-color: var(--border); + border: none; + + @include respond-to('desktop') { + margin: 24px 0; + } + } + + &__buttonCheckout { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 48px; + color: var(--btn-text); + background-color: var(--btn-bg); + cursor: pointer; + + @include button-text; + @include hover-transition(all); + + @include hover { + filter: brightness(1.2); + box-shadow: 0 3px 13px 0 rgba(23, 32, 49, 0.4); + } + } +} diff --git a/src/modules/CartPage/CartTotal/CartTotal.tsx b/src/modules/CartPage/CartTotal/CartTotal.tsx new file mode 100644 index 00000000000..8e782a14f99 --- /dev/null +++ b/src/modules/CartPage/CartTotal/CartTotal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useCart } from '../../../context/CartContext'; +import styles from './CartTotal.module.scss'; + +export const CartTotal: React.FC = () => { + const { cartItems, clearCart } = useCart(); + + const totalQuantity = cartItems.reduce((sum, item) => sum + item.quantity, 0); + const totalPrice = cartItems.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0, + ); + + const handleCheckout = () => { + const confirmed = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (confirmed) { + clearCart(); + } + }; + + return ( +
+
+

${totalPrice}

+ + Total for {totalQuantity} items + +
+ +
+ + +
+ ); +}; diff --git a/src/modules/CartPage/CartTotal/index.ts b/src/modules/CartPage/CartTotal/index.ts new file mode 100644 index 00000000000..1aec2724ccb --- /dev/null +++ b/src/modules/CartPage/CartTotal/index.ts @@ -0,0 +1 @@ +export { CartTotal } from './CartTotal'; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 00000000000..203fb0ea4bd --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export { CartPage } from './CartPage'; diff --git a/src/modules/CategoryPage/CategoryPage.tsx b/src/modules/CategoryPage/CategoryPage.tsx new file mode 100644 index 00000000000..7dec209d90e --- /dev/null +++ b/src/modules/CategoryPage/CategoryPage.tsx @@ -0,0 +1,61 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Catalog } from '../../components/Catalog'; +import { CatalogProducts, CategoriesType } from '../../types/Types'; +import { getAccessories, getPhones, getTablets } from '../../api/products'; + +interface CategoryPageProps { + title: string; + category: CategoriesType; +} + +export const CategoryPage: React.FC = ({ + title, + category, +}) => { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + const fetchProducts = useCallback(async () => { + setIsLoading(true); + setErrorMessage(''); + try { + let data: CatalogProducts[] = []; + + switch (category) { + case CategoriesType.PHONES: + data = await getPhones(); + break; + case CategoriesType.TABLETS: + data = await getTablets(); + break; + case CategoriesType.ACCESSORIES: + data = await getAccessories(); + break; + default: + data = []; + } + + setProducts(data); + } catch (error) { + setErrorMessage('Something went wrong. Please try again.'); + } finally { + setIsLoading(false); + } + }, [category]); + + useEffect(() => { + fetchProducts(); + }, [fetchProducts]); + + return ( + + ); +}; diff --git a/src/modules/CategoryPage/index.ts b/src/modules/CategoryPage/index.ts new file mode 100644 index 00000000000..ceaf8ad24ed --- /dev/null +++ b/src/modules/CategoryPage/index.ts @@ -0,0 +1 @@ +export { CategoryPage } from './CategoryPage'; diff --git a/src/modules/FavouritesPage/FavouritesPage.module.scss b/src/modules/FavouritesPage/FavouritesPage.module.scss new file mode 100644 index 00000000000..d58b0b6dde4 --- /dev/null +++ b/src/modules/FavouritesPage/FavouritesPage.module.scss @@ -0,0 +1,31 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.favourites { + margin-top: 24px; + + @include page-container; + + &__title { + color: var(--text-main); + margin-bottom: 8px; + + @include h1; + } + + &__quantity { + color: var(--text-secondary); + margin-bottom: 32px; + + @include body-text; + + @include respond-to('tablet') { + margin-bottom: 40px; + } + } + + &__empty { + text-align: center; + } +} diff --git a/src/modules/FavouritesPage/FavouritesPage.tsx b/src/modules/FavouritesPage/FavouritesPage.tsx new file mode 100644 index 00000000000..9af13d011d0 --- /dev/null +++ b/src/modules/FavouritesPage/FavouritesPage.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from 'react'; +import { ProductsList } from '../../components/ProductsList'; +import { useFavourites } from '../../context/FavoritesContext'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { useCatalogParams } from '../../hooks/useCatalogParams'; +import styles from './FavouritesPage.module.scss'; + +export const FavouritesPage: React.FC = () => { + const { favourites } = useFavourites(); + const { query } = useCatalogParams(); + + const visibleFavourites = useMemo(() => { + return favourites.filter(product => + product.name.toLowerCase().includes(query.toLowerCase()), + ); + }, [favourites, query]); + + return ( +
+ +

Favourites

+
+ {visibleFavourites.length}{' '} + {visibleFavourites.length === 1 ? 'item' : 'items'} +
+ + {favourites.length > 0 ? ( +
+ +
+ ) : ( +

+ Your favourites list is empty. +

+ )} +
+ ); +}; diff --git a/src/modules/FavouritesPage/index.ts b/src/modules/FavouritesPage/index.ts new file mode 100644 index 00000000000..0a16512c056 --- /dev/null +++ b/src/modules/FavouritesPage/index.ts @@ -0,0 +1 @@ +export { FavouritesPage } from './FavouritesPage'; diff --git a/src/modules/HomePage/Hero/Hero.module.scss b/src/modules/HomePage/Hero/Hero.module.scss new file mode 100644 index 00000000000..1323abf5713 --- /dev/null +++ b/src/modules/HomePage/Hero/Hero.module.scss @@ -0,0 +1,98 @@ +@import '../../../styles/variables'; +@import '../../../styles/mixins'; + +.hero { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + + grid-column: 1 / -1; + + &__main { + display: grid; + grid-template-columns: 1fr; + align-items: stretch; + gap: 0; + width: 100%; + + @include respond-to('tablet') { + grid-template-columns: 32px 1fr 32px; + gap: 19px; + } + + @include respond-to('desktop') { + gap: 16px; + } + } + + &__viewport { + width: 100%; + height: 320px; + overflow: hidden; + + @include respond-to('desktop') { + height: 400px; + } + } + + &__track { + display: flex; + height: 100%; + transition: transform 0.5s ease-in-out; + will-change: transform; + } + + &__image { + flex: 0 0 100%; + width: 100%; + height: 100%; + object-fit: cover; + } + + &__button { + display: none; + background: none; + border: 1px solid var(--border); + color: var(--text-main); + + cursor: pointer; + padding: 0; + align-items: center; + justify-content: center; + box-sizing: border-box; + + @include hover-transition(all, 0.3s); + + @include respond-to('tablet') { + display: flex; + } + + @include hover { + border-color: var(--text-main); + } + } + + &__pagination { + display: flex; + justify-content: center; + gap: 9px; + margin-top: 18px; + } + + &__dot { + width: 14px; + height: 4px; + padding: 0; + border: none; + background-color: var(--border); + cursor: pointer; + + @include hover-transition(all, 0.3s); + + &--active { + background-color: var(--text-main); + width: 14px; + } + } +} diff --git a/src/modules/HomePage/Hero/Hero.tsx b/src/modules/HomePage/Hero/Hero.tsx new file mode 100644 index 00000000000..6d6a6be836b --- /dev/null +++ b/src/modules/HomePage/Hero/Hero.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useState } from 'react'; +import { ArrowLeftIcon } from '../../../components/ui/ArrowLeftIcon'; +import { ArrowRightIcon } from '../../../components/ui/ArrowRightIcon'; +import { useSwipe } from '../../../hooks/useSwipe'; +import classNames from 'classnames'; +import styles from './Hero.module.scss'; + +const banners = [ + 'img/banner-accessories.png', + 'img/banner-phones.png', + 'img/banner-tablets.png', + 'img/banner-1.jpg', + 'img/banner-2.jpg', + 'img/banner-3.jpg', + 'img/banner-4.jpg', +]; + +export const Hero: React.FC = () => { + const [currentIndex, setCurrentIndex] = useState(0); + + const handleNextBanner = () => { + setCurrentIndex(prevIndex => (prevIndex + 1) % banners.length); + }; + + const handlePrevBanner = () => { + setCurrentIndex( + prevIndex => (prevIndex - 1 + banners.length) % banners.length, + ); + }; + + useEffect(() => { + const interval = setInterval(handleNextBanner, 5000); + + return () => clearInterval(interval); + }, []); + + const swipeHandlers = useSwipe({ + onSwipedLeft: handleNextBanner, + onSwipedRight: handlePrevBanner, + }); + + return ( +
+
+ +
+
+ {banners.map((banner, index) => ( + {`Banner + ))} +
+
+ +
+
+ {banners.map((_, index) => ( + + ))} +
+
+ ); +}; diff --git a/src/modules/HomePage/Hero/index.ts b/src/modules/HomePage/Hero/index.ts new file mode 100644 index 00000000000..76d2216b134 --- /dev/null +++ b/src/modules/HomePage/Hero/index.ts @@ -0,0 +1 @@ +export { Hero } from './Hero'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..14f4e69fd2b --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,43 @@ +@import '../../styles/variables'; +@import '../../styles/typography'; +@import '../../styles/mixins'; +@import '../../styles/fonts'; + +.home { + width: 100%; + + &__container { + @include page-container; + } + + &__title { + max-width: 15ch; + padding-top: 24px; + grid-column: 1 / -1; + + @include h2; + + @include respond-to('tablet') { + grid-column: 1 / -8; + padding: 32px 0; + } + + @include respond-to('desktop') { + grid-column: 1 / -1; + padding: 56px 0; + max-width: none; + } + } +} + +.title__hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..7b5835a7278 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Hero } from './Hero'; +import { ProductsSlider } from '../../components/ProductsSlider'; +import { ShopByCategory } from '../../components/ShopByCategory'; +import { HotPrices } from '../../components/HotPrices'; +import { CatalogProducts } from '../../types/Types'; +import { getProducts } from '../../api/products'; +import styles from './HomePage.module.scss'; + +export const HomePage: React.FC = () => { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + const fetchProducts = async () => { + try { + setIsLoading(true); + const data = await getProducts(); + + setProducts(data); + } catch (error) { + setErrorMessage('Something went wrong while fetching products'); + } finally { + setIsLoading(false); + } + }; + + fetchProducts(); + }, []); + + const brandNewProducts = useMemo( + () => + [...products] + .sort((item1, item2) => item2.year - item1.year) + .map(item => ({ + ...item, + fullPrice: item.price, + })), + [products], + ); + + const hotPricesProducts = useMemo( + () => + [...products].sort( + (item1, item2) => + item2.fullPrice - item2.price - (item1.fullPrice - item1.price), + ), + [products], + ); + + return ( +
+

Product Catalog

+

Welcome to Nice Gadgets store!

+ + {isLoading &&

Loading products...

} + {errorMessage &&

{errorMessage}

} + + + +
+ ); +}; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 00000000000..0799f479a25 --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export { HomePage } from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000000..d17546a7687 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,46 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.notFound { + &__emptyContent { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + @include respond-to('tablet') { + flex-direction: row; + justify-content: space-around; + text-align: left; + } + } + + &__emptyText { + color: var(--text-main); + + @include h2; + } + + &__emptyImage { + width: 100%; + max-width: 300px; + height: auto; + object-fit: contain; + } + + &__back { + display: flex; + justify-content: center; + align-items: center; + + cursor: pointer; + + @include hover-transition(color); + + @include hover { + color: var(--text-secondary); + } + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..deb0c06d483 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { PathType } from '../../types/Types'; +import noFoundPageImage from '../../../public/img/page-not-found.png'; +import styles from './NotFoundPage.module.scss'; + +export const NotFoundPage: React.FC = () => { + return ( + <> +
+

Page not found

+ No found page +
+ + Back to home page + + + ); +}; diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 00000000000..642c600088e --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export { NotFoundPage } from './NotFoundPage'; diff --git a/src/modules/ProductDetailsPage/CapacitySelector/CapacitySelector.module.scss b/src/modules/ProductDetailsPage/CapacitySelector/CapacitySelector.module.scss new file mode 100644 index 00000000000..397df87e191 --- /dev/null +++ b/src/modules/ProductDetailsPage/CapacitySelector/CapacitySelector.module.scss @@ -0,0 +1,64 @@ +@import '../../../styles/variables'; +@import '../../../styles/mixins'; +@import '../../../styles/typography'; + +.capacitySelector { + padding-bottom: 24px; + border-bottom: 1px solid var(--border); + + &__info { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__availableCapacity { + @include small-text; + + color: var(--text-secondary); + } + + &__capacity { + display: flex; + flex-wrap: nowrap; + gap: 8px; + } + + &__label { + @include body-text; + + display: flex; + align-items: center; + justify-content: center; + + min-width: 56px; + height: 32px; + padding: 0 8px; + white-space: nowrap; + + background: transparent; + border: 1px solid var(--icon-color); + color: var(--text-main); + cursor: pointer; + + @include hover-transition(border-color); + + @include hover { + border-color: var(--text-main); + } + + &--active { + background: var(--text-main); + border-color: var(--text-main); + color: var(--page-bg); + } + } + + &__radio { + @include radio-hidden; + } + + &__visuallyHidden { + @include visually-hidden; + } +} diff --git a/src/modules/ProductDetailsPage/CapacitySelector/CapacitySelector.tsx b/src/modules/ProductDetailsPage/CapacitySelector/CapacitySelector.tsx new file mode 100644 index 00000000000..b7bff08c7d6 --- /dev/null +++ b/src/modules/ProductDetailsPage/CapacitySelector/CapacitySelector.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { PathType, Product } from '../../../types/Types'; +import { useNavigate } from 'react-router-dom'; +import styles from './CapacitySelector.module.scss'; + +interface CapacitySelectorProps { + product: Product; +} + +export const CapacitySelector: React.FC = ({ + product, +}) => { + const navigate = useNavigate(); + + const handleCapacityChange = (newCapacity: string) => { + const capacityFormatted = newCapacity.toLowerCase(); + const colorFormatted = product.color.toLowerCase().replace(/\s+/g, '-'); + const newItemId = `${product.namespaceId}-${capacityFormatted}-${colorFormatted}`; + + navigate(`${PathType.PRODUCT}/${newItemId}`); + }; + + return ( +
+
+ + Select capacity + + +
+ {product.capacityAvailable.map(capacity => { + return ( + + ); + })} +
+
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/CapacitySelector/index.ts b/src/modules/ProductDetailsPage/CapacitySelector/index.ts new file mode 100644 index 00000000000..e347a453d56 --- /dev/null +++ b/src/modules/ProductDetailsPage/CapacitySelector/index.ts @@ -0,0 +1 @@ +export { CapacitySelector } from './CapacitySelector'; diff --git a/src/modules/ProductDetailsPage/ColorSelector/ColorSelector.module.scss b/src/modules/ProductDetailsPage/ColorSelector/ColorSelector.module.scss new file mode 100644 index 00000000000..da71ce904eb --- /dev/null +++ b/src/modules/ProductDetailsPage/ColorSelector/ColorSelector.module.scss @@ -0,0 +1,67 @@ +@import '../../../styles/variables'; +@import '../../../styles/mixins'; +@import '../../../styles/typography'; + +.colorSelector { + display: flex; + padding-bottom: 24px; + border-bottom: 1px solid var(--border); + + &__info { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__availableColors { + @include small-text; + + color: var(--text-secondary); + } + + &__colors { + display: flex; + align-items: center; + gap: 8px; + } + + &__label { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--border); + box-shadow: inset 0 0 0 2px #fff; + cursor: pointer; + + @include hover-transition(border-color); + + @include hover { + border-color: var(--icon-color); + } + + &--active { + border-color: var(--text-main); + } + } + + &__id { + @include small-text; + + margin-left: auto; + min-width: 85px; + text-align: right; + color: var(--icon-color); + + @include respond-to('desktop') { + min-width: 176px; + } + } + + &__radio { + @include radio-hidden; + } + + &__visuallyHidden { + @include visually-hidden; + } +} diff --git a/src/modules/ProductDetailsPage/ColorSelector/ColorSelector.tsx b/src/modules/ProductDetailsPage/ColorSelector/ColorSelector.tsx new file mode 100644 index 00000000000..e6ac3039cd5 --- /dev/null +++ b/src/modules/ProductDetailsPage/ColorSelector/ColorSelector.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { PathType, Product } from '../../../types/Types'; +import { useNavigate, useParams } from 'react-router-dom'; +import { getColorHex } from '../../../utils/helpers'; +import styles from './ColorSelector.module.scss'; + +interface ColorSelectorProps { + product: Product; +} + +export const ColorSelector: React.FC = ({ product }) => { + const navigate = useNavigate(); + const {} = useParams(); + + const handleColorChange = (newColor: string) => { + const capacityFormatted = product.capacity + ? product.capacity.toLowerCase() + : ''; + const colorFormatted = newColor.toLowerCase().replace(/\s+/g, '-'); + const newItemId = `${product.namespaceId}-${capacityFormatted}-${colorFormatted}`; + + navigate(`${PathType.PRODUCT}/${newItemId}`); + }; + + return ( +
+
+ + Available colors + + +
+ {product.colorsAvailable?.map(color => { + return ( + + ); + })} +
+
+ {product.namespaceId} +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/ColorSelector/index.ts b/src/modules/ProductDetailsPage/ColorSelector/index.ts new file mode 100644 index 00000000000..d46c68203ee --- /dev/null +++ b/src/modules/ProductDetailsPage/ColorSelector/index.ts @@ -0,0 +1 @@ +export { ColorSelector } from './ColorSelector'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..25aaec3d063 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,261 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; +@import '../../styles/typography'; + +.details { + margin-top: 24px; + padding-bottom: 56px; + + @include page-container; + + @include respond-to('tablet') { + padding-bottom: 64px; + } + + @include respond-to('desktop') { + padding-bottom: 80px; + } + + &__back { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 24px 0 16px; + padding: 0; + line-height: 1; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + + @include small-text; + @include hover-transition(color); + + @include hover { + color: var(--text-main); + } + } + + &__title { + color: var(--text-main); + + @include h2; + + @include respond-to('tablet') { + margin-bottom: 40px; + } + } + + &__hero { + display: flex; + flex-direction: column; + gap: 24px; + margin-bottom: 56px; + + @include respond-to('tablet') { + display: grid; + grid-template-columns: minmax(0, 339px) 237px; + column-gap: 16px; + align-items: start; + margin-bottom: 64px; + } + + @include respond-to('desktop') { + grid-template-columns: 560px 320px; + column-gap: 64px; + margin-bottom: 80px; + } + } + + &__info { + display: flex; + flex-direction: column; + gap: 24px; + + @include respond-to('tablet') { + width: 237px; + } + + @include respond-to('desktop') { + width: 320px; + } + } + + &__purchase { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 32px; + } + + &__priceContainer { + display: flex; + align-items: center; + gap: 8px; + } + + &__price { + color: var(--text-main); + + @include h2; + } + + &__fullPrice { + color: var(--text-secondary); + font-family: Mont, sans-serif; + font-size: 22px; + font-weight: 500; + line-height: 28px; + text-decoration: line-through; + } + + &__actions { + display: flex; + gap: 8px; + } + + &__actionAddButton { + flex: 1; + height: 48px; + background-color: var(--btn-bg); + color: var(--btn-text); + border: 1px solid var(--btn-bg); + cursor: pointer; + + @include button-text; + @include hover-transition(all); + + &--active { + background-color: var(--page-bg); + color: $cl-green; + border-color: var(--border); + cursor: default; + } + + &:not(&--active) { + @include hover { + filter: brightness(1.2); + box-shadow: 0 3px 13px 0 rgba(23, 32, 49, 0.4); + } + } + } + + &__actionFavouriteIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 48px; + height: 48px; + background: transparent; + border: 1px solid var(--icon-color); + cursor: pointer; + + @include hover-transition(all); + + @include hover { + border-color: var(--text-main); + } + } + + &__icon { + display: block; + fill: none; + stroke: var(--icon-color); + + @include hover-image-scale; + } + + &__bottom { + row-gap: 56px; + margin-bottom: 56px; + + @include page-grid; + + @include respond-to('desktop') { + row-gap: 0; + margin-bottom: 80px; + } + } + + &__about { + display: flex; + flex-direction: column; + grid-column: span 4; + + @include respond-to('tablet') { + grid-column: span 12; + } + + @include respond-to('desktop') { + grid-column: span 12; + } + } + + &__aboutTitle { + margin-bottom: 16px; + color: var(--text-main); + + @include h3; + } + + &__aboutDivider { + height: 1px; + margin-bottom: 32px; + background-color: var(--border); + } + + &__aboutContent { + display: flex; + flex-direction: column; + gap: 32px; + } + + &__article { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__articleTitle { + color: var(--text-main); + + @include h4; + } + + &__articleText { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__paragraph { + color: var(--text-secondary); + + @include body-text; + } + + &__specs { + grid-column: span 4; + + @include respond-to('tablet') { + grid-column: span 12; + } + + @include respond-to('desktop') { + grid-column: span 12; + } + } + + &__specsTitle { + margin-bottom: 16px; + + @include h3; + } + + &__specsDivider { + height: 1px; + background-color: var(--border); + margin-bottom: 25px; + } +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..6e22ec80991 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { Loader } from '../../components/Loader'; +import { ProductGallery } from './ProductGallery'; +import { ColorSelector } from './ColorSelector'; +import { CapacitySelector } from './CapacitySelector'; +import { TechSpecs } from './TechSpecs'; +import { ProductsSlider } from '../../components/ProductsSlider'; +import { ArrowLeftIcon } from '../../components/ui/ArrowLeftIcon'; +import { useFavourites } from '../../context/FavoritesContext'; +import { useCart } from '../../context/CartContext'; +import { FavouriteIcon } from '../../components/ui/FavouriteIcon'; +import { useProductDetails } from '../../hooks/useProductDetails'; +import { HeartFillIcon } from '../../components/ui/HeartFillIcon'; +import classNames from 'classnames'; +import styles from './ProductDetailsPage.module.scss'; + +export const ProductDetailsPage: React.FC = () => { + const { productId } = useParams<{ + productId: string; + }>(); + + const { + product, + catalogProduct, + isLoading, + errorMessage, + selectedImage, + setSelectedImage, + suggestedProducts, + } = useProductDetails(productId); + + const navigate = useNavigate(); + const { addToCart, isInCart } = useCart(); + const { toggleFavourite, isFavourite } = useFavourites(); + + const isAdded = catalogProduct ? isInCart(catalogProduct.id) : false; + const isActiveFavourite = catalogProduct + ? isFavourite(catalogProduct.id) + : false; + + const handleCartClick = () => { + if (catalogProduct && !isAdded) { + addToCart(catalogProduct); + } + }; + + const handleFavouriteClick = () => { + if (catalogProduct) { + toggleFavourite(catalogProduct); + } + }; + + return ( +
+ + + {isLoading && } + {errorMessage &&

{errorMessage}

} + {!isLoading && !errorMessage && product && ( + <> +

{product.name}

+
+ +
+ + +
+
+ + ${product.priceDiscount} + + {product.priceRegular !== product.priceDiscount && ( + + ${product.priceRegular} + + )} +
+ +
+ + +
+
+ +
+
+
+
+

About

+
+ +
+ {product.description.map((desc, index) => ( +
+

+ {desc.title} +

+
+ {desc.text.map((paragraph, pIndex) => ( +

+ {paragraph} +

+ ))} +
+
+ ))} +
+
+ +
+

Tech specs

+
+
+ +
+
+
+ + + )} +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/ProductGallery/ProductGallery.module.scss b/src/modules/ProductDetailsPage/ProductGallery/ProductGallery.module.scss new file mode 100644 index 00000000000..69921b4aa75 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductGallery/ProductGallery.module.scss @@ -0,0 +1,85 @@ +@import '../../../styles/variables'; +@import '../../../styles/mixins'; + +.gallery { + display: flex; + flex-direction: column-reverse; + gap: 16px; + width: 100%; + + @include respond-to('tablet') { + flex-direction: row; + align-items: flex-start; + gap: 16px; + } + + &__thumbnails { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + width: 100%; + + @include respond-to('tablet') { + grid-template-columns: 1fr; + width: 35px; + flex-shrink: 0; + } + + @include respond-to('desktop') { + width: 80px; + gap: 16px; + } + + img { + display: block; + width: 100%; + aspect-ratio: 50 / 50; + object-fit: cover; + padding: 2px; + + background-color: var(--card-bg); + border: 1px solid var(--border); + cursor: pointer; + + @include hover-transition(border-color); + + @include hover { + border-color: var(--icon-color); + } + + @include respond-to('tablet') { + aspect-ratio: 1 / 1; + object-fit: contain; + padding: 2px; + } + + @include respond-to('desktop') { + padding: 7px; + } + } + } + + &__thumbnail--active { + border-color: var(--text-main) !important; + } + + &__main { + display: block; + width: 100%; + max-width: 288px; + max-height: 288px; + margin: 0 auto; + object-fit: contain; + + @include respond-to('tablet') { + max-width: 287px; + max-height: 287px; + margin: 0; + } + + @include respond-to('desktop') { + max-width: 464px; + max-height: 464px; + } + } +} diff --git a/src/modules/ProductDetailsPage/ProductGallery/ProductGallery.tsx b/src/modules/ProductDetailsPage/ProductGallery/ProductGallery.tsx new file mode 100644 index 00000000000..c19f5210443 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductGallery/ProductGallery.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import styles from './ProductGallery.module.scss'; + +interface ProductGalleryProps { + images: string[]; + selectedImage: string; + onSelect: (image: string) => void; +} + +export const ProductGallery: React.FC = ({ + images, + selectedImage, + onSelect, +}) => { + return ( +
+
+ {images.map(image => { + return ( + onSelect(image)} + alt="Product thumbnail" + /> + ); + })} +
+ Product image +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/ProductGallery/index.ts b/src/modules/ProductDetailsPage/ProductGallery/index.ts new file mode 100644 index 00000000000..e72cefa3c7a --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductGallery/index.ts @@ -0,0 +1 @@ +export { ProductGallery } from './ProductGallery'; diff --git a/src/modules/ProductDetailsPage/TechSpecs/TechSpecs.module.scss b/src/modules/ProductDetailsPage/TechSpecs/TechSpecs.module.scss new file mode 100644 index 00000000000..1696dbff803 --- /dev/null +++ b/src/modules/ProductDetailsPage/TechSpecs/TechSpecs.module.scss @@ -0,0 +1,43 @@ +@import '../../../styles/variables'; +@import '../../../styles/typography'; + +.techSpecs { + display: flex; + flex-direction: column; + + &__specs { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + } + + &__specsName { + color: var(--text-secondary); + } + + &__specsValue { + color: var(--text-main); + text-align: right; + } + + &--short { + gap: 8px; + + .techSpecs__specsName, + .techSpecs__specsValue { + font-weight: 600; + font-size: 12px; + line-height: 100%; + } + } + + &--full { + gap: 16px; + + .techSpecs__specsName, + .techSpecs__specsValue { + @include body-text; + } + } +} diff --git a/src/modules/ProductDetailsPage/TechSpecs/TechSpecs.tsx b/src/modules/ProductDetailsPage/TechSpecs/TechSpecs.tsx new file mode 100644 index 00000000000..774e14422e7 --- /dev/null +++ b/src/modules/ProductDetailsPage/TechSpecs/TechSpecs.tsx @@ -0,0 +1,46 @@ +import { useMemo } from 'react'; +import { Product } from '../../../types/Types'; +import styles from './TechSpecs.module.scss'; + +interface TechSpecsProps { + product: Product; + variant: 'short' | 'full'; +} + +export const TechSpecs: React.FC = ({ product, variant }) => { + const specs = useMemo(() => { + const baseSpecs = [ + { name: 'Screen', value: product.screen }, + { name: 'Resolution', value: product.resolution }, + { name: 'Processor', value: product.processor }, + { name: 'RAM', value: product.ram }, + ]; + + if (variant === 'short') { + return baseSpecs; + } + + return [ + ...baseSpecs, + { name: 'Built in memory', value: product.capacity }, + ...('camera' in product + ? [{ name: 'Camera', value: product.camera }] + : []), + ...('zoom' in product ? [{ name: 'Zoom', value: product.zoom }] : []), + { name: 'Cell', value: product.cell.join(', ') }, + ]; + }, [product, variant]); + + const containerClassName = `${styles.techSpecs} ${styles[`techSpecs--${variant}`]}`; + + return ( +
+ {specs.map(spec => ( +
+ {spec.name} + {spec.value} +
+ ))} +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/TechSpecs/index.ts b/src/modules/ProductDetailsPage/TechSpecs/index.ts new file mode 100644 index 00000000000..fced6b5881a --- /dev/null +++ b/src/modules/ProductDetailsPage/TechSpecs/index.ts @@ -0,0 +1 @@ +export { TechSpecs } from './TechSpecs'; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 00000000000..ec50c119343 --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export { ProductDetailsPage } from './ProductDetailsPage'; diff --git a/src/styles/_fonts.scss b/src/styles/_fonts.scss new file mode 100644 index 00000000000..7362305a6da --- /dev/null +++ b/src/styles/_fonts.scss @@ -0,0 +1,20 @@ +@font-face { + font-family: Mont; + src: url(/fonts/Mont-Regular.otf); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: url(/fonts/Mont-SemiBold.otf); + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: url(/fonts/Mont-Bold.otf); + font-weight: 700; + font-style: normal; +} diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss new file mode 100644 index 00000000000..b1400e4fcc0 --- /dev/null +++ b/src/styles/_mixins.scss @@ -0,0 +1,81 @@ +@import '../styles/variables'; + +@mixin respond-to($breakpoint) { + @if map-has-key($breakpoints, $breakpoint) { + @media (min-width: map-get($breakpoints, $breakpoint)) { + @content; + } + } +} + +@mixin hover-transition($property: all, $duration: 0.3s, $timing: ease-in-out) { + transition: $property $duration $timing; +} + +@mixin hover-image-scale { + @include hover-transition(transform); + + @include respond-to('desktop') { + @media (hover: hover) { + &:hover { + transform: scale(1.1); + } + } + } +} + +@mixin hover { + @media (hover: hover) { + &:hover { + @content; + } + } +} + +@mixin page-container { + width: 100%; + margin: 0 auto; + padding: 0 16px; + + @include respond-to('tablet') { + padding: 0 24px; + } + + @include respond-to('desktop') { + max-width: 1136px; + padding: 0; + } +} + +@mixin page-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(4, 1fr); + + @include respond-to('tablet') { + grid-template-columns: repeat(12, 1fr); + } + + @include respond-to('desktop') { + grid-template-columns: repeat(24, 1fr); + } +} + +@mixin visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@mixin radio-hidden { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} diff --git a/src/styles/_reset.scss b/src/styles/_reset.scss new file mode 100644 index 00000000000..92dda33322b --- /dev/null +++ b/src/styles/_reset.scss @@ -0,0 +1,42 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body, +h1, +h2, +h3, +h4, +p, +ul, +li { + font-family: Mont, sans-serif; + margin: 0; + padding: 0; +} + +ul { + list-style: none; +} + +body { + min-height: 100vh; + -text-rendering: optimizeSpeed; + -webkit-font-smoothing: antialiased; + min-width: 320px; +} + +img, +svg { + max-width: 100%; + display: block; +} + +@media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + scrollbar-gutter: stable; + } +} diff --git a/src/styles/_typography.scss b/src/styles/_typography.scss new file mode 100644 index 00000000000..c5b23b0501e --- /dev/null +++ b/src/styles/_typography.scss @@ -0,0 +1,94 @@ +@mixin font-mont { + font-family: Mont, sans-serif; +} + +// H1 - Main page titles +@mixin h1 { + @include font-mont; + + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + + @media (min-width: 1200px) { + font-size: 48px; + line-height: 56px; + } +} + +// H2 - Section headings (Models, Accessories) +@mixin h2 { + @include font-mont; + + font-weight: 800; + font-size: 22px; + line-height: 31px; + + @media (min-width: 640px) { + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + } +} + +// H3 - Product card names +@mixin h3 { + @include font-mont; + + font-weight: 800; + font-size: 22px; + line-height: 31px; +} + +// H4 - Smaller headings or highlighted parameters +@mixin h4 { + @include font-mont; + + font-weight: 700; + font-size: 16px; + line-height: 20px; + + @media (min-width: 1200px) { + font-size: 20px; + line-height: 26px; + } +} + +// Uppercase - For categories or labels (NEW) +@mixin uppercase-label { + @include font-mont; + + font-weight: 800; + font-size: 12px; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +// Body Text - Characteristics, product description +@mixin body-text { + @include font-mont; + + font-weight: 500; + font-size: 14px; + line-height: 21px; +} + +// Small Text - Signatures, footers, small details +@mixin small-text { + @include font-mont; + + font-weight: 700; + font-size: 12px; + line-height: 15px; +} + +// Button Text +@mixin button-text { + @include font-mont; + + font-weight: 600; + font-size: 14px; + line-height: 21px; +} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss new file mode 100644 index 00000000000..5e2672620f6 --- /dev/null +++ b/src/styles/_variables.scss @@ -0,0 +1,51 @@ +/* --- LIGHT THEME PALETTE --- */ +$lt-primary: #313237; // Main text, buttons +$lt-secondary: #89939a; // Secondary text +$lt-icons: #b4bdc3; // Icon color (Default) +$lt-elements: #e2e6e9; // Borders, dividers +$lt-hover-bg: #fafbfc; // Hover state and block background +$lt-white: #fff; // Pure white (page background) + +/* Highlights (common to both topics) */ +$cl-green: #27ae60; // Success +$cl-red: #eb5757; // Favourite + +/* --- DARK THEME PALETTE --- */ +$dt-black: #0f1121; // Main page background (Black) +$dt-surface-1: #161927; // Product cards, menus +$dt-surface-2: #212642; // Selected items +$dt-elements: #3b3e4a; // Borders in dark theme +$dt-icons: #4a4d58; // Icons in dark theme +$dt-secondary: #75767f; // Secondary text +$dt-white: #f1f2f9; // Text and light accents +$dt-accent: #8970ef; // Purple accent (Primary Button in dark theme) + +:root { + --page-bg: #{$lt-white}; + --card-bg: #{$lt-white}; + --text-main: #{$lt-primary}; + --text-secondary: #{$lt-secondary}; + --border: #{$lt-elements}; + --btn-bg: #{$lt-primary}; + --btn-text: #{$lt-white}; + --icon-color: #{$lt-icons}; + --accent: #{$lt-primary}; +} + +[data-theme='dark'] { + --page-bg: #{$dt-black}; + --card-bg: #{$dt-surface-1}; + --text-main: #{$dt-white}; + --text-secondary: #{$dt-secondary}; + --border: #{$dt-elements}; + --btn-bg: #{$dt-accent}; + --btn-text: #{$dt-white}; + --icon-color: #{$dt-icons}; + --accent: #{$dt-accent}; +} + +$breakpoints: ( + 'tablet': 640px, + 'tablet-md': 768px, + 'desktop': 1200px, +); diff --git a/src/types/Types.ts b/src/types/Types.ts new file mode 100644 index 00000000000..54938f1557a --- /dev/null +++ b/src/types/Types.ts @@ -0,0 +1,90 @@ +export interface CatalogProducts { + id: number; + category: string; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +} + +export interface ProductDescription { + title: string; + text: string[]; +} + +export interface BaseProduct { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: ProductDescription[]; + screen: string; + resolution: string; + processor: string; + ram: string; + cell: string[]; +} + +export interface DeviceWithCamera extends BaseProduct { + camera: string; + zoom: string; +} + +export type PhoneType = DeviceWithCamera; +export type TabletType = DeviceWithCamera; +export type WatchType = BaseProduct; + +export type Product = PhoneType | TabletType | WatchType; +export interface Category { + name: string; + src: string; + alt: string; + link: string; + quantity: number; + type: CategoriesType; +} + +export enum SortType { + AGE = 'age', + TITLE = 'title', + PRICE = 'price', +} + +export enum PerPageType { + ALL = 'all', + FOUR = '4', + EIGHT = '8', + SIXTEEN = '16', +} + +export enum PathType { + HOME = '/', + PHONES = '/phones', + TABLETS = '/tablets', + ACCESSORIES = '/accessories', + FAVOURITES = '/favorites', + CART = '/cart', + PRODUCT = '/product', + PRODUCTS = '/products', + CATEGORY = 'category', + PRODUCT_ID = 'productId', +} + +export enum CategoriesType { + PHONES = 'phones', + TABLETS = 'tablets', + ACCESSORIES = 'accessories', +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 00000000000..9b1ca6aa2c2 --- /dev/null +++ b/src/utils/helpers.ts @@ -0,0 +1,59 @@ +import { CatalogProducts, PerPageType, SortType } from '../types/Types'; + +export const getSortedProducts = ( + products: CatalogProducts[], + sort: SortType, +) => { + const sortedProducts = [...products]; + + switch (sort) { + case SortType.AGE: + return sortedProducts.sort((item1, item2) => item2.year - item1.year); + case SortType.TITLE: + return sortedProducts.sort((item1, item2) => + item1.name.localeCompare(item2.name), + ); + case SortType.PRICE: + return sortedProducts.sort((item1, item2) => item1.price - item2.price); + default: + return sortedProducts; + } +}; + +export const getPaginatedProducts = ( + products: CatalogProducts[], + page: number, + perPage: number | PerPageType, +) => { + if (perPage === PerPageType.ALL) { + return products; + } + + const itemsPerPage = Number(perPage); + const currentPage = Math.max(1, page); + + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + + return products.slice(startIndex, endIndex); +}; + +export const getColorHex = (color: string): string => { + const normalizedColor = color.toLowerCase().replace(/[\s-]+/g, ''); + + const colorMap: Record = { + midnight: '#343b43', + spacegray: '#5f5f5f', + starlight: '#faf7f2', + gold: '#d4c9b1', + silver: '#e2e4e1', + rosegold: '#b76e79', + graphite: '#4b4845', + sierrablue: '#a7c1d3', + pink: '#e5ddea', + purple: '#e5ddea', + spaceblack: '#4b4845', + }; + + return colorMap[normalizedColor] || color; +}; diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..28c404271b9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,8 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + server: { // for testing on a mobile device + host: true, + port: 5173, + } })