diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..7bfc4d3c45b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,72 @@ import './App.scss'; +import { Routes, Route } from 'react-router-dom'; +import { Frame } from './components/Frame'; +import { HomePage } from './modules/HomePage'; +import { ProductsPage } from './modules/ProductsPage'; +import phones from '../public/api/phones.json'; +import tablets from '../public/api/tablets.json'; +import accessories from '../public/api/accessories.json'; +import products from '../public/api/products.json'; +import { ProductPage } from './modules/ProductPage'; +import { useContext } from 'react'; +import { AddToFavContext } from './contexts/AddToFavContext'; +import { NotFoundPage } from './modules/NotFoundPage'; +import { CartPage } from './modules/CartPage/CartPage'; -export const App = () => ( -
-

Product Catalog

-
-); +export const App = () => { + const phonesForCat = products.filter( + product => product.category === 'phones', + ); + const tabletsForCat = products.filter( + product => product.category === 'tablets', + ); + const accessoriesForCat = products.filter( + product => product.category === 'accessories', + ); + + const { fav } = useContext(AddToFavContext); + + return ( + + }> + } /> + + + } + /> + } /> + + + + } + /> + } /> + + + + } + /> + } + /> + + } + /> + } /> + } /> + + + ); +}; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..1d55559c3c2 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,106 @@ +@import '../../style/main'; + +.footer { + background-color: $header-footer-bg-color; + height: $footer-height-desktop-tablet; + box-shadow: 0 -1px 0 0 $shadow-color; + + @include on-mobile { + height: $footer-height-mobile; + } + + &__content { + @include page-grid; + + height: $footer-height-desktop-tablet; + align-content: center; + + @include on-mobile { + height: $footer-height-mobile; + row-gap: 32px; + } + } + + &__logo { + display: flex; + background-image: url(/img/header/logo.svg); + background-size: cover; + background-repeat: no-repeat; + + height: 32px; + width: 89px; + + &__container { + align-items: center; + } + } + + &__to-top { + font-family: inherit; + margin: 0; + padding: 0; + display: flex; + justify-self: end; + font-weight: 600; + font-size: 12px; + letter-spacing: 4%; + text-decoration: none; + color: $secondary-color; + width: 118px; + height: 32px; + line-height: 32px; + + background-image: url(/img/footer/slider-button-default.svg); + background-size: 32px; + background-position: right; + background-repeat: no-repeat; + + @include on-mobile { + justify-self: center; + } + } + + &__logo__container, + &__nav, + &__to-top { + border: none; + background-color: inherit; + cursor: pointer; + grid-column: span 8; + align-self: center; + + @include on-tablet { + grid-column: span 4; + } + } +} + +.nav { + &__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + justify-content: space-between; + + @include on-mobile { + flex-direction: column; + gap: 16px; + } + } + + &__item { + display: flex; + } + + &__link { + text-decoration: none; + color: $secondary-color; + font-weight: 700; + font-size: 12px; + line-height: 11px; + letter-spacing: 4%; + text-transform: uppercase; + justify-self: end; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..411f48a5120 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,67 @@ +import footer from './Footer.module.scss'; + +import { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import cn from 'classnames'; +import { ScrollToSectContext } from '../../contexts/ScrollToSectContext'; + +export const Footer = () => { + const { scrollToSect } = useContext(ScrollToSectContext); + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 00000000000..ddcc5a9cd18 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Frame/Frame.module.scss b/src/components/Frame/Frame.module.scss new file mode 100644 index 00000000000..19a6efb6ce9 --- /dev/null +++ b/src/components/Frame/Frame.module.scss @@ -0,0 +1,19 @@ +@import '../../style/main'; + +.main { + box-sizing: border-box; + margin: 0; + padding: 0; + min-height: calc(100vh - $header-height-desktop - $footer-height-desktop-tablet); + background-color: $main-bg-color; + + @include on-tablet { + min-height: calc( + 100vh - $header-height-tablet-mobile - $footer-height-desktop-tablet + ); + } + + @include on-mobile { + min-height: calc(100vh - $header-height-tablet-mobile - $footer-height-mobile); + } +} diff --git a/src/components/Frame/Frame.tsx b/src/components/Frame/Frame.tsx new file mode 100644 index 00000000000..ae5e12af425 --- /dev/null +++ b/src/components/Frame/Frame.tsx @@ -0,0 +1,39 @@ +import frame from './Frame.module.scss'; +import { Outlet } from 'react-router-dom'; +import { Header } from '../Header'; +import { Footer } from '../Footer'; +import { Menu } from '../Menu/Menu'; +import { useEffect, useState } from 'react'; + +export const Frame = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const media = window.matchMedia('(max-width: 639px)'); + const [isMobile, setIsMobile] = useState(media.matches); + + useEffect(() => { + const handler = () => setIsMobile(media.matches); + + media.addEventListener('change', handler); + + return () => media.removeEventListener('change', handler); + }, []); + + return ( +
+
+ + {isMenuOpen && isMobile ? ( + + ) : ( + <> +
+
+ +
+
+
+ ); +}; diff --git a/src/components/Frame/index.ts b/src/components/Frame/index.ts new file mode 100644 index 00000000000..70ff7252925 --- /dev/null +++ b/src/components/Frame/index.ts @@ -0,0 +1 @@ +export * from './Frame'; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..28ddf73fe4b --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,270 @@ +@import '../../style/main'; + +.header { + display: flex; + gap: 24px; + box-shadow: 0 1px 0 0 $shadow-color; + margin: 0; + padding: 0; + box-sizing: border-box; + height: $header-height-desktop; + background-color: $header-footer-bg-color; + position: sticky; + top: 0; + z-index: 100; + + @include on-tablet { + height: $header-height-tablet-mobile; + gap: 16px; + } + + &__logo { + margin: 18px 24px; + + @include on-tablet { + margin: 13px 16px; + } + + &__img { + height: 28px; + width: 80px; + + @include on-tablet { + width: 64px; + height: 22px; + } + } + } +} + +.nav { + display: flex; + + &__list { + list-style: none; + display: flex; + align-items: center; + gap: 64px; + + @include on-tablet { + gap: 32px; + } + + @include on-mobile { + display: none; + } + } + + &__link { + text-decoration: none; + color: $secondary-color; + font-weight: 800; + font-size: 12px; + letter-spacing: 4%; + text-transform: uppercase; + position: relative; + display: flex; + line-height: 64px; + + @include on-tablet { + line-height: 48px; + } + } +} + +.actions { + display: flex; + justify-content: flex-end; + align-items: center; + margin: 0; + padding: 0; + width: 100%; + + &__list { + display: flex; + list-style: none; + align-items: center; + margin: 0; + padding: 0; + } + + &__item { + margin: 0; + padding: 0; + position: relative; + } + + &__link { + position: relative; + box-shadow: -1px 0 0 0 $shadow-color; + height: 64px; + width: 64px; + background-size: 16px; + background-repeat: no-repeat; + background-position: center; + align-items: center; + justify-content: center; + + @include on-tablet { + height: 48px; + width: 48px; + } + + &__fav { + background-image: url(/img/header/favourites.svg); + } + + &__cart { + background-image: url(/img/header/shopping-bag.svg); + } + + &__fav, + &__cart { + display: flex; + + @include on-mobile { + display: none; + } + } + + &__menu { + background-image: url(/img/header/menu.svg); + display: none; + border: none; + margin: 0; + padding: 0; + background-color: inherit; + cursor: pointer; + + @include on-mobile { + display: flex; + } + + &--open { + background-image: url(/img/header/menu-close.svg); + } + } + } +} + +.link { + &--active { + color: $main-color; + + &::after { + position: absolute; + content: ''; + display: block; + left: 0; + bottom: 0; + height: 3px; + width: 100%; + background-color: $main-color; + } + } +} + +.counter { + padding: 0; + margin: 0; + font-weight: 400; + font-size: 9px; + line-height: 100%; + letter-spacing: 0%; + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + background-color: $button-color; + border: 1px solid $header-footer-bg-color; + border-radius: 14px; + color: $header-footer-bg-color; + + &__container { + height: 28px; + width: 28px; + display: flex; + align-items: flex-start; + justify-content: flex-end; + } +} + +.search { + width: 36px; + height: 36px; + border-radius: 36px; + border: 1px solid $shadow-color; + background-color: #fff; + font-size: 14px; + line-height: 1.2; + color: $shadow-color; + background-image: url(/img/header/search.svg); + background-repeat: no-repeat; + background-size: 20px; + background-position: center; + outline: none; + transition: + width 0.3s ease, + background-position 0.3s ease, + box-shadow 0.2s, + border-color 0.2s; + + // Focus state + &:focus { + width: 200px; // expanded width + background-position: 10px center; // icon inside input on left + border-color: $main-color; + box-shadow: 0 0 4px rgba($main-color, 0.3); + padding: 0 22px; + position: absolute; + right: 0; // keep right side fixed + top: 50%; + transform: translateY(-50%); + z-index: 200; + background-image: none; + color: $main-color; + } + + &::placeholder { + color: $shadow-color; + font-weight: 400; + } + + @include on-tablet { + height: 32px; + font-size: 13px; + } + + @include on-mobile { + height: 28px; + font-size: 12px; + } + + &__clear { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + width: 16px; + height: 16px; + border: none; + background: url(/img/cart/remove.svg) no-repeat right center; + background-size: 16px; + background-color: transparent; + z-index: 210; + + &:hover { + background-image: url(/img/cart/remove-hover.svg); + } + } + + &__wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: flex-end; + margin-right: 8px; + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..123db7ff0e6 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,184 @@ +import cn from 'classnames'; +import header from './Header.module.scss'; +import { Link, NavLink } from 'react-router-dom'; +import React, { useContext } from 'react'; +import { AddToFavContext } from '../../contexts/AddToFavContext'; +import { AddToCartContext } from '../../contexts/AddToCartContext'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { useEffect, useState } from 'react'; + +type Props = { + isMenuOpen: boolean; + setIsMenuOpen: React.Dispatch>; +}; + +export const Header: React.FC = ({ isMenuOpen, setIsMenuOpen }) => { + const { fav } = useContext(AddToFavContext); + const { cart } = useContext(AddToCartContext); + const location = useLocation(); + const showSearch = + (location.pathname.startsWith('/phones') || + location.pathname.startsWith('/tablets') || + location.pathname.startsWith('/accessories') || + location.pathname.startsWith('/favourites')) && + !isMenuOpen; + + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get('query') || ''; + const [searchValue, setSearchValue] = useState(query); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + setSearchValue(query); + }, [query]); + + useEffect(() => { + const timeout = setTimeout(() => { + const params = new URLSearchParams(searchParams); + + if (searchValue.trim()) { + params.set('query', searchValue); + } else { + params.delete('query'); + } + + setSearchParams(params); + }, 500); + + return () => clearTimeout(timeout); + }, [searchValue]); + + return ( +
+ + + + +
+
    + {showSearch && ( +
  • +
    + setSearchValue(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + className={header.search} + /> + {searchValue && ( + + )} +
    +
  • + )} + +
  • + + cn(header.actions__link, header.actions__link__fav, { + [header['link--active']]: isActive, + }) + } + > + {fav.length > 0 && ( +
    +

    {fav.length}

    +
    + )} +
    +
  • +
  • + + cn(header.actions__link, header.actions__link__cart, { + [header['link--active']]: isActive, + }) + } + > + {cart.length > 0 && ( +
    +

    + {cart.reduce((sum, item) => sum + item.quantity, 0)} +

    +
    + )} +
    +
  • +
  • + +
  • +
+
+
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 00000000000..266dec8a1bc --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/Menu/Menu.module.scss b/src/components/Menu/Menu.module.scss new file mode 100644 index 00000000000..dd82b3ebde2 --- /dev/null +++ b/src/components/Menu/Menu.module.scss @@ -0,0 +1,132 @@ +@import '../../style/main'; + +.menu { + background-color: $header-footer-bg-color; + margin: 0; + padding: 0; + + &__content { + height: calc(100vh - $header-height-tablet-mobile - $actions-height - 24px); + } +} + +.nav { + box-sizing: border-box; + margin: 0; + padding: 0; + width: 100%; + margin-top: 24px; + + &__list { + padding: 0; + margin: 0; + list-style: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + } + + &__item { + display: flex; + } + + &__link { + text-decoration: none; + color: $secondary-color; + font-weight: 800; + font-size: 12px; + letter-spacing: 4%; + text-transform: uppercase; + display: flex; + line-height: 27px; + } +} + +.actions { + margin: 0; + padding: 0; + border: 1px solid $shadow-color; + box-sizing: border-box; + height: $actions-height; + + &__list { + display: grid; + grid-template-columns: 1fr 1fr; + list-style: none; + margin: 0; + padding: 0; + width: 100%; + } + + &__item { + grid-column: span 1; + } + + &__link { + display: flex; + background-size: 16px; + background-repeat: no-repeat; + background-position: center; + align-items: center; + justify-content: center; + height: 62px; + box-sizing: border-box; + padding: 0; + border: none; + + &__fav { + background-image: url(/img/header/favourites.svg); + } + + &__cart { + background-image: url(/img/header/shopping-bag.svg); + box-shadow: -1px 0 0 0 $shadow-color; + } + } +} + +.link { + &--active { + color: $main-color; + position: relative; + + &::after { + position: absolute; + content: ''; + display: block; + left: 0; + bottom: 0; + height: 3px; + width: 100%; + background-color: $main-color; + } + } +} + +.counter { + padding: 0; + margin: 0; + font-weight: 400; + font-size: 9px; + line-height: 100%; + letter-spacing: 0%; + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + background-color: $button-color; + border: 1px solid $header-footer-bg-color; + border-radius: 14px; + color: $header-footer-bg-color; + + &__container { + height: 28px; + width: 28px; + display: flex; + align-items: flex-start; + justify-content: flex-end; + } +} diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx new file mode 100644 index 00000000000..a25f4ac39ae --- /dev/null +++ b/src/components/Menu/Menu.tsx @@ -0,0 +1,120 @@ +import { NavLink } from 'react-router-dom'; +import menu from './Menu.module.scss'; +import cn from 'classnames'; +import React, { useContext } from 'react'; +import { AddToCartContext } from '../../contexts/AddToCartContext'; +import { AddToFavContext } from '../../contexts/AddToFavContext'; + +type Props = { + setIsMenuOpen: React.Dispatch>; +}; + +export const Menu: React.FC = ({ setIsMenuOpen }) => { + const { cart } = useContext(AddToCartContext); + const { fav } = useContext(AddToFavContext); + + return ( + + ); +}; diff --git a/src/components/Menu/index.ts b/src/components/Menu/index.ts new file mode 100644 index 00000000000..629d3d0aa12 --- /dev/null +++ b/src/components/Menu/index.ts @@ -0,0 +1 @@ +export * from './Menu'; diff --git a/src/contexts/AddToCartContext.tsx b/src/contexts/AddToCartContext.tsx new file mode 100644 index 00000000000..8511e3e9c5b --- /dev/null +++ b/src/contexts/AddToCartContext.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { CartProduct } from '../types/CartProduct'; + +interface AddToCartContextType { + cart: CartProduct[]; + setCart: React.Dispatch>; +} + +export const AddToCartContext = React.createContext({ + cart: [], + setCart: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const AddToCartProvider: React.FC = ({ children }) => { + const [cart, setCart] = useLocalStorage('cart', []); + + const value = useMemo( + () => ({ + cart, + setCart, + }), + [cart], + ); + + return ( + + {children} + + ); +}; diff --git a/src/contexts/AddToFavContext.tsx b/src/contexts/AddToFavContext.tsx new file mode 100644 index 00000000000..cfb04624a50 --- /dev/null +++ b/src/contexts/AddToFavContext.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import { Product } from '../types/Product'; +import { useLocalStorage } from '../hooks/useLocalStorage'; + +interface AddToFavContextType { + fav: Product[]; + setFav: React.Dispatch>; +} + +export const AddToFavContext = React.createContext({ + fav: [], + setFav: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const AddToFavProvider: React.FC = ({ children }) => { + const [fav, setFav] = useLocalStorage('fav', []); + + const value = useMemo( + () => ({ + fav, + setFav, + }), + [fav], + ); + + return ( + + {children} + + ); +}; diff --git a/src/contexts/Providers.tsx b/src/contexts/Providers.tsx new file mode 100644 index 00000000000..4b32a18f8b4 --- /dev/null +++ b/src/contexts/Providers.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { AddToCartProvider } from './AddToCartContext'; +import { AddToFavProvider } from './AddToFavContext'; +import { ScrollToSectProvider } from './ScrollToSectContext'; + +type Props = { + children: React.ReactNode; +}; + +export const Providers: React.FC = ({ children }) => { + return ( + + + {children} + + + ); +}; diff --git a/src/contexts/ScrollToSectContext.tsx b/src/contexts/ScrollToSectContext.tsx new file mode 100644 index 00000000000..de6153a01cf --- /dev/null +++ b/src/contexts/ScrollToSectContext.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +interface ScrollToSectContextType { + scrollToSect: (id: string) => void; +} + +export const ScrollToSectContext = React.createContext( + { + scrollToSect: (id: string) => { + const el = document.getElementById(id); + + el?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, + }, +); + +type Props = { + children: React.ReactNode; +}; + +export const ScrollToSectProvider: React.FC = ({ children }) => { + const scrollToSect = (id: string) => { + const el = document.getElementById(id); + + el?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( + + {children} + + ); +}; diff --git a/src/hooks/useLocalStorage.tsx b/src/hooks/useLocalStorage.tsx new file mode 100644 index 00000000000..1e712e804cf --- /dev/null +++ b/src/hooks/useLocalStorage.tsx @@ -0,0 +1,33 @@ +import { useState } from 'react'; + +type SetValue = React.Dispatch>; + +export function useLocalStorage( + key: string, + initialValue: T, +): [T, SetValue] { + const [storedValue, setStoredValue] = useState(() => { + const data = localStorage.getItem(key); + + if (data === null) { + localStorage.setItem(key, JSON.stringify(initialValue)); + + return initialValue; + } + + try { + return JSON.parse(data) as T; + } catch { + return initialValue; + } + }); + + const save: SetValue = value => { + const newValue = value instanceof Function ? value(storedValue) : value; + + setStoredValue(newValue); + localStorage.setItem(key, JSON.stringify(newValue)); + }; + + return [storedValue, save]; +} diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..a453ce0620e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,13 @@ import { createRoot } from 'react-dom/client'; import { App } from './App'; +import { HashRouter as Router } from 'react-router-dom'; +import '../src/style/_main.scss'; +import { Providers } from './contexts/Providers'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + + + , +); diff --git a/src/modules/CartPage.module.scss b/src/modules/CartPage.module.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..4e3023a93bf --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,108 @@ +import { useContext, useState } from 'react'; +import { AddToCartContext } from '../../contexts/AddToCartContext'; +import style from './CartPage.module.scss'; +import { Link } from 'react-router-dom'; +import cn from 'classnames'; +import { CartItems } from './components/CartItems'; + +export const CartPage = () => { + const { cart, setCart } = useContext(AddToCartContext); + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleConfirm = () => { + setCart([]); + setIsModalOpen(false); + }; + + const handleCancel = () => { + setIsModalOpen(false); + }; + + return ( +
+
+ {cart.length > 0 ? ( + <> +
+
+ + back + +

Cart

+
+ +
+ + +
+
+

+ $ + {cart.reduce( + (sum, item) => sum + item.price * item.quantity, + 0, + )} +

+ +

+ {`Total for ${cart.reduce( + (sum, item) => sum + item.quantity, + 0, + )} items`} +

+ +
+ + +
+
+
+
+ + {isModalOpen && ( +
+
+ +
+

+ Checkout is not implemented yet. Do you want to clear the + Cart? +

+ +
+ + + +
+
+
+ )} + + ) : ( +
+ Cart is empty +
+ )} +
+
+ ); +}; diff --git a/src/modules/CartPage/components/CartItems/CartItems.module.scss b/src/modules/CartPage/components/CartItems/CartItems.module.scss new file mode 100644 index 00000000000..10871bbe284 --- /dev/null +++ b/src/modules/CartPage/components/CartItems/CartItems.module.scss @@ -0,0 +1,123 @@ +@import '../../../../style/main'; + +.items { + display: flex; + flex-direction: column; + gap: 16px; +} + +.item { + height: 128px; + width: 752px; + border: 1px solid $shadow-color; + border-radius: 16px; + + @include on-tablet { + width: 100%; + } + + @include on-mobile { + min-height: 160px; + } + + &__content { + margin: 24px; + display: flex; + justify-content: space-between; + align-items: center; + + @include on-mobile { + flex-direction: column; + margin: 16px; + } + } + + &__left { + display: flex; + gap: 24px; + align-items: center; + } + + &__right { + display: flex; + align-items: center; + gap: 24px; + + @include on-mobile { + width: 100%; + justify-content: space-between; + } + } + + &__remove { + width: 16px; + height: 16px; + background-image: url(/img/cart/remove.svg); + background-size: 16px; + border: none; + background-color: $header-footer-bg-color; + cursor: pointer; + + &:hover { + background-image: url(/img/cart/remove-hover.svg); + } + } + + &__img { + height: 80px; + width: 80px; + object-fit: contain; + } + + &__name { + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + display: flex; + width: 100%; + } + + &__count { + display: flex; + gap: 14px; + } + + &__button { + height: 32px; + width: 32px; + border: 1px solid $shadow-color; + border-radius: 48px; + background-color: $header-footer-bg-color; + background-size: 16px; + background-repeat: no-repeat; + background-position: center; + align-self: center; + cursor: pointer; + + @include hover(border-color, $main-color); + + &--minus { + background-image: url(/img/cart/minus.svg); + } + + &--plus { + background-image: url(/img/cart/plus.svg); + } + + &--disabled { + pointer-events: none; + background-image: url(/img/cart/minus-disabled.svg); + } + } + + &__total { + margin: 0; + padding: 0; + font-weight: 800; + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + width: 80px; + } +} diff --git a/src/modules/CartPage/components/CartItems/CartItems.tsx b/src/modules/CartPage/components/CartItems/CartItems.tsx new file mode 100644 index 00000000000..894eebfe31b --- /dev/null +++ b/src/modules/CartPage/components/CartItems/CartItems.tsx @@ -0,0 +1,81 @@ +import { useContext } from 'react'; +import { AddToCartContext } from '../../../../contexts/AddToCartContext'; +import { CartProduct } from '../../../../types/CartProduct'; +import style from './CartItems.module.scss'; +import cn from 'classnames'; + +export const CartItems = () => { + const { cart, setCart } = useContext(AddToCartContext); + + const handleMinus = (item: CartProduct) => { + setCart(currCart => + currCart.map(currItem => + currItem.itemId === item.itemId + ? { ...currItem, quantity: currItem.quantity - 1 } + : currItem, + ), + ); + }; + + const handlePlus = (item: CartProduct) => { + setCart(currCart => + currCart.map(currItem => + currItem.itemId === item.itemId + ? { ...currItem, quantity: currItem.quantity + 1 } + : currItem, + ), + ); + }; + + const handleDelete = (item: CartProduct) => { + setCart(currCart => currCart.filter(i => i.itemId !== item.itemId)); + }; + + return ( +
+ {cart.map(item => ( +
+
+
+ + {item.name} +

{item.name}

+
+
+
+ +

{item.quantity}

+ +
+

+ ${item.price * item.quantity} +

+
+
+
+ ))} +
+ ); +}; diff --git a/src/modules/CartPage/components/CartItems/index.ts b/src/modules/CartPage/components/CartItems/index.ts new file mode 100644 index 00000000000..36f288333a2 --- /dev/null +++ b/src/modules/CartPage/components/CartItems/index.ts @@ -0,0 +1 @@ +export * from './CartItems'; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 00000000000..90c010237a0 --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export * from './CartPage'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..b53799c11e1 --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,56 @@ +@import '../../style/main'; + +.home { + margin-block: 56px 80px; + display: flex; + flex-direction: column; + gap: 56px; + + @include on-tablet { + margin-block: 32px 64px; + gap: 32px; + } + + @include on-mobile { + margin-block: 24px 56px; + gap: 24px; + } + + &__sr-only { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; + } + + &__title { + font-weight: 800; + font-style: Bold; + font-size: 48px; + line-height: 56px; + letter-spacing: -1%; + margin: 0; + padding: 0; + + @include on-mobile { + font-size: 32px; + line-height: 41px; + } + } + + &__content { + display: flex; + flex-direction: column; + gap: 80px; + + @include on-tablet { + gap: 64px; + } + + @include on-mobile { + gap: 56px; + } + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..b3e183eed59 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Categories } from './components/Categories'; +import { Commertials } from './components/Commertials'; +import { ProductCards } from '../shared/ProductCards.tsx'; +import { Product } from '../../types/Product'; + +import home from './HomePage.module.scss'; + +type Props = { + products: Product[]; +}; + +export const HomePage: React.FC = ({ products }) => { + const newestProducts = products.sort( + (product1, product2) => product2.year - product1.year, + ); + const hotPrices = products + .filter(product => product.price !== product.fullPrice) + .sort( + (product1, product2) => + (product2.fullPrice - product2.price) / product2.fullPrice - + (product1.fullPrice - product1.price) / product1.fullPrice, + ); + + return ( +
+
+

Product Catalog

+

Welcome to Nice Gadgets store!

+
+
+ + + + + + + +
+
+ ); +}; diff --git a/src/modules/HomePage/components/Categories/Categories.module.scss b/src/modules/HomePage/components/Categories/Categories.module.scss new file mode 100644 index 00000000000..60b0fa7c804 --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.module.scss @@ -0,0 +1,77 @@ +@import '../../../../style/main'; + +.categories { + &__content { + display: flex; + flex-direction: column; + gap: 24px; + } + + &__title { + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; + margin: 0; + padding: 0; + + @include on-mobile { + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + } + } + + &__links { + @include page-grid; + + @include on-mobile { + row-gap: 32px; + } + } +} + +.category { + grid-column: span 8; + display: flex; + flex-direction: column; + gap: 24px; + text-decoration: none; + + @include on-tablet { + grid-column: span 4; + } + + &__img { + width: 100%; + border-radius: 8px; + + @include hover(scale, 1.05); + } + + &__details { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__title { + font-weight: 700; + font-size: 20px; + line-height: 100%; + letter-spacing: 0%; + color: $main-color; + margin: 0; + padding: 0; + } + + &__amount { + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: $secondary-color; + margin: 0; + padding: 0; + } +} diff --git a/src/modules/HomePage/components/Categories/Categories.tsx b/src/modules/HomePage/components/Categories/Categories.tsx new file mode 100644 index 00000000000..899d1d1c6b9 --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.tsx @@ -0,0 +1,99 @@ +import cn from 'classnames'; +import categories from './Categories.module.scss'; +import { Link } from 'react-router-dom'; +import { Product } from '../../../../types/Product'; +import React from 'react'; + +type Props = { + products: Product[]; +}; + +export const Categories: React.FC = ({ products }) => { + const phonesAmount = products.filter( + product => product.category === 'phones', + ).length; + const tabletssAmount = products.filter( + product => product.category === 'tablets', + ).length; + const accessoriesAmount = products.filter( + product => product.category === 'accessories', + ).length; + + return ( +
+
+
+

Shop by category

+
+ + phones +
+

Mobile phones

+

+ {phonesAmount} models +

+
+ + + tablets +
+

Tablets

+

+ {tabletssAmount} models +

+
+ + + accessories +
+

Accessories

+

+ {accessoriesAmount} models +

+
+ +
+
+
+
+ ); +}; diff --git a/src/modules/HomePage/components/Categories/index.ts b/src/modules/HomePage/components/Categories/index.ts new file mode 100644 index 00000000000..79c7c7dcde7 --- /dev/null +++ b/src/modules/HomePage/components/Categories/index.ts @@ -0,0 +1 @@ +export * from './Categories'; diff --git a/src/modules/HomePage/components/Commertials/Commertials.module.scss b/src/modules/HomePage/components/Commertials/Commertials.module.scss new file mode 100644 index 00000000000..20c06259149 --- /dev/null +++ b/src/modules/HomePage/components/Commertials/Commertials.module.scss @@ -0,0 +1,127 @@ +@import '../../../../style/main'; + +.content { + display: flex; + flex-direction: column; + gap: 8px; + margin-inline: 32px; + + @include on-tablet { + margin-inline: 24px; + } + + @include on-mobile { + margin: 0; + } + + &__top { + display: flex; + gap: 16px; + align-items: stretch; + + @include on-mobile { + gap: 0; + } + } + + &__button { + width: 32px; + border-radius: 48px; + border: 1px solid $button-border-color; + background-size: 16px; + background-position: center; + background-repeat: no-repeat; + background-color: $main-bg-color; + cursor: pointer; + + @include hover(border-color, $main-color); + + @include on-mobile { + display: none; + } + + &__left { + background-image: url(/img/home/arrow-left.svg); + } + + &__right { + background-image: url(/img/home/arrow-right.svg); + } + } + + &__swiper { + width: 100%; + min-width: 0; + margin: 0; + } + + &__swiper-slide { + width: 100%; + } + + &__commertial { + display: flex; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + width: 100%; + aspect-ratio: 490 / 189; + min-height: 189px; + max-height: 400px; + border-radius: 8px; + + @include on-mobile { + aspect-ratio: 1 / 1; + min-height: 320px; + max-height: $mobile-max-width; + border-radius: 8px; + } + + &__1 { + background-image: url(/img/home/banner-1.png); + + @include on-mobile { + background-image: url(/img/home/banner-1-mobile.png); + } + } + + &__2 { + background-image: url(/img/home/banner-2.png); + + @include on-mobile { + background-image: url(/img/home/banner-2-mobile.png); + } + } + + &__3 { + background-image: url(/img/home/banner-3.png); + + @include on-mobile { + background-image: url(/img/home/banner-3-mobile.png); + } + } + } + + &__bottom { + display: flex; + justify-content: center; + margin-top: 18px; + } + + &__pagination { + display: flex; + gap: 9px; + } + + &__bullet { + width: 14px; + height: 4px; + background-color: $shadow-color; + cursor: pointer; + border: none; + + &--active { + background-color: $main-color; + } + } +} diff --git a/src/modules/HomePage/components/Commertials/Commertials.tsx b/src/modules/HomePage/components/Commertials/Commertials.tsx new file mode 100644 index 00000000000..5d938fbfc65 --- /dev/null +++ b/src/modules/HomePage/components/Commertials/Commertials.tsx @@ -0,0 +1,91 @@ +import cn from 'classnames'; +import commertials from './Commertials.module.scss'; +import { Link } from 'react-router-dom'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Navigation, Autoplay } from 'swiper/modules'; +import { Swiper as SwiperInstance } from 'swiper/types'; +import 'swiper/css'; +import { useState, useRef } from 'react'; + +export const Commertials = () => { + const [activeIndex, setActiveIndex] = useState(0); + const swiperRef = useRef(null); + + const slides = [ + { + id: 1, + className: commertials.content__commertial__1, + link: '/phones/apple-iphone-14-pro-128gb-spaceblack', + }, + { + id: 2, + className: commertials.content__commertial__2, + link: '/phones/apple-iphone-14-pro-128gb-spaceblack', + }, + { + id: 3, + className: commertials.content__commertial__3, + link: '/phones/apple-iphone-14-pro-128gb-spaceblack', + }, + ]; + + return ( +
+
+ + (swiperRef.current = swiper)} + onSlideChange={swiper => setActiveIndex(swiper.realIndex)} + slidesPerView="auto" + slidesPerGroup={1} + loop={true} + autoplay={{ + delay: 5000, + disableOnInteraction: false, + }} + className={commertials.content__swiper} + > + {slides.map(slide => ( + + + + ))} + + + +
+
+
+ {slides.map((_, i) => ( + + ))} +
+
+
+ ); +}; diff --git a/src/modules/HomePage/components/Commertials/index.ts b/src/modules/HomePage/components/Commertials/index.ts new file mode 100644 index 00000000000..59237f0a6ca --- /dev/null +++ b/src/modules/HomePage/components/Commertials/index.ts @@ -0,0 +1 @@ +export * from './Commertials'; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 00000000000..11e53da674c --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export * from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000000..43db4938462 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,28 @@ +@import '../../style/main'; + +.not-found { + &__container { + display: flex; + justify-content: center; + } + + &__img { + width: 100%; + object-fit: contain; + height: calc( + 100vh - $header-height-desktop - $footer-height-desktop-tablet + ); + + @include on-tablet { + height: calc( + 100vh - $header-height-tablet-mobile - $footer-height-desktop-tablet + ); + } + + @include on-mobile { + height: calc( + 100vh - $header-height-tablet-mobile - $footer-height-mobile + ); + } + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..9db182dd245 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,31 @@ +import style from './NotFoundPage.module.scss'; + +type Props = { + type?: 'product'; +}; + +export const NotFoundPage: React.FC = ({ type }) => { + const isProduct = type === 'product'; + + return ( +
+
+
+ {isProduct ? ( + Product not found + ) : ( + Page not found + )} +
+
+
+ ); +}; diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 00000000000..6197aa75aa8 --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export * from './NotFoundPage'; diff --git a/src/modules/ProductPage/ProductPage.module.scss b/src/modules/ProductPage/ProductPage.module.scss new file mode 100644 index 00000000000..ca460cde5e9 --- /dev/null +++ b/src/modules/ProductPage/ProductPage.module.scss @@ -0,0 +1,172 @@ +@import '../../style/main'; + +.product { + padding: 0; + margin: 0; + + &, + &__outer { + margin-bottom: 80px; + + @include on-tablet { + margin-bottom: 64px; + } + + @include on-mobile { + margin-bottom: 56px; + } + } + + &__content { + display: flex; + flex-direction: column; + gap: 80px; + + @include on-tablet { + gap: 64px; + } + + @include on-mobile { + gap: 56px; + } + } + + &__top { + display: flex; + flex-direction: column; + gap: 40px; + } + + &__bottom { + @include page-grid; + + row-gap: 64px; + + @include on-mobile { + row-gap: 56px; + } + } + + &__header { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__back { + display: flex; + align-items: center; + margin: 0; + padding: 0; + padding-left: 20px; + width: 46px; + height: 16px; + background-image: url(/img/home/arrow-left.svg); + background-position: left center; + background-repeat: no-repeat; + text-decoration: none; + font-weight: 700; + font-size: 12px; + line-height: 12px; + letter-spacing: 0%; + color: $secondary-color; + + @include hover(color, $main-color); + } + + &__title { + margin: 0; + padding: 0; + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; + color: $main-color; + } + + &__main { + @include page-grid; + + row-gap: 40px; + } + + &__line { + width: 100%; + max-width: 320px; + height: 1px; + background-color: $shadow-color; + + @include on-mobile { + min-width: 100%; + } + } +} + +.about { + grid-column: span 12; + display: flex; + flex-direction: column; + gap: 16px; + + @include on-mobile { + grid-column: span 4; + } + + &__title { + margin: 0; + padding: 0; + font-weight: 800; + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + + @include on-mobile { + font-weight: 700; + font-style: SemiBold; + font-size: 20px; + line-height: 100%; + letter-spacing: 0%; + } + } + + &__line { + width: 100%; + height: 1px; + background-color: $shadow-color; + } + + &__details { + display: flex; + flex-direction: column; + gap: 32px; + } + + &__detail { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__subtitle { + font-weight: 700; + font-size: 20px; + line-height: 100%; + letter-spacing: 0%; + margin: 0; + padding: 0; + + @include on-mobile { + font-size: 16px; + } + } + + &__desc { + margin: 0; + padding: 0; + font-weight: 500; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: $secondary-color; + } +} diff --git a/src/modules/ProductPage/ProductPage.tsx b/src/modules/ProductPage/ProductPage.tsx new file mode 100644 index 00000000000..5ca608f528e --- /dev/null +++ b/src/modules/ProductPage/ProductPage.tsx @@ -0,0 +1,97 @@ +import React, { useMemo } from 'react'; +import style from './ProductPage.module.scss'; +import { Nav } from '../ProductsPage/components/Nav'; +import { Link, useParams } from 'react-router-dom'; +import { DetailedProduct } from '../../types/DetailedProduct'; +import cn from 'classnames'; +import { ProductCards } from '../shared/ProductCards.tsx'; +import prods from '../../../public/api/products.json'; +import { Product } from '../../types/Product'; +import { Gallery } from './components/Gallery'; +import { Purchase } from './components/Gallery/Purchase'; +import { Specs } from './components/Gallery/Specs'; +import { NotFoundPage } from '../NotFoundPage'; +import { CartProduct } from '../../types/CartProduct'; + +type Props = { + products: DetailedProduct[]; +}; + +export const ProductPage: React.FC = ({ products }) => { + const { product } = useParams<{ product?: string }>(); + + const foundProduct = useMemo(() => { + if (!product) { + return null; + } + + return products.find(p => p.id === product); + }, [product, products]); + + if (!foundProduct) { + return ; + } + + const favProduct = prods.find(p => p.itemId === product); + + if (!favProduct) { + return; + } + + const cartProduct: CartProduct = { + ...favProduct, + quantity: 1, + }; + + const analogProducts = products.filter( + p => p.namespaceId === foundProduct.namespaceId && p.id !== foundProduct.id, + ); + const alsoLike: Product[] = analogProducts + .map(p => prods.find(prod => prod.name === p.name)) + .filter((p): p is Product => Boolean(p)); + + return ( +
+
+
+
+
+
+
+
+

About

+
+
+ {foundProduct.description.map(desc => ( +
+

{desc.title}

+

{desc.text}

+
+ ))} +
+
+ +
+
+
+
+ +
+ ); +}; diff --git a/src/modules/ProductPage/components/Gallery/Gallery.module.scss b/src/modules/ProductPage/components/Gallery/Gallery.module.scss new file mode 100644 index 00000000000..4bbc98e402c --- /dev/null +++ b/src/modules/ProductPage/components/Gallery/Gallery.module.scss @@ -0,0 +1,70 @@ +@import '../../../../style/main'; + +.gallery { + grid-column: span 12; + display: flex; + gap: 16px; + min-width: 0; + + @include on-tablet { + grid-column: span 6; + } + + @include on-mobile { + grid-column: span 4; + flex-direction: column-reverse; + } + + &__thumbnails { + display: flex; + flex-direction: column; + gap: 16px; + + @include on-mobile { + flex-direction: row; + gap: 8px; + } + } + + &__thumbnail { + width: 80px; + height: 80px; + border: 1px solid $shadow-color; + background-color: $main-bg-color; + cursor: pointer; + + @include on-tablet { + width: 35px; + height: 35px; + } + + @include on-mobile { + width: 100%; + height: 49px; + } + + &--active { + border: 1px solid $main-color; + } + + &-img { + width: 100%; + height: 100%; + object-fit: contain; + } + } + + &__swiper { + width: 100%; + } + + &__main-image { + width: 100%; + max-height: 464px; + object-fit: contain; + + @include on-tablet { + max-height: 288px; + } + } +} diff --git a/src/modules/ProductPage/components/Gallery/Gallery.tsx b/src/modules/ProductPage/components/Gallery/Gallery.tsx new file mode 100644 index 00000000000..b260bad35dd --- /dev/null +++ b/src/modules/ProductPage/components/Gallery/Gallery.tsx @@ -0,0 +1,56 @@ +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Swiper as SwiperType } from 'swiper'; +import React, { useState } from 'react'; +import style from './Gallery.module.scss'; +import cn from 'classnames'; +import { DetailedProduct } from '../../../../types/DetailedProduct'; + +type Props = { + product: DetailedProduct; +}; + +export const Gallery: React.FC = ({ product }) => { + const [activeIndex, setActiveIndex] = useState(0); + const [swiper, setSwiper] = useState(null); + + return ( +
+
+ {product.images.map((imgSrc, index) => ( + + ))} +
+ + setActiveIndex(swiperInstance.realIndex) + } + className={style.gallery__swiper} + > + {product.images.map((imgSrc, index) => ( + + {`${product.name} + + ))} + +
+ ); +}; diff --git a/src/modules/ProductPage/components/Gallery/Purchase/Purchase.module.scss b/src/modules/ProductPage/components/Gallery/Purchase/Purchase.module.scss new file mode 100644 index 00000000000..8b02284a69e --- /dev/null +++ b/src/modules/ProductPage/components/Gallery/Purchase/Purchase.module.scss @@ -0,0 +1,279 @@ +@import '../../../../style/main'; + +.purchase { + grid-column: 14 / -1; + display: flex; + flex-direction: column; + gap: 32px; + + @include on-tablet { + grid-column: 8 / -1; + } + + @include on-mobile { + grid-column: span 4; + } + + &__line { + width: 100%; + max-width: 320px; + height: 1px; + background-color: $shadow-color; + + @include on-mobile { + min-width: 100%; + } + } + + &__id { + font-weight: 700; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + color: $secondary-color; + margin: 0; + padding: 0; + } + + &__top { + display: flex; + flex-direction: column; + gap: 24px; + } + + &__title { + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + margin: 0; + display: flex; + margin-top: 16px; + } + + &__main-info { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__prices { + display: flex; + gap: 8px; + } + + &__price { + font-weight: 800; + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + margin: 0; + + &--full { + font-weight: 600; + text-decoration: line-through; + color: $secondary-color; + } + } + + &__details { + display: flex; + + &-content { + margin-block: 8px; + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + } + } + + &__main-info, + &__details { + max-width: 320px; + + @include on-mobile { + max-width: 100%; + } + } + + &__buttons { + display: flex; + gap: 8px; + } + + &__add-to { + &-cart { + box-sizing: border-box; + border-radius: 48px; + background-color: $button-color; + color: $header-footer-bg-color; + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + text-align: center; + border: none; + height: 48px; + width: 100%; + cursor: pointer; + + @include hover(box-shadow, 0 3px 13px 0 $main-color); + + &--picked { + border: 1px solid $shadow-color; + color: $button-color; + background-color: $main-bg-color; + } + } + + &-fav { + min-width: 48px; + height: 48px; + background-image: url(/img/home/favourite.svg); + background-size: 16px; + background-repeat: no-repeat; + background-position: center; + border: 1px solid $button-border-color; + border-radius: 40px; + background-color: $header-footer-bg-color; + margin: 0; + padding: 0; + cursor: pointer; + + &--active { + background-image: url(/img/home/favourite-picked.svg); + } + } + } +} + +.detail { + display: flex; + justify-content: space-between; + + &__title { + padding: 0; + margin: 0; + font-weight: 700; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + color: $secondary-color; + } + + &__desc { + margin: 0; + font-weight: 700; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + } +} + +.colors { + display: flex; + flex-direction: column; + gap: 8px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__title { + font-weight: 600; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + margin: 0; + padding: 0; + color: $secondary-color; + } + + &__list { + display: flex; + margin: 0; + padding: 0; + list-style: none; + gap: 8px; + } + + &__item { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid $shadow-color; + border-radius: 36px; + + @include hover(border-color, $button-border-color); + + &--active { + border-color: $main-color; + pointer-events: none; + } + } + + &__link { + display: flex; + width: 30px; + height: 30px; + opacity: 1; + border: 2px solid $header-footer-bg-color; + border-radius: 36px; + cursor: pointer; + } +} + +.capacity { + display: flex; + flex-direction: column; + gap: 8px; + + &__title { + font-weight: 600; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + margin: 0; + padding: 0; + color: $secondary-color; + } + + &__list { + display: flex; + margin: 0; + padding: 0; + list-style: none; + gap: 8px; + } + + &__link { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 32px; + font-family: Mont, sans-serif; + font-weight: 500; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + text-align: center; + background-color: $main-bg-color; + color: $main-color; + border-radius: 4px; + border: 1px solid $button-border-color; + cursor: pointer; + box-sizing: border-box; + + &--active { + background-color: $main-color; + color: $main-bg-color; + pointer-events: none; + border: none; + } + } +} diff --git a/src/modules/ProductPage/components/Gallery/Purchase/Purchase.tsx b/src/modules/ProductPage/components/Gallery/Purchase/Purchase.tsx new file mode 100644 index 00000000000..86a1bcf7fd8 --- /dev/null +++ b/src/modules/ProductPage/components/Gallery/Purchase/Purchase.tsx @@ -0,0 +1,217 @@ +import cn from 'classnames'; +import style from './Purchase.module.scss'; +import { DetailedProduct } from '../../../../../types/DetailedProduct'; +import React, { useContext, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { AddToCartContext } from '../../../../../contexts/AddToCartContext'; +import { AddToFavContext } from '../../../../../contexts/AddToFavContext'; +import { Product } from '../../../../../types/Product'; +import { CartProduct } from '../../../../../types/CartProduct'; + +type Props = { + product: DetailedProduct; + analogProducts: DetailedProduct[]; + favProduct: Product; + cartProduct: CartProduct; +}; + +export const Purchase: React.FC = ({ + product, + analogProducts, + favProduct, + cartProduct, +}) => { + const navigate = useNavigate(); + const fullPrice = product.priceRegular; + const price = product.priceDiscount; + const { cart, setCart } = useContext(AddToCartContext); + const { fav, setFav } = useContext(AddToFavContext); + const [isPicked, setIsPicked] = useState( + cart.some(item => item.itemId === product.id), + ); + const appleColors: { [name: string]: string } = { + black: '#000000', // classic black + green: '#4D7B6A', // midnight green + yellow: '#FFD950', // iPhone yellow + white: '#FFFFFF', // standard white + purple: '#BFACE3', // iPhone purple + red: '#FF3B30', // (PRODUCT)RED + spacegray: '#4B4B4B', // Space Gray + midnightgreen: '#4D7B6A', // Midnight Green + gold: '#F6E0C9', // iPhone gold + silver: '#C0C0C0', // silver finish + rosegold: '#B76E79', // rose gold + coral: '#FF7F50', // coral + midnight: '#1C1C1C', // dark midnight + spaceblack: '#000000', // space black + blue: '#0071E3', // iPhone blue + pink: '#FFC0CB', // pink + graphite: '#383838', // graphite + sierrablue: '#96AED1', // Sierra Blue + 'rose gold': '#B76E79', // rose gold duplicate + 'sky blue': '#87CEEB', // sky blue + starlight: '#F5F5F5', // starlight + 'space gray': '#4B4B4B', // Space Gray duplicate + }; + + const pickByColor = (color: string) => { + const target = analogProducts.find( + p => p.color === color && p.capacity === product.capacity, + ); + + if (!target) { + return; // or handle fallback + } + + navigate(`/${product.category}/${target.id}`); + }; + + const pickByGB = (cap: string) => { + const target = analogProducts.find( + p => p.capacity === cap && p.color === product.color, + ); + + if (!target) { + return; // or handle fallback + } + + navigate(`/${product.category}/${target.id}`); + }; + + const handleFav = () => { + setFav(currFav => + currFav.some(item => item.itemId === favProduct.itemId) + ? currFav.filter(item => item.itemId !== favProduct.itemId) + : [...currFav, favProduct], + ); + }; + + const handleCart = () => { + if (cart.some(p => p.itemId === cartProduct.itemId)) { + setCart(currCart => currCart.filter(item => item.itemId !== product.id)); + } else { + setCart(currCart => [...currCart, cartProduct]); + } + + setIsPicked(!isPicked); + }; + + return ( +
+
+
+
+

Available colors

+

ID: 802390

+
+
    + {product.colorsAvailable.map(color => ( +
  • +
  • + ))} +
+
+
+
+

Select capacity

+
    + {product.capacityAvailable.map(capacity => ( +
  • + +
  • + ))} +
+
+
+
+
+
+ {fullPrice !== price ? ( + <> +

${price}

+

+ ${fullPrice} +

+ + ) : ( +

${fullPrice}

+ )} +
+
+ {!isPicked ? ( + + ) : ( + + )} + +
+
+
+
+
+

Screen

+

{product.screen}

+
+
+

Capacity

+

{product.capacity}

+
+
+

Processor

+

{product.processor}

+
+
+

RAM

+

{product.ram}

+
+
+
+
+ ); +}; diff --git a/src/modules/ProductPage/components/Gallery/Purchase/index.ts b/src/modules/ProductPage/components/Gallery/Purchase/index.ts new file mode 100644 index 00000000000..69000b67ba5 --- /dev/null +++ b/src/modules/ProductPage/components/Gallery/Purchase/index.ts @@ -0,0 +1 @@ +export * from './Purchase'; diff --git a/src/modules/ProductPage/components/Gallery/Specs/Specs.module.scss b/src/modules/ProductPage/components/Gallery/Specs/Specs.module.scss new file mode 100644 index 00000000000..9a2fe5cf68f --- /dev/null +++ b/src/modules/ProductPage/components/Gallery/Specs/Specs.module.scss @@ -0,0 +1,69 @@ +@import '../../../../style/main'; + +.specs { + grid-column: 14 / -1; + + @include on-tablet { + grid-column: span 12; + } + + @include on-mobile { + grid-column: span 4; + } + + &__title { + margin: 0; + padding: 0; + font-weight: 800; + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + display: flex; + margin-bottom: 16px; + + @include on-mobile { + font-weight: 700; + font-style: SemiBold; + font-size: 20px; + line-height: 100%; + } + } + + &__line { + width: 100%; + height: 1px; + background-color: $shadow-color; + display: flex; + margin-bottom: 25px; + } + + &__details { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__detail { + display: flex; + justify-content: space-between; + } + + &__subtitle { + margin: 0; + padding: 0; + font-weight: 500; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: $secondary-color; + } + + &__desc { + margin: 0; + padding: 0; + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + } +} diff --git a/src/modules/ProductPage/components/Gallery/Specs/Specs.tsx b/src/modules/ProductPage/components/Gallery/Specs/Specs.tsx new file mode 100644 index 00000000000..c3d791730fe --- /dev/null +++ b/src/modules/ProductPage/components/Gallery/Specs/Specs.tsx @@ -0,0 +1,55 @@ +import cn from 'classnames'; +import style from './Specs.module.scss'; +import { DetailedProduct } from '../../../../../types/DetailedProduct'; +import React from 'react'; + +type Props = { + product: DetailedProduct; +}; + +export const Specs: React.FC = ({ product }) => { + return ( +
+

Tech specs

+
+
+
+

Screen

+

{product.screen}

+
+
+

Resolution

+

{product.resolution}

+
+
+

Processor

+

{product.processor}

+
+
+

RAM

+

{product.ram}

+
+
+

Built in memory

+

{product.capacity}

+
+ {product.camera && ( +
+

Camera

+

{product.camera}

+
+ )} + {product.zoom && ( +
+

Zoom

+

{product.zoom}

+
+ )} +
+

Cell

+

{product.cell.join(', ')}

+
+
+
+ ); +}; diff --git a/src/modules/ProductPage/components/Gallery/Specs/index.ts b/src/modules/ProductPage/components/Gallery/Specs/index.ts new file mode 100644 index 00000000000..f59d0bf173d --- /dev/null +++ b/src/modules/ProductPage/components/Gallery/Specs/index.ts @@ -0,0 +1 @@ +export * from './Specs'; diff --git a/src/modules/ProductPage/components/Gallery/index.ts b/src/modules/ProductPage/components/Gallery/index.ts new file mode 100644 index 00000000000..18bb67f844e --- /dev/null +++ b/src/modules/ProductPage/components/Gallery/index.ts @@ -0,0 +1 @@ +export * from './Gallery'; diff --git a/src/modules/ProductPage/index.ts b/src/modules/ProductPage/index.ts new file mode 100644 index 00000000000..875dce3d23c --- /dev/null +++ b/src/modules/ProductPage/index.ts @@ -0,0 +1 @@ +export * from './ProductPage'; diff --git a/src/modules/ProductsPage/ProductsPage.module.scss b/src/modules/ProductsPage/ProductsPage.module.scss new file mode 100644 index 00000000000..df3e9f115a9 --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.module.scss @@ -0,0 +1,83 @@ +@import '../../style/main'; + +.products { + margin-bottom: 80px; + + @include on-tablet { + margin-bottom: 64px; + }; + + @include on-mobile { + margin-bottom: 56px; + } + + &__content { + display: flex; + flex-direction: column; + gap: 40px; + } + + &__top { + display: flex; + flex-direction: column; + gap: 24px; + } + + &__page-details { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__title { + font-weight: 800; + font-style: Bold; + font-size: 48px; + line-height: 56px; + letter-spacing: -1%; + margin: 0; + padding: 0; + } + + &__amount { + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: $secondary-color; + margin: 0; + padding: 0; + } + + &__settings { + display: flex; + gap: 16px; + } + + &__list { + @include page-grid; + + row-gap: 40px; + } + + &__product { + grid-column: span 6; + + @include on-tablet { + grid-column: span 4; + } + + @include on-ver-tablet { + grid-column: span 6; + } + + @include on-mobile { + grid-column: span 4; + } + } + + &__empty { + grid-column: 1 / -1; + text-align: center; + } +} diff --git a/src/modules/ProductsPage/ProductsPage.tsx b/src/modules/ProductsPage/ProductsPage.tsx new file mode 100644 index 00000000000..8cda4b2fb30 --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.tsx @@ -0,0 +1,157 @@ +import React, { useEffect } from 'react'; +import { Product } from '../../types/Product'; +import style from './ProductsPage.module.scss'; +import { Nav } from './components/Nav'; +import { Select } from './components/Select'; +import { useSearchParams } from 'react-router-dom'; +import { ProductCard } from '../shared/ProductCard'; +import { Pagination } from './components/Pagination'; + +const sortBy = { + title: 'Sort by', + param: 'sort', + options: ['Newest', 'Alphabetically', 'Cheapest'], +}; +const itemsPerPage = { + title: 'Items on page', + param: 'items', + options: ['12', '24', '48', 'All'], +}; + +type Props = { + products: Product[]; + title: string; +}; + +export const ProductsPage: React.FC = ({ products, title }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const page = searchParams.get('page'); + const sortOption = searchParams.get('sort') || 'Newest'; + const query = searchParams.get('query') || ''; + + const filteredProducts = products.filter(product => + product.name.toLowerCase().includes(query.toLowerCase()), + ); + const prodsLength = filteredProducts.length; + + const getSortedProducts = (): Product[] => { + const sorted = [...filteredProducts]; + + switch (sortOption) { + case 'Alphabetically': + return sorted.sort((a, b) => a.name.localeCompare(b.name)); + case 'Cheapest': + return sorted.sort((a, b) => a.price - b.price); + default: + return sorted.sort((a, b) => b.year - a.year); + } + }; + + const sortedProducts = getSortedProducts(); + + const itemsAmount = +(searchParams.get('items') === 'All' + ? products.length + : +(searchParams.get('items') || '12')); + const firstItem = page ? (Number(page) - 1) * itemsAmount : 0; + const lastItem = page ? firstItem + itemsAmount : itemsAmount; + const pageCount = Math.ceil(filteredProducts.length / itemsAmount); + + const handleAmount = (amount: string) => { + const params = new URLSearchParams(searchParams); + + params.set('items', amount); + + const newAmount = amount === 'All' ? products.length : Number(amount); + const newPageCount = Math.ceil(filteredProducts.length / newAmount); + const pageOfFirstItem = Math.min( + Math.floor(firstItem / newAmount) + 1, + newPageCount, + ); + + if (pageOfFirstItem === 1) { + params.delete('page'); + } else { + params.set('page', String(pageOfFirstItem)); + } + + setSearchParams(params); + }; + + const handleSort = (option: string) => { + const params = new URLSearchParams(searchParams); + + params.set('sort', option); + setSearchParams(params); + }; + + useEffect(() => { + const params = new URLSearchParams(searchParams); + const currentPage = Number(params.get('page') || '1'); + const itemsOnPage = 12; + + const firstItemIndex = (currentPage - 1) * itemsOnPage; + const lastItemIndex = firstItemIndex + itemsOnPage; + + // If last item exceeds total items, move to previous page(s) + if (lastItemIndex > products.length && currentPage > 1) { + const newPage = currentPage - 1; + + if (newPage === 1) { + params.delete('page'); + } else { + params.set('page', String(newPage)); + } + + setSearchParams(params); + } + }, [products]); + + return ( +
+
+
+
+
+
+ ); +}; diff --git a/src/modules/ProductsPage/components/Nav/Nav.module.scss b/src/modules/ProductsPage/components/Nav/Nav.module.scss new file mode 100644 index 00000000000..158b0303f92 --- /dev/null +++ b/src/modules/ProductsPage/components/Nav/Nav.module.scss @@ -0,0 +1,51 @@ +@import '../../../../style/main'; + +.nav { + margin-top: 24px; + + &__list { + display: flex; + gap: 8px; + align-items: center; + list-style: none; + margin: 0; + padding: 0; + height: 16px; + } + + &__home { + display: flex; + height: 16px; + width: 16px; + background-image: url(/img/nav/home-icon.svg); + background-size: 16px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + } + + &__span { + color: $secondary-color; + font-weight: 600; + font-size: 12px; + line-height: 15px; + letter-spacing: 0%; + margin: 0; + padding: 0; + display: flex; + height: 15px; + } + + &__link { + font-weight: 700; + font-size: 12px; + line-height: 15px; + letter-spacing: 0%; + text-decoration: none; + color: $main-color; + margin: 0; + padding: 0; + display: flex; + height: 15px; + } +} diff --git a/src/modules/ProductsPage/components/Nav/Nav.tsx b/src/modules/ProductsPage/components/Nav/Nav.tsx new file mode 100644 index 00000000000..ee4b82ae39c --- /dev/null +++ b/src/modules/ProductsPage/components/Nav/Nav.tsx @@ -0,0 +1,81 @@ +import { Link, useLocation } from 'react-router-dom'; +import nav from './Nav.module.scss'; +import { DetailedProduct } from '../../../../types/DetailedProduct'; +import React from 'react'; + +type Props = { + product?: DetailedProduct; +}; + +export const Nav: React.FC = ({ product }) => { + const { pathname } = useLocation(); + + return ( + + ); +}; diff --git a/src/modules/ProductsPage/components/Nav/index.ts b/src/modules/ProductsPage/components/Nav/index.ts new file mode 100644 index 00000000000..93467d75039 --- /dev/null +++ b/src/modules/ProductsPage/components/Nav/index.ts @@ -0,0 +1 @@ +export * from './Nav'; diff --git a/src/modules/ProductsPage/components/Pagination/Pagination.module.scss b/src/modules/ProductsPage/components/Pagination/Pagination.module.scss new file mode 100644 index 00000000000..ea9fd134fb9 --- /dev/null +++ b/src/modules/ProductsPage/components/Pagination/Pagination.module.scss @@ -0,0 +1,56 @@ +@import '../../../../style/main'; + +.pagination { + display: flex; + justify-content: center; + gap: 16px; + + &__button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 48px; + border: 1px solid $shadow-color; + background-position: center; + background-repeat: no-repeat; + text-decoration: none; + background-color: $header-footer-bg-color; + color: $main-color; + box-sizing: border-box; + margin: 0; + padding: 0; + + @include hover(border-color, $main-color); + + &--active { + border: none; + background-color: $main-color; + color: $header-footer-bg-color; + } + } + + &__prev { + background-image: url(/img/home/arrow-left.svg); + + &--disabled { + pointer-events: none; + background-image: url(/img/home/arrow-left-disabled.svg); + } + } + + &__next { + background-image: url(/img/home/arrow-right.svg); + + &--disabled { + pointer-events: none; + background-image: url(/img/home/arrow-right-disabled.svg); + } + } + + &__pages { + display: flex; + gap: 8px; + } +} diff --git a/src/modules/ProductsPage/components/Pagination/Pagination.tsx b/src/modules/ProductsPage/components/Pagination/Pagination.tsx new file mode 100644 index 00000000000..c5202509b4b --- /dev/null +++ b/src/modules/ProductsPage/components/Pagination/Pagination.tsx @@ -0,0 +1,97 @@ +import pagination from './Pagination.module.scss'; +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import cn from 'classnames'; +import { useContext } from 'react'; +import { ScrollToSectContext } from '../../../../contexts/ScrollToSectContext'; + +type Props = { + pageCount: number; +}; + +export const Pagination: React.FC = ({ pageCount }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const { scrollToSect } = useContext(ScrollToSectContext); + const page = searchParams.get('page'); + + const handleClick = () => { + const params = new URLSearchParams(searchParams); + const currentPage = page ? +page : 1; + const newPage = currentPage + 1; + + if (newPage <= pageCount) { + params.set('page', String(newPage)); + setSearchParams(params); + scrollToSect('top'); + } + }; + + return ( +
+ + ); + })} +
+ +
+ ); +}; diff --git a/src/modules/ProductsPage/components/Pagination/index.tsx b/src/modules/ProductsPage/components/Pagination/index.tsx new file mode 100644 index 00000000000..e016c96b72e --- /dev/null +++ b/src/modules/ProductsPage/components/Pagination/index.tsx @@ -0,0 +1 @@ +export * from './Pagination'; diff --git a/src/modules/ProductsPage/components/Select/Select.module.scss b/src/modules/ProductsPage/components/Select/Select.module.scss new file mode 100644 index 00000000000..7f1eeed7315 --- /dev/null +++ b/src/modules/ProductsPage/components/Select/Select.module.scss @@ -0,0 +1,93 @@ + +@import '../../../../style/main'; + +.select { + display: flex; + flex-direction: column; + gap: 4px; + + &__label { + font-weight: 600; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + color: $secondary-color; + margin: 0; + padding: 0; + } + + &__content { + position: relative; + } + + &__button { + position: relative; + display: flex; + font-weight: 400; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + font-family: Mont, sans-serif; + width: 176px; + height: 40px; + padding: 10px 12px; + align-items: center; + border: 1px solid $button-border-color; + border-radius: 8px; + background-color: $header-footer-bg-color; + cursor: pointer; + + @include on-mobile { + width: 136px; + } + + @include hover(border-color, $secondary-color); + @include action(border-color, $main-color); + } + + &__arrow { + position: absolute; + right: 12px; + } + + &__dropdown { + position: absolute; + padding-block: 8px; + top: 44px; + width: 176px; + border: 1px solid $shadow-color; + box-shadow: 0 2px 15px 0 #0000000d; + border-radius: 8px; + background-color: $header-footer-bg-color; + z-index: 10; + + @include on-mobile { + width: 136px; + } + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + } + + &__option { + font-family: Mont, sans-serif; + display: flex; + align-items: center; + justify-content: flex-start; + height: 32px; + width: 100%; + font-weight: 400; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: $secondary-color; + background-color: $header-footer-bg-color; + border: none; + cursor: pointer; + + @include hover(color, $main-color); + } +} diff --git a/src/modules/ProductsPage/components/Select/Select.tsx b/src/modules/ProductsPage/components/Select/Select.tsx new file mode 100644 index 00000000000..e4caf1f2faa --- /dev/null +++ b/src/modules/ProductsPage/components/Select/Select.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import select from './Select.module.scss'; +import { useSearchParams } from 'react-router-dom'; + +type Props = { + data: { + title: string; + param: string; + options: string[]; + }; + onSelect?: (option: string) => void; +}; + +export const Select: React.FC = ({ data, onSelect }) => { + const [isOpen, setIsOpen] = useState(false); + const [searchParams] = useSearchParams(); + const [selectedOption, setSelectedOption] = useState( + searchParams.get(data.param) || data.options[0], + ); + + const handlePick = (option: string) => { + setSelectedOption(option); + setIsOpen(false); + if (onSelect) { + onSelect(option); + } + }; + + return ( +
+

{data.title}

+
+ + + {isOpen && ( +
+
    + {data.options.map(option => ( +
  • + +
  • + ))} +
+
+ )} +
+
+ ); +}; diff --git a/src/modules/ProductsPage/components/Select/index.ts b/src/modules/ProductsPage/components/Select/index.ts new file mode 100644 index 00000000000..7868ecbae29 --- /dev/null +++ b/src/modules/ProductsPage/components/Select/index.ts @@ -0,0 +1 @@ +export * from './Select'; diff --git a/src/modules/ProductsPage/index.ts b/src/modules/ProductsPage/index.ts new file mode 100644 index 00000000000..8e350f20bf9 --- /dev/null +++ b/src/modules/ProductsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductsPage'; diff --git a/src/modules/shared/ProductCard.module.scss b/src/modules/shared/ProductCard.module.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/shared/ProductCard/ProductCard.module.scss b/src/modules/shared/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..0ce4f66435e --- /dev/null +++ b/src/modules/shared/ProductCard/ProductCard.module.scss @@ -0,0 +1,160 @@ +@import '../../../style/main'; + +.card { + height: 506px; + width: 100%; + min-width: 212px; + background-color: $header-footer-bg-color; + border: 1px solid $shadow-color; + + @include on-mobile { + height: 440px; + } + + @include hover(box-shadow, 0 2px 16px 0 #00001a); + + &__wrapper { + margin: 32px; + display: flex; + flex-direction: column; + gap: 8px; + height: 442px; + + @include on-mobile { + height: 376px; + } + } + + &__img { + width: 100%; + height: 100%; + min-height: 0; + object-fit: contain; + align-self: center; + + @include hover(scale, 1.1); + } + + &__title { + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + margin: 0; + display: flex; + margin-top: 16px; + text-decoration: none; + color: $main-color; + } + + &__prices { + display: flex; + gap: 8px; + } + + &__price { + font-weight: 800; + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + margin: 0; + + &--full { + font-weight: 600; + text-decoration: line-through; + color: $secondary-color; + } + } + + &__line { + height: 1px; + width: 100%; + background-color: $shadow-color; + flex-shrink: 0; + } + + &__details { + display: flex; + + &-content { + margin-block: 8px; + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + } + } + + &__buttons { + display: flex; + gap: 8px; + } + + &__add-to { + &-cart { + border-radius: 48px; + background-color: $button-color; + color: $header-footer-bg-color; + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + text-align: center; + border: none; + height: 40px; + width: 100%; + cursor: pointer; + + @include hover(box-shadow, 0 3px 13px 0 $main-color); + + &--picked { + border: 1px solid $shadow-color; + color: $button-color; + background-color: $main-bg-color; + } + } + + &-fav { + background-image: url(/img/home/favourite.svg); + background-size: 16px; + background-repeat: no-repeat; + background-position: center; + border: 1px solid $button-border-color; + border-radius: 48px; + background-color: $header-footer-bg-color; + min-width: 40px; + height: 40px; + cursor: pointer; + + @include hover(border-color, $main-color); + + &--active { + background-image: url(/img/home/favourite-picked.svg); + } + } + } +} + +.detail { + display: flex; + justify-content: space-between; + + &__title { + padding: 0; + margin: 0; + font-weight: 700; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + color: $secondary-color; + } + + &__desc { + margin: 0; + font-weight: 700; + font-size: 12px; + line-height: 100%; + letter-spacing: 0%; + } +} diff --git a/src/modules/shared/ProductCard/ProductCard.tsx b/src/modules/shared/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..229f1192f05 --- /dev/null +++ b/src/modules/shared/ProductCard/ProductCard.tsx @@ -0,0 +1,138 @@ +import cn from 'classnames'; +import card from './ProductCard.module.scss'; +import { Product } from '../../../types/Product'; +import React, { useContext, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ScrollToSectContext } from '../../../contexts/ScrollToSectContext'; +import { AddToCartContext } from '../../../contexts/AddToCartContext'; +import { AddToFavContext } from '../../../contexts/AddToFavContext'; +import { CartProduct } from '../../../types/CartProduct'; + +type Props = { + product: Product; + isFullPrice?: boolean; +}; + +export const ProductCard: React.FC = ({ + product, + isFullPrice = false, +}) => { + const { scrollToSect } = useContext(ScrollToSectContext); + const { cart, setCart } = useContext(AddToCartContext); + const { fav, setFav } = useContext(AddToFavContext); + const cartProduct: CartProduct = { + ...product, + quantity: 1, + }; + const [isPicked, setIsPicked] = useState( + cart.some(item => item.itemId === product.itemId), + ); + + const handleCart = () => { + if (cart.some(p => p.itemId === cartProduct.itemId)) { + setCart(currCart => + currCart.filter(item => item.itemId !== product.itemId), + ); + } else { + setCart(currCart => [...currCart, cartProduct]); + } + + setIsPicked(!isPicked); + }; + + const handleFav = () => { + setFav(currFav => + currFav.some(item => item.itemId === product.itemId) + ? currFav.filter(item => item.itemId !== product.itemId) + : [...currFav, product], + ); + }; + + return ( +
+
+ scrollToSect('top')} + > + {product.itemId} + + + scrollToSect('top')} + > + {product.name} + + +
+ {product.price !== product.fullPrice && isFullPrice === false ? ( + <> +

${product.price}

+

+ ${product.fullPrice} +

+ + ) : ( +

${product.fullPrice}

+ )} +
+ +
+
+
+
+

Screen

+

{product.screen}

+
+
+

Capacity

+

{product.capacity}

+
+
+

RAM

+

{product.ram}

+
+
+
+
+ {!isPicked ? ( + + ) : ( + + )} + +
+
+
+ ); +}; diff --git a/src/modules/shared/ProductCard/index.ts b/src/modules/shared/ProductCard/index.ts new file mode 100644 index 00000000000..7ce031c3820 --- /dev/null +++ b/src/modules/shared/ProductCard/index.ts @@ -0,0 +1 @@ +export * from './ProductCard'; diff --git a/src/modules/shared/ProductCards.tsx/ProductCards.module.scss b/src/modules/shared/ProductCards.tsx/ProductCards.module.scss new file mode 100644 index 00000000000..3d5bb2a5718 --- /dev/null +++ b/src/modules/shared/ProductCards.tsx/ProductCards.module.scss @@ -0,0 +1,71 @@ +@import '../../../style/main'; + +.cards { + &__content { + display: flex; + flex-direction: column; + gap: 24px; + } + + &__top { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__title { + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; + margin: 0; + padding: 0; + + @include on-mobile { + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + } + } + + &__slides { + display: flex; + gap: 16px; + } + + &__slide { + width: 32px; + height: 32px; + border-radius: 48px; + border: 1px solid $button-border-color; + background-size: 16px; + background-position: center; + background-repeat: no-repeat; + background-color: $main-bg-color; + cursor: pointer; + + @include hover(border-color, $main-color); + + &:disabled { + cursor: default; + + @include hover(border-color, $button-border-color); + } + + &__left { + background-image: url(/img/home/arrow-left.svg); + + &:disabled { + background-image: url(/img/home/arrow-left-disabled.svg); + } + } + + &__right { + background-image: url(/img/home/arrow-right.svg); + + &:disabled { + background-image: url(/img/home/arrow-right-disabled.svg); + } + } + } +} diff --git a/src/modules/shared/ProductCards.tsx/ProductCards.tsx b/src/modules/shared/ProductCards.tsx/ProductCards.tsx new file mode 100644 index 00000000000..1c1a3c6878b --- /dev/null +++ b/src/modules/shared/ProductCards.tsx/ProductCards.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import { ProductCard } from '../ProductCard/ProductCard'; +import cards from './ProductCards.module.scss'; +import type { Swiper, SwiperSlide } from 'swiper/react'; +import { Navigation } from 'swiper/modules'; +import { Swiper as SwiperType } from 'swiper'; +import cn from 'classnames'; +import { useRef } from 'react'; +import { Product } from '../../../types/Product'; + +type Props = { + title: string; + products: Product[]; +}; + +export const ProductCards: React.FC = ({ title, products }) => { + const swiperRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + const [slidesPerView, setSlidesPerView] = useState(1.3); + + const isPrevDisabled = activeIndex === 0; + const isNextDisabled = + swiperRef.current && + activeIndex + slidesPerView >= swiperRef.current.slides.length; + + let isFullPrice = false; + + if (title === 'Brand new models') { + isFullPrice = true; + } + + return ( +
+
+
+
+

{title}

+
+ + +
+
+
+ (swiperRef.current = swiper)} + onSlideChange={swiper => { + setActiveIndex(swiper.activeIndex); + setSlidesPerView(swiper.params.slidesPerView as number); + }} + slidesPerView={1.3} + watchOverflow + breakpoints={{ + 400: { slidesPerView: 1.5 }, + 450: { slidesPerView: 1.6 }, + 490: { slidesPerView: 2, slidesPerGroup: 2 }, + 600: { slidesPerView: 2.5 }, + 750: { slidesPerView: 3, slidesPerGroup: 3 }, + 900: { slidesPerView: 3.5 }, + 1050: { slidesPerView: 4, slidesPerGroup: 4 }, + }} + slidesPerGroup={1} + spaceBetween={16} + > + {products.map(product => ( + + + + ))} + +
+
+
+
+ ); +}; diff --git a/src/modules/shared/ProductCards.tsx/index.ts b/src/modules/shared/ProductCards.tsx/index.ts new file mode 100644 index 00000000000..f6c1e62b60d --- /dev/null +++ b/src/modules/shared/ProductCards.tsx/index.ts @@ -0,0 +1 @@ +export * from './ProductCards'; diff --git a/src/style/_font.scss b/src/style/_font.scss new file mode 100644 index 00000000000..22a0613b246 --- /dev/null +++ b/src/style/_font.scss @@ -0,0 +1,20 @@ +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Regular.otf'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Bold.otf'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-SemiBold.otf'); + font-weight: 600; // SemiBold weight + font-style: normal; +} diff --git a/src/style/_main.scss b/src/style/_main.scss new file mode 100644 index 00000000000..531111eb252 --- /dev/null +++ b/src/style/_main.scss @@ -0,0 +1,31 @@ +@import './utils'; +@import './font'; +@import './typography'; + +body { + margin: 0; + padding: 0; + box-sizing: border-box; + background-color: $main-bg-color; + color: $main-color; + min-width: 320px; +} + +.frame { + margin: 0 auto; + max-width: 1200px; + display: flex; + flex-direction: column; +} + +.container { + margin-inline: 32px; + + @include on-tablet { + margin-inline: 24px; + } + + @include on-mobile { + margin-inline: 16px; + } +} diff --git a/src/style/_typography.scss b/src/style/_typography.scss new file mode 100644 index 00000000000..8d0365a07a1 --- /dev/null +++ b/src/style/_typography.scss @@ -0,0 +1,3 @@ +html { + font-family: Mont, sans-serif; +} diff --git a/src/style/_utils.scss b/src/style/_utils.scss new file mode 100644 index 00000000000..f1078dced52 --- /dev/null +++ b/src/style/_utils.scss @@ -0,0 +1,2 @@ +@import 'utils/vars'; +@import 'utils/mixins'; diff --git a/src/style/utils/_mixins.scss b/src/style/utils/_mixins.scss new file mode 100644 index 00000000000..53f9a5486ec --- /dev/null +++ b/src/style/utils/_mixins.scss @@ -0,0 +1,57 @@ +@mixin hover($_property, $_toValue) { + transition: #{$_property} 0.3s; + &:hover { + #{$_property}: $_toValue; + } +} + +@mixin action($_property, $_toValue) { + transition: #{$_property} 0.3s; + &:active, + &:focus { + outline: none; + #{$_property}: $_toValue; + } +} + +@mixin on-mobile() { + @media (max-width: $mobile-max-width) { + @content; + } +} + +@mixin on-ver-tablet() { + @media (max-width: $ver-tablet-max-width) { + @content; + } +} + +@mixin on-tablet() { + @media (max-width: $hor-tablet-max-width) { + @content; + } +} + +@mixin on-desktop() { + @media (max-width: $desktop-max-width) { + @content; + } +} + +@mixin page-grid { + --columns: 24; + + display: grid; + column-gap: 16px; + grid-template-columns: repeat(var(--columns), 1fr); + + @include on-tablet { + --columns: 12; + + column-width: 100%; + } + + @include on-mobile { + --columns: 4; + } +} diff --git a/src/style/utils/_vars.scss b/src/style/utils/_vars.scss new file mode 100644 index 00000000000..a5f59b66974 --- /dev/null +++ b/src/style/utils/_vars.scss @@ -0,0 +1,16 @@ +$header-footer-bg-color: #fff; +$main-bg-color: #FAFBFC; +$main-color: #0f0f11; +$shadow-color: #e2e6e9; +$secondary-color: #89939a; +$button-border-color: #B4BDC3; +$button-color: #4219D0; +$mobile-max-width: 639px; +$hor-tablet-max-width: 1199px; +$ver-tablet-max-width: 767px; +$desktop-max-width: 1200px; +$header-height-desktop: 64px; +$header-height-tablet-mobile: 48px; +$footer-height-desktop-tablet: 96px; +$footer-height-mobile: 257px; +$actions-height: 64px; diff --git a/src/types/CartProduct.ts b/src/types/CartProduct.ts new file mode 100644 index 00000000000..8aff3ce4617 --- /dev/null +++ b/src/types/CartProduct.ts @@ -0,0 +1,5 @@ +import { Product } from './Product'; + +export type CartProduct = Product & { + quantity: number; +}; diff --git a/src/types/DetailedProduct.ts b/src/types/DetailedProduct.ts new file mode 100644 index 00000000000..fd5428430da --- /dev/null +++ b/src/types/DetailedProduct.ts @@ -0,0 +1,24 @@ +export type DetailedProduct = { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: { + title: string; + text: string[]; + }[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera?: string; + zoom?: string; + cell: string[]; +}; diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 00000000000..8111167715a --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,14 @@ +export type Product = { + id: number; + category: string; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +};