diff --git a/index.html b/index.html index 095fb3a4537..175af61f0e0 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,8 @@ - Vite + React + TS + Nice Gadgets +
diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..7ef0cda3bbb 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", @@ -1184,10 +1184,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", diff --git a/package.json b/package.json index ae251685c8b..6fd0a6cbfe9 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", diff --git a/public/api/products.json b/public/api/products.json index 41ecb5e5a6f..ba6fe45f5a7 100644 --- a/public/api/products.json +++ b/public/api/products.json @@ -1001,7 +1001,7 @@ "fullPrice": 1056, "price": 980, "screen": "6.1' IPS", - "capacity": "32GB", + "capacity": "128GB", "color": "midnight", "ram": "6GB", "year": 2022, @@ -2715,4 +2715,4 @@ "year": 2022, "image": "img/phones/apple-iphone-14-pro/gold/00.webp" } -] \ No newline at end of file +] diff --git a/public/img/icons/Arrow_Left.png b/public/img/icons/Arrow_Left.png new file mode 100644 index 00000000000..346d89aa611 Binary files /dev/null and b/public/img/icons/Arrow_Left.png differ diff --git a/public/img/icons/Arrow_Right.png b/public/img/icons/Arrow_Right.png new file mode 100644 index 00000000000..95fd32520f0 Binary files /dev/null and b/public/img/icons/Arrow_Right.png differ diff --git a/public/img/icons/Home.png b/public/img/icons/Home.png new file mode 100644 index 00000000000..9e0db5f6b94 Binary files /dev/null and b/public/img/icons/Home.png differ diff --git a/public/img/icons/Icons/Phone catalog (V2) Original.zip b/public/img/icons/Icons/Phone catalog (V2) Original.zip new file mode 100644 index 00000000000..f7f071f62f4 Binary files /dev/null and b/public/img/icons/Icons/Phone catalog (V2) Original.zip differ diff --git a/public/img/icons/Logo.png b/public/img/icons/Logo.png new file mode 100644 index 00000000000..8622c2274d1 Binary files /dev/null and b/public/img/icons/Logo.png differ diff --git a/public/img/icons/ToTop.png b/public/img/icons/ToTop.png new file mode 100644 index 00000000000..5abadad1331 Binary files /dev/null and b/public/img/icons/ToTop.png differ diff --git a/public/img/icons/burgerMenu.png b/public/img/icons/burgerMenu.png new file mode 100644 index 00000000000..cabd552cca2 Binary files /dev/null and b/public/img/icons/burgerMenu.png differ diff --git a/public/img/icons/cart.png b/public/img/icons/cart.png new file mode 100644 index 00000000000..7c4fd20dc40 Binary files /dev/null and b/public/img/icons/cart.png differ diff --git a/public/img/icons/close.png b/public/img/icons/close.png new file mode 100644 index 00000000000..1b5256e28fe Binary files /dev/null and b/public/img/icons/close.png differ diff --git a/public/img/icons/down.png b/public/img/icons/down.png new file mode 100644 index 00000000000..c1f3a1c1ed9 Binary files /dev/null and b/public/img/icons/down.png differ diff --git a/public/img/icons/left.png b/public/img/icons/left.png new file mode 100644 index 00000000000..1848e0324f6 Binary files /dev/null and b/public/img/icons/left.png differ diff --git a/public/img/icons/like.png b/public/img/icons/like.png new file mode 100644 index 00000000000..7754bb32733 Binary files /dev/null and b/public/img/icons/like.png differ diff --git a/public/img/icons/minus.png b/public/img/icons/minus.png new file mode 100644 index 00000000000..1675218a4ac Binary files /dev/null and b/public/img/icons/minus.png differ diff --git a/public/img/icons/noneLike.png b/public/img/icons/noneLike.png new file mode 100644 index 00000000000..73d76942082 Binary files /dev/null and b/public/img/icons/noneLike.png differ diff --git a/public/img/icons/plus.png b/public/img/icons/plus.png new file mode 100644 index 00000000000..579b7acff3b Binary files /dev/null and b/public/img/icons/plus.png differ diff --git a/public/img/icons/right.png b/public/img/icons/right.png new file mode 100644 index 00000000000..a23571d537a Binary files /dev/null and b/public/img/icons/right.png differ diff --git a/public/img/icons/search.png b/public/img/icons/search.png new file mode 100644 index 00000000000..d44003577cf Binary files /dev/null and b/public/img/icons/search.png differ diff --git a/public/img/icons/up.png b/public/img/icons/up.png new file mode 100644 index 00000000000..7f6722d42cc Binary files /dev/null and b/public/img/icons/up.png differ diff --git a/src/App.scss b/src/App.scss index 71bc413aade..5ebb4f09d52 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,35 @@ -// not empty +// not empty +@import './variables'; + +*{ + margin: 0; + padding: 0; +} + +.App{ + font-family: $font-family; + display: flex; + flex-direction: column; + justify-items: center; + min-height: 100vh; +} + +.is-hidden{ + display: none; +} + +.line{ + height: 1px; + width: 100%; + background-color: $Elements; + margin: 0; +} + +.no-scroll { + overflow: hidden; + height: 100vh; +} + +.pages{ + flex-grow: 1; +} diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..3559884446c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,32 @@ +import React from 'react'; +import { Routes, Route } from 'react-router-dom'; + import './App.scss'; +import { Header } from './components/Header/Header'; +import { HomePage } from './Pages/HomePage'; +import { Footer } from './components/Footer/Footer'; +import { Catalog } from './Pages/Catalog/Catalog'; +import { ItemCard } from './Pages/ItemCard'; +import { Favorites } from './Pages/Favorites'; +import { Cart } from './Pages/Cart'; -export const App = () => ( -
-

Product Catalog

-
-); +export const App = () => { + return ( +
+
+

Product Catalog

+
+ + } /> + } /> + } /> + } /> + } /> + Page not found} /> + +
+
+
+
+ ); +}; diff --git a/src/ItemsProvider.tsx b/src/ItemsProvider.tsx new file mode 100644 index 00000000000..9aec0cf88ee --- /dev/null +++ b/src/ItemsProvider.tsx @@ -0,0 +1,73 @@ +import React, { createContext, useState, ReactNode, useEffect } from 'react'; +import { Product } from './types/product'; + +interface CartContextType { + cartItems: CartItemType[]; + setCartItems: React.Dispatch>; +} + +export interface CartItemType { + product: Product; + quantity: number; +} + +interface FavoritesContextType { + favoritesItems: Product[]; + setFavoritesItems: React.Dispatch>; +} + +export const CartContext = createContext(null); + +export const FavoritesContext = createContext( + null, +); + +export const useCart = () => { + const context = React.useContext(CartContext); + + if (!context) { + throw new Error('useCart must be used within a ItemsProvider'); + } + + return context; +}; + +export const useFavorites = () => { + const context = React.useContext(FavoritesContext); + + if (!context) { + throw new Error('useFavorites must be used within a ItemsProvider'); + } + + return context; +}; + +export const ItemsProvider = ({ children }: { children: ReactNode }) => { + const [cartItems, setCartItems] = useState(() => { + const saved = localStorage.getItem('cartItems'); + + return saved ? JSON.parse(saved) : []; + }); + + const [favoritesItems, setFavoritesItems] = useState(() => { + const saved = localStorage.getItem('favoritesItems'); + + return saved ? JSON.parse(saved) : []; + }); + + useEffect(() => { + localStorage.setItem('cartItems', JSON.stringify(cartItems)); + }, [cartItems]); + + useEffect(() => { + localStorage.setItem('favoritesItems', JSON.stringify(favoritesItems)); + }, [favoritesItems]); + + return ( + + + {children} + + + ); +}; diff --git a/src/Pages/Cart/Cart.module.scss b/src/Pages/Cart/Cart.module.scss new file mode 100644 index 00000000000..25625c23995 --- /dev/null +++ b/src/Pages/Cart/Cart.module.scss @@ -0,0 +1,109 @@ +@use "sass:color"; +@import '../../variables'; +@import '../../utils/mixins'; + +.cart { + @include inline-padding; + @include grid; + + padding-bottom: 80px; + column-gap: 24px; + min-height: calc(100vh - 48px - 96px); + align-content: flex-start; + + &__back { + grid-column: 1 / -1; + width: 66px; + height: 16px; + border: none; + background-color: $white; + display: flex; + align-items: center; + gap: 8px; + margin-top: 40px; + margin-bottom: 16px; + text-decoration: none; + color: $Secondary; + font-size: 12px; + cursor: pointer; + } + + &__title { + grid-column: 1 / -1; + font-size: 32px; + font-weight: 800; + margin-bottom: 32px; + color: $Primary; + } + + &__container { + display: flex; + flex-direction: column; + gap: 24px; + + @include on-desktop { grid-column: 1 / span 16; } + + @include on-tablet { grid-column: 1 / span 24; } + + @include on-phone { grid-column: 1 / -1; } + } + + &__line { + height: 1px; + width: 100%; + background-color: $Elements; + margin-block: 24px; + } + + &__summary { + border: 1px solid $Elements; + display: flex; + flex-direction: column; + align-items: center; + height: fit-content; + width: 100%; + padding: 24px; + box-sizing: border-box; + + @include on-desktop { grid-column: 18 / span 7; } + + @include on-tablet { + grid-column: 1/-1; + margin-top: 32px; + } + + @include on-phone { + grid-column: 1/-1; + margin-top: 32px; + } + } + + &__total { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; + color: $Primary; + } + + &__count { + font-size: 14px; + color: $Secondary; + } + + &__checkout { + width: 100%; + height: 48px; + background-color: $Primary; + color: white; + border: none; + font-weight: 700; + cursor: pointer; + transition: background 0.3s; + + &:hover { + background-color: color.scale($Primary, $lightness: 10%); + } + } +} diff --git a/src/Pages/Cart/Cart.tsx b/src/Pages/Cart/Cart.tsx new file mode 100644 index 00000000000..9b6fe2aa72d --- /dev/null +++ b/src/Pages/Cart/Cart.tsx @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useCart } from '../../ItemsProvider'; +import { CartItem } from '../../components/CartItem/CartItem'; +import styles from './Cart.module.scss'; + +export const Cart = () => { + const { cartItems, setCartItems } = useCart(); + const navigate = useNavigate(); + + const totalPrice = useMemo(() => { + return cartItems.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0, + ); + }, [cartItems]); + + const totalQuantity = cartItems.reduce((sum, item) => sum + item.quantity, 0); + + const handleCheckout = () => { + const isConfirmed = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (isConfirmed) { + setCartItems([]); + } + }; + + return ( +
+ + +

Cart

+ +
+ {cartItems.map(item => ( + + ))} +
+ +
+

${totalPrice}

+

Total for {totalQuantity} items

+
+ + +
+
+ ); +}; diff --git a/src/Pages/Cart/index.ts b/src/Pages/Cart/index.ts new file mode 100644 index 00000000000..796516835ca --- /dev/null +++ b/src/Pages/Cart/index.ts @@ -0,0 +1 @@ +export { Cart } from './Cart'; diff --git a/src/Pages/Catalog/Catalog.module.scss b/src/Pages/Catalog/Catalog.module.scss new file mode 100644 index 00000000000..7b47da9bcb5 --- /dev/null +++ b/src/Pages/Catalog/Catalog.module.scss @@ -0,0 +1,207 @@ +@import '../../variables'; +@import '../../utils/mixins'; + +.catalog { + @include inline-padding; + + padding-bottom: 80px; + + &__grid { + @include grid; + + gap: 40px 16px; + margin-bottom: 80px; + align-items: stretch; + } + + &__grid-card { + @include on-desktop { grid-column: span 6; } + + + @include on-tablet { grid-column: span 6; } + + @include on-phone { grid-column: 1/-1; } + } + + &__title { + font-family: $font-family; + font-size: 32px; + font-weight: 800; + margin-bottom: 8px; + color: $Primary; + } + + &__models-count { + font-size: 14px; + font-weight: 600; + color: $Secondary; + margin-bottom: 40px; + } + + &__filters { + display: flex; + gap: 16px; + margin-bottom: 24px; + + @include on-phone { + flex-direction: column; + } + } + + &__filter-group { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__label { + font-size: 12px; + font-weight: 700; + color: $Secondary; + } + + &__select { + height: 40px; + padding-inline: 12px; + border: 1px solid $Icons; + background-color: $white; + font-family: $font-family; + font-weight: 700; + min-width: 128px; + cursor: pointer; + transition: border-color 0.3s; + + &:hover { + border-color: $Secondary; + } + } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 40px; + + &__list { + display: flex; + gap: 8px; + list-style: none; + padding: 0; + margin: 0; + } + + &__button { + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + border: 1px solid $Icons; + background: $white; + cursor: pointer; + font-family: $font-family; + font-weight: 600; + transition: all 0.3s; + + &:hover:not(&--active) { + border-color: $Primary; + } + + &--active { + background: $Primary; + color: $white; + border-color: $Primary; + cursor: default; + } + } + + &__arrow { + width: 32px; + height: 32px; + border: 1px solid $Icons; + background: $white no-repeat center; + cursor: pointer; + transition: all 0.3s; + + &:disabled { + opacity: 0.5; + cursor: default; + } + + &:hover:not(:disabled) { + border-color: $Primary; + } + + + &--left { + background-image: url('/img/icons/Arrow_Left.png'); + } + + &--right { + background-image: url('/img/icons/Arrow_Right.png'); + } + } +} + +.catalog__nav { + display: flex; + align-items: center; + gap: 8px; + margin-block: 24px; + height: 16px; + grid-column: 1 / -1; + font-family: $font-family; + font-weight: 600; + font-size: 12px; + + line-height: 100%; + letter-spacing: 0%; + color: $Secondary; +} + +.catalog__home-icon { + display: block; + width: 16px; + height: 16px; + background: url('/img/icons/Home.png') no-repeat center; + background-size: contain; + flex-shrink: 0; +} + +.catalog__arrow { + display: block; + width: 16px; + height: 16px; + background: url('/img/icons/Arrow_Right.png') no-repeat center; + background-size: contain; + opacity: 0.5; + flex-shrink: 0; +} + +.catalog__current-page-category{ + font-family: $font-family; + font-size: 12px; + font-weight: 700; + text-transform: capitalize; + text-decoration: none; + line-height: 1; + white-space: nowrap; + color: $Primary; + overflow: visible; +} + +.catalog__current-page { + font-family: $font-family; + font-size: 12px; + font-weight: 700; + color: $Secondary; + text-transform: capitalize; + text-decoration: none; + line-height: 1; + white-space: nowrap; + + @include truncate(1); +} + diff --git a/src/Pages/Catalog/Catalog.tsx b/src/Pages/Catalog/Catalog.tsx new file mode 100644 index 00000000000..2fa68a6ee59 --- /dev/null +++ b/src/Pages/Catalog/Catalog.tsx @@ -0,0 +1,125 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { useEffect, useMemo, useState } from 'react'; +import { useParams, useSearchParams, NavLink } from 'react-router-dom'; +import { Product } from '../../types/product'; +import { getProducts } from '../../api/getProducts'; +import { getSearchParams, SearchParams } from '../../utils/searchParams'; +import { CatalogView } from '../../components/CatalogView'; + +import styles from './Catalog.module.scss'; +import { Loader } from '../../components/Loader'; +import { ErrorMessage } from '../../components/ErrorMessage'; + +export const Catalog = () => { + const { category } = useParams(); + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + const [, setRetry] = useState(0); + + const handleRetry = () => { + setRetry(prev => prev + 1); + }; + + const sortBy = searchParams.get('sort') || 'age'; + const perPage = searchParams.get('perPage') || '16'; + + useEffect(() => { + setIsLoading(true); + setIsError(false); + + getProducts() + .then(data => { + setProducts(data.filter(product => product.category === category)); + }) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, [category]); + + const updateSearchParams = (params: SearchParams) => { + setSearchParams(getSearchParams(params, searchParams)); + }; + + const sortedProducts = useMemo(() => { + const copy = [...products]; + + switch (sortBy) { + case 'title': + return copy.sort((a, b) => a.name.localeCompare(b.name)); + case 'price': + return copy.sort((a, b) => a.price - b.price); + default: + return copy.sort((a, b) => b.year - a.year); + } + }, [products, sortBy]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ +
+ ); + } + + return ( +
+ + +

+ {category === 'phones' ? 'Mobile phones' : category} +

+ +

+ {products.length} models +

+ +
+
+ + +
+ +
+ + +
+
+ + +
+ ); +}; diff --git a/src/Pages/Catalog/index.ts b/src/Pages/Catalog/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Pages/Favorites/Favorites.scss b/src/Pages/Favorites/Favorites.scss new file mode 100644 index 00000000000..41c04a56ad4 --- /dev/null +++ b/src/Pages/Favorites/Favorites.scss @@ -0,0 +1,54 @@ +@import '../../utils/mixins'; +@import '../../variables'; + +.favorites{ + @include inline-padding; + + display: flex; + flex-direction: column; + + row-gap: 40px; + + + &__title{ + font-family: $font-family; + font-weight: 800; + font-style: Bold; + font-size: 48px; + + line-height: 56px; + letter-spacing: -1%; + color: $Primary; + } + + &__itemsCount{ + font-family: $font-family; + font-weight: 600; + font-size: 14px; + + line-height: 21px; + letter-spacing: 0%; + color: $Secondary; + } + + &__itemsContainer{ + @include grid; + + row-gap: 40px; + margin-bottom: 40px; + } + &__card{ + @include on-phone{ + grid-column: span 4; + } + + @include on-tablet{ + grid-column: span 6; + } + + @include on-desktop{ + grid-column: span 6; + } + } +} + diff --git a/src/Pages/Favorites/Favorites.tsx b/src/Pages/Favorites/Favorites.tsx new file mode 100644 index 00000000000..f0bf725349b --- /dev/null +++ b/src/Pages/Favorites/Favorites.tsx @@ -0,0 +1,34 @@ +import { NavLink } from 'react-router-dom'; +import styles from '../Catalog/Catalog.module.scss'; +import { useFavorites } from '../../ItemsProvider'; +import { ProductCard } from '../../components/ProductCard'; +import './Favorites.scss'; + +export const Favorites = () => { + const { favoritesItems } = useFavorites(); + + return ( +
+ +
+

Favourites

+

{favoritesItems.length} items

+
+
+ {favoritesItems.map(item => { + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/Pages/Favorites/index.ts b/src/Pages/Favorites/index.ts new file mode 100644 index 00000000000..628792478b0 --- /dev/null +++ b/src/Pages/Favorites/index.ts @@ -0,0 +1 @@ +export { Favorites } from './Favorites'; diff --git a/src/Pages/HomePage/HomePage.module.scss b/src/Pages/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..5c2976055f1 --- /dev/null +++ b/src/Pages/HomePage/HomePage.module.scss @@ -0,0 +1,19 @@ +@import '../../utils/mixins'; +@import '../../variables'; + +.HomePage{ + @include inline-padding; + + font-family: $font-family; + + width: 100%; +} + +.title{ + padding-block: 56px; + font-family: $font-family; +} + +.hidden{ + display: none; +} diff --git a/src/Pages/HomePage/HomePage.tsx b/src/Pages/HomePage/HomePage.tsx new file mode 100644 index 00000000000..bf6a539d605 --- /dev/null +++ b/src/Pages/HomePage/HomePage.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { HomePageSlider } from '../../components/HomePageSlider'; +import styles from './HomePage.module.scss'; +import { Product } from '../../types/product'; +import { getProducts } from '../../api/getProducts'; +import { ProductsSlider } from '../../components/ProductsSlider'; +import { Categories } from '../../components/Categories'; +import { Loader } from '../../components/Loader'; +import { ErrorMessage } from '../../components/ErrorMessage'; + +export const HomePage = () => { + const [products, setProducts] = useState([]); + const [isLoader, setIsLoader] = useState(false); + const [isError, setIsError] = useState(false); + const [, setRetry] = useState(0); + + const handleRetry = () => { + setRetry(prev => prev + 1); + }; + + useEffect(() => { + const loadProducts = async () => { + setIsLoader(true); + try { + const data = await getProducts(); + + setProducts(data); + } catch { + setIsError(true); + } finally { + setIsLoader(false); + } + }; + + loadProducts(); + }, []); + + return ( +
+

Product Catalog

+ {isError ? ( + + ) : ( + <> +

Welcome to Nice Gadgets store!

+ + + {isLoader ? ( + + ) : ( + <> + + + + + )} + + )} +
+ ); +}; diff --git a/src/Pages/HomePage/index.ts b/src/Pages/HomePage/index.ts new file mode 100644 index 00000000000..0799f479a25 --- /dev/null +++ b/src/Pages/HomePage/index.ts @@ -0,0 +1 @@ +export { HomePage } from './HomePage'; diff --git a/src/Pages/ItemCard/ItemCard.module.scss b/src/Pages/ItemCard/ItemCard.module.scss new file mode 100644 index 00000000000..caca1fc5e90 --- /dev/null +++ b/src/Pages/ItemCard/ItemCard.module.scss @@ -0,0 +1,368 @@ +@import '../../variables'; +@import '../../utils/mixins'; + +.cartItem { + @include inline-padding; + @include grid; + + padding-bottom: 80px; + + &__nav { + grid-column: 1 / -1; + } + + &__title { + grid-column: 1 / -1; + font-family: $font-family; + font-size: 32px; + font-weight: 800; + margin-block: 24px 40px; + color: $Primary; + } + + &__content { + grid-column: 1 / -1; + + @include grid; + + margin-bottom: 80px; + align-items: flex-start; + row-gap: 40px; + } + + &__images { + display: flex; + flex-direction: column; + gap: 16px; + + @include on-desktop { + grid-column: 1 / span 2; + } + + @include on-tablet { + grid-column: 1 / span 1; + gap: 8px; + } + + @include on-phone { + grid-column: 1 / -1; + flex-direction: row; + justify-content: center; + height: 50px; + order: 2; + } + } + + &__preview { + object-fit: contain; + border: 1px solid $Elements; + cursor: pointer; + background-color: $white; + transition: border-color 0.3s; + + @include on-desktop { + width: 80px; + height: 80px; + } + + @include on-tablet { + width: 35px; + height: 35px; + } + + @include on-phone { + height: 50px; + width: 50px; + } + + &:hover { + border-color: $Primary; + } + } + + &__photo { + width: 100%; + object-fit: contain; + + @include on-desktop { + grid-column: 3 / span 10; + height: 464px; + } + + @include on-tablet { + grid-column: 2 / span 6; + height: auto; + } + + @include on-phone { + grid-column: 1 / -1; + } + } + + &__select { + display: flex; + flex-direction: column; + + @include on-desktop { + grid-column: 14 / span 7; + } + + @include on-tablet { + grid-column: 8 / span 5; + } + + @include on-phone { + grid-column: 1 / -1; + order: 3; + } + + &_title { + font-size: 12px; + font-weight: 700; + color: $Secondary; + margin-bottom: 8px; + } + } + + &__idProduct { + font-size: 12px; + color: $Icons; + + @include on-desktop { + grid-column: 21 / span 4; + text-align: right; + } + + @include on-tablet { + grid-column: 8 / span 5; + text-align: left; + margin-top: 8px; + } + + @include on-phone { + grid-column: 1 / -1; + text-align: right; + order: 2; + } + } + + &__line { + height: 1px; + background-color: $Elements; + margin-block: 24px; + } + + &__buttons { + display: flex; + gap: 8px; + margin-bottom: 32px; + } + + &__table { + width: 100%; + border-collapse: collapse; + } + + &__info_cell { + &_title { + font-size: 12px; + font-weight: 600; + color: $Secondary; + padding-block: 4px; + } + + &_value { + font-size: 12px; + font-weight: 600; + color: $Primary; + text-align: right; + padding-block: 4px; + } + } +} + +.description { + grid-column: 1 / -1; + + @include on-desktop { + grid-column: 1 / span 12; + } + + row-gap: 32px; + + h2 { + font-size: 24px; + font-weight: 800; + border-bottom: 1px solid $Elements; + padding-bottom: 16px; + margin-bottom: 32px; + color: $Primary; + } + + h3 { + font-size: 20px; + margin-bottom: 16px; + color: $Primary; + } + + p { + font-size: 14px; + line-height: 21px; + color: $Secondary; + margin-bottom: 32px; + } +} + +.techSpecs { + grid-column: 1 / -1; + + @include on-desktop { + grid-column: 14 / span 11; + } + + &__title { + font-size: 24px; + font-weight: 800; + border-bottom: 1px solid $Elements; + padding-bottom: 16px; + margin-bottom: 32px; + color: $Primary; + } + + table { + width: 100%; + border-collapse: collapse; + } + + &__row { + display: table-row; + } + + &__cell { + padding-block: 8px; + font-size: 14px; + font-weight: 600; + color: $Primary; + text-align: right; + + &-title { + text-align: left; + color: $Secondary; + } + } +} + +.button-back { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: $Secondary; + font-weight: 700; + margin-block: 24px; + font-size: 12px; + transition: color 0.3s; + width: 32px; + height: 16px; + background-color: $white; + border: none; + + &:hover { + color: $Primary; + } +} + +.button__title { + margin: 0; +} + +.colors-list { + display: flex; + gap: 8px; + margin-bottom: 24px; +} + +.color-button { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid $Elements; + display: flex; + justify-content: center; + align-items: center; + transition: border-color 0.3s; + + &--active { + border-color: $Primary; + } + + &__internal { + width: 26px; + height: 26px; + border-radius: 50%; + } +} + +.capacity-list { + display: flex; + gap: 8px; +} + +.capacity-button { + padding: 10px 12px; + border: 1px solid $Icons; + font-family: $font-family; + font-weight: 500; + font-size: 14px; + color: $Primary; + text-decoration: none; + transition: all 0.3s; + + &--active { + background-color: $Primary; + color: $white; + border-color: $Primary; + } + + &:hover:not(.capacity-button--active) { + border-color: $Primary; + } +} + +.buttonCard { + flex-grow: 1; + height: 40px; + background-color: $Primary; + color: $white; + border: 1px solid $Primary; + font-family: $font-family; + font-weight: 700; + font-size: 14px; + cursor: pointer; + transition: all 0.3s; + + &--selected { + background-color: $white; + color: $Green; + border: 1px solid $Elements; + } +} + +.buttonFavorites { + width: 40px; + height: 40px; + border: 1px solid $Icons; + background: $white url('/img/icons/noneLike.png') no-repeat center; + cursor: pointer; + transition: all 0.3s; + + &:hover { + border-color: $Primary; + } + + &--selected { + background-image: url('/img/icons/like.png'); + border-color: $Elements; + } +} diff --git a/src/Pages/ItemCard/ItemCard.tsx b/src/Pages/ItemCard/ItemCard.tsx new file mode 100644 index 00000000000..d04a45246fe --- /dev/null +++ b/src/Pages/ItemCard/ItemCard.tsx @@ -0,0 +1,487 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link, NavLink, useParams } from 'react-router-dom'; +import classNames from 'classnames'; +import { Product, ProductItem } from '../../types/product'; +import { getProducts } from '../../api/getProducts'; +import { ProductsSlider } from '../../components/ProductsSlider'; +import { useCart, useFavorites } from '../../ItemsProvider'; +import { useNavigate } from 'react-router-dom'; + +import styles from './ItemCard.module.scss'; +import catalogStyles from '../../Pages/Catalog/Catalog.module.scss'; +import { Loader } from '../../components/Loader'; +import { ErrorMessage } from '../../components/ErrorMessage'; + +const colorMap: Record = { + black: '#212122', + white: '#F0F0F0', + green: '#E2E9E1', + yellow: '#FFE681', + purple: '#D1CDDA', + red: '#BA0C2E', + spacegray: '#535150', + silver: '#E2E4E1', + gold: '#F9E5C9', + midnight: '#191970', +}; + +export const ItemCard = () => { + const navigate = useNavigate(); + const { category, product } = useParams<{ + category: string; + product: string; + }>(); + + const [currentProduct, setCurrentProduct] = useState( + null, + ); + const [isError, setIsError] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [similarProducts, setSimilarProducts] = useState([]); + const [currentImage, setCurrentImage] = useState(0); + + const [, setRetry] = useState(0); + + const handleRetry = () => { + setRetry(prev => prev + 1); + }; + + const getNewModelPath = (newCapacity?: string, newColor?: string) => { + if (!currentProduct) { + return ''; + } + + const capacity = newCapacity || currentProduct.capacity; + const color = newColor || currentProduct.color; + + return `/${category}/${currentProduct.namespaceId}-${capacity.toLowerCase()}-${color.replace(/\s+/g, '-')}`; + }; + + const productSummary = useMemo(() => { + if (!currentProduct) { + return null; + } + + return similarProducts.find(p => p.itemId === currentProduct.id) || null; + }, [similarProducts, currentProduct]); + + const { cartItems, setCartItems } = useCart(); + const { favoritesItems, setFavoritesItems } = useFavorites(); + + const isFavorite = useMemo(() => { + return favoritesItems.some(item => item.itemId === product); + }, [favoritesItems, product]); + + const isCart = useMemo(() => { + return cartItems.some(item => item.product.itemId === product); + }, [cartItems, product]); + + const addToCart = () => { + if (!productSummary) { + return; + } + + setCartItems(prev => { + const exists = prev.some(item => item.product.id === productSummary.id); + + return exists + ? prev.filter(item => item.product.id !== productSummary.id) + : [...prev, { product: productSummary, quantity: 1 }]; + }); + }; + + const addToFavorites = () => { + if (!productSummary) { + return; + } + + setFavoritesItems(prev => { + const exists = prev.some(prod => prod.itemId === productSummary.itemId); + + return exists + ? prev.filter(prod => prod.itemId !== productSummary.itemId) + : [...prev, productSummary]; + }); + }; + + useEffect(() => { + const loadProduct = async () => { + setIsLoading(true); + setIsError(false); + + let currentURL = ''; + + switch (category) { + case 'phones': + currentURL = 'api/phones.json'; + break; + case 'tablets': + currentURL = 'api/tablets.json'; + break; + case 'accessories': + currentURL = 'api/accessories.json'; + break; + default: + setIsError(true); + setIsLoading(false); + + return; + } + + try { + const data = await getProducts(currentURL); + + const foundProduct = data.find(item => item.id === product); + + const simProductsResponse = await getProducts(); + + if (foundProduct) { + setCurrentProduct(foundProduct); + setCurrentImage(0); + } else { + setIsError(true); + } + + setSimilarProducts( + simProductsResponse.filter(prod => prod.category === category), + ); + } catch (error) { + setIsError(true); + } finally { + setIsLoading(false); + } + }; + + if (product && category) { + loadProduct(); + } + }, [category, product]); + + if (isLoading) { + return ; + } + + if (isError || !currentProduct) { + return ( + + ); + } + + return ( +
+ + + + +

{currentProduct.name}

+ +
+
+ {currentProduct.images.map((img, index) => ( + preview setCurrentImage(index)} + /> + ))} +
+ + {currentProduct.name} + +
+

Available colors

+
+ {currentProduct.colorsAvailable.map(color => ( + + classNames(styles['color-button'], { + [styles['color-button--active']]: + color === currentProduct.color, + }) + } + title={color} + > +
+ + ))} +
+ +
+ +

Select capacity

+
+ {currentProduct.capacityAvailable.map(cap => ( + + classNames(styles['capacity-button'], { + [styles['capacity-button--active']]: + cap === currentProduct.capacity, + }) + } + > + {cap} + + ))} +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ Screen + + {currentProduct.screen} +
+ Resolution + + {currentProduct.resolution} +
+ Processor + + {currentProduct.processor} +
+ RAM + + {currentProduct.ram} +
+
+
+ +
+

About

+ {currentProduct.description.map((item, index) => ( +
+

{item.title}

+ {item.text.map((t, i) => ( +

{t}

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

Tech specs

+ + + + + + + + + + + + + + + + + + + + + + + {currentProduct.camera && ( + + + + + )} + + + + + + + + + +
+ Screen + + {currentProduct.screen} +
+ Resolution + + {currentProduct.resolution} +
+ Processor + + {currentProduct.processor} +
+ RAM + {currentProduct.ram}
+ Built in memory + + {currentProduct.capacity} +
+ Camera + + {currentProduct.camera} +
+ Zoom + {currentProduct.zoom}
+ Cell + + {currentProduct.cell.map(x => `${x} `)} +
+
+ + +
+ ); +}; diff --git a/src/Pages/ItemCard/index.ts b/src/Pages/ItemCard/index.ts new file mode 100644 index 00000000000..92d89c51063 --- /dev/null +++ b/src/Pages/ItemCard/index.ts @@ -0,0 +1 @@ +export { ItemCard } from './ItemCard'; diff --git a/src/api/getProducts.ts b/src/api/getProducts.ts new file mode 100644 index 00000000000..fac5073fa6e --- /dev/null +++ b/src/api/getProducts.ts @@ -0,0 +1,15 @@ +const wait = (delay: number) => { + return new Promise(resolve => setTimeout(resolve, delay)); +}; + +export function getProducts(url = './api/products.json'): Promise { + return wait(500) + .then(() => fetch(url)) + .then(response => { + if (!response.ok) { + throw new Error('Failed to fetch products'); + } + + return response.json(); + }); +} diff --git a/src/components/CartItem/CartItem.module.scss b/src/components/CartItem/CartItem.module.scss new file mode 100644 index 00000000000..8992a28dfa5 --- /dev/null +++ b/src/components/CartItem/CartItem.module.scss @@ -0,0 +1,108 @@ +@import '../../variables'; +@import '../../utils/mixins'; + +.cartItem { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + border: 1px solid $Elements; + + @include on-desktop { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 24px; + } + + &__left { + display: flex; + align-items: center; + gap: 16px; + } + + &__right { + display: flex; + align-items: center; + justify-content: space-between; + gap: 32px; + + @include on-desktop { + justify-content: flex-end; + } + } + + .deleteItem { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + color: $Icons; + transition: color 0.3s; + &:hover { color: $Primary; } + } + + .link { + display: flex; + align-items: center; + gap: 16px; + text-decoration: none; + } + + .image { + width: 64px; + height: 64px; + object-fit: contain; + } + + .name { + font-family: $font-family; + font-size: 14px; + font-weight: 500; + color: $Primary; + } + + .counter { + display: flex; + align-items: center; + gap: 12px; + } + +.button { + width: 32px; + height: 32px; + border: 1px solid $Icons; + background: $white; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + transition: all 0.3s; + outline: none; + &:hover:not(.disabled) { + border-color: $Primary; + } + + &.disabled { + cursor: default; + border-color: $Elements; + color: $Elements; + } + } + + .countItem { + font-weight: 600; + min-width: 20px; + text-align: center; + } + + .price { + font-family: $font-family; + font-size: 22px; + font-weight: 800; + color: $Primary; + min-width: 80px; + text-align: right; + } +} diff --git a/src/components/CartItem/CartItem.tsx b/src/components/CartItem/CartItem.tsx new file mode 100644 index 00000000000..1479404fbca --- /dev/null +++ b/src/components/CartItem/CartItem.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import styles from './CartItem.module.scss'; +import { useCart } from '../../ItemsProvider'; +import { CartItemType } from '../../ItemsProvider'; + +type Props = { + item: CartItemType; +}; + +export const CartItem: React.FC = ({ item }) => { + const { setCartItems } = useCart(); + const { product, quantity } = item; + + const handleUpdateQuantity = (delta: number) => { + setCartItems(prev => + prev.map(cartItem => { + if (cartItem.product.id === product.id) { + return { + ...cartItem, + quantity: Math.max(1, cartItem.quantity + delta), + }; + } + + return cartItem; + }), + ); + }; + + const handleDelete = () => { + setCartItems(prev => prev.filter(p => p.product.id !== product.id)); + }; + + return ( +
+
+ + + {product.name} +

{product.name}

+ +
+ +
+
+ +

{quantity}

+ +
+ +

${product.price * quantity}

+
+
+ ); +}; diff --git a/src/components/CartItem/index.ts b/src/components/CartItem/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/CatalogView/CatalogView.module.scss b/src/components/CatalogView/CatalogView.module.scss new file mode 100644 index 00000000000..5ba1135b954 --- /dev/null +++ b/src/components/CatalogView/CatalogView.module.scss @@ -0,0 +1,144 @@ +@import '../../variables'; +@import '../../utils/mixins'; + +.catalog { + @include inline-padding; + + padding-bottom: 80px; + + &__nav { + display: flex; + align-items: center; + gap: 8px; + margin-block: 24px; + } + + &__title { + font-family: $font-family; + font-size: 32px; + font-weight: 800; + margin-block: 24px 8px; + } + + &__models-count { + color: $Secondary; + font-weight: 600; + margin-bottom: 40px; + } + + &__filters { + display: flex; + gap: 16px; + margin-bottom: 24px; + } + + &__label { + display: block; + font-size: 12px; + font-weight: 700; + color: $Secondary; + margin-bottom: 4px; + } + + &__select { + height: 40px; + padding-inline: 12px; + border: 1px solid $Icons; + background-color: $white; + font-family: $font-family; + font-weight: 700; + cursor: pointer; + min-width: 128px; + + &:hover { border-color: $Secondary; } + } + + &__grid { + @include grid; + + row-gap: 40px; + margin-bottom: 80px; + align-items: stretch; + &-container{ + @include grid; + } + &-card { + @include on-desktop { + grid-column: span 6; + } + + @include on-tablet { + grid-column: span 6; + } + + @include on-phone { + grid-column: span 4; + } + + display: flex; + } + + } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + + &__list { + display: flex; + gap: 8px; + list-style: none; + padding: 0; + margin: 0; + } + + &__button { + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + border: 1px solid $Icons; + background: $white; + cursor: pointer; + font-family: $font-family; + font-weight: 600; + transition: all 0.3s; + + &--active { + background: $Primary; + color: $white; + border-color: $Primary; + } + + &:hover:not(&--active) { + border-color: $Primary; + } + } + + &__arrow { + width: 32px; + height: 32px; + border: 1px solid $Icons; + background: $white no-repeat center; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: default; + } + &--left { + background-image: url('../../../public/img/icons/Arrow_Left.png'); + } + &--right { + background-image: url('../../../public/img/icons/Arrow_Right.png'); + } + } +} + +.catalogView{ + margin: 0 auto; +} diff --git a/src/components/CatalogView/CatalogView.tsx b/src/components/CatalogView/CatalogView.tsx new file mode 100644 index 00000000000..162714c4e2c --- /dev/null +++ b/src/components/CatalogView/CatalogView.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Product } from '../../types/product'; +import { ProductCard } from '../ProductCard'; +import { getSearchParams } from '../../utils/searchParams'; +import { getPaginationRange } from '../../utils/pagination'; + +type Props = { + products: Product[]; + styles: Record; +}; + +export const CatalogView: React.FC = ({ products, styles }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const perPage = searchParams.get('perPage') || '16'; + const currentPage = +(searchParams.get('page') || '1'); + + const isPagination = perPage !== 'all'; + const itemsPerPage = isPagination ? +perPage : products.length; + const totalPages = Math.ceil(products.length / itemsPerPage); + + const visibleProducts = products.slice( + (currentPage - 1) * itemsPerPage, + (currentPage - 1) * itemsPerPage + itemsPerPage, + ); + + const handlePageChange = (page: number) => { + setSearchParams(getSearchParams({ page: page.toString() }, searchParams)); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+
+ {visibleProducts.map(product => ( + + ))} +
+ + {isPagination && totalPages > 1 && ( + + )} +
+ ); +}; diff --git a/src/components/CatalogView/index.ts b/src/components/CatalogView/index.ts new file mode 100644 index 00000000000..42f802caa43 --- /dev/null +++ b/src/components/CatalogView/index.ts @@ -0,0 +1 @@ +export { CatalogView } from './CatalogView'; diff --git a/src/components/Categories/Categories.module.scss b/src/components/Categories/Categories.module.scss new file mode 100644 index 00000000000..7332c44de46 --- /dev/null +++ b/src/components/Categories/Categories.module.scss @@ -0,0 +1,92 @@ +@import '../../variables'; +@import '../../utils/mixins'; + +.categories { + width: 100%; + max-width: 1136px; + margin: 64px auto; + + @include grid; + + &__title { + grid-column: 1 / -1; + font-family: $font-family; + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; + color: $Primary; + margin-bottom: 24px; + } + + &__card { + display: flex; + flex-direction: column; + + @include on-desktop { + grid-column: span 8; + } + + + @include on-tablet { + grid-column: span 4; + } + + @include on-phone { + grid-column: span 4; + margin-bottom: 32px; + } + } + + &__photo { + width: 100%; + aspect-ratio: 1 / 1; + background-size: cover; + background-position: center; + margin-bottom: 24px; + transition: transform 0.3s ease; + + &:hover { + transform: scale(1.05); + } + + &_phones { + background-image: url('../../../public/img/category-phones.png'); + background-color: #89939A; + } + &_tablets { + background-image: url('../../../public/img/category-tablets.png'); + background-color: #E2E6E9; + } + &_accessories { + background-image: url('../../../public/img/category-accessories.png'); + background-color: #D53C51; + } + } + + &__linkTitle { + font-family: $font-family; + font-weight: 600; + font-size: 20px; + line-height: 100%; + letter-spacing: 0%; + color: $Primary; + text-decoration: none; + margin-bottom: 4px; + + &:hover { + text-decoration: underline; + } + } + + &__count { + font-family: $font-family; + font-weight: 500; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + + color: $Secondary; + margin: 0; + } +} diff --git a/src/components/Categories/Categories.tsx b/src/components/Categories/Categories.tsx new file mode 100644 index 00000000000..d2a342c5576 --- /dev/null +++ b/src/components/Categories/Categories.tsx @@ -0,0 +1,43 @@ +import styles from './Categories.module.scss'; +import { NavLink } from 'react-router-dom'; + +export const Categories = () => { + return ( +
+

Shop by category

+ +
+ + + Mobile phones + +

95 models

+
+ +
+ + + Tablets + +

24 models

+
+ +
+ + + Accessories + +

100 models

+
+
+ ); +}; diff --git a/src/components/Categories/index.ts b/src/components/Categories/index.ts new file mode 100644 index 00000000000..8ae5932a87f --- /dev/null +++ b/src/components/Categories/index.ts @@ -0,0 +1 @@ +export { Categories } from './Categories'; diff --git a/src/components/ErrorMessage/ErrorMessage.module.scss b/src/components/ErrorMessage/ErrorMessage.module.scss new file mode 100644 index 00000000000..b243c471287 --- /dev/null +++ b/src/components/ErrorMessage/ErrorMessage.module.scss @@ -0,0 +1,34 @@ +@import '../../variables'; + +.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24px; + padding: 64px 24px; + text-align: center; + + &__text { + font-family: $font-family; + font-size: 18px; + font-weight: 600; + color: $Secondary; + } + + &__button { + padding: 12px 32px; + background-color: $Primary; + color: $white; + border: none; + font-family: $font-family; + font-weight: 700; + font-size: 14px; + cursor: pointer; + transition: opacity 0.3s; + + &:hover { + opacity: 0.9; + } + } +} diff --git a/src/components/ErrorMessage/ErrorMessage.tsx b/src/components/ErrorMessage/ErrorMessage.tsx new file mode 100644 index 00000000000..fba9f5a8b19 --- /dev/null +++ b/src/components/ErrorMessage/ErrorMessage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './ErrorMessage.module.scss'; + +type Props = { + onRetry: () => void; + message?: string; +}; + +export const ErrorMessage: React.FC = ({ + onRetry, + message = 'Something went wrong. Please try again later.', +}) => { + return ( +
+

{message}

+ +
+ ); +}; diff --git a/src/components/ErrorMessage/index.ts b/src/components/ErrorMessage/index.ts new file mode 100644 index 00000000000..1f26cdd2a3a --- /dev/null +++ b/src/components/ErrorMessage/index.ts @@ -0,0 +1 @@ +export { ErrorMessage } from './ErrorMessage'; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..6265268daf9 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,98 @@ +@import '../../utils/mixins'; +@import '../../variables'; + +.footer { + @include inline-padding; + + display: flex; + align-items: center; + justify-content: space-between; + padding-block: 32px; + + @include on-phone { + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + row-gap: 32px; + } +} + +.logo { + &--footer { + flex-shrink: 0; + display: flex; + justify-content: flex-start; + align-items: flex-start; + } + + &__icon { + &--footer { + height: 32px; + width: 89px; + display: block; + background-image: url('/img/icons/Logo.png'); + background-size: contain; + background-repeat: no-repeat; + } + } +} + +.list { + list-style-type: none; + display: flex; + gap: 40px; + padding: 0; + margin: 0; + + @include on-phone { + flex-direction: column; + row-gap: 16px; + } + + &__item { + text-decoration: none; + color: $Secondary; + font-family: $font-family; + font-weight: 800; + font-size: 12px; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + } +} + +.backTop { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; + + @include on-phone { + gap: 8px; + margin: 0 auto; + } + + &__title { + font-family: $font-family; + font-weight: 700; + font-size: 12px; + line-height: 100%; + color: $Secondary; + white-space: nowrap; + } + + &__button { + background-image: url('../../../public/img/icons/ToTop.png'); + height: 32px; + width: 32px; + background-size: contain; + background-position: center; + background-repeat: no-repeat; + background-color: $white; + flex-shrink: 0; + border: none; + padding: 0; + cursor: pointer; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..4690f630ab2 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,58 @@ +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import styles from './Footer.module.scss'; + +export const Footer = () => { + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + return ( +
+
+ +
+ +
    +
  • + + Github + +
  • +
  • + + Contacts + +
  • +
  • + + rights + +
  • +
+ +
+

Back to top

+
+
+ ); +}; 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..600782d8f58 --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,346 @@ +@import '../../variables'; +@import '../../utils/mixins'; + +.header { + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + background: $white; + border-bottom: 1px solid $Elements; + z-index: 1000; + position: sticky; + top: 0; + + @include on-desktop { + height: 64px; + } +} + +.header__left { + display: flex; + align-items: center; + gap: 64px; +} + +.logo { + display: flex; + align-items: center; + margin-left: 24px; + + &__icon { + background-image: url('/img/icons/Logo.svg'); + background-size: contain; + background-repeat: no-repeat; + &--header { + height: 28px; + width: 80px; + } + } +} + +.menu { + @include on-phone { + display: none; + } + + &__list { + display: flex; + gap: 64px; + list-style: none; + margin: 0; + padding: 0; + } + + &__item { + text-decoration: none; + font-family: $font-family; + font-weight: 800; + font-size: 12px; + text-transform: uppercase; + color: $Secondary; + position: relative; + cursor: pointer; + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: -25px; + width: 0; + height: 3px; + background-color: $Primary; + transition: width 0.3s ease + } + + &.active { + color: $Primary; + &::after { + content: ''; + position: absolute; + left: 0; + bottom: -25px; + width: 100%; + height: 3px; + background-color: $Primary; + } + } + } +} + +.buttons { + display: flex; + list-style: none; + margin: 0; + padding: 0; + height: 100%; + + &__item { + width: 48px; + height: 48px; + display: flex; + justify-content: center; + align-items: center; + box-shadow: -1px 0 0 0 $Elements; + position: relative; + + @include on-desktop { + width: 64px; + height: 64px; + } + } + + &__link { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + text-decoration: none; + position: relative; + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 0; + height: 3px; + background-color: $Primary; + transition: width 0.3s ease; + } + + &.active::after { + width: 100%; + } + } + + &__icon { + width: 16px; + height: 16px; + background-size: contain; + background-repeat: no-repeat; + position: relative; + + &--fav { + background-image: url('/img/icons/noneLike.svg'); + } + &--cart { + background-image: url('/img/icons/cart.svg'); + } + } +} + +.counter { + position: absolute; + top: -10px; + right: -10px; + + width: 14px; + height: 14px; + + display: flex; + justify-content: center; + align-items: center; + + background-color: $Red; + border-radius: 50%; + border: 2px solid $white; + + font-family: $font-family; + font-weight: 700; + font-size: 9px; + line-height: 1; + text-align: center; + color: $white; + + transform-origin: center; + animation: appear 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.27) forwards; + +} + +.burger-btn { + display: none; + border-left: 1px solid $Elements; + padding: 0; + border-top: none; + border-bottom: none; + border-right: none; + background: none; + + @include on-phone { + display: flex; + justify-content: center; + align-items: center; + width: 48px; + height: 48px; + } + + &__icon { + width: 24px; + height: 24px; + background: url('/img/icons/burgerMenu.svg') no-repeat center; + border: none; + cursor: pointer; + padding: 0; + } +} + +.burger-menu { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: white; + z-index: 100; + flex-direction: column; + + &.active { + display: flex; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + height: 48px; + padding: 0 24px; + border-bottom: 1px solid $Elements; + position: relative; + } + + &__close { + position: absolute; + top: 0; + right: 0; + width: 48px; + height: 48px; + background: url('/img/icons/close.svg') no-repeat center; + border: none; + cursor: pointer; + border-left: 1px solid $Elements; + } + + &__nav { + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 32px; + } + + &__item { + font-family: $font-family; + font-size: 20px; + font-weight: 800; + text-transform: uppercase; + text-decoration: none; + color: $Secondary; + position: relative; + + &.active { + color: $Primary; + &::after { + content: ''; + position: absolute; + left: 0; + bottom: -10px; + width: 100%; + height: 3px; + background-color: $Primary; + } + } + } + + &__footer { + display: flex; + list-style: none; + padding: 0; + margin: 0; + border-top: 1px solid $Elements; + } + + &__footer-item { + flex: 1; + display: flex; + border-right: 1px solid $Elements; + &:last-child { + border-right: none; + } + } + + &__footer-link { + width: 100%; + height: 64px; + display: flex; + justify-content: center; + align-items: center; + text-decoration: none; + position: relative; + + &.active { + &::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + background-color: $Primary; + } + } + } +} + +.hide-on-mobile { + @include on-phone { + display: none; + } +} + +@keyframes appear { + 0% { + transform: scale(0); + opacity: 0; + } + + 70% { + transform: scale(1.2); + } + + 100% { + transform: scale(1); + opacity: 1; + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..4aafa2e0f95 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useState } from 'react'; +import { Link, NavLink } from 'react-router-dom'; +import classNames from 'classnames'; +import { Categories } from '../../types/product'; +import styles from './Header.module.scss'; +import { useCart, useFavorites } from '../../ItemsProvider'; + +export const Header = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const toggleMenu = () => setIsMenuOpen(!isMenuOpen); + const { cartItems } = useCart(); + const { favoritesItems } = useFavorites(); + + const cartItemsCount = () => { + let count = 0; + + cartItems.forEach(item => (count += item.quantity)); + + return count; + }; + + const navLinks = [ + { to: '/', name: 'home' }, + { to: `/${Categories.Phones}`, name: 'phones' }, + { to: `/${Categories.Tablets}`, name: 'tablets' }, + { to: `/${Categories.Accessories}`, name: 'accessories' }, + ]; + + useEffect(() => { + if (isMenuOpen) { + document.body.classList.add('no-scroll'); + } else { + document.body.classList.remove('no-scroll'); + } + + return () => { + document.body.classList.remove('no-scroll'); + }; + }, [isMenuOpen]); + + return ( +
+
+ +
+ + + +
+ +
    +
  • + + classNames(styles.buttons__link, { [styles.active]: isActive }) + } + > +
    + {favoritesItems.length > 0 && ( +
    + {favoritesItems.length} +
    + )} +
    +
    +
  • +
  • + + classNames(styles.buttons__link, { [styles.active]: isActive }) + } + > +
    + {cartItems.length > 0 && ( +
    + {cartItemsCount()} +
    + )} +
    +
    +
  • +
  • +
  • +
+ +
+
+ +
+ + + +
    +
  • + + classNames(styles['burger-menu__footer-link'], { + [styles.active]: isActive, + }) + } + > +
    + {favoritesItems.length > 0 && ( +
    + {favoritesItems.length} +
    + )} +
    +
    +
  • +
  • + + classNames(styles['burger-menu__footer-link'], { + [styles.active]: isActive, + }) + } + > +
    + {cartItems.length > 0 && ( +
    + {cartItemsCount()} +
    + )} +
    +
    +
  • +
+
+
+ ); +}; 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/HomePageSlider/HomePageSlider.module.scss b/src/components/HomePageSlider/HomePageSlider.module.scss new file mode 100644 index 00000000000..5d06d09af34 --- /dev/null +++ b/src/components/HomePageSlider/HomePageSlider.module.scss @@ -0,0 +1,90 @@ +@import '../../utils/mixins'; +@import '../../variables'; + +.HomePageSlider { + @include grid; + + grid-template-rows: 400px 32px; + row-gap: 16px; +} + +.slider__button { + grid-row: 1; + background-position: center; + background-color: $white; + border: 1px solid $Icons; + background-repeat: no-repeat; + cursor: pointer; + height: 100%; + + @include on-phone { + display: none; + } + + &:hover { + border: 1px solid $Primary; + } + + &--left { + grid-column: 1; + background-image: url('../../../public/img/icons/Arrow_Left.png'); + } + + &--right { + grid-column: -2/-1; + background-image: url('../../../public/img/icons/Arrow_Right.png'); + } +} + +.banner { + grid-row: 1; + grid-column: 2 / -2; + position: relative; + overflow: hidden; + + @include on-phone { + grid-column: 1/-1; + position: absolute; + height: 320px; + width: 100vw; + left: 0; + } + + &__image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.5s ease-in-out; + + &--active { + opacity: 1; + position: relative; + } + } +} + +.dots { + grid-row: 2; + grid-column: 1 / -1; + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + + &__item { + width: 16px; + height: 4px; + background-color: $Elements; + border: none; + cursor: pointer; + transition: background-color 0.3s; + + &--active { + background-color: $Primary; + } + } +} diff --git a/src/components/HomePageSlider/HomePageSlider.tsx b/src/components/HomePageSlider/HomePageSlider.tsx new file mode 100644 index 00000000000..13e28d61e07 --- /dev/null +++ b/src/components/HomePageSlider/HomePageSlider.tsx @@ -0,0 +1,103 @@ +import { useState, useEffect, useCallback } from 'react'; +import classNames from 'classnames'; +import styles from './HomePageSlider.module.scss'; + +const images = [ + 'img/banner-accessories.png', + 'img/banner-phones.png', + 'img/banner-tablets.png', +]; + +export const HomePageSlider = () => { + const [currentIndex, setCurrentIndex] = useState(0); + const [touchStart, setTouchStart] = useState(0); + + const nextSlide = useCallback(() => { + setCurrentIndex(prevIndex => (prevIndex + 1) % images.length); + }, []); + + const prevSlide = () => { + setCurrentIndex( + prevIndex => (prevIndex - 1 + images.length) % images.length, + ); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + setTouchStart(e.targetTouches[0].clientX); + }; + + const handleTouchEnd = (e: React.TouchEvent) => { + const touchEnd = e.changedTouches[0].clientX; + const distance = touchStart - touchEnd; + const minSwipeDistance = 50; + + if (distance > minSwipeDistance) { + nextSlide(); + } + + if (distance < -minSwipeDistance) { + prevSlide(); + } + }; + + useEffect(() => { + const interval = setInterval(nextSlide, 5000); + + return () => clearInterval(interval); + }, [nextSlide]); + + return ( +
+
+ + ); +}; diff --git a/src/components/HomePageSlider/index.ts b/src/components/HomePageSlider/index.ts new file mode 100644 index 00000000000..09b324d1872 --- /dev/null +++ b/src/components/HomePageSlider/index.ts @@ -0,0 +1 @@ +export { HomePageSlider } from './HomePageSlider'; diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 00000000000..c701ff3b36b --- /dev/null +++ b/src/components/Loader/Loader.module.scss @@ -0,0 +1,25 @@ +@import '../../variables'; + +.loader { + flex-grow: 1; + + display: flex; + justify-content: center; + align-items: center; + width: 100%; + padding: 40px 0; + + &__spinner { + width: 48px; + height: 48px; + border: 5px solid $Elements; + border-top: 5px solid $Primary; + 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..e998761d945 --- /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..d7027885251 --- /dev/null +++ b/src/components/Loader/index.ts @@ -0,0 +1 @@ +export { Loader } from './Loader'; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..a1724b62fb1 --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,154 @@ +@import '../../variables'; +@import '../../utils/mixins'; + +.ProductCard { + display: flex; + flex-direction: column; + width: 100%; + padding: 32px; + border: 1px solid $Elements; + background-color: $white; + transition: box-shadow 0.3s ease; + box-sizing: border-box; + + .photo { + width: 100%; + height: 196px; + object-fit: contain; + margin-bottom: 16px; + transition: transform 0.3s ease; + + @include on-phone { + height: 129px; + } + } + + &:hover { + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.1); + + .photo { + transform: scale(1.1); + } + } +} + +.link { + display: flex; + flex-direction: column; + align-items: center; + text-decoration: none; + margin-bottom: 8px; +} + +.title { + font-family: $font-family; + font-weight: 600; + font-size: 14px; + line-height: 21px; + color: $Primary; + text-align: left; + align-self: flex-start; + min-height: 42px; + margin: 0; +} + +.priceContainer { + display: flex; + align-items: baseline; + gap: 8px; + margin-top: 8px; + margin-bottom: 8px; +} + +.price { + font-family: $font-family; + font-weight: 800; + font-size: 22px; + line-height: 1; + color: $Primary; +} + +.fullPrice { + font-family: $font-family; + font-weight: 600; + font-size: 22px; + line-height: 1; + text-decoration: line-through; + color: $Secondary; +} + +.divider { + width: 100%; + height: 1px; + background-color: $Elements; + margin-block: 8px; +} + +.infoTable { + width: 100%; + border-collapse: collapse; + margin-bottom: 16px; +} + +.infoRow { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.infoCellTitle { + font-family: $font-family; + font-size: 12px; + font-weight: 700; + color: $Secondary; +} + +.infoCellDetails { + font-family: $font-family; + font-size: 12px; + font-weight: 700; + color: $Primary; +} + +.buttons { + display: flex; + gap: 8px; + margin-top: auto; +} + +.buttonCard { + flex-grow: 1; + height: 40px; + background-color: $Primary; + color: $white; + border: 1px solid $Primary; + font-family: $font-family; + font-weight: 700; + font-size: 14px; + cursor: pointer; + transition: all 0.3s; + + &--selected { + background-color: $white; + color: $Green; + border: 1px solid #E2E6E9; + } +} + +.buttonFavorites { + width: 40px; + height: 40px; + border: 1px solid $Icons; + background: $white url('/img/icons/noneLike.png') no-repeat center; + cursor: pointer; + transition: all 0.3s; + + &:hover { + border-color: $Primary; + } + + &--selected { + background-image: url('/img/icons/like.png'); + border-color: $Elements; + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..70ea741f45b --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,112 @@ +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { Product } from '../../types/product'; +import classNames from 'classnames'; +import styles from './ProductCard.module.scss'; +import { useCart, useFavorites } from '../../ItemsProvider'; + +type Props = { + product: Product; + classForCard?: string; + discount?: boolean; +}; + +export const ProductCard: React.FC = ({ + product, + classForCard, + discount = true, +}) => { + const { cartItems, setCartItems } = useCart(); + const { favoritesItems, setFavoritesItems } = useFavorites(); + + const isFavorite = useMemo(() => { + return favoritesItems.some(item => item.id === product.id); + }, [favoritesItems, product.id]); + + const isCart = useMemo(() => { + return cartItems.some(item => item.product.id === product.id); + }, [cartItems, product.id]); + + const addToCart = () => { + setCartItems(prev => { + const exists = prev.some(item => item.product.id === product.id); + + if (exists) { + return prev.filter(item => item.product.id !== product.id); + } + + return [...prev, { product, quantity: 1 }]; + }); + }; + + const addToFavorites = () => { + setFavoritesItems(prev => { + const exists = prev.some(prod => prod.id === product.id); + + if (exists) { + return prev.filter(prod => prod.id !== product.id); + } + + return [...prev, product]; + }); + }; + + return ( +
+ + {product.name} +

{product.name}

+ + +
+ ${product.price} + {discount && ( + ${product.fullPrice} + )} +
+ +
+ + + + + + + + + + + + + + + + +
Screen{product.screen}
Capacity{product.capacity}
RAM{product.ram}
+ +
+ +
+
+ ); +}; 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/ProductsSlider/ProductsSlider.module.scss b/src/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 00000000000..1c2b717b1ec --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,84 @@ +@import '../../variables'; +@import '../../utils/mixins'; + +.ProductsSlider { + grid-column: 1/-1; + width: 100%; + margin: 64px auto; + box-sizing: border-box; + + &__top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + &__title { + font-family: $font-family; + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + color: $Primary; + margin: 0; + } + + &__buttons { + display: flex; + gap: 16px; + } + + &__btn { + width: 32px; + height: 32px; + border: 1px solid $Icons; + background-color: $white; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + transition: all 0.3s ease; + + &:disabled { + opacity: 0.5; + border-color: $Elements; + cursor: default; + } + &:hover:not(:disabled) { border-color: $Primary; } + + + &--prev { background-image: url('/img/icons/Arrow_Left.png'); } + &--next { background-image: url('/img/icons/Arrow_Right.png'); } + } + + &__window { + width: 100%; + overflow: hidden; + } + + &__list { + display: flex; + gap: 16px; + transition: transform 0.5s cubic-bezier(0.45, 0, 0.55, 1); + + & > * { + flex-shrink: 0; + flex-grow: 0; + + @include on-desktop { + width: 256px; + height: 505px; + } + + @include on-tablet { + width: 237px; + height: 528px; + } + + @include on-phone { + width: 212px; + height: 455px; + } + } + } +} diff --git a/src/components/ProductsSlider/ProductsSlider.tsx b/src/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..e96e55a0e79 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,129 @@ +import { useMemo, useState, useRef, useEffect } from 'react'; +import { Product } from '../../types/product'; +import { getSuggestedProducts } from '../../utils/productUtils'; +import { ProductCard } from '../ProductCard'; +import styles from './ProductsSlider.module.scss'; + +type Props = { + title: string; + products: Product[]; +}; + +export const ProductsSlider: React.FC = ({ title, products }) => { + const [currentIndex, setCurrentIndex] = useState(0); + const [step, setStep] = useState(0); + const listRef = useRef(null); + const showFullPrice = title !== 'Brand new models'; + + const filteredProducts = useMemo(() => { + if (products.length === 0) { + return []; + } + + switch (title) { + case 'Brand new models': { + const maxYear = Math.max(...products.map(p => p.year)); + + return products.filter(product => product.year === maxYear); + } + + case 'Hot prices': { + return products + .filter(p => p.fullPrice > p.price) + .sort((a, b) => b.fullPrice - b.price - (a.fullPrice - a.price)); + } + + case 'You may also like': { + return getSuggestedProducts(products).slice(0, 8); + } + + default: + return products; + } + }, [products, title]); + + const updateStep = () => { + if (listRef.current && listRef.current.children.length > 0) { + const card = listRef.current.children[0] as HTMLElement; + const gap = 16; + + setStep(card.offsetWidth + gap); + } + }; + + useEffect(() => { + updateStep(); + window.addEventListener('resize', updateStep); + + return () => window.removeEventListener('resize', updateStep); + }, [filteredProducts]); + + const maxIndex = useMemo(() => { + if (!listRef.current || step === 0) { + return 0; + } + + const containerWidth = listRef.current.parentElement?.offsetWidth || 0; + const visibleCards = Math.floor(containerWidth / step); + + return Math.max(0, filteredProducts.length - visibleCards); + }, [filteredProducts.length, step]); + + useEffect(() => { + if (currentIndex > maxIndex) { + setCurrentIndex(maxIndex); + } + }, [maxIndex, currentIndex]); + + const handleNext = () => { + if (currentIndex < maxIndex) { + setCurrentIndex(prev => prev + 1); + } + }; + + const handlePrev = () => { + if (currentIndex > 0) { + setCurrentIndex(prev => prev - 1); + } + }; + + return ( +
+
+

{title}

+
+
+
+ +
+
+ {filteredProducts.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/index.tsx b/src/index.tsx index 50470f1508d..fa686893c23 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,12 @@ import { createRoot } from 'react-dom/client'; import { App } from './App'; +import { HashRouter as Router } from 'react-router-dom'; +import { ItemsProvider } from './ItemsProvider'; -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..33977f5efa2 --- /dev/null +++ b/src/types/product.ts @@ -0,0 +1,47 @@ +export enum Categories { + Phones = 'phones', + Accessories = 'accessories', + Tablets = 'tablets', +} + +export type Product = { + id: number; + category: Categories; + 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 type ProductItem = { + 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; + camera?: string; + zoom: string; + cell: string[]; +}; diff --git a/src/utils/mixins.scss b/src/utils/mixins.scss new file mode 100644 index 00000000000..b1f560145b4 --- /dev/null +++ b/src/utils/mixins.scss @@ -0,0 +1,69 @@ +@import '../variables'; + +@mixin on-phone { + @media (max-width: $phone-max-width) { + @content; + } +} + +@mixin on-tablet { + @media (min-width: $tablet-min-width) and (max-width: $tablet-max-width) { + @content; + } +} + +@mixin on-desktop { + @media (min-width: $desktop-min-width) { + @content; + } +} + +@mixin inline-padding { + width: 100%; + margin: 0 auto; + padding-inline: 16px; + box-sizing: border-box; + + @include on-desktop{ + max-width: 1200px; + padding-inline: 64px; + } + + @include on-tablet{ + padding-inline: 32px; + } + + @include on-phone{ + padding: 16px; + } +} + +@mixin grid{ + display: grid; + column-gap: 16px; + + @include on-desktop{ + grid-template-columns: repeat(24, 1fr); + } + + @include on-tablet{ + grid-template-columns: repeat(12, 1fr); + } + + @include on-phone{ + grid-template-columns: repeat(4, 1fr); + } +} + +@mixin truncate($lines: 1) { + overflow: hidden; + text-overflow: ellipsis; + + @if $lines == 1 { + white-space: nowrap; + } @else { + display: -webkit-box; + -webkit-line-clamp: $lines; + -webkit-box-orient: vertical; + } +} diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts new file mode 100644 index 00000000000..8354f30c4e7 --- /dev/null +++ b/src/utils/pagination.ts @@ -0,0 +1,33 @@ +export const getPaginationRange = (currentPage: number, totalPages: number) => { + const delta = 1; + const range = []; + const rangeWithDots = []; + + for ( + let i = Math.max(1, currentPage - delta); + i <= Math.min(totalPages, currentPage + delta); + i++ + ) { + range.push(i); + } + + if (currentPage - delta > 2) { + rangeWithDots.push(1, '...'); + } else { + rangeWithDots.push(1); + } + + for (const i of range) { + if (i !== 1 && i !== totalPages) { + rangeWithDots.push(i); + } + } + + if (currentPage + delta < totalPages - 1) { + rangeWithDots.push('...', totalPages); + } else if (totalPages > 1) { + rangeWithDots.push(totalPages); + } + + return Array.from(new Set(rangeWithDots)); +}; diff --git a/src/utils/productUtils.ts b/src/utils/productUtils.ts new file mode 100644 index 00000000000..6185db7644f --- /dev/null +++ b/src/utils/productUtils.ts @@ -0,0 +1,5 @@ +import { Product } from '../types/product'; + +export const getSuggestedProducts = (products: Product[]): Product[] => { + return [...products].sort(() => 0.5 - Math.random()); +}; diff --git a/src/utils/searchParams.ts b/src/utils/searchParams.ts new file mode 100644 index 00000000000..b8a7b74909a --- /dev/null +++ b/src/utils/searchParams.ts @@ -0,0 +1,25 @@ +export type SearchParams = { + [key: string]: string | string[] | null; +}; + +export function getSearchParams( + paramsToUpdate: SearchParams, + search?: string | URLSearchParams, +): string { + const newParams = new URLSearchParams(search); + + Object.entries(paramsToUpdate).forEach(([key, value]) => { + if (value === null) { + newParams.delete(key); + } else if (Array.isArray(value)) { + newParams.delete(key); + value.forEach(part => { + newParams.append(key, part); + }); + } else { + newParams.set(key, value); + } + }); + + return newParams.toString(); +} diff --git a/src/variables.scss b/src/variables.scss new file mode 100644 index 00000000000..68c1e689b55 --- /dev/null +++ b/src/variables.scss @@ -0,0 +1,14 @@ +$phone-min-width: 320px; +$phone-max-width: 639px; +$tablet-min-width: 640px; +$tablet-max-width: 1199px; +$desktop-min-width: 1200px; +$white: #FFF; +$Hover_plus_BG: #FAFBFC; +$Elements: #E2E6E9; +$Icons: #B4BDC3; +$Secondary: #89939A; +$Primary: #313237; +$Red: #EB5757; +$Green: #27AE60; +$font-family: 'Mont', sans-serif;