diff --git a/src/App.scss b/src/App.scss index 71bc413aade..7f309bd824b 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,39 @@ -// not empty +// not empty +.App { + min-height: 100vh; + margin: 0; + background-color: #0f1121; + display: flex; + flex-direction: column; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +@font-face { + font-family: Mont; + src: url('../fonts/Mont-Regular.otf') format('opentype'); + font-weight: 400; +} + +@font-face { + font-family: Mont; + src: url('../fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: 600; +} + +@font-face { + font-family: Mont; + src: url('../fonts/Mont-Bold.otf') format('opentype'); + font-weight: 700; +} + +.main { + background-color: #0f1121; + width: 100%; + flex: 1 0 auto; +} + diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..622d99b3f7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,65 @@ +import React from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; import './App.scss'; +import { Header } from './components/Header/Header'; +import { CatalogPage } from './components/CatalogPage'; +import { getAccessories, getPhones, getTablets } from './api'; +import { HomePage } from './components/HomePage/HomePage'; +import { FavoritesPage } from './components/FavoritesPage/FavoritesPage'; +import { CartPage } from './components/CartPage/CartPage'; +// eslint-disable-next-line max-len +import { ProductDetailsPage } from './components/ProductDeatils/ProductDetailsPage'; +import { NotFoundPage } from './components/NotFoundPage/NotFoundPage'; +import { Footer } from './components/Footer/Footer'; export const App = () => (
-

Product Catalog

+
+ +
+ + } /> + } /> + + } + /> + + } + /> + + } + /> + } /> + } /> + } /> + } /> + +
+ +
); diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 00000000000..ccb497608fc --- /dev/null +++ b/src/api.ts @@ -0,0 +1,66 @@ +import { Product } from './types/Product'; +import { ProductDetails } from './types/ProductDetails'; + +const BASE_URL = import.meta.env.BASE_URL; + +const getProductDetailsByPath = (path: string): Promise => + fetch(`${BASE_URL}/${path}`).then(response => response.json()); + +const getProductsByCategory = ( + category: Product['category'], +): Promise => { + return fetch(`${BASE_URL}/api/products.json`) + .then(response => response.json()) + .then((products: Product[]) => + products.filter(product => product.category === category), + ); +}; + +export const getAllProducts = (): Promise => { + return fetch(`${BASE_URL}/api/products.json`).then(response => + response.json(), + ); +}; + +export const getPhones = (): Promise => + getProductsByCategory('phones'); + +export const getTablets = (): Promise => + getProductsByCategory('tablets'); + +export const getAccessories = (): Promise => + getProductsByCategory('accessories'); + +export const getProductVariants = async ( + category: ProductDetails['category'], + namespaceId: string, +): Promise => { + const products = await getProductDetailsByPath(`api/${category}.json`); + + return products.filter(product => product.namespaceId === namespaceId); +}; + +export const getProductById = async ( + productId: string, +): Promise => { + const [phones, tablets, accessories] = await Promise.all([ + getProductDetailsByPath('api/phones.json'), + getProductDetailsByPath('api/tablets.json'), + getProductDetailsByPath('api/accessories.json'), + ]); + + const allProducts: ProductDetails[] = [...phones, ...tablets, ...accessories]; + + return allProducts.find(product => product.id === productId); +}; + +export const getSuggestedProducts = async ( + currentItemId: string, +): Promise => { + const products = await getAllProducts(); + + return products + .filter(product => product.itemId !== currentItemId) + .sort(() => Math.random() - 0.5) + .slice(0, 10); +}; diff --git a/src/components/BurgerMenu/BurgerMenu.module.scss b/src/components/BurgerMenu/BurgerMenu.module.scss new file mode 100644 index 00000000000..4a168add852 --- /dev/null +++ b/src/components/BurgerMenu/BurgerMenu.module.scss @@ -0,0 +1,114 @@ +.menu { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + flex-direction: column; + background-color: #0f1121; +} + +.top { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 48px; + border-bottom: 1px solid #323542; +} + +.logo { + display: flex; + align-items: center; + height: 100%; + padding-left: 16px; +} + +.logoImage { + width: 64px; + height: 22px; +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 0; + border-left: 1px solid #323542; + background: transparent; + cursor: pointer; +} + +.closeIcon { + width: 16px; + height: 16px; +} + +.nav { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + gap: 32px; + padding-top: 32px; +} + +.navLink { + position: relative; + color: #75767f; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 800; + line-height: 11px; + letter-spacing: 0.04em; + text-decoration: none; + text-transform: uppercase; +} + +.navLinkActive { + color: #f1f2f9; +} + +.navLinkActive::after { + content: ''; + position: absolute; + right: 0; + bottom: -10px; + left: 0; + height: 2px; + background-color: #f1f2f9; +} + +.actions { + display: grid; + grid-template-columns: repeat(2, 1fr); + border-top: 1px solid #323542; +} + +.actionLink { + display: flex; + align-items: center; + justify-content: center; + height: 64px; + border-left: 1px solid #323542; + background: transparent; +} + +.actionLink:first-child { + border-left: 0; +} + +.actionLinkActive { + box-shadow: inset 0 -2px 0 #f1f2f9; +} + +.actionIcon { + width: 16px; + height: 16px; +} + +@media (min-width: 640px) { + .menu { + display: none; + } +} diff --git a/src/components/BurgerMenu/BurgerMenu.tsx b/src/components/BurgerMenu/BurgerMenu.tsx new file mode 100644 index 00000000000..fbf56c20369 --- /dev/null +++ b/src/components/BurgerMenu/BurgerMenu.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import styles from './BurgerMenu.module.scss'; +import close from './components/img/Close.png'; +import logo from '../Header/components/img/logo.png'; +import favourites from '../Header/components/img/favourites.png'; +import bag from '../Header/components/img/bag.png'; + +interface Props { + onClose: () => void; +} + +export const BurgerMenu: React.FC = ({ onClose }) => { + const getNavLinkClass = ({ isActive }: { isActive: boolean }) => + isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink; + + const getActionLinkClass = ({ isActive }: { isActive: boolean }) => + isActive + ? `${styles.actionLink} ${styles.actionLinkActive}` + : styles.actionLink; + + return ( +
+
+ + Nice Gadgets + + + +
+ + + +
+ + + + + + + +
+
+ ); +}; diff --git a/src/components/BurgerMenu/components/img/Close.png b/src/components/BurgerMenu/components/img/Close.png new file mode 100644 index 00000000000..9a1ff4afb1e Binary files /dev/null and b/src/components/BurgerMenu/components/img/Close.png differ diff --git a/src/components/BurgerMenu/index.ts b/src/components/BurgerMenu/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/CartPage/CartPage.module.scss b/src/components/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..2a3a4c0a5da --- /dev/null +++ b/src/components/CartPage/CartPage.module.scss @@ -0,0 +1,245 @@ +.page { + min-height: 100%; + padding: 72px 16px 56px; + background: #0f1121; + color: #f1f2f9; + height: 100%; +} + +.backButton { + display: inline-flex; + align-items: center; + gap: 4px; + margin-bottom: 24px; + padding: 0; + border: 0; + background: transparent; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + cursor: pointer; +} + +.backButtonIcon { + width: 16px; + height: 16px; +} + +.title { + margin: 0 0 32px; + font-family: Mont, sans-serif; + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + color: #f1f2f9; +} + +.emptyState { + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: #75767f; +} + +.content { + display: grid; + gap: 32px; +} + +.items { + display: grid; + gap: 16px; +} + +.item { + display: grid; + gap: 16px; + padding: 16px; + background: #161827; + border: 1px solid #161827; +} + +.itemTop { + display: grid; + grid-template-columns: auto auto 1fr; + align-items: center; + column-gap: 16px; +} + +.removeButton { + width: 16px; + height: 16px; + padding: 0; + border: 0; + background: transparent; + color: #4a4d58; + font-family: Mont, sans-serif; + font-size: 18px; + line-height: 1; + cursor: pointer; +} + +.imageLink { + display: flex; + align-items: center; + justify-content: center; + width: 66px; + height: 66px; + text-decoration: none; +} + +.image { + width: 100%; + height: 100%; + object-fit: contain; +} + +.nameLink { + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + text-decoration: none; +} + +.itemBottom { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.quantityControls { + display: flex; + align-items: center; + gap: 13px; +} + +.quantityButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid #323542; + background: #161827; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 16px; + line-height: 1; + cursor: pointer; +} + +.quantityButton:disabled { + color: #4a4d58; + border-color: #3b3e4a; + cursor: not-allowed; +} + +.quantityValue { + min-width: 10px; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + text-align: center; +} + +.price { + margin: 0; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 22px; + font-weight: 800; + line-height: 31px; +} + +.summary { + padding: 24px; + border: 1px solid #323542; + background: #0f1121; +} + +.totalPrice { + margin: 0; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + text-align: center; +} + +.totalItems { + margin: 0; + color: #75767f; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + text-align: center; +} + +.summaryDivider { + height: 1px; + margin: 16px 0 24px; + background: #323542; +} + +.checkoutButton { + width: 100%; + height: 48px; + border: 0; + background: #905bff; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 21px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.checkoutButton:hover { + background-color: #a378ff; +} + +@media (min-width: 640px) { + .page { + padding: 72px 24px 64px; + } + + .item { + grid-template-columns: 1fr auto; + align-items: center; + gap: 24px; + } + + .itemBottom { + gap: 24px; + } +} + +@media (min-width: 1200px) { + .page { + // padding: 88px 152px 80px; + max-width: 1136px; + padding-bottom: 80px; + padding-top: 88px; + padding-inline: 0; + margin: auto; + } + + .content { + grid-template-columns: minmax(0, 1fr) 368px; + align-items: start; + } +} diff --git a/src/components/CartPage/CartPage.tsx b/src/components/CartPage/CartPage.tsx new file mode 100644 index 00000000000..be4108fe93a --- /dev/null +++ b/src/components/CartPage/CartPage.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useCart } from '../../context/CartContext'; +import styles from './CartPage.module.scss'; +import arrowLeft from './components/img/arrow-left.png'; +import { getAssetUrl } from '../../utils/getAssetUrl'; + +export const CartPage = () => { + const navigate = useNavigate(); + const { + cartItems, + removeFromCart, + increaseQuantity, + decreaseQuantity, + totalQuantity, + totalPrice, + clearCart, + } = useCart(); + + return ( +
+ + +

Cart

+ + {!cartItems.length ? ( +

Your cart is empty

+ ) : ( +
+
+ {cartItems.map(({ product, quantity }) => ( +
+
+ + + + {product.name} + + + + {product.name} + +
+ +
+
+ + + {quantity} + + +
+ +

${product.price * quantity}

+
+
+ ))} +
+ + +
+ )} +
+ ); +}; diff --git a/src/components/CartPage/components/img/arrow-left.png b/src/components/CartPage/components/img/arrow-left.png new file mode 100644 index 00000000000..a189b6f4f70 Binary files /dev/null and b/src/components/CartPage/components/img/arrow-left.png differ diff --git a/src/components/CartPage/index.ts b/src/components/CartPage/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/CatalogPage/CatalogPage.module.scss b/src/components/CatalogPage/CatalogPage.module.scss new file mode 100644 index 00000000000..e4798cfb493 --- /dev/null +++ b/src/components/CatalogPage/CatalogPage.module.scss @@ -0,0 +1,244 @@ +.page { + margin-top: 72px; + margin-inline: 16px; + padding-bottom: 64px; +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; +} + +.breadcrumbHome { + display: flex; + align-items: center; + justify-content: center; + color: #f1f2f9; + text-decoration: none; +} + +.breadcrumbIcon { + width: 16px; + height: 16px; +} + +.breadcrumbSeparator { + width: 16px; + height: 16px; + color: #4a4d58; +} + +.breadcrumbCurrent { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 12px; + line-height: 100%; + color: #75767f; +} + +.title { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; + color: #f1f2f9; +} + +.count { + margin-top: 8px; + margin-bottom: 32px; + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: #75767f; +} + +.selectors { + display: flex; + gap: 16px; + margin-bottom: 46px; + flex-wrap: wrap; +} + +.field__one { + display: flex; + flex-direction: column; + gap: 4px; + width: 136px; + height: 40px; +} + +.field__two { + display: flex; + flex-direction: column; + gap: 4px; + width: 136px; + height: 40px; +} + +.fieldLabel { + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 12px; + line-height: 15px; + color: #75767f; +} + +.selectWrapper { + position: relative; +} + +.select { + width: 100%; + height: 40px; + padding-inline: 12px 34px; + border: 1px solid #323542; + background-color: #323542; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + appearance: none; + outline: none; + cursor: pointer; +} + +.select:focus { + border-color: #905bff; +} + +.selectArrow { + position: absolute; + top: 50%; + right: 12px; + width: 8px; + height: 8px; + border-right: 1.5px solid #75767f; + border-bottom: 1.5px solid #75767f; + transform: translateY(-65%) rotate(45deg); + pointer-events: none; +} + +.pagination { + display: flex; + gap: 8px; + margin-top: 24px; + flex-wrap: wrap; + justify-content: center; +} + +.result__message { + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: #75767f; +} + +.paginationButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid #323542; + background-color: #161827; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + cursor: pointer; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease; +} + +.paginationButton:disabled { + background-color: #323542; + color: #75767f; + cursor: not-allowed; +} + +.paginationButton:hover:not(:disabled) { + border-color: #905bff; +} + +.paginationButtonActive { + border-color: #905bff; + background-color: #905bff; + color: #f1f2f9; +} + +.slider { + width: 32px; + height: 32px; +} + +@media (min-width: 640px) { + .page { + padding-top: 72px; + max-width: 592px; + margin: auto; + padding-bottom: 64px; + } + + .field__one { + width: 187px; + height: 40px; + } + + .field__two { + width: 136px; + height: 40px; + } + + .pagination { + gap: 8px; + margin-top: 40px; + } +} + +@media (min-width: 768px) { + .page { + padding-top: 72px; + max-width: 720px; + margin: auto; + padding-bottom: 64px; + } +} + +@media (min-width: 1200px) { + .page { + margin-top: 88px; + margin-inline: 32px; + padding-bottom: 80px; + } + + .field__one { + width: 176px; + height: 40px; + } + + .field__two { + width: 128px; + height: 40px; + } +} + +@media (min-width: 1201px) { + .page { + padding-top: 88px; + max-width: 1136px; + margin: auto; + padding-bottom: 80px; + } +} diff --git a/src/components/CatalogPage/CatalogPage.tsx b/src/components/CatalogPage/CatalogPage.tsx new file mode 100644 index 00000000000..4354df8a171 --- /dev/null +++ b/src/components/CatalogPage/CatalogPage.tsx @@ -0,0 +1,264 @@ +import React, { useEffect, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { Product } from '../../types/Product'; +import { ProductList } from '../ProductList/ProductList'; +import styles from './CatalogPage.module.scss'; +import homeIcon from '../ProductCard/components/img/Home.png'; +import sliderLeft from '../ProductCard/components/img/slider-button-left.png'; +import sliderRight from '../ProductCard/components/img/slider-button-right.png'; +import arrowRight from '../CatalogPage/components/img/arrow-right.png'; +import { Loader } from '../Loader/Loader'; + +interface Props { + breadcrumbLabel: string; + emptyMessage: string; + fetchProducts: () => Promise; + title: string; +} + +export const CatalogPage: React.FC = ({ + breadcrumbLabel, + emptyMessage, + fetchProducts, + title, +}) => { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + + const sort = searchParams.get('sort') || 'age'; + const perPage = searchParams.get('perPage') || 'all'; + const currentPage = Number(searchParams.get('page')) || 1; + const query = searchParams.get('query') || ''; + + // const visibleProducts = [...products]; + const normalizedQuery = query.trim().toLowerCase(); + + const visibleProducts = products.filter(product => + product.name.toLowerCase().includes(normalizedQuery), + ); + + if (sort === 'age') { + visibleProducts.sort((a, b) => b.year - a.year); + } else if (sort === 'name') { + visibleProducts.sort((a, b) => a.name.localeCompare(b.name)); + } else if (sort === 'price') { + visibleProducts.sort((a, b) => a.price - b.price); + } + + let preparedProducts = visibleProducts; + + if (perPage !== 'all') { + const itemsPerPage = Number(perPage); + const start = (currentPage - 1) * itemsPerPage; + const end = start + itemsPerPage; + + preparedProducts = visibleProducts.slice(start, end); + } + + const totalItems = visibleProducts.length; + const itemsPerPage = perPage === 'all' ? totalItems : Number(perPage); + const totalPages = + perPage === 'all' ? 1 : Math.ceil(totalItems / itemsPerPage); + + const pages: number[] = []; + + for (let page = 1; page <= totalPages; page++) { + pages.push(page); + } + + const pagesPerBlock = 4; + const currentBlock = Math.floor((currentPage - 1) / pagesPerBlock); + const startPage = currentBlock * pagesPerBlock; + const visiblePages = pages.slice(startPage, startPage + pagesPerBlock); + + const setPage = (page: number) => { + if (page < 1 || page > totalPages) { + return; + } + + const params = new URLSearchParams(searchParams); + + if (page === 1) { + params.delete('page'); + } else { + params.set('page', String(page)); + } + + setSearchParams(params); + }; + + useEffect(() => { + setIsLoading(true); + setHasError(false); + + fetchProducts() + .then(data => { + setProducts(data); + }) + .catch(() => { + setHasError(true); + }) + .finally(() => { + setIsLoading(false); + }); + }, [fetchProducts]); + + if (isLoading) { + return ; + } + + if (hasError) { + return ( + <> +

Something went wrong...

+ + + + ); + } + + if (!products.length) { + return

{emptyMessage}

; + } + + const hasSearchQuery = normalizedQuery.length > 0; + const hasNoSearchResults = hasSearchQuery && !visibleProducts.length; + const noResultMessage = `There are no ${breadcrumbLabel.toLowerCase()} matching the query`; + const shouldShowPagination = !hasNoSearchResults && totalPages > 1; + + return ( +
+ + +

{title}

+

{visibleProducts.length} models

+ +
+ + + +
+ + {hasNoSearchResults ? ( +

{noResultMessage}

+ ) : ( + + )} + + {shouldShowPagination && ( + + )} +
+ ); +}; diff --git a/src/components/CatalogPage/components/img/arrow-right.png b/src/components/CatalogPage/components/img/arrow-right.png new file mode 100644 index 00000000000..14a12c1515e Binary files /dev/null and b/src/components/CatalogPage/components/img/arrow-right.png differ diff --git a/src/components/CatalogPage/index.ts b/src/components/CatalogPage/index.ts new file mode 100644 index 00000000000..1cad0ffbfe4 --- /dev/null +++ b/src/components/CatalogPage/index.ts @@ -0,0 +1 @@ +export * from './CatalogPage'; diff --git a/src/components/FavoritesPage/FavoritesPage.module.scss b/src/components/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..4db6396b1c9 --- /dev/null +++ b/src/components/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,95 @@ +.page { + margin-top: 72px; + margin-inline: 16px; + padding-bottom: 56px; + height: 100%; +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 1; +} + +.breadcrumbHome { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +.breadcrumbIcon { + width: 14px; + height: 14px; +} + +.breadcrumbSeparator { + width: 16px; + height: 16px; + opacity: 0.65; +} + +.breadcrumbCurrent { + min-width: 0; + color: #75767f; +} + +.title { + margin: 0; + font-family: Mont, sans-serif; + font-weight: 800; + font-style: Bold; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; + color: #f1f2f9; +} + +.desctiption { + margin-top: 8px; + margin-bottom: 32px; + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: #75767f; +} + +.emptyState { + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: #75767f; +} + +@media (min-width: 640px) { + .page { + margin-inline: calc(24px + (100vw - 640px) / 2); + padding-bottom: 64px; + } +} + +@media (min-width: 1200px) { + .page { + margin-top: 89px; + margin-inline: 32px; + padding-bottom: 80px; + } +} + +@media (min-width: 1201px) { + .page { + max-width: 1136px; + padding-bottom: 80px; + padding-top: 89px; + margin-inline: 0; + margin: auto; + } +} diff --git a/src/components/FavoritesPage/FavoritesPage.tsx b/src/components/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..ac920645a40 --- /dev/null +++ b/src/components/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react'; +import { useFavorites } from '../../context/FavoritesContext'; +import { Product } from '../../types/Product'; +import { getAllProducts } from '../../api'; +import { ProductList } from '../ProductList/ProductList'; +import styles from './FavoritesPage.module.scss'; +import { Link, useSearchParams } from 'react-router-dom'; +import homeIcon from '../ProductCard/components/img/Home.png'; +import arrowRight from '../CatalogPage/components/img/arrow-right.png'; + +export const FavoritesPage = () => { + const { favoriteIds } = useFavorites(); + const [products, setProducts] = useState([]); + + useEffect(() => { + getAllProducts().then(setProducts); + }, []); + + const favoriteProducts = products.filter(product => + favoriteIds.includes(product.itemId), + ); + + const [searchParams] = useSearchParams(); + const query = searchParams.get('query') || ''; + + const visibleFavoriteProducts = favoriteProducts.filter(product => + product.name.toLowerCase().includes(query.trim().toLowerCase()), + ); + + return ( +
+ + +

Favorites

+

+ {visibleFavoriteProducts.length} items +

+ + {!favoriteProducts.length ? ( +

Your favorites list is empty

+ ) : !visibleFavoriteProducts.length ? ( +

+ There are no products matching the query +

+ ) : ( + + )} +
+ ); +}; diff --git a/src/components/FavoritesPage/index.ts b/src/components/FavoritesPage/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..56cf50f8499 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,160 @@ +.footer { + border-top: 1px solid #323542; + background-color: #0f1121; +} + +.container { + margin-left: 16px; + margin-right: 16px; +} + +.logo { + display: flex; + align-items: center; + flex-shrink: 0; + margin-top: 32px; +} + +.logo__image { + width: 89px; + height: 32px; +} + +.nav { + margin-top: 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.nav__link { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 12px; + line-height: 11px; + letter-spacing: 4%; + text-transform: uppercase; + color: #f1f2f9; + text-decoration: none; +} + +.button__bottom { + margin-top: 32px; + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding-bottom: 32px; +} + +.button__top__text { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + text-align: right; + text-decoration: none; + background: none; + border: none; + outline: none; + cursor: pointer; + color: #75767f; +} + +.button__top { + background: none; + border: none; + outline: none; + cursor: pointer; +} + +.button__top__image { + width: 32px; + height: 32px; +} + +@media (min-width: 640px) { + .container { + margin-left: 32px; + margin-right: 32px; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 96px; + } + + .logo { + margin-top: 0; + } + + .logo__image { + width: 89px; + height: 32px; + } + + .nav { + margin-top: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 13, 5px; + } + + .nav__link { + text-decoration: none; + } + + .button__bottom { + margin-top: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; + padding-bottom: 0; + } +} + +@media (min-width: 1200px) { + .container { + margin-left: 152px; + margin-right: 152px; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 96px; + } + + .logo { + margin-top: 0; + } + + .logo__image { + width: 89px; + height: 32px; + } + + .nav { + margin-top: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 107px; + } + + .nav__link { + text-decoration: none; + } + + .button__bottom { + margin-top: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; + padding-bottom: 0; + flex-shrink: 0; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..1df7f8fcb0a --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import styles from './Footer.module.scss'; +import { NavLink } from 'react-router-dom'; +import logo from './components/img/logo.png'; +import slider from './components/img/slider-button.png'; + +export const Footer = () => { + return ( + + ); +}; diff --git a/src/components/Footer/components/img/logo.png b/src/components/Footer/components/img/logo.png new file mode 100644 index 00000000000..f382dbca108 Binary files /dev/null and b/src/components/Footer/components/img/logo.png differ diff --git a/src/components/Footer/components/img/slider-button.png b/src/components/Footer/components/img/slider-button.png new file mode 100644 index 00000000000..55f20160f00 Binary files /dev/null and b/src/components/Footer/components/img/slider-button.png differ diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..d12ac725b21 --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,330 @@ +.header { + position: fixed; + top: 0; + z-index: 100; + box-shadow: 0 1px 0 0 #323542; + background-color: #0f1121; + width: 100%; + height: 48px; + border-bottom: 1px solid #323542; + overflow: visible; +} + +.container { + display: flex; + align-items: center; + height: 100%; + width: 100%; +} + +.burger { + width: 48px; + height: 48px; + + display: flex; + align-items: center; + justify-content: center; + + border: none; + background: transparent; + cursor: pointer; + + border-left: 1px solid #3b3e4a; + + flex-shrink: 0; +} + +.icon__burger { + width: 16px; + height: 16px; +} + +.logo__image { + margin-top: 13px; + margin-bottom: 13px; + margin-left: 16px; + height: 22px; + width: 64px; +} + +.nav { + display: none; +} + +.button__left { + display: none; +} + +.search { + position: relative; + display: flex; + align-items: center; + + margin-left: auto; + + flex-shrink: 0; +} + +.searchButton { + width: 48px; + height: 48px; + border: none; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-left: 1px solid #323542; +} + +.searchIcon { + width: 16px; + height: 16px; +} + +.searchInput { + position: absolute; + top: 60px; + right: 2px; + + width: 0; + opacity: 0; + pointer-events: none; + + height: 40px; + + border: 1px solid #323542; + background-color: #161827; + color: #f1f2f9; + + padding-inline: 12px; + border-radius: 8px; + + transition: + width 0.3s ease, + opacity 0.3s ease; + + font-family: Mont, sans-serif; + + z-index: 200; +} + +.burgerRight { + margin-left: auto; +} + +.searchInput::placeholder { + color: #75767f; +} + +.searchOpen .searchInput { + width: calc(100vw - 16px); + max-width: 260px; + + opacity: 1; + pointer-events: auto; +} + +@media (min-width: 640px) { + .header { + height: 48px; + } + + .logo { + display: flex; + align-items: center; + padding-inline: 16px 32px; + flex-shrink: 0; + } + + .logo__image { + margin: 0; + width: 64px; + height: 22px; + } + + .nav { + display: flex; + align-items: center; + gap: 32px; + height: 100%; + } + + .burger { + display: none; + } + + .container { + display: flex; + justify-content: flex-start; + } + + .link { + display: flex; + align-items: center; + height: 100%; + position: relative; + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 12px; + line-height: 11px; + letter-spacing: 4%; + text-transform: uppercase; + text-decoration: none; + color: #75767f; + } + + .linkActive { + color: #f1f2f9; + } + + .bag { + position: relative; + } + + .linkActive::after { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + background-color: #f1f2f9; + } + + .icon__bag { + width: 16px; + height: 16px; + } + + .icon__favorites { + width: 16px; + height: 16px; + } + + .actionButton { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: none; + border-left: 1px solid #323542; + background: transparent; + cursor: pointer; + } + + .actionButtonActive { + box-shadow: inset 0 -3px 0 #f1f2f9; + } + + .actionButtonActive .icon__favorites, + .actionButtonActive .icon__bag { + filter: brightness(0) saturate(100%) invert(97%) sepia(7%) saturate(287%) + hue-rotate(191deg) brightness(101%) contrast(96%); + } + + .favorites { + position: relative; + } + + .counter { + position: absolute; + top: 8px; + right: 8px; + min-width: 14px; + height: 14px; + border-radius: 50%; + background-color: #eb5757; + color: #f1f2f9; + font-size: 9px; + line-height: 14px; + text-align: center; + font-family: Mont, sans-serif; + font-weight: 700; + } + .button__left { + display: flex; + align-items: center; + margin-left: 0; + height: 100%; + border-right: 1px solid #323542; + } + + .button__leftRight { + margin-left: auto; + } + + .search { + margin-left: auto; + margin-right: 0; + display: flex; + align-items: center; + } +} + +@media (min-width: 1200px) { + .header { + height: 64px; + } + + .logo { + display: flex; + align-items: center; + padding-inline: 24px 48px; + flex-shrink: 0; + } + + .logo__image { + margin: 0; + width: 80px; + height: 28px; + } + + .nav { + display: flex; + align-items: center; + gap: 64px; + height: 100%; + } + + .actionButton { + width: 64px; + height: 64px; + } + + .favorites { + position: relative; + } + + .button__leftRight { + margin-left: auto; + } + + .counter { + top: 16px; + right: 17px; + } + .searchButton { + display: none; + } + + .searchInput { + position: static; + + width: 280px; + min-width: 280px; + + opacity: 1; + pointer-events: auto; + + height: 40px; + + flex-shrink: 0; + } + + .search { + margin-left: auto; + margin-right: 10px; + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..9a3740c1de3 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from 'react'; +import styles from './Header.module.scss'; +import logo from './components/img/logo.png'; +import menu from './components/img/Menu.png'; +import favorites from './components/img/favourites.png'; +import bag from './components/img/bag.png'; +import { NavLink, useLocation, useSearchParams } from 'react-router-dom'; +import { BurgerMenu } from '../BurgerMenu/BurgerMenu'; +import { useFavorites } from '../../context/FavoritesContext'; +import { useCart } from '../../context/CartContext'; +import searchIcon from './components/img/Search.png'; + +export const Header = () => { + const getLinkClass = ({ isActive }: { isActive: boolean }) => + isActive ? `${styles.link} ${styles.linkActive}` : styles.link; + + const getActionClass = ({ isActive }: { isActive: boolean }) => + isActive + ? `${styles.actionButton} ${styles.actionButtonActive}` + : styles.actionButton; + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const { favoriteIds } = useFavorites(); + const { totalQuantity } = useCart(); + + const location = useLocation(); + + const isCatalogPage = + location.pathname === '/phones' || + location.pathname === '/tablets' || + location.pathname === '/accessories' || + location.pathname === '/favorites'; + + const [searchParams, setSearchParams] = useSearchParams(); + + const query = searchParams.get('query') || ''; + const [searchValue, setSearchValue] = useState(query); + + useEffect(() => { + setSearchValue(query); + }, [query]); + + useEffect(() => { + if (searchValue.trim() === query) { + return; + } + + const timer = window.setTimeout(() => { + const params = new URLSearchParams(searchParams); + + if (searchValue.trim()) { + params.set('query', searchValue.trim()); + } else { + params.delete('query'); + } + + params.delete('page'); + setSearchParams(params); + }, 500); + + return () => window.clearTimeout(timer); + }, [searchValue, query, searchParams, setSearchParams]); + + return ( +
+
+ + Logo + + + + + {isCatalogPage && ( +
+ + + setSearchValue(e.target.value)} + /> +
+ )} + +
+ + `${getActionClass({ isActive })} ${styles.favorites}` + } + > + favorites + + {favoriteIds.length > 0 && ( + {favoriteIds.length} + )} + + + + `${getActionClass({ isActive })} ${styles.bag}` + } + > + bag + + {totalQuantity > 0 && ( + {totalQuantity} + )} + +
+ + + + {isMenuOpen && setIsMenuOpen(false)} />} +
+
+ ); +}; diff --git a/src/components/Header/components/img/Menu.png b/src/components/Header/components/img/Menu.png new file mode 100644 index 00000000000..355d38d357a Binary files /dev/null and b/src/components/Header/components/img/Menu.png differ diff --git a/src/components/Header/components/img/Search.png b/src/components/Header/components/img/Search.png new file mode 100644 index 00000000000..52698550001 Binary files /dev/null and b/src/components/Header/components/img/Search.png differ diff --git a/src/components/Header/components/img/bag.png b/src/components/Header/components/img/bag.png new file mode 100644 index 00000000000..3bd8b5c3d99 Binary files /dev/null and b/src/components/Header/components/img/bag.png differ diff --git a/src/components/Header/components/img/favourites.png b/src/components/Header/components/img/favourites.png new file mode 100644 index 00000000000..8f01553c011 Binary files /dev/null and b/src/components/Header/components/img/favourites.png differ diff --git a/src/components/Header/components/img/logo.png b/src/components/Header/components/img/logo.png new file mode 100644 index 00000000000..f382dbca108 Binary files /dev/null and b/src/components/Header/components/img/logo.png differ diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/HomePage/HomePage.module.scss b/src/components/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..4e89f487f77 --- /dev/null +++ b/src/components/HomePage/HomePage.module.scss @@ -0,0 +1,590 @@ +.page { + min-height: 100%; +} + +.content { + padding: 56px 16px 64px; +} + +.title { + padding: 72px 16px 24px; + font-family: Mont, sans-serif; + font-weight: 800; + font-style: Bold; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; + color: #f1f2f9; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + white-space: nowrap; + border: 0; + clip: rect(0 0 0 0); +} + +.slider__banner { + aspect-ratio: 1 / 1; + overflow: hidden; + background-color: transparent; +} + +.banner__viewport { + width: 100%; + height: 100%; + overflow: hidden; +} + +.slider__banner__track { + display: flex; + height: 100%; + transition: transform 0.4s ease; +} + +.slide__banner { + flex: 0 0 100%; + height: 100%; +} + +.slider__banner__picture { + display: block; + height: 100%; +} + +.slide__banner__img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.banner__dots { + margin-top: 18px; + display: flex; + justify-content: center; + gap: 10px; +} + +.dot, +.dotActive { + width: 14px; + height: 4px; + border: none; +} + +.dot { + background-color: #3b3e4a; +} + +.dotActive { + background-color: #f1f2f9; +} + +.slider__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 24px; +} + +.slider__title { + font-family: Mont, sans-serif; + font-weight: 800; + font-style: Bold; + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + color: #f1f2f9; + margin: 0; +} + +.slider__buttons { + display: flex; + gap: 16px; +} + +.slider__arrow { + width: 32px; + height: 32px; + border: 1px solid #3b3e4a; + background-color: #323542; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.slider__arrow:hover { + background-color: #4a4d58; +} + +.slider__arrow:disabled { + background: transparent; + border-color: #323542; + cursor: not-allowed; + opacity: 0.5; +} + +.slider__arrow__img { + width: 16px; + height: 16px; +} + +.viewport { + min-width: 0; + overflow: hidden; +} + +.track { + display: flex; + gap: 16px; + width: max-content; + transition: transform 0.3s ease; +} + +.category { + padding-top: 56px; +} + +.category__title { + font-family: Mont, sans-serif; + font-weight: 800; + font-style: Bold; + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + color: #f1f2f9; + padding-bottom: 24px; +} + +.category__list { + display: grid; + gap: 32px; +} + +.category__card { + display: block; + text-decoration: none; + transition: + transform 0.35s ease, + filter 0.35s ease; +} + +.category__card:hover { + transform: translateY(-8px); +} + +.category__imageWrap { + width: 100%; + min-width: 288px; + min-height: 288px; + overflow: hidden; + background-color: #6d6475; + position: relative; + + transition: + transform 0.35s ease, + box-shadow 0.35s ease, + background-color 0.35s ease; +} + +.category__card:hover .category__imageWrap { + box-shadow: + 0 10px 25px rgba(0, 0, 0, 0.45), + 0 0 20px rgba(144, 91, 255, 0.25); + + background-color: #7a7184; +} + +.category__imageWrapPhones { + background-color: #6d6474; +} + +.category__imageWrapTablets { + color: #8d8d92; +} + +.category__imageWrapAccessories { + background-color: #973d5f; +} + +.category__image { + position: absolute; + bottom: 0; + width: 100%; + height: 100%; + + transition: + transform 0.45s ease, + filter 0.35s ease; +} + +.category__card:hover .category__image { + transform: scale(1.08); + filter: brightness(1.08); +} + +.category__name { + margin-top: 24px; + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 20px; + line-height: 100%; + letter-spacing: 0%; + color: #f1f2f9; + transition: color 0.3s ease; +} + +.category__card:hover .category__name { + color: #905bff; +} + +.category__count { + margin-top: 4px; + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: #75767f; +} + +.slider__prices { + margin-top: 56px; +} + +.banner__button { + display: none; +} + +@media (min-width: 640px) { + .content { + padding: 64px 24px; + } + + .title { + font-size: 48px; + padding: 80px 24px 32px; + } + + .slider__banner { + display: flex; + height: 189px; + margin-inline: auto; + gap: 19px; + background-color: transparent; + min-width: 590px; + } + + .banner__viewport { + flex: 1; + min-width: 0; + height: 100%; + overflow: hidden; + } + + .slider__banner__track { + display: flex; + height: 100%; + transition: transform 0.4s ease; + } + + .slide__banner { + flex: 0 0 100%; + height: 100%; + align-items: center; + } + .slider__banner__picture { + display: block; + height: 100%; + } + + .slide__banner__img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + } + + .orderButton { + margin-top: 20px; + width: 160px; + height: 48px; + border: 1px solid #3b3e4a; + border-radius: 28px; + display: flex; + align-items: center; + justify-content: center; + color: #f1f2f9; + text-decoration: none; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 700; + } + + .banner__button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + background-color: #323542; + flex: 0 0 32px; + border: none; + cursor: pointer; + transition: background-color 0.3s ease; + } + + .banner__button:hover { + background-color: #4a4d58; + } + + .banner__button_img { + width: 16px; + height: 16px; + } + + .slider__title { + font-size: 32px; + } + + .category { + padding-top: 64px; + } + + .category__list { + display: grid; + gap: 15px; + grid-template-columns: repeat(3, 1fr); + } + + .category__card { + display: block; + text-decoration: none; + } + + .category__imageWrap { + width: 100%; + aspect-ratio: 1 / 1; + min-width: 0; + min-height: 0; + overflow: hidden; + background-color: #6d6475; + position: relative; + } + + .category__title { + font-size: 32px; + padding-bottom: 24px; + } +} + +@media (min-width: 1200px) { + .content { + padding: 80px 32px; + } + + .title { + font-size: 48px; + padding: 120px 32px 56px; + } + + .slider__banner { + overflow: hidden; + display: flex; + height: 400px; + margin-inline: 32px; + aspect-ratio: auto; + gap: 16px; + background-color: transparent; + } + + .banner__viewport { + flex: 1; + min-width: 0; + height: 100%; + overflow: hidden; + } + + .slider__banner__track { + display: flex; + height: 100%; + transition: transform 0.4s ease; + } + + .slide__banner { + flex: 0 0 100%; + height: 100%; + } + .slider__banner__picture { + display: block; + height: 100%; + } + + .slide__banner__img { + display: block; + width: 100%; + height: 100%; + } + + .banner__button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + background-color: #323542; + flex: 0 0 32px; + border: none; + cursor: pointer; + } + + .banner__button_img { + width: 16px; + height: 16px; + } + + .slider__title { + font-size: 32px; + } + + .category { + padding-top: 80px; + } + + .category__list { + display: grid; + gap: 15px; + grid-template-columns: repeat(3, 1fr); + } + + .category__card { + display: block; + text-decoration: none; + } + + .category__imageWrap { + width: 100%; + aspect-ratio: 1 / 1; + min-width: 0; + min-height: 0; + overflow: hidden; + background-color: #6d6475; + position: relative; + } + + .category__title { + font-size: 32px; + padding-bottom: 24px; + } +} + +@media (min-width: 1201px) { + .page { + max-width: 1136px; + margin: auto; + } + + .content { + padding-top: 80px; + padding-bottom: 80px; + padding-inline: 0; + } + + .title { + font-size: 48px; + padding: 120px 0 56px; + padding-top: 120px; + } + + .slider__banner { + overflow: hidden; + display: flex; + margin-inline: 152px; + max-height: 432px; + gap: 19px; + background-color: transparent; + margin: 0; + } + + .banner__viewport { + flex: 1; + min-width: 0; + height: 100%; + overflow: hidden; + } + + .slider__banner__track { + display: flex; + height: 100%; + transition: transform 0.4s ease; + } + + .slide__banner { + flex: 0 0 100%; + height: 100%; + } + .slider__banner__picture { + display: block; + height: 100%; + } + + .slide__banner__img { + display: block; + width: 100%; + height: 100%; + } + + .banner__button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + background-color: #323542; + flex: 0 0 32px; + border: none; + cursor: pointer; + } + + .banner__button_img { + width: 16px; + height: 16px; + } + + .slider__title { + font-size: 32px; + } + + .category { + padding-top: 80px; + } + + .category__list { + display: grid; + gap: 15px; + grid-template-columns: repeat(3, 1fr); + } + + .category__card { + display: block; + text-decoration: none; + } + + .category__imageWrap { + width: 100%; + aspect-ratio: 1 / 1; + min-width: 0; + min-height: 0; + overflow: hidden; + background-color: #6d6475; + position: relative; + } + + .category__title { + font-size: 32px; + padding-bottom: 24px; + } +} diff --git a/src/components/HomePage/HomePage.tsx b/src/components/HomePage/HomePage.tsx new file mode 100644 index 00000000000..dbdbe2a4b53 --- /dev/null +++ b/src/components/HomePage/HomePage.tsx @@ -0,0 +1,369 @@ +import React, { useEffect, useRef, useState } from 'react'; +import styles from './HomePage.module.scss'; +import bannerMobileFirst from './components/img/banner-mobile-first.png'; +import categoryPhones from './components/img/category-phones.png'; +import categoryTablets from './components/img/category-tablets.png'; +import categoryAccessories from './components/img/category-accsesories.png'; +import { ProductCard } from '../ProductCard/ProductCard'; +import { Product } from '../../types/Product'; +import { getAllProducts } from '../../api'; +// eslint-disable-next-line max-len +import arrowRight__slider from '../ProductDeatils/components/img/arrow-right-slider.png'; +// eslint-disable-next-line max-len +import arrowLeft from '../ProductDeatils/components/img/arrow-left.png'; +import bannerTablet from './components/img/banner-tablets.png'; +import bannerDekstop from './components/img/banner-desktop.png'; +import { Link } from 'react-router-dom'; +import { getAssetUrl } from '../../utils/getAssetUrl'; + +export const HomePage = () => { + const slides = [ + { + image: bannerMobileFirst, + link: '/product/apple-iphone-14-pro-128gb-spaceblack', + imageTablet: bannerTablet, + imageDesktop: bannerDekstop, + }, + { + image: getAssetUrl('img/banner-accessories.png'), + imageTablet: getAssetUrl('img/banner-accessories.png'), + link: '/accessories', + imageDesktop: getAssetUrl('img/banner-accessories.png'), + }, + { + image: getAssetUrl('img/banner-tablets.png'), + imageTablet: getAssetUrl('img/banner-tablets.png'), + link: '/tablets', + imageDesktop: getAssetUrl('img/banner-tablets.png'), + }, + ]; + + const categories = [ + { + name: 'Mobile phones', + category: 'phones', + image: categoryPhones, + bgClass: 'category__imageWrapPhones', + }, + { + name: 'Tablets', + category: 'tablets', + image: categoryTablets, + bgClass: 'category__imageWrapTablets', + }, + { + name: 'Accessories', + category: 'accessories', + image: categoryAccessories, + bgClass: 'category__imageWrapAccessories', + }, + ]; + + const [activeSlide, setActiveSlide] = useState(0); + const [brandNewProducts, setBrandNewProducts] = useState([]); + const [hotPriceProducts, setHotPriceProducts] = useState([]); + const [canScrollLeftNew, setCanScrollLeftNew] = useState(false); + const [canScrollRightNew, setCanScrollRightNew] = useState(false); + + const [canScrollLeftHot, setCanScrollLeftHot] = useState(false); + const [canScrollRightHot, setCanScrollRightHot] = useState(false); + const [products, setProducts] = useState([]); + const newModelsRef = useRef(null); + const hotPricesRef = useRef(null); + + const updateScrollButtons = (ref: React.RefObject) => { + const slider = ref.current; + + if (!slider) { + return; + } + + const canScrollLeft = slider.scrollLeft > 0; + const canScrollRight = + slider.scrollLeft + slider.clientWidth < slider.scrollWidth - 1; + + if (ref === newModelsRef) { + setCanScrollLeftNew(canScrollLeft); + setCanScrollRightNew(canScrollRight); + } + + if (ref === hotPricesRef) { + setCanScrollLeftHot(canScrollLeft); + setCanScrollRightHot(canScrollRight); + } + }; + + const scrollSlider = ( + ref: React.RefObject, + direction: 'left' | 'right', + ) => { + const slider = ref.current; + + if (!slider) { + return; + } + + const track = slider.querySelector(`.${styles.track}`); + const card = track?.firstElementChild as HTMLElement | null; + const gap = track ? parseInt(getComputedStyle(track).gap, 10) : 16; + const scrollAmount = (card?.offsetWidth || 212) + gap; + + slider.scrollBy({ + left: direction === 'right' ? scrollAmount : -scrollAmount, + behavior: 'smooth', + }); + + setTimeout(() => updateScrollButtons(ref), 300); + }; + + useEffect(() => { + updateScrollButtons(newModelsRef); + updateScrollButtons(hotPricesRef); + }, [brandNewProducts, hotPriceProducts]); + + useEffect(() => { + getAllProducts().then(product => { + setProducts(product); + + const brandNew = [...product] + .sort((a, b) => b.year - a.year) + .slice(0, 10); + + const hotPrices = [...product] + .sort((a, b) => { + const discountA = a.fullPrice - a.price; + const discountB = b.fullPrice - b.price; + + return discountB - discountA; + }) + .slice(0, 10); + + setBrandNewProducts(brandNew); + setHotPriceProducts(hotPrices); + }); + }, []); + + const goPrevSlide = () => { + setActiveSlide(current => + current === 0 ? slides.length - 1 : current - 1, + ); + }; + + const goNextSlide = () => { + setActiveSlide(current => + current === slides.length - 1 ? 0 : current + 1, + ); + }; + + useEffect(() => { + const interval = setInterval(() => { + setActiveSlide(current => + current === slides.length - 1 ? 0 : current + 1, + ); + }, 5000); + + return () => clearInterval(interval); + }, [slides.length]); + + return ( +
+

Product Catalog

+

Welcome to Nice Gadgets store!

+ +
+ + +
+
+ {slides.map(slide => ( +
+ + + {slide.imageDesktop && ( + + )} + + {slide.imageTablet && ( + + )} + + slideImage + + +
+ ))} +
+
+ + +
+ +
+ {slides.map((_, index) => ( + + ))} +
+ +
+
+
+

Brand new models

+
+ + +
+
+ +
updateScrollButtons(newModelsRef)} + > +
+ {brandNewProducts.map(recommendedProduct => ( + + ))} +
+
+
+ +
+

Shop by category

+ +
+ {categories.map(category => { + const count = products.filter( + product => product.category === category.category, + ).length; + + return ( + +
+ {category.name} +
+ +

{category.name}

+

{count} models

+ + ); + })} +
+
+ +
+
+

Hot prices

+
+ + +
+
+ +
updateScrollButtons(hotPricesRef)} + > +
+ {hotPriceProducts.map(recommendedProduct => ( + + ))} +
+
+
+
+
+ ); +}; diff --git a/src/components/HomePage/components/img/banner-desktop.png b/src/components/HomePage/components/img/banner-desktop.png new file mode 100644 index 00000000000..a84b79c5492 Binary files /dev/null and b/src/components/HomePage/components/img/banner-desktop.png differ diff --git a/src/components/HomePage/components/img/banner-mobile-first.png b/src/components/HomePage/components/img/banner-mobile-first.png new file mode 100644 index 00000000000..c53f6ed5839 Binary files /dev/null and b/src/components/HomePage/components/img/banner-mobile-first.png differ diff --git a/src/components/HomePage/components/img/banner-tablets.png b/src/components/HomePage/components/img/banner-tablets.png new file mode 100644 index 00000000000..d1d7beb7dcd Binary files /dev/null and b/src/components/HomePage/components/img/banner-tablets.png differ diff --git a/src/components/HomePage/components/img/category-accsesories.png b/src/components/HomePage/components/img/category-accsesories.png new file mode 100644 index 00000000000..837761ce417 Binary files /dev/null and b/src/components/HomePage/components/img/category-accsesories.png differ diff --git a/src/components/HomePage/components/img/category-phones.png b/src/components/HomePage/components/img/category-phones.png new file mode 100644 index 00000000000..a3d5ed358c5 Binary files /dev/null and b/src/components/HomePage/components/img/category-phones.png differ diff --git a/src/components/HomePage/components/img/category-tablets.png b/src/components/HomePage/components/img/category-tablets.png new file mode 100644 index 00000000000..d31a4209603 Binary files /dev/null and b/src/components/HomePage/components/img/category-tablets.png differ diff --git a/src/components/HomePage/index.ts b/src/components/HomePage/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 00000000000..858fcc17687 --- /dev/null +++ b/src/components/Loader/Loader.module.scss @@ -0,0 +1,25 @@ +.loader__container { + display: flex; + justify-content: center; + align-items: center; + height: 100px; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid rgba(0, 0, 0, 0.1); + border-top: 4px solid #3498db; + border-radius: 50%; + + animation: spin 1s 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..5b79f2aea19 --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './Loader.module.scss'; + +export const Loader = () => { + return ( +
+
+
+ ); +}; diff --git a/src/components/Loader/index.ts b/src/components/Loader/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/NotFoundPage/NotFoundPage.module.scss b/src/components/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000000..2d71fa502d9 --- /dev/null +++ b/src/components/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,85 @@ +.container { + min-height: 70vh; + padding: 55px 16px 64px; + display: flex; + align-items: center; + justify-content: center; +} + +.content { + max-width: 520px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.image { + width: min(100%, 320px); + margin-bottom: 32px; +} + +.label { + margin-bottom: 8px; + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 14px; + line-height: 21px; + color: #905bff; +} + +.title { + margin-bottom: 12px; + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 32px; + line-height: 41px; + color: #f1f2f9; +} + +.text { + margin-bottom: 24px; + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + color: #75767f; +} + +.home { + height: 48px; + padding-inline: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + background-color: #905bff; + color: #fff; + text-decoration: none; + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 14px; + line-height: 21px; + transition: + background-color 0.2s ease, + transform 0.2s ease; +} + +.home:hover { + background-color: #a378ff; + transform: translateY(-2px); +} + +@media (min-width: 640px) { + .container { + padding-inline: 24px; + } + + .title { + font-size: 48px; + line-height: 56px; + } + + .image { + width: 360px; + } +} diff --git a/src/components/NotFoundPage/NotFoundPage.tsx b/src/components/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..4f191fe62dc --- /dev/null +++ b/src/components/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import styles from './NotFoundPage.module.scss'; +import { getAssetUrl } from '../../utils/getAssetUrl'; + +export const NotFoundPage = () => ( +
+
+ Page not found + +

404 error

+ +

Page not found

+ +

+ The page you are looking for does not exist or was moved. +

+ + + Back to Home + +
+
+); diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..8f90705ccf6 --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,238 @@ +.card { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 440px; + width: 100%; + max-width: 288px; + padding: 32px; + background-color: #161827; + border: 1px solid #323542; + color: #f1f2f9; +} + +.imageLink { + display: flex; + align-items: center; + justify-content: center; + height: 130px; + margin-bottom: 24px; + transition: transform 0.3s ease; +} + +.imageLink:hover { + transform: scale(1.1); +} + +.image { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.titleLink { + display: block; + min-height: 42px; + margin-bottom: 8px; + color: inherit; + text-decoration: none; +} + +.title { + display: -webkit-box; + overflow: hidden; + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: #f1f2f9; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.prices { + display: flex; + align-items: baseline; + gap: 8px; + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 1px solid #3b3e4a; +} + +.price { + margin: 0; + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 22px; + line-height: 31px; + color: #f1f2f9; +} + +.fullPrice { + margin: 0; + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 22px; + line-height: 28px; + color: #75767f; + text-decoration: line-through; +} + +.specs { + display: grid; + gap: 8px; + margin-bottom: 16px; +} + +.specRow { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.specLabel, +.specValue { + font-family: Mont, sans-serif; + font-size: 12px; + line-height: 15px; +} + +.specLabel { + font-weight: 600; + color: #75767f; +} + +.specValue { + font-weight: 700; + color: #f1f2f9; + text-align: right; +} + +.actions { + display: flex; + gap: 8px; + margin-top: auto; +} + +.addToCart, +.favoriteButton { + height: 40px; + border: none; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.addToCart { + flex: 1; + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + text-align: center; + color: #fff; + background-color: #905bff; + white-space: nowrap; +} + +.addToCart:hover, +.favoriteButton:hover { + opacity: 0.9; +} + +.addToCartActive { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + text-align: center; + color: #f1f2f9; + background-color: #323542; + cursor: default; +} + +.favoriteButton { + flex: 0 0 40px; + display: flex; + align-items: center; + justify-content: center; + background-color: #323542; + border: 1px solid #3b3e4a; +} + +.favoriteButtonActive { + border-color: #3b3e4a; + background-color: #161827; +} + +.favoriteIcon { + width: 16px; + height: 16px; + object-fit: contain; +} + +.card.sliderCard { + flex: 0 0 212px; + width: 212px; + height: 440px; + margin-top: 0; + padding: 32px; +} + +@media (min-width: 640px) { + .imageLink { + height: 196px; + } + + .card.sliderCard { + flex: 0 0 237px; + width: 237px; + height: 512px; + margin-top: 0; + padding: 24px; + } + + .card { + height: 506px; + width: 100%; + max-width: 288px; + } +} + +@media (min-width: 768px) { + .card { + height: 506px; + padding: 32px; + width: 100%; + max-width: 229px; + } + + .imageLink { + min-height: 196px; + margin-bottom: 24px; + } +} + +@media (min-width: 1200px) { + .card { + height: 506px; + padding: 32px; + width: 100%; + max-width: 272px; + } + + .card.sliderCard { + flex: 0 0 272px; + width: 272px; + height: 506px; + margin-top: 0; + padding: 24px; + } + + .imageLink { + height: 196px; + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..81c73edec55 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Product } from '../../types/Product'; +import { Link } from 'react-router-dom'; +import styles from './ProductCard.module.scss'; +import favouritesIcon from '../Header/components/img/favourites.png'; +import favoritedActive from './components/img/favorited-active.png'; +import { useFavorites } from '../../context/FavoritesContext'; +import { useCart } from '../../context/CartContext'; +import { getAssetUrl } from '../../utils/getAssetUrl'; + +interface Props { + product: Product; + variant?: 'default' | 'slider'; + showDiscount?: boolean; +} + +export const ProductCard: React.FC = ({ + product, + variant, + showDiscount = true, +}) => { + const { toggleFavorite, isFavorite } = useFavorites(); + + const isProductFavorite = isFavorite(product.itemId); + + const { addToCart, isInCart } = useCart(); + const isProductInCart = isInCart(product.itemId); + + return ( +
+ + {product.name} + + + +

{product.name}

+ + +
+

+ ${showDiscount ? product.price : product.fullPrice} +

+ {showDiscount && ( +

${product.fullPrice}

+ )} +
+ +
+
+ Screen + {product.screen} +
+ +
+ Capacity + {product.capacity} +
+ +
+ RAM + {product.ram} +
+
+ +
+ + + +
+
+ ); +}; diff --git a/src/components/ProductCard/components/img/Home.png b/src/components/ProductCard/components/img/Home.png new file mode 100644 index 00000000000..e581ac3beca Binary files /dev/null and b/src/components/ProductCard/components/img/Home.png differ diff --git a/src/components/ProductCard/components/img/favorited-active.png b/src/components/ProductCard/components/img/favorited-active.png new file mode 100644 index 00000000000..916f5e91f50 Binary files /dev/null and b/src/components/ProductCard/components/img/favorited-active.png differ diff --git a/src/components/ProductCard/components/img/slider-button-left.png b/src/components/ProductCard/components/img/slider-button-left.png new file mode 100644 index 00000000000..8b3e6c55773 Binary files /dev/null and b/src/components/ProductCard/components/img/slider-button-left.png differ diff --git a/src/components/ProductCard/components/img/slider-button-right.png b/src/components/ProductCard/components/img/slider-button-right.png new file mode 100644 index 00000000000..26f49457d3a Binary files /dev/null and b/src/components/ProductCard/components/img/slider-button-right.png differ diff --git a/src/components/ProductCard/index.ts b/src/components/ProductCard/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/ProductDeatils/ProductDetails.module.scss b/src/components/ProductDeatils/ProductDetails.module.scss new file mode 100644 index 00000000000..7aa19a42cbb --- /dev/null +++ b/src/components/ProductDeatils/ProductDetails.module.scss @@ -0,0 +1,714 @@ +.page { + min-height: 100%; + padding: 72px 16px 56px; + background: #0f1121; + color: #f1f2f9; + font-family: Mont, sans-serif; +} + +.productIdDekstop { + display: none; +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 1; + color: #75767f; +} + +.breadcrumbHome { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.breadcrumbIcon { + width: 16px; + height: 16px; +} + +.breadcrumbSeparator { + width: 16px; + height: 16px; + opacity: 0.65; +} + +.breadcrumbLink { + color: #f1f2f9; + text-decoration: none; +} + +.breadcrumbCurrent { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: #75767f; +} + +.backButton { + display: inline-flex; + align-items: center; + gap: 4px; + margin-bottom: 16px; + padding: 0; + border: 0; + background: transparent; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.backButton__icon { + width: 16px; + height: 16px; +} + +.title { + margin: 0 0 32px; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 22px; + font-weight: 800; + line-height: 1.4; + letter-spacing: 0; +} + +.gallery { + display: grid; + gap: 16px; + margin-bottom: 24px; +} + +.mainImageWrap { + display: flex; + align-items: center; + justify-content: center; + min-height: 288px; + padding: 16px; + background: #0f1121; +} + +.mainImage { + width: 100%; + max-width: 250px; + max-height: 260px; + object-fit: contain; +} + +.thumbnails { + display: flex; + gap: 8px; +} + +.thumbnailButton { + width: 52px; + height: 52px; + padding: 3px; + border: 1px solid #313237; + background: #0f1121; + cursor: pointer; +} + +.thumbnailButtonActive { + border-color: #905bff; +} + +.thumbnailImage { + width: 100%; + height: 100%; + object-fit: contain; +} + +.optionsRow { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 16px; + margin-bottom: 24px; +} + +.optionLabel { + margin: 0 0 10px; + color: #75767f; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 1; +} + +.colors { + display: flex; + gap: 8px; +} + +.colorLabel, +.capacityLabel { + display: inline-flex; + cursor: pointer; +} + +.radioInput { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.colorButton { + display: block; + position: relative; + width: 32px; + height: 32px; + border: 1px solid #313237; + border-radius: 50%; + background: transparent; +} + +.colorButton::after { + content: ''; + position: absolute; + inset: 2px; + border-radius: 50%; + background: var(--swatch-color); +} + +.colorButtonActive { + border-color: #f1f2f9; +} + +.productId { + color: #4a4d58; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 1; + text-align: right; +} + +.divider { + height: 1px; + margin: 0 0 24px; + background: #3b3e4a; +} + +.capacities { + display: flex; + gap: 8px; + margin-bottom: 24px; +} + +.capacityButton { + display: inline-flex; + min-width: 58px; + padding: 8px; + border: 1px solid #313237; + background: #0f1121; + color: #f1f2f9; + text-align: center; + text-decoration: none; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 1.5; +} + +.capacityButtonActive { + border-color: #f1f2f9; + background: #f1f2f9; + color: #0f1121; +} + +.priceBlock { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 16px; +} + +.discountPrice { + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 32px; + font-weight: 800; + line-height: 1.28; + letter-spacing: -0.01em; +} + +.regularPrice { + color: #75767f; + font-family: Mont, sans-serif; + font-size: 22px; + font-weight: 600; + line-height: 1; + text-decoration: line-through; +} + +.actions { + display: flex; + gap: 8px; + margin-bottom: 32px; +} + +.addToCart { + width: 231px; + height: 48px; + border: 0; + background: #905bff; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 700; + cursor: pointer; + flex-shrink: 0; +} + +.favoriteButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 1px solid #313237; + background: #323542; + cursor: pointer; + flex-shrink: 0; +} + +.favoriteButtonIcon { + width: 16px; + height: 16px; +} + +.addToCartActive { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + text-align: center; + color: #f1f2f9; + background-color: #323542; + cursor: default; +} + +.favoriteButtonActive { + border-color: #3b3e4a; + background-color: #0f1121; +} + +.specs { + display: grid; + gap: 8px; + padding-top: 24px; + border-top: 1px solid #3b3e4a; +} + +.specRow { + display: grid; + grid-template-columns: 1fr auto; + gap: 16px; + align-items: start; +} + +.specName { + color: #75767f; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 1.25; +} + +.specValue { + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 1.25; + text-align: right; +} + +.about { + margin-top: 56px; +} + +.sectionTitle { + margin: 0 0 16px; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 22px; + font-weight: 800; + line-height: 1.4; +} + +.descriptionBlock { + padding-top: 24px; + border-top: 1px solid #3b3e4a; +} + +.descriptionBlock + .descriptionBlock { + margin-top: 24px; +} + +.descriptionTitle { + margin: 0 0 16px; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 20px; + font-weight: 700; + line-height: 1.3; +} + +.descriptionText { + margin: 0; + color: #89939a; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 1.5; +} + +.descriptionText + .descriptionText { + margin-top: 16px; +} + +.techSpecs { + margin-top: 56px; +} + +.fallbackCard { + display: grid; + gap: 16px; + max-width: 420px; + font-family: Mont, sans-serif; +} + +.fallbackImage { + width: 100%; + max-width: 280px; + object-fit: contain; +} + +.slider { + margin-top: 56px; +} + +.slider__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 24px; +} + +.slider__title { + font-family: Mont, sans-serif; + font-weight: 800; + font-style: Bold; + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + color: #f1f2f9; + margin: 0; +} + +.slider__buttons { + display: flex; + gap: 16px; +} + +.slider__arrow { + width: 32px; + height: 32px; + border: 1px solid #3b3e4a; + background: #323542; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.slider__arrow:hover { + background-color: #4a4d58; +} + +.slider__arrow:disabled { + background: transparent; + border-color: #323542; + cursor: not-allowed; + opacity: 0.5; +} + +.slider__arrow__img { + width: 16px; + height: 16px; +} + +.viewport { + min-width: 0; + overflow: hidden; +} + +.track { + display: flex; + gap: 16px; + width: max-content; + transition: transform 0.3s ease; +} + +@media (min-width: 640px) { + .page { + padding: 72px 24px 64px; + } + + .addToCart { + width: 180px; + height: 48px; + } + + .phoneCard { + max-width: none; + display: grid; + grid-template-columns: repeat(12, 1fr); + column-gap: 16px; + align-items: start; + } + + .gallery { + grid-column: 1 / 7; + margin: 0; + } + + .productInfo { + grid-row: 1; + grid-column: 8 / -1; + } + + .thumbnails { + display: flex; + flex-direction: column; + gap: 8px; + } + + .mainImageWrap { + grid-column: 2; + grid-row: 1; + min-height: 288px; + padding: 0; + } + + .about { + grid-column: 1 / -1; + margin-top: 64px; + } + + .techSpecs { + grid-column: 1 / -1; + margin-top: 64px; + } + + .breadcrumbs { + margin-bottom: 40px; + } + + .title { + margin: 0 0 40px; + } + + .thumbnailButton { + width: 35px; + height: 35px; + } + + .slider { + margin-top: 64px; + } +} + +@media (width: 1200px) { + .page { + padding: 88px 32px 81px; + } + + .addToCart { + width: 263px; + height: 48px; + } + + .phoneCard { + max-width: none; + display: grid; + grid-template-columns: repeat(24, 1fr); + column-gap: 16px; + align-items: start; + } + + .gallery { + grid-column: 1 / 12; + margin: 0; + } + + .productInfo { + grid-row: 1; + grid-column: 14 / 21; + } + + .productId { + display: none; + } + + .productIdDekstop { + grid-row: 1; + display: inline; + grid-column: 23 / -1; + color: #4a4d58; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 1; + text-align: right; + } + + .thumbnails { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mainImage { + min-height: 442px; + min-width: 442px; + } + + .mainImageWrap { + min-height: 464px; + padding: 0; + } + + .thumbnailButton { + width: 80px; + height: 80px; + } + + .about { + grid-column: 1 / 12; + margin-top: 80px; + } + + .techSpecs { + margin-top: 80px; + grid-column: 14 / -1; + } + + .breadcrumbs { + margin-bottom: 40px; + } + + .title { + margin: 0 0 40px; + } + + .slider { + margin-top: 80px; + } +} + +@media (min-width: 1201px) { + .page { + // padding: 88px 152px 81px; + max-width: 1136px; + margin: auto; + padding-top: 88px; + padding-bottom: 81px; + padding-inline: 0; + } + + .addToCart { + width: 263px; + height: 48px; + } + + .phoneCard { + max-width: none; + display: grid; + grid-template-columns: repeat(24, 1fr); + column-gap: 16px; + align-items: start; + } + + .gallery { + grid-column: 1 / 12; + margin: 0; + } + + .productInfo { + grid-row: 1; + grid-column: 14 / 23; + } + + .productId { + display: none; + } + + .productIdDekstop { + grid-row: 1; + display: inline; + grid-column: 23 / -1; + color: #4a4d58; + font-family: Mont, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 1; + text-align: right; + } + + .thumbnails { + display: flex; + flex-direction: column; + gap: 16px; + } + + .mainImage { + min-height: 442px; + min-width: 442px; + } + + .mainImageWrap { + min-height: 464px; + padding: 0; + } + + .thumbnailButton { + width: 80px; + height: 80px; + } + + .about { + grid-column: 1 / 12; + margin-top: 80px; + } + + .techSpecs { + margin-top: 80px; + grid-column: 14 / -1; + } + + .breadcrumbs { + margin-bottom: 40px; + } + + .title { + margin: 0 0 40px; + } + + .slider { + margin-top: 80px; + } +} diff --git a/src/components/ProductDeatils/ProductDetailsPage.tsx b/src/components/ProductDeatils/ProductDetailsPage.tsx new file mode 100644 index 00000000000..15ef970f2b2 --- /dev/null +++ b/src/components/ProductDeatils/ProductDetailsPage.tsx @@ -0,0 +1,541 @@ +import React, { CSSProperties, useEffect, useRef, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { ProductDetails } from '../../types/ProductDetails'; +import { + getAllProducts, + getProductById, + getProductVariants, + getSuggestedProducts, +} from '../../api'; +import styles from './ProductDetails.module.scss'; +import homeIcon from '../ProductCard/components/img/Home.png'; +// eslint-disable-next-line max-len +import arrowRight from '../CatalogPage/components/img/arrow-right.png'; +import arrowRight__slider from './components/img/arrow-right-slider.png'; +// eslint-disable-next-line max-len +import arrowLeft from './components/img/arrow-left.png'; +import favouritesIcon from '../Header/components/img/favourites.png'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; +import { useCart } from '../../context/CartContext'; +import { useFavorites } from '../../context/FavoritesContext'; +// eslint-disable-next-line max-len +import favoritedActive from '../ProductCard/components/img/favorited-active.png'; +import { Loader } from '../Loader/Loader'; +import { getAssetUrl } from '../../utils/getAssetUrl'; + +const colorMap: Record = { + black: '#111111', + blue: '#4f6d8a', + coral: '#ff7f50', + gold: '#f1dac4', + graphite: '#4f4f57', + green: '#7d9d8b', + midnight: '#1f2433', + midnightgreen: '#56645a', + pink: '#f3c7cf', + purple: '#b39ddb', + red: '#c62828', + rosegold: '#d9a6a0', + sierrablue: '#9db7d5', + silver: '#f1f2f6', + spaceblack: '#232323', + spacegray: '#535150', + white: '#fafafa', + yellow: '#f3d250', +}; + +const categoryLabels: Record = { + phones: 'Phones', + tablets: 'Tablets', + accessories: 'Accessories', +}; + +const normalizeColorName = (color: string) => + color.charAt(0).toUpperCase() + color.slice(1).replace(/([A-Z])/g, ' $1'); + +export const ProductDetailsPage = () => { + const [product, setProduct] = useState(null); + const [productVariants, setProductVariants] = useState([]); + const [selectedImage, setSelectedImage] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [recommendedProducts, setRecommendedProducts] = useState([]); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const [currentProduct, setCurrentProduct] = useState(null); + const navigate = useNavigate(); + + const { addToCart, isInCart } = useCart(); + const { toggleFavorite, isFavorite } = useFavorites(); + + const updateScrollButtons = () => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const slider = sliderRef.current; + + if (!slider) { + return; + } + + setCanScrollLeft(slider.scrollLeft > 0); + setCanScrollRight( + slider.scrollLeft + slider.clientWidth < slider.scrollWidth - 1, + ); + }; + + const sliderRef = useRef(null); + + const scrollSlider = (direction: 'left' | 'right') => { + const slider = sliderRef.current; + const track = slider?.querySelector(`.${styles.track}`); + const card = track?.firstElementChild as HTMLElement | null; + const gap = track ? parseInt(getComputedStyle(track).gap, 10) : 16; + const scrollAmount = (card?.offsetWidth || 212) + gap; + + slider?.scrollBy({ + left: direction === 'right' ? scrollAmount : -scrollAmount, + behavior: 'smooth', + }); + + setTimeout(updateScrollButtons, 300); + }; + + useEffect(() => { + updateScrollButtons(); + }, [recommendedProducts]); + + const { productId } = useParams(); + + useEffect(() => { + if (!product) { + return; + } + + getAllProducts().then(products => { + const current = products.find(item => item.itemId === product.id) || null; + + setCurrentProduct(current); + }); + + getSuggestedProducts(product.id).then(suggestedProducts => { + setRecommendedProducts(suggestedProducts); + }); + }, [product]); + + useEffect(() => { + if (!productId) { + return; + } + + setProduct(null); + setIsLoading(true); + setHasError(false); + + getProductById(productId) + .then(data => { + if (!data) { + setProduct(null); + + return; + } + + setProduct(data); + setSelectedImage(data.images[0] || ''); + + getProductVariants(data.category, data.namespaceId) + .then(variants => { + setProductVariants(variants); + }) + .catch(() => { + setProductVariants([]); + }); + }) + .catch(() => { + setHasError(true); + }) + .finally(() => { + setIsLoading(false); + }); + }, [productId]); + + if (isLoading) { + return ; + } + + if (hasError) { + return ( + <> +

Something went wrong...

+ + + + ); + } + + if (!product) { + return

Product was not found

; + } + + const currentImage = selectedImage || product.images[0]; + const productCode = product.id.slice(-6).toUpperCase(); + + const getVariantByOptions = (color: string, capacity: string) => + productVariants.find( + variant => variant.color === color && variant.capacity === capacity, + ); + + const isProductInCart = currentProduct + ? isInCart(currentProduct.itemId) + : false; + + const isProductFavorite = currentProduct + ? isFavorite(currentProduct.itemId) + : false; + + return ( +
+ + + + +

{product.name}

+ +
+
+
+ {product.name} +
+ +
+ {product.images.map(image => ( + + ))} +
+
+ +
+
+
+

Available colors

+
+ {product.colorsAvailable.map(color => { + const colorVariant = getVariantByOptions( + color, + product.capacity, + ); + + if (!colorVariant) { + return null; + } + + return ( + + ); + })} +
+
+

ID: {productCode}

+
+
+
+

Select capacity

+
+ {product.capacityAvailable.map(capacity => { + const capacityVariant = getVariantByOptions( + product.color, + capacity, + ); + + if (!capacityVariant) { + return null; + } + + return ( + + ); + })} +
+
+
+ + ${product.priceDiscount} + + ${product.priceRegular} +
+
+ + +
+
+
+ Screen + {product.screen} +
+
+ Resolution + {product.resolution} +
+
+ Processor + {product.processor} +
+
+ RAM + {product.ram} +
+
+ Built in memory + {product.capacity} +
+ {product.camera && ( +
+ Camera + {product.camera} +
+ )} + {product.zoom && ( +
+ Zoom + {product.zoom} +
+ )} +
+ Cell + + {product.cell.join(', ')} + +
+
+
+ +

ID: {productCode}

+ +
+

About

+ {product.description.map(section => ( +
+

{section.title}

+ {section.text.map(paragraph => ( +

+ {paragraph} +

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

Tech specs

+ +
+
+ Screen + {product.screen} +
+
+ Resolution + {product.resolution} +
+
+ Processor + {product.processor} +
+
+ RAM + {product.ram} +
+
+ Built in memory + {product.capacity} +
+ {product.camera && ( +
+ Camera + {product.camera} +
+ )} + {product.zoom && ( +
+ Zoom + {product.zoom} +
+ )} +
+ Cell + + {product.cell.join(', ')} + +
+
+
+
+ +
+
+

You may also like

+
+ + +
+
+ +
+
+ {recommendedProducts.map(recommendedProduct => ( + + ))} +
+
+
+
+ ); +}; diff --git a/src/components/ProductDeatils/components/img/arrow-left-slider.png b/src/components/ProductDeatils/components/img/arrow-left-slider.png new file mode 100644 index 00000000000..545c506c08a Binary files /dev/null and b/src/components/ProductDeatils/components/img/arrow-left-slider.png differ diff --git a/src/components/ProductDeatils/components/img/arrow-left.png b/src/components/ProductDeatils/components/img/arrow-left.png new file mode 100644 index 00000000000..ada601ed7e4 Binary files /dev/null and b/src/components/ProductDeatils/components/img/arrow-left.png differ diff --git a/src/components/ProductDeatils/components/img/arrow-right-slider.png b/src/components/ProductDeatils/components/img/arrow-right-slider.png new file mode 100644 index 00000000000..8f1e717558b Binary files /dev/null and b/src/components/ProductDeatils/components/img/arrow-right-slider.png differ diff --git a/src/components/ProductDeatils/index.ts b/src/components/ProductDeatils/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/ProductList/ProductList.module.scss b/src/components/ProductList/ProductList.module.scss new file mode 100644 index 00000000000..0e40c8fce6c --- /dev/null +++ b/src/components/ProductList/ProductList.module.scss @@ -0,0 +1,26 @@ +.list { + display: grid; + grid-template-columns: 1fr; + justify-items: center; + gap: 40px; +} + +@media (min-width: 640px) { + .list { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + justify-items: stretch; + } +} + +@media (min-width: 768px) { + .list { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 1200px) { + .list { + grid-template-columns: repeat(4, 1fr); + } +} diff --git a/src/components/ProductList/ProductList.tsx b/src/components/ProductList/ProductList.tsx new file mode 100644 index 00000000000..efdf267d7c9 --- /dev/null +++ b/src/components/ProductList/ProductList.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; +import styles from './ProductList.module.scss'; + +interface Props { + products: Product[]; +} + +export const ProductList: React.FC = ({ products }) => { + return ( +
+ {products.map(product => ( + + ))} +
+ ); +}; diff --git a/src/components/ProductList/index.ts b/src/components/ProductList/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx new file mode 100644 index 00000000000..093105d8e57 --- /dev/null +++ b/src/context/CartContext.tsx @@ -0,0 +1,118 @@ +import { Product } from '../types/Product'; +import React, { createContext, useContext, useEffect, useState } from 'react'; + +interface CartItem { + product: Product; + quantity: number; +} + +interface CartContextType { + cartItems: CartItem[]; + addToCart: (product: Product) => void; + removeFromCart: (itemID: string) => void; + increaseQuantity: (itemID: string) => void; + decreaseQuantity: (itemID: string) => void; + isInCart: (itemID: string) => boolean; + totalQuantity: number; + totalPrice: number; + clearCart: () => void; +} + +const CartContext = createContext(null); + +export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [cartItems, setCartItems] = useState(() => { + const saved = localStorage.getItem('cart'); + + return saved ? JSON.parse(saved) : []; + }); + + useEffect(() => { + localStorage.setItem('cart', JSON.stringify(cartItems)); + }, [cartItems]); + + const addToCart = (product: Product) => { + setCartItems(currentItems => { + const isAlreadyInCart = currentItems.some( + item => item.product.itemId === product.itemId, + ); + + if (isAlreadyInCart) { + return currentItems; + } + + return [...currentItems, { product, quantity: 1 }]; + }); + }; + + const removeFromCart = (itemId: string) => { + setCartItems(currentItems => + currentItems.filter(item => item.product.itemId !== itemId), + ); + }; + + const increaseQuantity = (itemId: string) => { + setCartItems(currentItems => + currentItems.map(item => + item.product.itemId === itemId + ? { ...item, quantity: item.quantity + 1 } + : item, + ), + ); + }; + + const decreaseQuantity = (itemId: string) => { + setCartItems(currentItems => + currentItems.map(item => + item.product.itemId === itemId + ? { ...item, quantity: Math.max(1, item.quantity - 1) } + : item, + ), + ); + }; + + const isInCart = (itemId: string) => { + return cartItems.some(item => item.product.itemId === itemId); + }; + + const totalQuantity = cartItems.reduce((sum, item) => sum + item.quantity, 0); + + const totalPrice = cartItems.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0, + ); + + const clearCart = () => { + setCartItems([]); + }; + + return ( + + {children} + + ); +}; + +export const useCart = () => { + const value = useContext(CartContext); + + if (!value) { + throw new Error('useCart must be use inside CartProvider'); + } + + return value; +}; diff --git a/src/context/FavoritesContext.tsx b/src/context/FavoritesContext.tsx new file mode 100644 index 00000000000..cd655728e53 --- /dev/null +++ b/src/context/FavoritesContext.tsx @@ -0,0 +1,56 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { Product } from '../types/Product'; + +interface FavoritesContextType { + favoriteIds: string[]; + toggleFavorite: (product: Product) => void; + isFavorite: (itemId: string) => boolean; +} + +const FavoritesContext = createContext(null); + +export const FavoritesProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [favoriteIds, setFavoriteIds] = useState(() => { + const saved = localStorage.getItem('favorites'); + + return saved ? JSON.parse(saved) : []; + }); + + useEffect(() => { + localStorage.setItem('favorites', JSON.stringify(favoriteIds)); + }, [favoriteIds]); + + const toggleFavorite = (product: Product) => { + setFavoriteIds(currentIds => { + if (currentIds.includes(product.itemId)) { + return currentIds.filter(id => id !== product.itemId); + } + + return [...currentIds, product.itemId]; + }); + }; + + const isFavorite = (itemId: string) => { + return favoriteIds.includes(itemId); + }; + + return ( + + {children} + + ); +}; + +export const useFavorites = () => { + const value = useContext(FavoritesContext); + + if (!value) { + throw new Error('useFavorites must be used inside FavoritesProvider'); + } + + return value; +}; diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..873c2d452bc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,16 @@ import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; import { App } from './App'; +import React from 'react'; +import { FavoritesProvider } from './context/FavoritesContext'; +import { CartProvider } from './context/CartContext'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + , +); diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 00000000000..052d1dceead --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,14 @@ +export interface Product { + id: number; + category: string; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +} diff --git a/src/types/ProductDetails.ts b/src/types/ProductDetails.ts new file mode 100644 index 00000000000..f91c6d31e92 --- /dev/null +++ b/src/types/ProductDetails.ts @@ -0,0 +1,24 @@ +export interface ProductDetails { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: { + title: string; + text: string[]; + }[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera?: string; + zoom?: string; + cell: string[]; +} diff --git a/src/utils/getAssetUrl.ts b/src/utils/getAssetUrl.ts new file mode 100644 index 00000000000..f9254c41a17 --- /dev/null +++ b/src/utils/getAssetUrl.ts @@ -0,0 +1,2 @@ +export const getAssetUrl = (path: string) => + `${import.meta.env.BASE_URL.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;