diff --git a/src/App.scss b/src/App.scss index 71bc413aad..5294541bf7 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,29 @@ -// not empty +html { + scroll-behavior: smooth; +} + +p, +h1, +h2, +h3, +h4, +ul, +button, +span, +body { + margin: 0; + padding: 0; +} + +body { + box-sizing: border-box; + font-family: "Mont", arial, helvetica, sans-serif; + font-weight: 500; + font-size: 14px; + line-height: 21px; +} + +.container { + max-width: 1136px; + margin: 0 auto; +} diff --git a/src/App.tsx b/src/App.tsx index a1715e52b3..3f503cf72d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,19 @@ +import { Outlet } from 'react-router-dom'; import './App.scss'; +import { Footer } from './components/Footer/Footer'; +import { Header } from './components/Header/Header'; const App = () => (
-

React Phone Catalog

+
+ +
+
+ +
+
+ +
); diff --git a/src/api/CategoriesList.json b/src/api/CategoriesList.json new file mode 100644 index 0000000000..9b3f0f1c09 --- /dev/null +++ b/src/api/CategoriesList.json @@ -0,0 +1,23 @@ +[ + { + "title": "Mobile phones", + "image": "img/categories/phones.jpg", + "type": "phones", + "itemType": "phone", + "url": "/phones" + }, + { + "title": "Tablets", + "image": "img/categories/tablets.jpg", + "type": "tablets", + "itemType": "tablet", + "url": "/tablets" + }, + { + "title": "Accessories", + "image": "img/categories/accessories.jpg", + "type": "accessories", + "itemType": "accessory", + "url": "/accessories" + } +] diff --git a/src/api/Products.ts b/src/api/Products.ts new file mode 100644 index 0000000000..3f64583c3c --- /dev/null +++ b/src/api/Products.ts @@ -0,0 +1,11 @@ +import { Product } from '../types/Product'; +import { ProductDetails } from '../types/ProductDetails'; +import { client } from '../utils/fetchClient'; + +export const getProducts = () => { + return client.get('/products.json'); +}; + +export const getProductDetails = (productId: string) => { + return client.get(`/products/${productId}.json`); +}; diff --git a/src/api/navFooter.json b/src/api/navFooter.json new file mode 100644 index 0000000000..7c0deaf4ea --- /dev/null +++ b/src/api/navFooter.json @@ -0,0 +1,14 @@ +[ + { + "title": "Github", + "path": "https://github.com/NikaNika12" + }, + { + "title": "Contacts", + "path": "https://github.com/NikaNika12" + }, + { + "title": "Rights", + "path": "https://github.com/NikaNika12" + } +] diff --git a/src/api/navHeader.json b/src/api/navHeader.json new file mode 100644 index 0000000000..a1f5e17190 --- /dev/null +++ b/src/api/navHeader.json @@ -0,0 +1,18 @@ +[ + { + "title": "Home", + "path": "/" + }, + { + "title": "Phones", + "path": "/phones" + }, + { + "title": "Tablets", + "path": "/tablets" + }, + { + "title": "Accessories", + "path": "/accessories" + } +] diff --git a/src/components/AddToCartButton/AddToCartButton.scss b/src/components/AddToCartButton/AddToCartButton.scss new file mode 100644 index 0000000000..0131884428 --- /dev/null +++ b/src/components/AddToCartButton/AddToCartButton.scss @@ -0,0 +1,38 @@ +@import "../../main.scss"; + +.button { + width: 176px; + height: 40px; + + @extend %buttons-text; + + color: $color-white; + background-color: $color-primary; + border: none; + outline: none; + + transition: box-shadow 0.5s; + cursor: pointer; + + &--small { + width: 176px; + height: 40px; + } + + &--big { + width: 100%; + height: 48px; + } + + &--selected { + color: $color-green; + background: none; + border: 1px solid $color-elements; + } + + &:hover { + color: $color-white; + background-color: $color-primary; + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.26); + } +} diff --git a/src/components/AddToCartButton/AddToCartButton.tsx b/src/components/AddToCartButton/AddToCartButton.tsx new file mode 100644 index 0000000000..47e6437e03 --- /dev/null +++ b/src/components/AddToCartButton/AddToCartButton.tsx @@ -0,0 +1,34 @@ +import classNames from 'classnames'; +import { useContext } from 'react'; +import { Product } from '../../types/Product'; +import { Context } from '../../context/Context'; +import './AddToCartButton.scss'; +import { ButtonType } from '../../types/ButtonType'; + +type Props = { + product: Product; + size: ButtonType; +}; + +export const AddToCartButton: React.FC = ({ product, size }) => { + const { changeCart, cart } = useContext(Context); + + const isInCart = cart.length > 0 + ? cart.find(item => item.id === product?.id) : false; + + return ( + + ); +}; diff --git a/src/components/BackButton/BackButton.scss b/src/components/BackButton/BackButton.scss new file mode 100644 index 0000000000..979d2e66dd --- /dev/null +++ b/src/components/BackButton/BackButton.scss @@ -0,0 +1,21 @@ +@import "../../main.scss"; + +.backButton { + display: flex; + align-items: center; + margin-bottom: 24px; + padding: 10px 20px; + width: 50px; + height: 30px; + background-color: $color-white; + background-image: url("../../images/ArrowLeft.svg"); + background-position: left; + background-repeat: no-repeat; + border: 0; + color: $color-secondary; + cursor: pointer; + + &:hover { + color: $color-primary; + } +} diff --git a/src/components/BackButton/BackButton.tsx b/src/components/BackButton/BackButton.tsx new file mode 100644 index 0000000000..b26fef7bb3 --- /dev/null +++ b/src/components/BackButton/BackButton.tsx @@ -0,0 +1,21 @@ +import { useNavigate } from 'react-router-dom'; +import './BackButton.scss'; + +export const BackButton = () => { + const navigate = useNavigate(); + + return ( + + ); +}; diff --git a/src/components/BannerSlider/BannerSlider.scss b/src/components/BannerSlider/BannerSlider.scss new file mode 100644 index 0000000000..531643ca78 --- /dev/null +++ b/src/components/BannerSlider/BannerSlider.scss @@ -0,0 +1,70 @@ +@import "../../main.scss"; + +.banner { + &__container { + display: flex; + gap: 16px; + margin-bottom: 8px; + } + + &__images { + display: flex; + height: 400px; + order: 2; + } + + &__image { + opacity: 0; + height: 0; + width: 0; + transition: opacity 0.3s; + + &--active { + opacity: 1; + height: 100%; + width: 100%; + } + } + + &__button { + flex: 0 0 32px; + border: 1px solid $color-icons; + background-color: $color-white; + transition: all 0.3s ease; + + &:hover { + border: 1px solid $color-primary; + } + + &--prev { + order: 1; + } + + &--next { + order: 3; + } + } + + &__pagination-container { + display: flex; + justify-content: center; + align-items: center; + gap: 15px; + height: 24px; + margin-bottom: 72px; + } + + &__button-pg { + border: 1px solid transparent; + transition: all 0.3s; + background-color: $color-elements; + + &:hover { + border: 1px solid $color-primary; + } + + &--active { + background-color: $color-primary; + } + } +} diff --git a/src/components/BannerSlider/BannerSlider.tsx b/src/components/BannerSlider/BannerSlider.tsx new file mode 100644 index 0000000000..236420515c --- /dev/null +++ b/src/components/BannerSlider/BannerSlider.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import './BannerSlider.scss'; + +const BannerImages = [ + 'img/banner1.jpg', + 'img/banner2.jpg', + 'img/banner3.jpg', +]; + +export const BannerSlider: React.FC = () => { + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + const interval = setInterval( + () => setCurrentIndex((prev) => prev + 1), + 5000, + ); + + return () => clearInterval(interval); + }, [currentIndex]); + + return ( +
+
+ +
+ {BannerImages.map((image, index) => { + if (currentIndex > BannerImages.length - 1) { + setCurrentIndex(0); + } + + if (currentIndex < 0) { + setCurrentIndex(BannerImages.length - 1); + } + + return ( + {image} + ); + })} +
+ +
+
+ {BannerImages.map((image, index) => ( +
+
+ ); +}; diff --git a/src/components/CartItem/CartItem.scss b/src/components/CartItem/CartItem.scss new file mode 100644 index 0000000000..ddc37926ec --- /dev/null +++ b/src/components/CartItem/CartItem.scss @@ -0,0 +1,121 @@ +@import "../../main.scss"; + +.cart { + &__empty-title { + display: flex; + justify-content: center; + margin-bottom: 40px; + padding-top: 16px; + color: $color-primary; + } + + &__list { + display: flex; + flex-direction: column; + } + + &__item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + border: 1px solid $color-elements; + transition: all 0.5s; + padding: 24px; + + &:last-child { + margin-bottom: 0; + } + + &:hover { + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.26); + } + + &__icon { + background-color: $color-white; + border: none; + transition: all 0.5s; + cursor: pointer; + + &--close { + display: block; + background-position: center; + background-repeat: no-repeat; + @include squareSize(16px); + border: 1px solid $color-icons; + background-image: url(../../images/Close.svg); + } + } + } + + &__image { + height: 100%; + &--container { + @include squareSize(80px); + padding: 7px; + } + } + + &__item-title { + width: 295px; + } + + &__buttons { + display: flex; + align-items: center; + + &__icon { + border: 1px solid $color-icons; + transition: all 0.5s; + background-color: $color-white; + cursor: pointer; + + &:hover { + border-color: $color-icons; + } + + &:hover:disabled { + border-color: $color-elements; + } + + &--substract { + display: block; + background-position: center; + background-repeat: no-repeat; + @include squareSize(32px); + border: 1px solid $color-icons; + background-image: url(../../images/Minus.svg); + } + + &--add { + display: block; + background-position: center; + background-repeat: no-repeat; + @include squareSize(32px); + border: 1px solid $color-icons; + background-image: url(../../images/Plus.svg); + } + } + } + + &__quantity { + min-width: 32px; + width: 100%; + text-align: center; + color: #000; + } + + &__price { + min-width: 60px; + @extend %h2-text; + color: $color-primary; + } + + &__empty-phrase { + width: 100%; + margin-top: 30px; + @extend %h1-text; + text-align: center; + color: $color-primary; + } +} diff --git a/src/components/CartItem/CartItem.tsx b/src/components/CartItem/CartItem.tsx new file mode 100644 index 0000000000..d068a8a96a --- /dev/null +++ b/src/components/CartItem/CartItem.tsx @@ -0,0 +1,90 @@ +import { useContext } from 'react'; +import { Context } from '../../context/Context'; +import { Product } from '../../types/Product'; +import './CartItem.scss'; + +export const CartItem: React.FC = (product) => { + const { + imageUrl, + name, + price, + quantity, + discount, + } = product; + + const priceAfterDiscount = price * ((100 - discount) / 100); + + const { changeCart } = useContext(Context); + + const addItem = () => { + changeCart({ + ...product, + quantity: quantity && quantity + 1, + }); + }; + + const substractItem = () => { + changeCart({ + ...product, + quantity: quantity && quantity - 1, + }); + }; + + return ( +
  • + + +
    + product +
    + +
    + {name} +
    + +
    + + +
    + {quantity} +
    + + +
    + +
    + {`$${priceAfterDiscount * (quantity || 1)}`} +
    +
  • + ); +}; diff --git a/src/components/Dropdown/Dropdown.scss b/src/components/Dropdown/Dropdown.scss new file mode 100644 index 0000000000..e628ca3723 --- /dev/null +++ b/src/components/Dropdown/Dropdown.scss @@ -0,0 +1,82 @@ +@import "../../main.scss"; + +.dropdown { + position: relative; + + &__label { + @extend %small-text; + color: $color-secondary; + margin-bottom: 4px; + position: relative; + } + + &__select { + display: flex; + align-items: center; + justify-content: space-between; + @extend %buttons-text; + padding: 10px; + height: 40px; + width: 100%; + border: 1px solid $color-icons; + outline: none; + color: $color-primary; + background-color: $color-white; + text-transform: capitalize; + + &:hover { + border: 1px solid $color-primary; + } + } + + &__arrow { + display: flex; + justify-content: center; + align-items: center; + transition: transform 0.3s; + + &--active { + transform: rotate(180deg); + } + } + + &__options { + position: absolute; + top: 110%; + width: 100%; + border: 1px solid $color-elements; + list-style: none; + padding: 8px 0; + background-color: $color-white; + } + + &__option { + cursor: pointer; + height: 32px; + padding: 0 12px; + transition: all 0.3s; + + &:hover { + background-color: $color-background; + } + } + + &__option a { + display: flex; + align-items: center; + color: $color-secondary; + width: 100%; + height: 100%; + text-decoration: none; + text-transform: capitalize; + transition: all 0.3s; + + &:last-child { + margin-bottom: 0; + } + + &:hover { + color: $color-primary; + } + } +} diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 0000000000..321942691f --- /dev/null +++ b/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,96 @@ +import classNames from 'classnames'; +import { useRef, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { getSearchWith } from '../../utils/searchHelper'; +import './Dropdown.scss'; + +type Props = { + dropdownList: string[]; + defaultValue: string; + label: string; + searchParamsKey: string; +}; + +export const Dropdown: React.FC = ({ + dropdownList, + defaultValue, + label, + searchParamsKey, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchParams] = useSearchParams(); + const [value, setValue] = useState(defaultValue); + const buttonRef = useRef(null); + + const getSearchParams = (params: string) => { + if (searchParamsKey === 'perPage') { + return getSearchWith(searchParams, { + page: '1', + [searchParamsKey]: params, + }); + } + + return getSearchWith(searchParams, { + [searchParamsKey]: params, + }); + }; + + document.addEventListener('click', (e) => { + if (e.target !== buttonRef.current) { + setIsOpen(false); + } + }); + + document.addEventListener('keyup', (e) => { + if (e.key === 'Tab' || e.key === 'Escape') { + setIsOpen(false); + } + }); + + return ( +
    +

    {label}

    + + + {isOpen && ( +
      + {dropdownList.map(dropdownItem => ( + + ))} +
    + )} +
    + ); +}; diff --git a/src/components/Footer/Footer.scss b/src/components/Footer/Footer.scss new file mode 100644 index 0000000000..433529ae6a --- /dev/null +++ b/src/components/Footer/Footer.scss @@ -0,0 +1,55 @@ +@import "../../main.scss"; + +.footer { + display: flex; + justify-content: space-between; + align-items: center; + height: 96px; + box-sizing: border-box; + border-top: 1px solid $color-elements; + + &__container { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 1136px; + width: 100%; + margin: 0 auto; + } + + &__nav { + display: flex; + gap: 64px; + } + + &__link { + @extend %uppercase-text; + color: $color-secondary; + text-decoration: none; + } +} + +.button-top { + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + font-family: "Mont", arial, helvetica, sans-serif; + font-weight: 600; + font-size: 12px; + line-height: 15px; + text-decoration: none; + + &__text { + color: $color-secondary; + } + + &__icon { + display: block; + background-position: center; + background-repeat: no-repeat; + @include squareSize(32px); + border: 1px solid $color-icons; + background-image: url(../../images/ArrowUp.svg); + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..6b73cdac88 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,50 @@ +import { Link, useLocation } from 'react-router-dom'; +import { Logo } from '../Logo/Logo'; +import footerList from '../../api/navFooter.json'; +import './Footer.scss'; + +export const Footer: React.FC = () => { + const location = useLocation(); + + const handleScrollToTop = () => { + window.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth', + }); + }; + + return ( +
    +
    + + +
    + {footerList.map(item => ( + + {item.title} + + ))} +
    + + +

    + Back to top +

    + + + +
    +
    + ); +}; diff --git a/src/components/Header/Header.scss b/src/components/Header/Header.scss new file mode 100644 index 0000000000..a0363f68df --- /dev/null +++ b/src/components/Header/Header.scss @@ -0,0 +1,33 @@ +@import "../../main.scss"; + +.header { + display: flex; + justify-content: space-between; + align-items: center; + height: 64px; + margin: 0 0 40px; + border-bottom: 1px solid $color-elements; + + &__container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: 1440px; + margin: 0 auto; + padding-left: 40px; + } + + &__left { + display: flex; + justify-content: center; + align-items: center; + gap: 64px; + } + + &__right { + display: flex; + justify-content: center; + align-items: center; + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..18a5d6db6e --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,43 @@ +import { useLocation, useParams } from 'react-router-dom'; +import { Logo } from '../Logo/Logo'; +import { Nav } from '../Nav/Nav'; +import { SearchBar } from '../SearchBar/SearchBar'; +import { HeaderButtons } from '../HeaderButtons/HeaderButtons'; +import CategoriesList from '../../api/CategoriesList.json'; +import './Header.scss'; + +export const Header: React.FC = () => { + const location = useLocation(); + const { productId = '' } = useParams(); + + const cartCondition = location.pathname.includes('cart'); + const favoriteCondition = location.pathname.includes('favorite'); + + const productCategoriesList = CategoriesList.map(item => item.type).some( + item => location.pathname.includes(item), + ) && !productId; + + return ( +
    +
    +
    +
    + +
    + {!cartCondition &&
    +
    + {(favoriteCondition || productCategoriesList) && } + {!cartCondition && ( + + )} + +
    +
    +
    + ); +}; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 0000000000..266dec8a1b --- /dev/null +++ b/src/components/Header/index.tsx @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/HeaderButtons/HeaderButtons.scss b/src/components/HeaderButtons/HeaderButtons.scss new file mode 100644 index 0000000000..52660893c2 --- /dev/null +++ b/src/components/HeaderButtons/HeaderButtons.scss @@ -0,0 +1,55 @@ +@import "../../main.scss"; + +.top-item { + position: relative; + display: flex; + align-items: center; + border-left: 1px solid $color-elements; + + &-count { + position: absolute; + top: 14px; + right: 17px; + + display: flex; + justify-content: center; + align-items: center; + @include squareSize(14px); + + font-family: inherit; + font-weight: 600; + font-size: 9px; + line-height: 12px; + color: $color-white; + background-color: $color-red; + border: 1px solid $color-white; + border-radius: 50%; + } + + &__link { + display: flex; + padding: 22px 24px; + color: $color-primary; + transition: border 0.5s; + + &:hover { + border-bottom: 3px solid $color-primary; + cursor: pointer; + } + } + + &__icon { + display: block; + background-position: center; + background-repeat: no-repeat; + padding: 8px; + + &--favorite { + background-image: url(../../images/Favorite.svg); + } + + &--cart { + background-image: url(../../images/Cart.svg); + } + } +} diff --git a/src/components/HeaderButtons/HeaderButtons.tsx b/src/components/HeaderButtons/HeaderButtons.tsx new file mode 100644 index 0000000000..d3b3389a9b --- /dev/null +++ b/src/components/HeaderButtons/HeaderButtons.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; +import { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import { Context } from '../../context/Context'; +import './HeaderButtons.scss'; + +type ButtonType = 'favorite' | 'cart'; + +type Props = { + type: ButtonType, +}; + +export const HeaderButtons: React.FC = ({ type }) => { + const { cart, favorite } = useContext(Context); + + const productsAmount = (type === 'cart') + ? cart.length + : favorite.length; + + return ( +
    + + {type === 'favorite' + && ( + + )} + + {type === 'cart' + && ( + + )} + + {productsAmount > 0 + && ( + + {productsAmount} + + )} + +
    + ); +}; diff --git a/src/components/LikeButton/LikeButton.scss b/src/components/LikeButton/LikeButton.scss new file mode 100644 index 0000000000..841abfc86b --- /dev/null +++ b/src/components/LikeButton/LikeButton.scss @@ -0,0 +1,43 @@ +@import "../../main.scss"; + +.button-like { + display: flex; + justify-content: center; + align-items: center; + padding: 0; + + background-color: $color-white; + border: 1px solid $color-icons; + transition: all 0.5s; + cursor: pointer; + + &--small { + @include squareSize(40px); + } + + &--big { + @include squareSize(48px); + } + + &--icon { + display: block; + background-position: center; + background-repeat: no-repeat; + background-image: url(../../images/FavouritesHeartLike.svg); + + &--selected { + display: block; + background-position: center; + background-repeat: no-repeat; + background-image: url(../../images/FavouritesFilledHeartLike.svg); + } + } + + &--selected { + border-color: $color-elements; + } + + &:hover { + border-color: $color-primary; + } +} diff --git a/src/components/LikeButton/LikeButton.tsx b/src/components/LikeButton/LikeButton.tsx new file mode 100644 index 0000000000..2f4d6b0cb6 --- /dev/null +++ b/src/components/LikeButton/LikeButton.tsx @@ -0,0 +1,36 @@ +import { useContext } from 'react'; +import classNames from 'classnames'; +import { Product } from '../../types/Product'; +import { Context } from '../../context/Context'; +import './LikeButton.scss'; +import { ButtonType } from '../../types/ButtonType'; + +type Props = { + product: Product; + size: ButtonType; +}; + +export const LikeButton: React.FC = ({ product, size }) => { + const { changeFavorite, favorite } = useContext(Context); + + const isInFavs = favorite.length > 0 + ? favorite.find(item => item.id === product?.id) : false; + + return ( + + ); +}; diff --git a/src/components/Loader/Loader.scss b/src/components/Loader/Loader.scss new file mode 100644 index 0000000000..36624981d2 --- /dev/null +++ b/src/components/Loader/Loader.scss @@ -0,0 +1,25 @@ +.loader { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + + &__content { + border-radius: 50%; + width: 2em; + height: 2em; + margin: 1em auto; + border: 0.3em solid #ddd; + border-left-color: #000; + animation: load8 1.2s infinite linear; + } +} + +@keyframes load8 { + 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 0000000000..36d7ba0c3c --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,5 @@ +export const Loader = () => ( +
    +
    +
    +); diff --git a/src/components/Logo/Logo.scss b/src/components/Logo/Logo.scss new file mode 100644 index 0000000000..32a1301507 --- /dev/null +++ b/src/components/Logo/Logo.scss @@ -0,0 +1,7 @@ +.logo { + display: flex; + justify-content: center; + align-items: center; + height: 24px; + width: 40px; +} diff --git a/src/components/Logo/Logo.tsx b/src/components/Logo/Logo.tsx new file mode 100644 index 0000000000..265922305d --- /dev/null +++ b/src/components/Logo/Logo.tsx @@ -0,0 +1,7 @@ +import { Link } from 'react-router-dom'; + +export const Logo: React.FC = () => ( + + logo + +); diff --git a/src/components/Nav/Nav.scss b/src/components/Nav/Nav.scss new file mode 100644 index 0000000000..62ea7f3238 --- /dev/null +++ b/src/components/Nav/Nav.scss @@ -0,0 +1,6 @@ +.navbar { + display: flex; + gap: 64px; + align-items: center; + justify-content: space-between; +} diff --git a/src/components/Nav/Nav.tsx b/src/components/Nav/Nav.tsx new file mode 100644 index 0000000000..1953037634 --- /dev/null +++ b/src/components/Nav/Nav.tsx @@ -0,0 +1,17 @@ +import pageNavList from '../../api/navHeader.json'; +import { PageNavLink } from '../PageNavLink/PageNavLink'; +import './Nav.scss'; + +export const Nav = () => { + return ( +
    + {pageNavList.map(link => ( + + ))} +
    + ); +}; diff --git a/src/components/NavLinkByCategory/NavLinkByCategory.tsx b/src/components/NavLinkByCategory/NavLinkByCategory.tsx new file mode 100644 index 0000000000..ff297cd46e --- /dev/null +++ b/src/components/NavLinkByCategory/NavLinkByCategory.tsx @@ -0,0 +1,37 @@ +import { NavLink } from 'react-router-dom'; + +interface Props { + path: string, + type: string, + text: string, + amount: number, +} + +export const NavLinkByCategory:React.FC = ({ + path, + type, + text, + amount, +}) => { + return ( +
    + + {} + + + {text} + +
    + {amount} + {' '} + models +
    +
    + ); +}; diff --git a/src/components/NoResults/NoResults.scss b/src/components/NoResults/NoResults.scss new file mode 100644 index 0000000000..b2ca5165a2 --- /dev/null +++ b/src/components/NoResults/NoResults.scss @@ -0,0 +1,37 @@ +@import "../../main.scss"; + +.noresults { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + + &__title { + @extend %h2-text; + text-transform: uppercase; + color: $color-secondary; + margin-bottom: 40px; + } + + &__button { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + + @extend %buttons-text; + + width: 176px; + height: 40px; + background-color: $color-primary; + color: $color-white; + border: none; + outline: none; + + &:hover { + color: $color-white; + background-color: $color-primary; + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.26); + } + } +} diff --git a/src/components/NoResults/NoResults.tsx b/src/components/NoResults/NoResults.tsx new file mode 100644 index 0000000000..52a9374ad4 --- /dev/null +++ b/src/components/NoResults/NoResults.tsx @@ -0,0 +1,19 @@ +import { useNavigate } from 'react-router-dom'; +import './NoResults.scss'; + +export const NoResults = () => { + const navigate = useNavigate(); + + return ( +
    +
    Products can not be found
    + +
    + ); +}; diff --git a/src/components/PageNavLink/PageNavLink.scss b/src/components/PageNavLink/PageNavLink.scss new file mode 100644 index 0000000000..7b4cb09cde --- /dev/null +++ b/src/components/PageNavLink/PageNavLink.scss @@ -0,0 +1,25 @@ +@import "../../main.scss"; + +.nav { + &__link { + display: flex; + align-items: center; + color: $color-secondary; + + @extend %uppercase-text; + text-decoration: none; + transition: border 0.5s, color 0.5s; + + &--active { + color: $color-primary; + padding: 25px 0; + border-bottom: 3px solid $color-primary; + } + + &:hover { + color: $color-primary; + padding: 25px 0; + border-bottom: 3px solid $color-primary; + } + } +} diff --git a/src/components/PageNavLink/PageNavLink.tsx b/src/components/PageNavLink/PageNavLink.tsx new file mode 100644 index 0000000000..6dff39ef79 --- /dev/null +++ b/src/components/PageNavLink/PageNavLink.tsx @@ -0,0 +1,28 @@ +import { NavLink } from 'react-router-dom'; +import classNames from 'classnames'; +import './PageNavLink.scss'; + +type Props = { + name: string, + to: string, +}; + +export const PageNavLink:React.FC = ({ + name, + to, +}) => { + return ( + classNames( + 'nav__link', + { 'nav__link--active': isActive }, + )} + > + {name} + + ); +}; diff --git a/src/components/Pagination/Pagination.scss b/src/components/Pagination/Pagination.scss new file mode 100644 index 0000000000..71a2167cbc --- /dev/null +++ b/src/components/Pagination/Pagination.scss @@ -0,0 +1,42 @@ +@import "../../main.scss"; + +.pagination { + display: flex; + &__list { + display: flex; + gap: 8px; + margin: 0 8px; + } + + &__item { + display: flex; + justify-content: center; + align-items: center; + @include squareSize(32px); + border: 1px solid $color-icons; + + &--disabled { + pointer-events: none; + border: 1px solid $color-elements; + } + + &:hover { + border: 1px solid $color-primary; + } + } + + &__link { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + color: $color-primary; + text-decoration: none; + + &--active { + background-color: $color-primary; + color: $color-white; + } + } +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000000..710fd3ec7b --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,87 @@ +import { Link, useSearchParams } from 'react-router-dom'; +import classNames from 'classnames'; +import { getSearchWith } from '../../utils/searchHelper'; +import './Pagination.scss'; + +type Props = { + productsAmount: number; + perPage: number; + currentPage: number; +}; + +export const Pagination: React.FC = ({ + productsAmount, perPage, currentPage, +}) => { + const [searchParams] = useSearchParams(''); + const pagesAmount = () => (perPage ? Math.ceil(productsAmount / perPage) : 0); + + const pagesList = (pagesAmount() > 1) + ? (Array.from( + { length: pagesAmount() }, + (_, i) => i + 1, + )) + : [1]; + + return ( +
    + + arrowLeft + + +
      + {pagesList.map((n) => { + const numberPage = n.toString(); + + return ( +
    • + + {numberPage} + +
    • + ); + })} +
    + + + arrowRigth + +
    + ); +}; diff --git a/src/components/ProductCard/ProductCard.scss b/src/components/ProductCard/ProductCard.scss new file mode 100644 index 0000000000..faf14819ba --- /dev/null +++ b/src/components/ProductCard/ProductCard.scss @@ -0,0 +1,150 @@ +@import "../../main.scss"; + +.product-card { + &__container { + display: flex; + flex-direction: column; + padding: 24px; + width: 222px; + max-width: 272px; + border: 1px solid $color-elements; + + &:hover { + border: 1px solid $color-primary; + } + } + + &__link { + display: flex; + flex-direction: column; + text-decoration: none; + margin-bottom: 8px; + } + + &__image { + display: flex; + height: 208px; + flex-basis: 208px; + object-fit: contain; + margin-bottom: 24px; + } + + &__title { + @extend %buttons-text; + flex: 1 0 42px; + color: $color-primary; + } + + &__prices { + border-bottom: 1px solid $color-elements; + padding-bottom: 4px; + margin-bottom: 16px; + } + + &__price { + @extend %h2-text; + color: $color-primary; + margin-right: 8px; + } + + &__discount { + @extend %h2-text; + color: $color-secondary; + text-decoration: line-through; + } + + &__features { + display: flex; + justify-content: space-between; + text-transform: capitalize; + width: 100%; + margin-bottom: 16px; + } + + &__list { + @extend %small-text; + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + } + + &__key { + color: $color-secondary; + } + + &__value { + color: $color-primary; + text-align: right; + } + + &__buttons { + display: flex; + gap: 8px; + + &__to-cart { + padding: 0; + width: 176px; + height: 40px; + + @extend %buttons-text; + + color: $color-white; + background-color: $color-primary; + border: none; + outline: none; + + transition: box-shadow 0.5s; + cursor: pointer; + + &--selected { + color: $color-green; + background: none; + border: 1px solid $color-elements; + } + + &:hover { + color: $color-white; + background-color: $color-primary; + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.26); + } + } + + &__to-favs { + display: flex; + justify-content: center; + align-items: center; + @include squareSize(40px); + padding: 0; + + background-color: $color-white; + border: 1px solid $color-icons; + transition: all 0.5s; + cursor: pointer; + + &--icon { + @include squareSize(16px); + display: block; + background-position: center; + background-repeat: no-repeat; + background-image: url(../../images/FavouritesHeartLike.svg); + + &--selected { + @include squareSize(16px); + display: block; + background-position: center; + background-repeat: no-repeat; + background-image: url(../../images/FavouritesFilledHeartLike.svg); + } + } + + &--selected { + border-color: $color-elements; + } + + &:hover { + border-color: $color-primary; + } + } + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000000..1f7bccfa5d --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,157 @@ +import { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { Product } from '../../types/Product'; +import { Context } from '../../context/Context'; +import { getProductDetails } from '../../api/Products'; +import { Error } from '../../types/ErrorType'; +import './ProductCard.scss'; + +type Props = { + product: Product, +}; + +export const ProductCard: React.FC = ({ product }) => { + const { + products, + setSelectedProduct, + setIsError, + setIsLoading, + } = useContext(Context); + + const { + id, + type, + imageUrl, + name, + price, + discount, + screen, + capacity, + ram, + } = product; + + const { + cart, changeCart, favorite, changeFavorite, + } = useContext(Context); + + const isInCart = cart.length > 0 + ? cart.find(item => item.id === id) : false; + + const isInFavs = favorite.length > 0 + ? favorite.find(item => item.id === id) : false; + + const getProductLink = (searchProduct: Product) => { + switch (searchProduct.type) { + case 'phone': + return `../phones/${searchProduct.id}`; + + case 'tablet': + return `../tablets/${searchProduct.id}`; + + case 'accessories': + return `../accessories/${searchProduct.id}`; + + default: + return ''; + } + }; + + const currentProduct = products.find( + productCard => productCard.id === id, + ); + + const discountPrice = price - (price / 100) * discount; + + const getCardDetails = async (productId: string) => { + setIsError(null); + setIsLoading(true); + + try { + const currentProductDetails = await getProductDetails(productId); + + setSelectedProduct(currentProductDetails); + } catch { + setIsError(Error.GET_PRODUCT_DETAILS); + } finally { + setIsLoading(false); + } + }; + + const features = { + keys: ['screen', 'capacity', 'RAM'], + values: [`${screen}`, `${capacity}`, `${ram}`], + }; + + return ( +
    +
    + currentProduct && getCardDetails(currentProduct.id)} + > + {`${type}`} +

    {name}

    + + +
    + {`$${price}`} + {discount > 0 && {`$${discountPrice}`}} +
    + +
    +
      + {features.keys.map((key) => ( +
    • + {key} +
    • + ))} +
    + +
      + {features.values.map((value) => ( +
    • + {value} +
    • + ))} +
    +
    +
    + + +
    +
    +
    + ); +}; diff --git a/src/components/ProductsList/ProductList.scss b/src/components/ProductsList/ProductList.scss new file mode 100644 index 0000000000..f84d393275 --- /dev/null +++ b/src/components/ProductsList/ProductList.scss @@ -0,0 +1,33 @@ +@import "../../main.scss"; + +.product-list { + &__products { + display: grid; + grid-template-columns: repeat(4, 1fr); + row-gap: 40px; + column-gap: 16px; + margin-bottom: 40px; + } + + &__title { + margin-bottom: 8px; + } + + &__count { + margin-bottom: 40px; + color: $color-secondary; + } + + &__dropdowns { + display: grid; + grid-template-columns: 176px 128px; + column-gap: 16px; + margin-bottom: 24px; + } + + &__pagination { + display: flex; + justify-content: center; + margin-bottom: 80px; + } +} diff --git a/src/components/ProductsList/ProductsList.tsx b/src/components/ProductsList/ProductsList.tsx new file mode 100644 index 0000000000..e0a4d6feca --- /dev/null +++ b/src/components/ProductsList/ProductsList.tsx @@ -0,0 +1,115 @@ +import { useSearchParams } from 'react-router-dom'; +import { NoResults } from '../NoResults/NoResults'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; +import { Dropdown } from '../Dropdown/Dropdown'; +import { Pagination } from '../Pagination/Pagination'; +import './ProductList.scss'; + +type Props = { + title: string + products: Product[] +}; + +const optionsSort = ['newest', 'alphabetically', 'price', 'all']; +const optionsPage = ['4', '8', '16', 'all']; + +export const ProductsList: React.FC = ({ title, products }) => { + const [searchParams] = useSearchParams(); + + const productsAmount = products.length; + + const currentPage = Number(searchParams.get('page')) || 1; + const sort = searchParams.get('sort') || ''; + const perPage = Number(searchParams.get('perPage')) || productsAmount; + const query = searchParams.get('query') || ''; + + const getSortedProducts = (productsList: Product[]) => { + let sortedProducts = [...productsList]; + + if (sort === 'age') { + sortedProducts = sortedProducts.sort( + (product1, product2) => product1.age - product2.age, + ); + } + + if (sort === 'name') { + sortedProducts = sortedProducts.sort( + (product1, product2) => product1.name.localeCompare(product2.name), + ); + } + + if (sort === 'price') { + sortedProducts = sortedProducts.sort( + (product1, product2) => product1.price - product2.price, + ); + } + + if (query) { + const queryFilter = (param?: string | null) => { + return param + ? param.toLowerCase().includes(query.toLowerCase()) + : null; + }; + + sortedProducts = sortedProducts.filter( + product => queryFilter(product.name), + ); + } + + return sortedProducts || null; + }; + + const lastPage = Math.ceil(productsAmount / +perPage); + + const start = currentPage * perPage - perPage; + const end = currentPage * perPage <= productsAmount + ? currentPage * perPage + : productsAmount; + + const sortedProducts = getSortedProducts(products).slice(start, end); + + return ( +
    +
    +

    {title}

    +

    {`${productsAmount} models`}

    + {productsAmount === 0 ? ( + + ) : ( + <> +
    + + +
    + +
    + {sortedProducts.map((product) => ( + + ))} +
    + + )} +
    + {perPage !== productsAmount && lastPage > 1 && ( + + )} +
    +
    +
    + ); +}; diff --git a/src/components/ProductsNav/ProductsNav.scss b/src/components/ProductsNav/ProductsNav.scss new file mode 100644 index 0000000000..0685c31376 --- /dev/null +++ b/src/components/ProductsNav/ProductsNav.scss @@ -0,0 +1,42 @@ +@import "../../main.scss"; + +.productsNav { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 40px; + + &__home { + @include squareSize(16px); + background-image: url("../../images/Home.svg"); + background-position: center; + background-repeat: no-repeat; + } + + &__link { + @extend %small-text; + color: $color-primary; + display: flex; + gap: 8px; + text-decoration: none; + + &::before { + content: ""; + display: block; + @include squareSize(16px); + background-image: url("../../images/ArrowRight.svg"); + background-position: center; + background-repeat: no-repeat; + color: $color-icons; + } + + &--disabled { + pointer-events: none; + color: $color-secondary; + } + + &--title { + color: $color-secondary; + } + } +} diff --git a/src/components/ProductsNav/ProductsNav.tsx b/src/components/ProductsNav/ProductsNav.tsx new file mode 100644 index 0000000000..f8ef9db460 --- /dev/null +++ b/src/components/ProductsNav/ProductsNav.tsx @@ -0,0 +1,66 @@ +import { + useContext, useEffect, useMemo, useState, +} from 'react'; +import { Link, useLocation, useParams } from 'react-router-dom'; +import './ProductsNav.scss'; +import { Context } from '../../context/Context'; +import { getProductDetails } from '../../api/Products'; + +export const ProductsNav: React.FC = () => { + const { selectedProduct, setSelectedProduct } = useContext(Context); + const location = useLocation(); + const { productId = '' } = useParams(); + const [isProduct, setIsProduct] = useState(false); + + const pageLink = location.pathname.split('/').slice(1)[0]; + const pageTitle = pageLink.charAt(0).toUpperCase() + pageLink.slice(1); + + const productTitle = useMemo(() => { + if (!selectedProduct && productId) { + getProductDetails(productId) + .then((productItem) => { + setSelectedProduct(productItem); + }); + } + + return selectedProduct?.name; + }, [selectedProduct, productId]); + + useEffect(() => { + if (productId.length > 0) { + setIsProduct(true); + } else { + setIsProduct(false); + } + }, []); + + return ( + <> +
    + + + {isProduct ? ( + <> + + {pageTitle} + + +

    + {productTitle} +

    + + ) : ( + + {pageTitle} + + )} +
    + + ); +}; diff --git a/src/components/ProductsSlider/ProductsSlider.scss b/src/components/ProductsSlider/ProductsSlider.scss new file mode 100644 index 0000000000..c0998c3bb2 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.scss @@ -0,0 +1,60 @@ +@import "../../main.scss"; + +.products-slider { + &__container { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 33px; + } + + &__header { + @extend %h1-text; + } + + &__buttons { + display: flex; + gap: 16px; + } + + &__button { + @include squareSize(32px); + background-color: $color-white; + border: 1px solid $color-icons; + cursor: pointer; + + &:hover { + border-color: $color-primary; + } + + &--disabled { + border: 1px solid $color-elements; + pointer-events: none; + opacity: 0.5; + } + } + + &__products { + display: flex; + justify-content: space-between; + margin-bottom: 72px; + gap: 16px; + } + + &__icon { + display: block; + margin: 0 auto; + background-position: center; + background-repeat: no-repeat; + @include squareSize(16px); + cursor: pointer; + &--right { + background-image: url(../../images/ArrowRight.svg); + } + + &--left { + background-image: url(../../images/ArrowLeft.svg); + } + + } +} diff --git a/src/components/ProductsSlider/ProductsSlider.tsx b/src/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 0000000000..db877dac25 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,59 @@ +import { memo, useState } from 'react'; +import classNames from 'classnames'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; +import './ProductsSlider.scss'; + +type Props = { + title: string; + products: Product[]; +}; + +export const ProductsSlider: React.FC = memo(({ title, products }) => { + const [start, setStart] = useState(0); + const end = start + 4; + + const disablePrevButton = start <= 0; + const disableNextButton = end > products.length - 1; + + return ( +
    +
    +

    {title}

    +
    + + +
    +
    +
    + {products.slice(start, end).map((product: Product) => ( + + ))} +
    +
    + ); +}); diff --git a/src/components/SearchBar/SearchBar.scss b/src/components/SearchBar/SearchBar.scss new file mode 100644 index 0000000000..7a2e3aacc4 --- /dev/null +++ b/src/components/SearchBar/SearchBar.scss @@ -0,0 +1,42 @@ +@import "../../main.scss"; + +.searchbar { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 60px; + + padding: 15px 24px; + border-left: 1px solid $color-elements; + + &__input { + width: 90%; + + @extend %buttons-text; + line-height: 18px; + color: $color-icons; + border: none; + outline: none; + + &::placeholder { + cursor: text; + color: $color-icons; + } + } + + &__icon { + display: block; + background-position: center; + background-repeat: no-repeat; + @include squareSize(16px); + padding: 8px; + + &--search { + background-image: url(../../images/Search.svg); + } + + &--close { + background-image: url(../../images/Close.svg); + } + } +} diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000000..f914cc780c --- /dev/null +++ b/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,68 @@ +import { debounce } from 'lodash'; +import { useMemo, useState } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { getSearchWith } from '../../utils/searchHelper'; +import './SearchBar.scss'; + +export const SearchBar: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get('query') || ''; + const [inputValue, setInputValue] = useState(query); + const location = useLocation(); + const currentLocation = location.pathname.slice(1); + + useMemo(() => { + setInputValue(query); + }, [query]); + + const handleDebounceChange = debounce( + (event: React.ChangeEvent) => { + setSearchParams( + getSearchWith(searchParams, + { query: event.target.value.trim() || null }), + ); + }, 1000, + ); + + const handleInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + handleDebounceChange(event); + }; + + const handleDeleteChange = () => { + setInputValue(''); + setSearchParams(getSearchWith(searchParams, { query: null })); + }; + + return ( +
    + + + {!inputValue && ( + + )} + + { inputValue.length > 0 && ( +
    handleDeleteChange()} + onClick={() => handleDeleteChange()} + > + +
    + )} +
    + ); +}; diff --git a/src/components/ShopByCategory/ShopByCategory.scss b/src/components/ShopByCategory/ShopByCategory.scss new file mode 100644 index 0000000000..5bfd2e6cdb --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.scss @@ -0,0 +1,49 @@ +@import "../../main.scss"; + +.categories { + margin-bottom: 80px; + + &__title { + @extend %h1-text; + margin-bottom: 24px; + } + + &__container { + display: flex; + justify-content: space-between; + } + + &__image { + @include squareSize(368px); + margin-bottom: 14px; + object-fit: scale-down; + background-position: bottom right; + } + + &__item { + text-decoration: none; + } + + &__name { + @extend %h3-text; + margin-bottom: 4px; + color: $color-primary; + } + + &__count { + color: $color-secondary; + } + + &__item-container { + transition: all 0.5s; + cursor: pointer; + + &:hover { + transform: scale(0.95); + } + + &:hover .categories__image { + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.26); + } + } +} diff --git a/src/components/ShopByCategory/ShopByCategory.tsx b/src/components/ShopByCategory/ShopByCategory.tsx new file mode 100644 index 0000000000..c79a663f5e --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.tsx @@ -0,0 +1,74 @@ +import { useContext, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import CategoriesList from '../../api/CategoriesList.json'; +import { Context } from '../../context/Context'; +import './ShopByCategory.scss'; + +export const ShopByCategory: React.FC = () => { + const { products } = useContext(Context); + + const phones = useMemo(() => ( + products.filter(product => product.type === 'phone') + ), [products]); + + const tablets = useMemo(() => ( + products.filter(product => product.type === 'tablet') + ), [products]); + + const accessories = useMemo(() => ( + products.filter(product => product.type === 'accessory') + ), [products]); + + const categoryCount = (type: string) => { + switch (type) { + case 'phones': + return phones.length; + + case 'tablets': + return tablets.length; + + case 'accessory': + return accessories.length; + + default: + return 0; + } + }; + + return ( +
    +

    + Shop by category +

    +
    + {CategoriesList.map(type => ( +
    + + {type.title} + +

    + {type.title} +

    + +

    + {`${categoryCount(type.type)} models`} +

    + +
    + ))} +
    +
    + ); +}; diff --git a/src/context/Context.tsx b/src/context/Context.tsx new file mode 100644 index 0000000000..1a40f5de58 --- /dev/null +++ b/src/context/Context.tsx @@ -0,0 +1,111 @@ +import React, { + Dispatch, SetStateAction, useEffect, useState, +} from 'react'; +import { Product } from '../types/Product'; +import { ProductDetails } from '../types/ProductDetails'; +import { Error } from '../types/ErrorType'; +import { getProducts } from '../api/Products'; +import { useLocalStorage } from '../utils/useLocaleStorage'; +import { getProductToSave } from '../utils/getProductToSave'; + +type ContextValue = { + products: Product[], + selectedProduct: ProductDetails, + cart: Product[], + favorite: Product[], + isError: Error | null, + isLoading: boolean; + setProducts: Dispatch>, + setSelectedProduct: Dispatch>, + setCart: Dispatch>, + setFavorite: Dispatch>, + setIsError: Dispatch>, + setIsLoading: Dispatch>, + changeCart: (newProduct: Product) => void, + changeFavorite: (newProduct: Product) => void, +}; + +export const Context = React.createContext({ + products: [], + selectedProduct: JSON.parse(`${localStorage.getItem('selectedProduct')}`), + cart: [], + favorite: [], + isError: null, + isLoading: false, + setProducts: () => {}, + setSelectedProduct: () => {}, + setCart: () => {}, + setFavorite: () => {}, + setIsError: () => {}, + setIsLoading: () => {}, + changeCart: () => {}, + changeFavorite: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const ContextProvider: React.FC = ({ children }) => { + const selectedProductStorage = JSON.parse(`${localStorage.getItem('selectedProduct')}`); + + const [products, setProducts] = useState([]); + const [ + selectedProduct, setSelectedProduct, + ] = useState(selectedProductStorage); + const [cart, setCart] = useLocalStorage('cart', []); + const [favorite, setFavorite] = useLocalStorage('favorite', []); + const [isError, setIsError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const changeCart = getProductToSave( + 'cart', + cart, + setCart, + ); + const changeFavorite = getProductToSave( + 'favorite', + favorite, + setFavorite, + ); + + const contextValue: ContextValue = { + products, + selectedProduct, + cart, + favorite, + isError, + isLoading, + setProducts, + setSelectedProduct, + setCart, + setFavorite, + setIsError, + setIsLoading, + changeCart, + changeFavorite, + }; + + useEffect(() => { + const loadProducts = async () => { + try { + setIsLoading(true); + const loadedProducts = await getProducts(); + + setProducts(loadedProducts); + } catch (error) { + setIsError(Error.GET_PRODUCTS); + } finally { + setIsLoading(false); + } + }; + + loadProducts(); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/fonts/FontsFree-Net-Mont-Bold.ttf b/src/fonts/FontsFree-Net-Mont-Bold.ttf new file mode 100644 index 0000000000..d359b09ba6 Binary files /dev/null and b/src/fonts/FontsFree-Net-Mont-Bold.ttf differ diff --git a/src/fonts/FontsFree-regular.ttf b/src/fonts/FontsFree-regular.ttf new file mode 100644 index 0000000000..27a0f98c04 Binary files /dev/null and b/src/fonts/FontsFree-regular.ttf differ diff --git a/src/fonts/FontsFree-semibold.ttf b/src/fonts/FontsFree-semibold.ttf new file mode 100644 index 0000000000..f3d6cef520 Binary files /dev/null and b/src/fonts/FontsFree-semibold.ttf differ diff --git a/src/fonts/_fonts.scss b/src/fonts/_fonts.scss new file mode 100644 index 0000000000..9eb66e0626 --- /dev/null +++ b/src/fonts/_fonts.scss @@ -0,0 +1,17 @@ +@font-face { + font-family: Mont; + font-weight: 500; + src: url(./FontsFree-regular.ttf); +} + +@font-face { + font-family: Mont; + font-weight: 600; + src: url(./FontsFree-semibold.ttf); +} + +@font-face { + font-family: Mont; + font-weight: 700; + src: url(./FontsFree-Net-Mont-Bold.ttf); +} diff --git a/src/images/ArrowDown.svg b/src/images/ArrowDown.svg new file mode 100644 index 0000000000..e51b6915b6 --- /dev/null +++ b/src/images/ArrowDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/ArrowLeft.svg b/src/images/ArrowLeft.svg new file mode 100644 index 0000000000..5e3a0a6280 --- /dev/null +++ b/src/images/ArrowLeft.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/ArrowRight.svg b/src/images/ArrowRight.svg new file mode 100644 index 0000000000..d996658665 --- /dev/null +++ b/src/images/ArrowRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/ArrowUp.svg b/src/images/ArrowUp.svg new file mode 100644 index 0000000000..46cc1e3b34 --- /dev/null +++ b/src/images/ArrowUp.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/Cart.svg b/src/images/Cart.svg new file mode 100644 index 0000000000..6030970f2e --- /dev/null +++ b/src/images/Cart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/images/Close.svg b/src/images/Close.svg new file mode 100644 index 0000000000..78d418ab46 --- /dev/null +++ b/src/images/Close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/Favorite.svg b/src/images/Favorite.svg new file mode 100644 index 0000000000..802e30c79e --- /dev/null +++ b/src/images/Favorite.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/FavouritesFilledHeartLike.svg b/src/images/FavouritesFilledHeartLike.svg new file mode 100644 index 0000000000..7138d7522b --- /dev/null +++ b/src/images/FavouritesFilledHeartLike.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/FavouritesHeartLike.svg b/src/images/FavouritesHeartLike.svg new file mode 100644 index 0000000000..ec808b48e6 --- /dev/null +++ b/src/images/FavouritesHeartLike.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/Home.svg b/src/images/Home.svg new file mode 100644 index 0000000000..474476cb02 --- /dev/null +++ b/src/images/Home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/images/Minus.svg b/src/images/Minus.svg new file mode 100644 index 0000000000..97c41038ac --- /dev/null +++ b/src/images/Minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/Plus.svg b/src/images/Plus.svg new file mode 100644 index 0000000000..ab3c34061b --- /dev/null +++ b/src/images/Plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/images/Search.svg b/src/images/Search.svg new file mode 100644 index 0000000000..3dc966918a --- /dev/null +++ b/src/images/Search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/index.tsx b/src/index.tsx index 5f7410fd92..5f33fab9f7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,54 @@ import ReactDOM from 'react-dom'; +import { + HashRouter as Router, + Navigate, + Route, + Routes, +} from 'react-router-dom'; import App from './App'; +import { PageNotFound } from './pages/PageNotFound/PageNotFound'; +import { HomePage } from './pages/HomePage/HomePage'; +import { PhonesPage } from './pages/PhonesPage/PhonesPage'; +import { + ProductDetailsPage, +} from './pages/ProductDetailsPage/ProductDetailsPage'; +import { TabletsPage } from './pages/TabletsPage/TabletsPage'; +import { AccessoriesPage } from './pages/AccessoriesPage/AccessoriesPage'; +import { FavoritesPage } from './pages/FavoritesPage/FavoritesPage'; +import { CartPage } from './pages/CartPage/CartPage'; +import { ContextProvider } from './context/Context'; ReactDOM.render( - , + + + + }> + } /> + + } /> + } /> + + + } /> + } /> + + + + } /> + } /> + + + + } /> + } /> + + + } /> + } /> + + + + , document.getElementById('root'), ); diff --git a/src/main.scss b/src/main.scss new file mode 100644 index 0000000000..c405e1b5fa --- /dev/null +++ b/src/main.scss @@ -0,0 +1,4 @@ +@import "./utils/variables"; +@import "./utils/mixins"; +@import "./utils/extends"; +@import "./fonts/fonts"; diff --git a/src/pages/AccessoriesPage/AccessoriesPage.tsx b/src/pages/AccessoriesPage/AccessoriesPage.tsx new file mode 100644 index 0000000000..e76cacf44c --- /dev/null +++ b/src/pages/AccessoriesPage/AccessoriesPage.tsx @@ -0,0 +1,52 @@ +import { useContext, useEffect, useState } from 'react'; +import { Product } from '../../types/Product'; +import { Context } from '../../context/Context'; +import { getProducts } from '../../api/Products'; +import { Error } from '../../types/ErrorType'; +import { Loader } from '../../components/Loader/Loader'; +import { ProductsNav } from '../../components/ProductsNav/ProductsNav'; +import { ProductsList } from '../../components/ProductsList/ProductsList'; + +export const AccessoriesPage: React.FC = () => { + const [accessories, setAccessories] = useState([]); + const { isLoading, setIsLoading, setIsError } = useContext(Context); + + const getAccessories = (products: Product[], type: string) => { + const accessoriesFromApi = products.filter( + product => product.type === type, + ); + + setAccessories(accessoriesFromApi); + }; + + useEffect(() => { + const loadPhones = async () => { + try { + setIsLoading(true); + const products = await getProducts(); + + getAccessories(products, 'accessory'); + } catch (error) { + setIsError(Error.GET_PRODUCTS); + } finally { + setIsLoading(false); + } + }; + + loadPhones(); + }, []); + + return ( +
    + {isLoading ? ( + + ) : ( + <> + + + + + )} +
    + ); +}; diff --git a/src/pages/CartPage/CartPage.scss b/src/pages/CartPage/CartPage.scss new file mode 100644 index 0000000000..41d87f81f2 --- /dev/null +++ b/src/pages/CartPage/CartPage.scss @@ -0,0 +1,102 @@ +@import "../../main.scss"; + +.cart { + margin-bottom: 80px; + + &__navigate { + margin: 40px 0 16px; + } + + &__empty { + display: flex; + justify-content: center; + align-items: center; + @extend %h2-text; + color: $color-secondary; + margin-bottom: 40px; + } + + &__title { + margin-bottom: 24px; + } + + &__content { + display: grid; + grid-template-columns: 1fr 368px; + gap: 16px; + } + + &__products { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__total-box { + align-self: start; + padding: 24px; + border: 1px solid $color-elements; + position: relative; + } + + &__info { + display: flex; + flex-direction: column; + align-items: center; + border-bottom: 1px solid $color-elements; + margin-bottom: 24px; + } + + &__total-price { + @extend %h2-text; + color: $color-primary; + } + + &__total-count { + @extend %buttons-text; + color: $color-secondary; + margin-bottom: 24px; + } +} + +.modal { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-around; + border: 1px solid $color-elements; + background-color: $color-red; + + &__content { + padding: 10px 24px; + } + + &__title { + display: flex; + flex-direction: column; + text-align: center; + color: $color-white; + margin-bottom: 10px; + } +} + +.button { + width: 100%; + height: 48px; + + @extend %buttons-text; + text-align: center; + color: $color-white; + background-color: $color-primary; + border: none; + transition: all 0.5s; + cursor: pointer; + + &:hover { + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.26); + } +} diff --git a/src/pages/CartPage/CartPage.tsx b/src/pages/CartPage/CartPage.tsx new file mode 100644 index 0000000000..a55fae3560 --- /dev/null +++ b/src/pages/CartPage/CartPage.tsx @@ -0,0 +1,124 @@ +import { useContext, useState } from 'react'; +import { Context } from '../../context/Context'; +import { Loader } from '../../components/Loader/Loader'; +import { BackButton } from '../../components/BackButton/BackButton'; +import './CartPage.scss'; +import { CartItem } from '../../components/CartItem/CartItem'; + +export const CartPage: React.FC = () => { + const { isLoading, cart } = useContext(Context); + const [isOpenModal, setIsOpenModal] = useState(false); + /* const [cartList, setCartList] = useState([]); + + function getUniqueItems(arr: Product[], getId: (item: Product) => string) { + const visibleItems: Record = {}; + + return arr.filter((item) => { + const key = getId(item); + + if (visibleItems[key]) { + return false; + } + + visibleItems[key] = true; + + return true; + }); + } + + const cartItems = () => { + const list = getUniqueItems(cart, (item: Product) => item.id); + + setCartList(list); + }; + + useEffect(() => { + cartItems(); + }, [cart.length]); */ + + /* const totalPrice = () => { + const currentItems = JSON.parse(localStorage.getItem('cart') || '[]'); + + return currentItems.reduce( + (sum: number, current: Product) => sum + current.price, 0, + ); + }; */ + + const totalPrice = cart.length === 0 ? 0 : cart.map((item) => { + const priceAfterDiscount = item.price * ((100 - item.discount) / 100); + const quantity = item.quantity || 1; + + return priceAfterDiscount * quantity; + }).reduce((a, b) => a + b, 0); + + let totalItems = 0; + + cart.forEach(item => { + const quantity = item.quantity || 1; + + totalItems += quantity; + }); + + return ( + <> + {isLoading + ? + : ( +
    +
    + +
    + +

    Cart

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

    Your cart is empty

    + ) : ( + <> +
    +
    + {cart.map((product) => ( + + ))} +
    +
    +
    +

    {`$${totalPrice}`}

    +

    {`Total for ${totalItems} items`}

    +
    + + + + {isOpenModal && ( +
    +
    +

    + We are sorry, + but this feature is not implemented yet +

    + +
    +
    + )} +
    +
    + + )} +
    + )} + + ); +}; diff --git a/src/pages/FavoritesPage/FavoritesPage.scss b/src/pages/FavoritesPage/FavoritesPage.scss new file mode 100644 index 0000000000..5189c252ca --- /dev/null +++ b/src/pages/FavoritesPage/FavoritesPage.scss @@ -0,0 +1,33 @@ +@import "../../main.scss"; + +.favourites { + margin-bottom: 80px; + &__list { + display: grid; + grid-template-columns: repeat(4, 1fr); + row-gap: 40px; + column-gap: 16px; + } + + &__top { + margin: 25px 0 40px; + } + + &__title { + margin-bottom: 8px; + } + + &__empty-title { + display: flex; + justify-content: center; + align-items: center; + @extend %h2-text; + color: $color-secondary; + margin-bottom: 40px; + } + + &__count { + margin-bottom: 40px; + color: $color-secondary; + } +} diff --git a/src/pages/FavoritesPage/FavoritesPage.tsx b/src/pages/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 0000000000..11f3f4df9a --- /dev/null +++ b/src/pages/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,32 @@ +import { useContext } from 'react'; +import { Context } from '../../context/Context'; +import { ProductsNav } from '../../components/ProductsNav/ProductsNav'; +import { ProductCard } from '../../components/ProductCard/ProductCard'; +import './FavoritesPage.scss'; + +export const FavoritesPage: React.FC = () => { + const { favorite } = useContext(Context); + + const favoriteCount = favorite.length; + + return ( +
    +
    + +
    +

    Favourites

    +

    {`${favoriteCount} items`}

    + {favoriteCount === 0 ? ( +

    + There no favorite products found +

    + ) : ( +
    + {favorite.map((product) => ( + + ))} +
    + )} +
    + ); +}; diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx new file mode 100644 index 0000000000..d0a8e16c51 --- /dev/null +++ b/src/pages/HomePage/HomePage.tsx @@ -0,0 +1,43 @@ +import { useContext } from 'react'; +import { BannerSlider } from '../../components/BannerSlider/BannerSlider'; +import { ProductsSlider } from '../../components/ProductsSlider/ProductsSlider'; +import { Product } from '../../types/Product'; +import { Context } from '../../context/Context'; +import { ShopByCategory } from '../../components/ShopByCategory/ShopByCategory'; + +export const HomePage: React.FC = () => { + const { products } = useContext(Context); + + const getHotPriceProducts = (items: Product[]) => { + return items + .filter(item => item.discount > 0) + .sort((product1, product2) => ( + (product2.price * ((100 - product2.discount) / 100)) + - (product1.price * ((100 - product1.discount) / 100)) + )); + }; + + const getBrandNewProducts = (items: Product[]) => { + return items + .filter(item => item.discount === 0) + .sort((product1, product2) => product2.price - product1.price); + }; + + return ( + <> + + + + + + + + + ); +}; diff --git a/src/pages/PageNotFound/PageNotFound.scss b/src/pages/PageNotFound/PageNotFound.scss new file mode 100644 index 0000000000..c26b301b3c --- /dev/null +++ b/src/pages/PageNotFound/PageNotFound.scss @@ -0,0 +1,10 @@ +@import "../../main.scss"; + +.not-found { + margin-bottom: 40px; + + &__title { + @extend %h1-text; + color: $color-primary; + } +} diff --git a/src/pages/PageNotFound/PageNotFound.tsx b/src/pages/PageNotFound/PageNotFound.tsx new file mode 100644 index 0000000000..358db75c3b --- /dev/null +++ b/src/pages/PageNotFound/PageNotFound.tsx @@ -0,0 +1,12 @@ +import { BackButton } from '../../components/BackButton/BackButton'; +import './PageNotFound.scss'; + +export const PageNotFound: React.FC = () => ( +
    + + +

    + Page not found! +

    +
    +); diff --git a/src/pages/PhonesPage/PhonesPage.tsx b/src/pages/PhonesPage/PhonesPage.tsx new file mode 100644 index 0000000000..fd2da5b581 --- /dev/null +++ b/src/pages/PhonesPage/PhonesPage.tsx @@ -0,0 +1,52 @@ +import { useContext, useEffect, useState } from 'react'; +import { Context } from '../../context/Context'; +import { Loader } from '../../components/Loader/Loader'; +import { ProductsNav } from '../../components/ProductsNav/ProductsNav'; +import { ProductsList } from '../../components/ProductsList/ProductsList'; +import { Product } from '../../types/Product'; +import { getProducts } from '../../api/Products'; +import { Error } from '../../types/ErrorType'; + +export const PhonesPage: React.FC = () => { + const [phones, setPhones] = useState([]); + const { isLoading, setIsLoading, setIsError } = useContext(Context); + + const getPhones = (products: Product[], type: string) => { + const phonesFromApi = products.filter( + product => product.type === type, + ); + + setPhones(phonesFromApi); + }; + + useEffect(() => { + const loadPhones = async () => { + try { + setIsLoading(true); + const products = await getProducts(); + + getPhones(products, 'phone'); + } catch (error) { + setIsError(Error.GET_PRODUCTS); + } finally { + setIsLoading(false); + } + }; + + loadPhones(); + }, []); + + return ( +
    + {isLoading ? ( + + ) : ( + <> + + + + + )} +
    + ); +}; diff --git a/src/pages/ProductDetailsPage/ProductDetailsPage.scss b/src/pages/ProductDetailsPage/ProductDetailsPage.scss new file mode 100644 index 0000000000..d2ea814826 --- /dev/null +++ b/src/pages/ProductDetailsPage/ProductDetailsPage.scss @@ -0,0 +1,259 @@ +@import "../../main.scss"; + +.product-details { + margin-bottom: 80px; + + &__title { + @extend %h1-text; + margin-bottom: 40px; + color: $color-primary; + } + + &__content { + margin-bottom: 80px; + } + + &__top { + display: grid; + grid-template-columns: repeat(24, 1fr); + column-gap: 16px; + margin-bottom: 80px; + } + + &__slider { + grid-column: 1 / 13; + } + + &__action { + grid-column: 14 / 21; + } + + &__colors { + margin-bottom: 48px; + } + + &__small-text { + @extend %small-text; + color: $color-secondary; + margin-bottom: 8px; + } + + &__select-buttons { + display: flex; + gap: 8px; + } + + &__select { + border-bottom: 1px solid $color-elements; + padding-bottom: 24px; + &:first-child { + margin-bottom: 24px; + } + + margin-bottom: 32px; + } + + &__prices { + border-bottom: 1px solid $color-elements; + padding-bottom: 4px; + margin-bottom: 16px; + } + + &__price { + @extend %h2-text; + color: $color-primary; + margin-right: 8px; + } + + &__discount { + @extend %h2-text; + color: $color-secondary; + text-decoration: line-through; + } + + &__actions-button { + display: flex; + gap: 8px; + margin-bottom: 32px; + } + + &__features { + display: flex; + justify-content: space-between; + text-transform: capitalize; + width: 100%; + } + + &__list { + @extend %small-text; + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + } + + &__key { + color: $color-secondary; + } + + &__value { + color: $color-primary; + text-align: right; + } + + &__botom { + display: grid; + grid-template-columns: repeat(24, 1fr); + column-gap: 16px; + } + + &__subtitle { + @extend %h2-text; + padding-bottom: 16px; + border-bottom: 1px solid $color-elements; + margin-bottom: 32px; + } + + &__text { + display: flex; + flex-direction: column; + gap: 32px; + } + + &__info-title { + @extend %h3-text; + padding-bottom: 16px; + color: $color-primary; + } + + &__info-text { + color: $color-secondary; + } + + &__description { + grid-column: 1 / 13; + } + + &__tech-specs { + grid-column: 14 / 25; + } +} + +.slider { + display: flex; + width: 100%; + height: 464px; + gap: 16px; + + &__col { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__button { + border: 1px solid $color-icons; + height: 80px; + width: 80px; + background-size: contain; + transition: all 0.3s; + + &:hover { + border: 1px solid $color-primary; + } + + &--active { + border: 1px solid $color-primary; + } + } + + &__main { + display: flex; + justify-content: center; + align-items: center; + flex: 1 1 auto; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + transition: 0.2s ease; + } +} + +.colors { + list-style: none; + + &__item-circle { + width: 32px; + height: 32px; + border: 1px solid $color-elements; + border-radius: 36px; + cursor: pointer; + transition: all 0.3s; + + &--is-active { + border-color: $color-primary; + } + + &:hover { + border-color: $color-primary; + } + } + + &__item { + width: 28px; + height: 28px; + border: 2px solid $color-white; + border-radius: 36px; + + &--pink { + background-color: #fcdbc1; + } + + &--grey { + background-color: #5f7170; + } + + &--black { + background-color: #4c4c4c; + } + + &--white { + background-color: #f0f0f0; + } + + } +} + +.capacity { + &__list { + display: flex; + gap: 8px; + margin-bottom: 32px; + padding-bottom: 24px; + + border-bottom: 1px solid $color-elements; + list-style-type: none; + } + + &__item { + padding: 8px; + font-weight: 500; + font-size: 14px; + line-height: 21px; + color: $color-primary; + background-color: $color-white; + border: 1px solid $color-icons; + + transition: all 0.3s; + cursor: pointer; + + &:hover { + border-color: $color-primary; + } + + &--is-active { + color: $color-white; + background-color: $color-primary; + } + } +} diff --git a/src/pages/ProductDetailsPage/ProductDetailsPage.tsx b/src/pages/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 0000000000..bf82dbf6e2 --- /dev/null +++ b/src/pages/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,293 @@ +import { + useContext, useEffect, useMemo, useState, +} from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import classNames from 'classnames'; +import { Loader } from '../../components/Loader/Loader'; +import { ProductsNav } from '../../components/ProductsNav/ProductsNav'; +import { Context } from '../../context/Context'; +import { ProductsSlider } from '../../components/ProductsSlider/ProductsSlider'; +import CategoriesList from '../../api/CategoriesList.json'; +import { Product } from '../../types/Product'; +import { BackButton } from '../../components/BackButton/BackButton'; +import { + AddToCartButton, +} from '../../components/AddToCartButton/AddToCartButton'; +import { LikeButton } from '../../components/LikeButton/LikeButton'; +import { getProductDetails } from '../../api/Products'; +import { Error } from '../../types/ErrorType'; +import './ProductDetailsPage.scss'; + +const colorsList = ['pink', 'grey', 'black', 'white']; +const capacityList = [16, 256, 512]; + +export const ProductDetailsPage: React.FC = () => { + const { + isLoading, products, setIsError, setIsLoading, + selectedProduct, setSelectedProduct, + } = useContext(Context); + const { productId = '' } = useParams(); + const location = useLocation(); + // const [productInfo, setProductInfo] = useState(); + const [selectedImage, setSelectedImage] = useState( + selectedProduct?.images[0], + ); + const [selectedColor, setSelectedColor] = useState(colorsList[0]); + const [selectedCapacity, setSelectedCapacity] = useState(capacityList[0]); + + const priceWithDiscount = (product: Product) => { + return product.price - (product.discount * product.price) / 100; + }; + + const product = products.find( + item => item.id === selectedProduct?.id, + ); + + const newPrice = product ? priceWithDiscount(product) : 0; + + const featuresShort = { + keys: ['screen', 'resolution', 'processor', 'RAM'], + values: [ + `${selectedProduct?.display.screenSize}`, + `${selectedProduct?.display.screenResolution}`, + `${selectedProduct?.hardware.cpu}`, + `${selectedProduct?.storage.ram}`], + }; + + const featuresLong = { + keys: ['screen', 'resolution', 'processor', 'RAM', 'built in memory', + 'android', 'bluetooth', 'battery'], + values: [ + `${selectedProduct?.display.screenSize}`, + `${selectedProduct?.display.screenResolution}`, + `${selectedProduct?.hardware.cpu}`, + `${selectedProduct?.storage.ram}`, + `${selectedProduct?.storage.flash}`, + `${selectedProduct?.android.os}`, + `${selectedProduct?.connectivity.bluetooth}`, + `${selectedProduct?.battery.standbyTime}`, + ], + }; + + const currentCategory = location.pathname.split('/').slice(1)[0]; + + const productCategory = useMemo(() => { + const list = CategoriesList.filter( + item => item.type === currentCategory, + )[0]; + + return list.itemType; + }, [location]); + + const getRecommendations = ( + items: Product[], category: string, currentId: string, + ) => { + return items + .filter(item => (item.type === category) + && (currentId !== item.id)) + .sort((product1, product2) => ( + product2.price - product1.price + )); + }; + + const getProductInfo = async (id: string) => { + setIsError(null); + + try { + const item = await getProductDetails(id); + + setSelectedProduct(item); + } catch { + setIsError(Error.GET_PRODUCT_DETAILS); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (!selectedProduct && productId.length > 0) { + getProductInfo(productId); + } + }, [productId]); + + return ( +
    + {isLoading ? ( + + ) : ( +
    + + +
    {selectedProduct?.name}
    + +
    +
    +
    +
    + {selectedProduct?.images.map((image) => ( +
    +
    +
    + +
    +
    +

    + Available colors +

    +
      + {colorsList.map((el, index) => ( +
      setSelectedColor(el)} + onKeyDown={() => setSelectedColor(el)} + > +
    • +
    • + ))} +
    +
    + +
    +

    + Select capacity +

    +
    + {capacityList.map(el => ( + + ))} +
    +
    + +
    + {`$${product?.price}`} + {product?.discount !== 0 + && ( + + {`$${newPrice}`} + + + )} +
    + + {product + && ( +
    + + +
    + )} + +
    +
      + {featuresShort.keys.map((key) => ( +
    • + {key} +
    • + ))} +
    + +
      + {featuresShort.values.map((value) => ( +
    • + {value} +
    • + ))} +
    +
    +
    +
    + +
    + {selectedProduct && ( +
    +

    About

    + +
    + {selectedProduct.description} +
    +
    + )} + +
    +

    Tech specs

    +
    +
      + {featuresLong.keys.map((key) => ( +
    • + {key} +
    • + ))} +
    + +
      + {featuresLong.values.map((value) => ( +
    • + {value} +
    • + ))} +
    +
    +
    +
    +
    + + +
    + )} +
    + ); +}; diff --git a/src/pages/TabletsPage/TabletsPage.tsx b/src/pages/TabletsPage/TabletsPage.tsx new file mode 100644 index 0000000000..6080356350 --- /dev/null +++ b/src/pages/TabletsPage/TabletsPage.tsx @@ -0,0 +1,52 @@ +import { useContext, useEffect, useState } from 'react'; +import { Context } from '../../context/Context'; +import { Loader } from '../../components/Loader/Loader'; +import { ProductsNav } from '../../components/ProductsNav/ProductsNav'; +import { ProductsList } from '../../components/ProductsList/ProductsList'; +import { Product } from '../../types/Product'; +import { getProducts } from '../../api/Products'; +import { Error } from '../../types/ErrorType'; + +export const TabletsPage: React.FC = () => { + const [tablets, setTablets] = useState([]); + const { isLoading, setIsLoading, setIsError } = useContext(Context); + + const getTablets = (products: Product[], type: string) => { + const tabletsFromApi = products.filter( + product => product.type === type, + ); + + setTablets(tabletsFromApi); + }; + + useEffect(() => { + const loadPhones = async () => { + try { + setIsLoading(true); + const products = await getProducts(); + + getTablets(products, 'tablet'); + } catch (error) { + setIsError(Error.GET_PRODUCTS); + } finally { + setIsLoading(false); + } + }; + + loadPhones(); + }, []); + + return ( +
    + {isLoading ? ( + + ) : ( + <> + + + + + )} +
    + ); +}; diff --git a/src/types/ButtonType.ts b/src/types/ButtonType.ts new file mode 100644 index 0000000000..064c1e4137 --- /dev/null +++ b/src/types/ButtonType.ts @@ -0,0 +1 @@ +export type ButtonType = 'big' | 'small'; diff --git a/src/types/ErrorType.ts b/src/types/ErrorType.ts new file mode 100644 index 0000000000..eb9728510e --- /dev/null +++ b/src/types/ErrorType.ts @@ -0,0 +1,5 @@ +export enum Error { + PAGE_NOT_FOUND = 'Page not found', + GET_PRODUCTS = 'Unable to get the products', + GET_PRODUCT_DETAILS = 'Unable to get the product\'s details', +} diff --git a/src/types/IconType.ts b/src/types/IconType.ts new file mode 100644 index 0000000000..df08d2ea00 --- /dev/null +++ b/src/types/IconType.ts @@ -0,0 +1,22 @@ +export enum IconType { + ARROW_DOWN = 'icon__arrow-down', + ARROW_DOWN_DISABLED = 'icon__arrow-down--disabled', + ARROW_LEFT = 'icon__arrow-left', + ARROW_LEFT_DISABLED = 'icon__arrow-left--disabled', + ARROW_RIGHT = 'icon__arrow-right', + ARROW_RIGHT_DISABLED = 'icon__arrow-right--disabled', + ARROW_UP = 'icon__arrow-up', + ARROW_UP_DISABLED = 'icon__arrow-up--disabled', + CART = 'icon__cart', + CLOSE = 'icon__close', + CLOSE_DISABLED = 'icon__close--disabled', + CROSS = 'icon__cross', + FAVORITE = 'icon__favorite', + FAVORITE_FILLED = 'icon__favorite--active', + HOME = 'icon__home', + MENU_BURGER = 'icon__menu-burger', + MINUS = 'icon__minus', + MINUS_DISABLED = 'icon__minus--disabled', + PLUS = 'icon__plus', + SEARCH = 'icon__search', +} diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 0000000000..6659cc75a9 --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,14 @@ +export interface Product { + age: number, + id: string, + type: string, + imageUrl: string, + name: string, + snippet: string, + price: number, + discount: number, + screen: string, + capacity: string, + ram: string, + quantity?: number, +} diff --git a/src/types/ProductDetails.ts b/src/types/ProductDetails.ts new file mode 100644 index 0000000000..7a133b202d --- /dev/null +++ b/src/types/ProductDetails.ts @@ -0,0 +1,49 @@ +export interface ProductDetails { + additionalFeatures: string, + android: { + os: string, + ui: string, + }, + availability: string[], + battery: { + standbyTime: string, + talkTime: string, + type: string + }, + camera: { + features: string[], + primary?: string + }, + connectivity: { + bluetooth: string, + cell: string, + gps: boolean, + infrared: boolean, + wifi: string + }, + description: string, + display: { + screenResolution: string, + screenSize: string, + touchScreen: boolean + }, + hardware: { + accelerometer: boolean, + audioJack: string, + cpu: string, + fmRadio: boolean, + physicalKeyboard: boolean, + usb: string + }, + id: string, + images: string[], + name: string, + sizeAndWeight: { + dimensions: string[], + weight: string + }, + storage: { + flash: string, + ram: string + } +} diff --git a/src/types/SortType.ts b/src/types/SortType.ts new file mode 100644 index 0000000000..8ca98468ef --- /dev/null +++ b/src/types/SortType.ts @@ -0,0 +1,8 @@ +export enum SortType { + Newest = 'newest', + Alphabetically = 'alphabetically', + Price = 'price', + Year = 'year', + MaxDiscount = 'maxDiscount', + Random = 'random', +} diff --git a/src/utils/_extends.scss b/src/utils/_extends.scss new file mode 100644 index 0000000000..7f9ad33918 --- /dev/null +++ b/src/utils/_extends.scss @@ -0,0 +1,48 @@ +%h1-text { + font-family: inherit; + font-weight: 700; + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; +} + +%h2-text { + font-family: inherit; + font-weight: 700; + font-size: 22px; + line-height: 31px; + letter-spacing: 0; +} + +%h3-text { + font-family: inherit; + font-weight: 600; + font-size: 20px; + line-height: 26px; + letter-spacing: 0; +} + +%uppercase-text { + font-family: inherit; + font-weight: 700; + font-size: 12px; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +%buttons-text { + font-family: inherit; + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0; +} + +%small-text { + font-family: inherit; + font-weight: 600; + font-size: 12px; + line-height: 15px; + letter-spacing: 0; +} diff --git a/src/utils/_mixins.scss b/src/utils/_mixins.scss new file mode 100644 index 0000000000..b9032dca38 --- /dev/null +++ b/src/utils/_mixins.scss @@ -0,0 +1,4 @@ +@mixin squareSize($size) { + width: $size; + height: $size; +} diff --git a/src/utils/_variables.scss b/src/utils/_variables.scss new file mode 100644 index 0000000000..8647d4386e --- /dev/null +++ b/src/utils/_variables.scss @@ -0,0 +1,8 @@ +$color-primary: #313237; +$color-secondary: #89939a; +$color-icons: #b4bdc3; +$color-elements: #e2e6e9; +$color-background: #fafbfc; +$color-white: #fff; +$color-green: #27ae60; +$color-red: #eb5757; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..fb828e82ff --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,41 @@ +const BASE_URL = 'https://mate-academy.github.io/react_phone-catalog/api'; + +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: unknown = null, +): Promise { + const options: RequestInit = { method }; + + if (data) { + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + return wait(300) + .then(() => fetch(BASE_URL + url, options)) + .then(response => { + if (!response.ok) { + throw new Error(); + } + + return response.json(); + }); +} + +export const client = { + get: (url: string) => request(url), + post: (url: string, data: unknown) => request(url, 'POST', data), + patch: (url: string, data: unknown) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +}; diff --git a/src/utils/getProductToSave.ts b/src/utils/getProductToSave.ts new file mode 100644 index 0000000000..93f0bcafcb --- /dev/null +++ b/src/utils/getProductToSave.ts @@ -0,0 +1,49 @@ +import { Product } from '../types/Product'; + +export const getProductToSave = ( + key: string, + savedProducts: Product[], + saveToState: React.Dispatch>, +) => { + return (newProduct: Product) => { + let newProducts: Product[]; + const copyOfProducts = [...savedProducts]; + const savedProduct = savedProducts.find( + item => item.id === newProduct.id, + ); + const savedProductIndex = savedProduct + ? savedProducts.indexOf(savedProduct) + : -1; + const isChangingQuantity = ( + savedProduct?.quantity !== newProduct.quantity + ) && newProduct?.quantity; + + if (isChangingQuantity && savedProductIndex >= 0) { + copyOfProducts[savedProductIndex].quantity = newProduct.quantity; + } + + if (savedProduct) { + newProducts = copyOfProducts.filter( + (product) => { + if (isChangingQuantity) { + return true; + } + + return product.id !== newProduct.id; + }, + ); + } else { + const useProduct = key === 'cart' + ? { ...newProduct, quantity: 1 } + : { ...newProduct }; + + newProducts = [ + ...savedProducts, + useProduct, + ]; + } + + saveToState(newProducts); + localStorage.setItem(key, JSON.stringify(newProducts)); + }; +}; diff --git a/src/utils/searchHelper.ts b/src/utils/searchHelper.ts new file mode 100644 index 0000000000..42d8db56d5 --- /dev/null +++ b/src/utils/searchHelper.ts @@ -0,0 +1,47 @@ +export type SearchParams = { + [key: string]: string | string[] | null, +}; + +/** + * This function prepares a correct search string + * from a given currentParams and paramsToUpdate. + */ +export function getSearchWith( + currentParams: URLSearchParams, + paramsToUpdate: SearchParams, // it's our custom type +): string { + // copy currentParams by creating new object from a string + const newParams = new URLSearchParams( + currentParams.toString(), + ); + + // Here is the example of paramsToUpdate + // { + // sex: 'm', ['sex', 'm'] + // order: null, ['order', null] + // centuries: ['16', '19'], ['centuries', ['16', '19']] + // } + // + // - params with the `null` value are deleted; + // - string value is set to given param key; + // - array of strings adds several params with the same key; + + Object.entries(paramsToUpdate) + .forEach(([key, value]) => { + if (value === null) { + newParams.delete(key); + } else if (Array.isArray(value)) { + // we delete the key to remove old values + newParams.delete(key); + + value.forEach(part => { + newParams.append(key, part); + }); + } else { + newParams.set(key, value); + } + }); + + // we return a string to use it inside links + return newParams.toString(); +} diff --git a/src/utils/useLocaleStorage.ts b/src/utils/useLocaleStorage.ts new file mode 100644 index 0000000000..4fd098576c --- /dev/null +++ b/src/utils/useLocaleStorage.ts @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Product } from '../types/Product'; + +export const useLocalStorage = (key: string, initialValue: Product[]) => { + const valueFromStorage = localStorage.getItem(key) || '[]'; + + const [value, setValue] = useState( + valueFromStorage ? JSON.parse(valueFromStorage) : initialValue, + ); + + return [value, setValue]; +}; + +/* export const useLocalStorage = (key: string, initialValue: Product[]) => { + const [value, setValue] = useState(() => { + try { + const data = localStorage.getItem(key); + + return data ? JSON.parse(data) : initialValue; + } catch { + return initialValue; + } + }); + + const save = (currentValue: Product) => { + setValue(currentValue); + localStorage.setItem(key, JSON.stringify(currentValue)); + }; + + return [value, save]; +}; */