diff --git a/index.html b/index.html index 095fb3a453..8dffdf9bde 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,26 @@ + - - Vite + React + TS + + Nice Gadgets store! + - + +
- + + diff --git a/package.json b/package.json index ae251685c8..0a6735fcad 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,18 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", - "bulma": "^1.0.1", "classnames": "^2.5.1", + "eslint-plugin-css": "^0.11.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "swiper": "^11.1.14", + "uuid": "^11.0.3" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@linthtml/linthtml": "^0.10.1", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.scss b/src/App.scss deleted file mode 100644 index 71bc413aad..0000000000 --- a/src/App.scss +++ /dev/null @@ -1 +0,0 @@ -// not empty diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 372e4b4206..0000000000 --- a/src/App.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import './App.scss'; - -export const App = () => ( -
-

Product Catalog

-
-); diff --git a/src/components/BreadCrumbs/BreadCrumbs.module.scss b/src/components/BreadCrumbs/BreadCrumbs.module.scss new file mode 100644 index 0000000000..c6c3d54bf1 --- /dev/null +++ b/src/components/BreadCrumbs/BreadCrumbs.module.scss @@ -0,0 +1,59 @@ +@import '../../styles/main'; + +.breadcrumbs_container { + display: flex; +} + +.crumb { + @extend %small-text; + + text-decoration: none; + font-weight: 600; + color: var(--c-primary); + + &_last { + color: var(--c-secondary); + cursor: default !important; + } + + + + &_container { + display: flex; + align-items: center; + gap: 8px; + padding-right: 8px; + } +} + +.breadcrumbs_container .icon { + display: flex; + align-items: center; + + &_container { + @extend %icon-container; + + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + } + + &_right { + @include icon-bg(var(--fls-right-disabled)); + + width: 16px; + height: 16px; + } + + &_home { + @include icon-bg(var(--fls-home)); + + width: 16px; + height: 16px; + cursor: pointer; + } +} diff --git a/src/components/BreadCrumbs/BreadCrumbs.tsx b/src/components/BreadCrumbs/BreadCrumbs.tsx new file mode 100644 index 0000000000..889a6628a0 --- /dev/null +++ b/src/components/BreadCrumbs/BreadCrumbs.tsx @@ -0,0 +1,71 @@ +import { NavLink, useLocation } from 'react-router-dom'; +import style from './BreadCrumbs.module.scss'; +import classNames from 'classnames'; +import { sentenseFormating } from '../../utils/sentenseFormating'; +import { Product } from '../../types/Product'; +import { ProductItem } from '../../types/ProductItem'; + +type Props = { + product?: Product | ProductItem; +}; +export const BreadCrumbs: React.FC = ({ product }) => { + const location = useLocation(); + + return ( +
+ {location.pathname.split('/').map((crumb, index, arr) => { + const icon = ( +
+
+
+ ); + + if (index !== arr.length - 1) { + if (index === 0) { + return ( +
+ +
+
+
+ + {icon} +
+ ); + } + + return ( +
+ + {sentenseFormating(crumb)} + + {icon} +
+ ); + } + + return ( + + {product ? product.name : sentenseFormating(crumb)} + + ); + })} +
+ ); +}; diff --git a/src/components/BreadCrumbs/index.ts b/src/components/BreadCrumbs/index.ts new file mode 100644 index 0000000000..8ffa35f61f --- /dev/null +++ b/src/components/BreadCrumbs/index.ts @@ -0,0 +1 @@ +export * from './BreadCrumbs'; diff --git a/src/components/ButtonsAddCardFav/ButtonsAddCardFav.module.scss b/src/components/ButtonsAddCardFav/ButtonsAddCardFav.module.scss new file mode 100644 index 0000000000..84043b4572 --- /dev/null +++ b/src/components/ButtonsAddCardFav/ButtonsAddCardFav.module.scss @@ -0,0 +1,19 @@ +@import '../../styles/main'; + +.container { + box-sizing: border-box; + width: 100%; + height: 100%; + display: flex; + gap: 8px; + + &_addToCart { + box-sizing: border-box; + width: 100%; + } + + &_favorite { + box-sizing: border-box; + aspect-ratio: 1/1; + } +} diff --git a/src/components/ButtonsAddCardFav/ButtonsAddCardFav.tsx b/src/components/ButtonsAddCardFav/ButtonsAddCardFav.tsx new file mode 100644 index 0000000000..3504127dac --- /dev/null +++ b/src/components/ButtonsAddCardFav/ButtonsAddCardFav.tsx @@ -0,0 +1,43 @@ +import style from './ButtonsAddCardFav.module.scss'; +import classNames from 'classnames'; +import React, { useContext } from 'react'; +import { DispatchContext, StateContext } from '../GlobalProvider'; +import { FavoriteIcon } from './FavoriteIcon'; +import { Product } from '../../types/Product'; + +type Props = { + productId: string; +}; + +export const ButtonsAddCardFav: React.FC = ({ productId }) => { + const { inCart } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + const prodInCart = inCart + ? !!inCart.find((prod: Product) => prod.itemId === productId) + : false; + + return ( +
+
{ + dispatch({ type: 'toggleInCart', payload: productId }); + }} + > +
+ {!prodInCart ? 'Add to cart' : 'Added to cart'} +
+
+ +
+ +
+
+ ); +}; diff --git a/src/components/ButtonsAddCardFav/FavoriteIcon/FavoriteIcon.module.scss b/src/components/ButtonsAddCardFav/FavoriteIcon/FavoriteIcon.module.scss new file mode 100644 index 0000000000..dfd36a2b30 --- /dev/null +++ b/src/components/ButtonsAddCardFav/FavoriteIcon/FavoriteIcon.module.scss @@ -0,0 +1,18 @@ +@import '../../../styles/main'; + +.icon { + &_container { + @extend %icon-container; + + height: 100%; + width: 100%; + } + + &_favorite { + @include icon-bg(var(--fls-favorite)); + } +} + +.selected { + @include icon-bg(var(--fls-favorite-selected)); +} diff --git a/src/components/ButtonsAddCardFav/FavoriteIcon/FavoriteIcon.tsx b/src/components/ButtonsAddCardFav/FavoriteIcon/FavoriteIcon.tsx new file mode 100644 index 0000000000..995f54a346 --- /dev/null +++ b/src/components/ButtonsAddCardFav/FavoriteIcon/FavoriteIcon.tsx @@ -0,0 +1,32 @@ +import style from './FavoriteIcon.module.scss'; +import classNames from 'classnames'; +import React, { useContext } from 'react'; +import { DispatchContext, StateContext } from '../../GlobalProvider'; +import { Product } from '../../../types/Product'; + +type Props = { + curProductId: string; +}; +export const FavoriteIcon: React.FC = ({ curProductId }) => { + const { inFavorites } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const prodInFavorite = inFavorites + ? !!inFavorites.find((prod: Product) => prod.itemId === curProductId) + : false; + + return ( +
+ dispatch({ type: 'toggleInFavorites', payload: curProductId }) + } + > +
+
+ ); +}; diff --git a/src/components/ButtonsAddCardFav/FavoriteIcon/index.ts b/src/components/ButtonsAddCardFav/FavoriteIcon/index.ts new file mode 100644 index 0000000000..b44fd9acf6 --- /dev/null +++ b/src/components/ButtonsAddCardFav/FavoriteIcon/index.ts @@ -0,0 +1 @@ +export * from './FavoriteIcon'; diff --git a/src/components/ButtonsAddCardFav/index.ts b/src/components/ButtonsAddCardFav/index.ts new file mode 100644 index 0000000000..fbce0eaaad --- /dev/null +++ b/src/components/ButtonsAddCardFav/index.ts @@ -0,0 +1 @@ +export * from './ButtonsAddCardFav'; diff --git a/src/components/CapacitySelector/CapacitySelector.module.scss b/src/components/CapacitySelector/CapacitySelector.module.scss new file mode 100644 index 0000000000..9474242cda --- /dev/null +++ b/src/components/CapacitySelector/CapacitySelector.module.scss @@ -0,0 +1,7 @@ +@import '../../styles/main'; + +.capacity_selector_container { + display: flex; + gap: 8px; + height: 32px; +} diff --git a/src/components/CapacitySelector/CapacitySelector.tsx b/src/components/CapacitySelector/CapacitySelector.tsx new file mode 100644 index 0000000000..908c60bd3b --- /dev/null +++ b/src/components/CapacitySelector/CapacitySelector.tsx @@ -0,0 +1,39 @@ +import classNames from 'classnames'; +import style from './CapacitySelector.module.scss'; + +type Props = { + capacities: string[]; + selectedCapacity: string; + onClick: (capacity: string) => void; +}; +export const CapacitySelector: React.FC = ({ + capacities, + selectedCapacity, + onClick, +}) => { + return ( +
+ {capacities.map(capacity => { + return ( +
{ + onClick(capacity); + }} + > +
+ {capacity} +
+
+ ); + })} +
+ ); +}; diff --git a/src/components/CapacitySelector/index.ts b/src/components/CapacitySelector/index.ts new file mode 100644 index 0000000000..a62c133303 --- /dev/null +++ b/src/components/CapacitySelector/index.ts @@ -0,0 +1 @@ +export * from './CapacitySelector'; diff --git a/src/components/Catalog/Catalog.module.scss b/src/components/Catalog/Catalog.module.scss new file mode 100644 index 0000000000..90dfada743 --- /dev/null +++ b/src/components/Catalog/Catalog.module.scss @@ -0,0 +1,61 @@ +@import '../../styles/main'; + +.catalog_container { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 24px; + padding-block: 24px 64px; + width: 100%; + + @include inline-padding; + + & .container { + &_title { + @include ontablet { + grid-area: 2/ 1/ 2/ -1; + } + + &_text { + padding-bottom: 8px; + } + + &_count { + color: var(--c-secondary); + } + } + + &_catalog { + display: flex; + flex-flow: row wrap; + gap: 40px 16px; + + @include ontablet { + justify-content: space-between; + flex-direction: row; + } + } + + &_product { + display: flex; + flex-direction: row; + flex: 1 1 220px; + } + } + + & .products_not_found_container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 20px; + + & .img { + flex-grow: 1; + width: 30vw; + aspect-ratio: 1/1; + background: url(../../../public/img/product-not-found.png) no-repeat + center/contain; + } + } +} diff --git a/src/components/Catalog/Catalog.tsx b/src/components/Catalog/Catalog.tsx new file mode 100644 index 0000000000..c134012f5e --- /dev/null +++ b/src/components/Catalog/Catalog.tsx @@ -0,0 +1,121 @@ +import classNames from 'classnames'; +import { Product } from '../../types/Product'; +import style from './Catalog.module.scss'; +import { BreadCrumbs } from '../BreadCrumbs'; +import { SortFilter } from '../SortFilter'; +import { ProductCard } from '../ProductCard'; +import { useSearchParams } from 'react-router-dom'; +import { SearchParams } from '../../types/SearchParams'; +import { catalogHelper } from '../../utils/catalogHelper'; +import { useContext, useEffect, useState } from 'react'; +import { Pagination } from './Pagination'; +import { DispatchContext } from '../GlobalProvider'; + +type Props = { + title: string; + products: Product[]; + sortPerPageEnable?: boolean; +}; + +export const Catalog: React.FC = ({ + title, + products, + sortPerPageEnable = true, +}) => { + const dispatch = useContext(DispatchContext); + const [searchParams] = useSearchParams(); + const [sortedProducts, setSortedProducts] = useState([]); + const [pages, setPages] = useState([[]]); + const [curPage, setCurPage] = useState(0); + + useEffect( + () => dispatch({ type: 'setShowSearch', payload: true }), + [dispatch], + ); + + useEffect(() => { + setSortedProducts(() => + catalogHelper.sort(searchParams.get(SearchParams.order), products), + ); + + setCurPage(() => catalogHelper.getCurrenPageParam(searchParams) - 1); + }, [searchParams, products]); + + useEffect(() => { + setPages(() => + catalogHelper.perPage( + searchParams.get(SearchParams.perPage), + sortedProducts, + ), + ); + }, [sortedProducts, searchParams, products]); + + return ( +
+ {products ? ( + <> +
+ +
+ +
+

{title}

+

+ {products.length !== 0 + ? products.length !== 1 + ? `${products.length} models` + : '1 model' + : 'no models'} +

+
+ +
+ {sortPerPageEnable && } +
+ + {products.length ? ( +
+ {pages[curPage] && + pages[curPage].map(product => { + return ( +
+ +
+ ); + })} +
+ ) : ( +

+
+ {pages[curPage] && + pages[curPage].map(product => { + return ( +
+ +
+ ); + })} +
+ {`There are no ${title.toLocaleLowerCase()} yet.`} +

+ )} + +
+ {pages.length > 1 && } +
+ + ) : ( +
+

Sorry Products Not Found

+
+
+ )} +
+ ); +}; diff --git a/src/components/Catalog/Pagination/Pagination.module.scss b/src/components/Catalog/Pagination/Pagination.module.scss new file mode 100644 index 0000000000..2aaaadd6e9 --- /dev/null +++ b/src/components/Catalog/Pagination/Pagination.module.scss @@ -0,0 +1,67 @@ +@import '../../../styles/main'; + +.pagination_container { + display: flex; + align-items: center; + justify-content: center; + height: 32px; + + & .icon { + &_container { + box-sizing: border-box; + + @extend %icon-container; + + width: 32px; + height: 32px; + background-color: var(--c-buttons-nav); + + + } + + &_Left { + @include icon-bg(var(--fls-left)); + + &_disabled { + @include icon-bg(var(--fls-left-disabled)); + } + } + + &_Right { + @include icon-bg(var(--fls-right)); + + &_disabled { + @include icon-bg(var(--fls-right-disabled)); + } + } + } + + .pages_container { + display: flex; + gap: 8px; + padding-inline: 8px; + + & .page_txt { + line-height: 100%; + vertical-align: middle; + + } + + & .to_end { + display: flex; + gap: 4px; + } + + } + + & .selected_container { + background-color: var(--c-primary); + border-color: var(--c-primary); + } + + & .selected_text { + color: var(--c-background); + + } + +} diff --git a/src/components/Catalog/Pagination/Pagination.tsx b/src/components/Catalog/Pagination/Pagination.tsx new file mode 100644 index 0000000000..998a73598f --- /dev/null +++ b/src/components/Catalog/Pagination/Pagination.tsx @@ -0,0 +1,166 @@ +import classNames from 'classnames'; +import { Product } from '../../../types/Product'; +import style from './Pagination.module.scss'; +import { useSearchParams } from 'react-router-dom'; +import { SearchParams } from '../../../types/SearchParams'; +import { useEffect, useMemo, useState } from 'react'; +import { catalogHelper } from '../../../utils/catalogHelper'; + +const generatePages = (pages: Product[][]) => { + const pgObj = Array.from({ length: pages.length }, (_, i) => i + 1); + + return pgObj; +}; + +type Props = { + pages: Product[][]; +}; +export const Pagination: React.FC = ({ pages }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [currentPage, setCurrentPage] = useState(1); + const [prevPage, setPrevPage] = useState(1); + const [visiblePages, setVisiblePages] = useState([]); + + const allPages = useMemo(() => generatePages(pages), [pages]); + + useEffect(() => { + setPrevPage(currentPage); + setCurrentPage(catalogHelper.getCurrenPageParam(searchParams)); + }, [searchParams, currentPage]); + + useEffect(() => { + const newVisPgs = allPages; + + if (currentPage < 4) { + setVisiblePages(() => newVisPgs.slice(0, 4)); + + return; + } + + if (currentPage > newVisPgs.length - 3) { + setVisiblePages(() => newVisPgs.slice(newVisPgs.length - 4)); + + return; + } + + if (currentPage > prevPage) { + setVisiblePages(newVisPgs.slice(currentPage - 3, currentPage + 1)); + } else { + setVisiblePages(newVisPgs.slice(currentPage - 2, currentPage + 2)); + } + }, [currentPage, prevPage, allPages]); + + const handlePageSet = (pageNum: number) => { + window.scrollTo(0, 0); + const params = new URLSearchParams(searchParams); + + params.set(SearchParams.page, pageNum.toString()); + setSearchParams(() => params); + }; + + const handleNext = (direction: 'next' | 'prev') => { + const params = new URLSearchParams(searchParams); + let newPageNum = currentPage; + + switch (direction) { + case 'prev': + if (currentPage > 1) { + newPageNum--; + } + + break; + + case 'next': + if (currentPage < pages.length) { + newPageNum++; + } + + break; + } + + params.set(SearchParams.page, newPageNum.toString()); + setSearchParams(() => params); + }; + + return ( +
+
handleNext('prev')} + > +
+
+ +
+ {currentPage > 3 && ( +
+
handlePageSet(1)} + > +
+

1

+
+
+

...

+
+ )} + + {visiblePages.map(pg => { + const selected = pg === currentPage; + + return ( +
handlePageSet(pg)} + > +
+

+ {pg} +

+
+
+ ); + })} + + {currentPage < allPages.length - 2 && ( +
+

...

+ +
handlePageSet(allPages[allPages.length - 1])} + > +
+

+ {allPages[allPages.length - 1]} +

+
+
+
+ )} +
+
handleNext('next')} + > +
+
+
+ ); +}; diff --git a/src/components/Catalog/Pagination/index.ts b/src/components/Catalog/Pagination/index.ts new file mode 100644 index 0000000000..e016c96b72 --- /dev/null +++ b/src/components/Catalog/Pagination/index.ts @@ -0,0 +1 @@ +export * from './Pagination'; diff --git a/src/components/Categories/Categories.module.scss b/src/components/Categories/Categories.module.scss new file mode 100644 index 0000000000..230d3eb060 --- /dev/null +++ b/src/components/Categories/Categories.module.scss @@ -0,0 +1,68 @@ +@import '../../styles/main'; + +.container { + display: flex; + flex-direction: column; + +} + +.categories_container { + display: flex; + flex-direction: column; + gap: 32px; + + @include ontablet { + flex-direction: row; + justify-content: space-between; + gap: 16px; + } +} + +h2.title { + padding-bottom: 24px; +} + +.category { + &_container { + width: 100%; + box-sizing: border-box; + } + + &_link { + box-sizing: border-box; + display: flex; + height: 100%; + padding-bottom: 24px; + } + + &_title { + padding-bottom: 8px; + } + + &_count { + color: var(--c-secondary); + } +} + +.image { + width: 100%; + aspect-ratio: 1/1; + + @include hover(transform, scale(1.05)); + + + &_phones { + background: url('../../../public/img/category-phones.png') no-repeat left/cover; + background-color: #6d6474; + } + + &_tablet { + background: url('../../../public/img/category-tablets.png') no-repeat left/cover; + background-color: #8d8d92; + } + + &_accessories { + background: url('../../../public/img/category-accessories.png') no-repeat left/cover; + background-color: #973d5f; + } +} diff --git a/src/components/Categories/Categories.tsx b/src/components/Categories/Categories.tsx new file mode 100644 index 0000000000..f47fdb8aec --- /dev/null +++ b/src/components/Categories/Categories.tsx @@ -0,0 +1,67 @@ +import classNames from 'classnames'; +import style from './Categories.module.scss'; +import { Link } from 'react-router-dom'; +import { useContext } from 'react'; +import { StateContext } from '../GlobalProvider'; + +export const Categories = () => { + const { products } = useContext(StateContext); + + return ( +
+

Shop by category

+ +
+
+ +
+ + +

+ {'Mobile phones'} +

+ +

+ {products.filter(prod => prod.category === 'phones').length} models +

+
+
+ +
+ + +

{'Tablets'}

+ +

+ {products.filter(prod => prod.category === 'tablets').length} models +

+
+
+ +
+ + +

{'Accessories'}

+ +

+ { + products.filter(prod => { + return prod.category === 'accessories'; + }).length + }{' '} + models +

+
+
+
+ ); +}; diff --git a/src/components/Categories/index.ts b/src/components/Categories/index.ts new file mode 100644 index 0000000000..79c7c7dcde --- /dev/null +++ b/src/components/Categories/index.ts @@ -0,0 +1 @@ +export * from './Categories'; diff --git a/src/components/ColorSelector/ColorSelector.module.scss b/src/components/ColorSelector/ColorSelector.module.scss new file mode 100644 index 0000000000..c1b5a74dd4 --- /dev/null +++ b/src/components/ColorSelector/ColorSelector.module.scss @@ -0,0 +1,41 @@ +@import '../../styles/main'; + +.color_selector_container { + display: flex; + flex-direction: column; + gap: 8px; + + & .title { + color: var(--c-secondary); + } +} + +.colors_container { + box-sizing: border-box; + display: flex; + gap: 8px; + + & .circle { + box-sizing: border-box; + display: flex; + align-items: center; + border-radius: 50%; + } + + & .outer { + display: flex; + height: 32px; + width: 32px; + border: 2px solid var(--c-elements); + } + + & .inner { + border: 3px solid var(--c-white); + width: 100%; + height: 100%; + } + + & .selected { + border-color: var(--c-primary); + } +} diff --git a/src/components/ColorSelector/ColorSelector.tsx b/src/components/ColorSelector/ColorSelector.tsx new file mode 100644 index 0000000000..2de4f5b06f --- /dev/null +++ b/src/components/ColorSelector/ColorSelector.tsx @@ -0,0 +1,44 @@ +import style from './ColorSelector.module.scss'; +import { productColors } from '../../modules/constants'; +import classNames from 'classnames'; + +type Props = { + colors: string[]; + selectedColor: string; + onClick: (color: string) => void; +}; + +export const ColorSelector: React.FC = ({ + colors, + selectedColor, + onClick, +}) => { + return ( +
+

Available colors

+
+ {colors && + colors.map(color => { + const cleanColor = color.replace(' ', ''); + + return ( +
onClick(cleanColor)} + > +
+
+ ); + })} +
+
+ ); +}; diff --git a/src/components/ColorSelector/index.ts b/src/components/ColorSelector/index.ts new file mode 100644 index 0000000000..3bca0bb79c --- /dev/null +++ b/src/components/ColorSelector/index.ts @@ -0,0 +1 @@ +export * from './ColorSelector'; diff --git a/src/components/FavCarIcons/FavCarIcons.module.scss b/src/components/FavCarIcons/FavCarIcons.module.scss new file mode 100644 index 0000000000..2090647baf --- /dev/null +++ b/src/components/FavCarIcons/FavCarIcons.module.scss @@ -0,0 +1,84 @@ + @import '../../styles/icons'; + + .container { + display: flex; + justify-content: flex-end; + align-items: center; + width: 100%; + height: 100%; + + &_icon { + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + height: 100%; + + &_link { + display: flex; + justify-content: flex-end; + align-items: center; + + } + } + } + + .icon { + display: flex; + align-items: center; + justify-content: center; + position: relative; + + + & .container_count { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border: 1px solid var(--c-white); + border-radius: 50%; + background-color: red; + left: 50%; + bottom: 50%; + } + + & .count { + font-size: 9px; + font-weight: 700; + color: var(--c-white); + } + + &_container { + @extend %icon-container; + + @include ontablet { + width: 48px; + height: 48px; + } + + @include ondesktop { + width: 64px; + height: 64px; + } + } + + &_dark { + @include icon-bg(var(--fls-dark-mode)); + + } + + &_favorite { + @include icon-bg(var(--fls-favorite)); + } + + &_cart { + @include icon-bg(var(--fls-cart)); + } + } + + .mobile_menu { + width: 100%; + height: 64px; + } diff --git a/src/components/FavCarIcons/FavCarIcons.tsx b/src/components/FavCarIcons/FavCarIcons.tsx new file mode 100644 index 0000000000..4987941c9c --- /dev/null +++ b/src/components/FavCarIcons/FavCarIcons.tsx @@ -0,0 +1,76 @@ +import React, { useContext } from 'react'; +import { NavLink } from 'react-router-dom'; +import classNames from 'classnames'; + +import style from './FavCarIcons.module.scss'; +import { DispatchContext, StateContext } from '../GlobalProvider'; + +type Props = { mobileView?: boolean }; +export const FavCarIcons: React.FC = ({ mobileView = false }) => { + const dispatch = useContext(DispatchContext); + const { inDarkMode, inCart, inFavorites } = useContext(StateContext); + + return ( +
+
+
{ + dispatch({ type: 'setInDarkMode', payload: !inDarkMode }); + document.body.classList.toggle('theme_dark'); + }} + > +
+
+ + + classNames(style.icon_container, style.icon_container_favorite, { + isActive_link: isActive, + [style.mobile_menu]: mobileView, + }) + } + onClick={() => dispatch({ type: 'setShowMenu', payload: false })} + > +
+ {inFavorites.length > 0 && ( +
+ {inFavorites.length} +
+ )} +
+
+ + + classNames(style.icon_container, style.icon_container_cart, { + isActive_link: isActive, + [style.mobile_menu]: mobileView, + }) + } + onClick={() => dispatch({ type: 'setShowMenu', payload: false })} + > +
+ {inCart.length > 0 && ( +
+ {inCart.length} +
+ )} +
+
+
+
+ ); +}; diff --git a/src/components/FavCarIcons/index.ts b/src/components/FavCarIcons/index.ts new file mode 100644 index 0000000000..e21f84f2ff --- /dev/null +++ b/src/components/FavCarIcons/index.ts @@ -0,0 +1 @@ +export * from './FavCarIcons'; diff --git a/src/components/Footer/CopyrightModal/CopyrightModal.module.scss b/src/components/Footer/CopyrightModal/CopyrightModal.module.scss new file mode 100644 index 0000000000..68acff5cde --- /dev/null +++ b/src/components/Footer/CopyrightModal/CopyrightModal.module.scss @@ -0,0 +1,51 @@ +@import '../../../styles/main'; + +.copyright_container { + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + overflow: auto; + background-color: #89939af4; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + + & .modal_overlay { + box-sizing: border-box; + width: 75vw; + max-width: 600px; + background-color: var(--c-elements); + border-radius: 10px; + box-shadow: 0 0 50px rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + } + & .modal_content { + padding: 15px; + } + + & .link { + @extend %uppercase; + @extend %link; + + cursor: pointer; + + @include hover; + } + + & .button { + width: 50%; + height: 40px; + &_wrap { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + padding-top: 15px; + } + } +} diff --git a/src/components/Footer/CopyrightModal/CopyrightModal.tsx b/src/components/Footer/CopyrightModal/CopyrightModal.tsx new file mode 100644 index 0000000000..a5ff0ecfb2 --- /dev/null +++ b/src/components/Footer/CopyrightModal/CopyrightModal.tsx @@ -0,0 +1,90 @@ +import { useEffect } from 'react'; +import style from './CopyrightModal.module.scss'; +import classNames from 'classnames'; +type Props = { + toggleModal: () => void; +}; +export const CopyrightModal: React.FC = ({ toggleModal }) => { + useEffect(() => { + document.body.style.overflowY = 'hidden'; + + return () => { + document.body.style.overflowY = 'auto'; + }; + }, []); + + return ( +
+
+
+

+ Copyright Notice +

+ +

© 2024 Seva Podolskiy’s Web Studio. All Rights Reserved.

+ +

+ This website and its content, including but not limited to text, + images, graphics, logos, icons, and design elements, are the + exclusive property of Seva Podolskiy’s Web Studio. Unauthorized use, + duplication, reproduction, distribution, or modification of any + content on this site is strictly prohibited without prior written + consent. +

+ +

Permitted Uses:

+ +
    +
  • Personal, non-commercial viewing of the website content.
  • +
  • + Sharing content via social media platforms with proper credit and + a direct link to this website. +
  • +
+ +

Prohibited Uses:

+ +
    +
  • Republishing material from this site without attribution.
  • +
  • Utilizing website content for commercial purposes.
  • +
  • + Using automated tools to scrape or extract data without written + authorization. +
  • +
+ +

+ Seva Podolskiy’s Web Studio respects intellectual property rights + and expects visitors to do the same. If you believe any content on + this site infringes on your copyright, please contact us immediately + at{' '} + + copyright@sevawebstudio.com + + . +

+ +

+ This copyright notice is subject to change without notice. By using + this website, you agree to comply with these terms and respect all + applicable copyright laws. +

+ +

+ Thank you for visiting and respecting the intellectual property on + this website! +

+ +
+
+
Close
+
+
+
+
+
+ ); +}; diff --git a/src/components/Footer/CopyrightModal/index.ts b/src/components/Footer/CopyrightModal/index.ts new file mode 100644 index 0000000000..b4876cd737 --- /dev/null +++ b/src/components/Footer/CopyrightModal/index.ts @@ -0,0 +1 @@ +export * from './CopyrightModal'; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 0000000000..2e884881c2 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,76 @@ +@import '../../styles/main'; + +.footer { + &_container { + display: flex; + flex-direction: column; + justify-content: space-between; + row-gap: 32px; + height: 100%; + width: 100%; + padding: 32px 16px; + + box-shadow: 0 1px 0 0 var(--c-elements); + + @include ontablet { + flex-direction: row; + } + + &_logo { + display: flex; + box-sizing: border-box; + align-items: center; + height: 32px; + width: 100%; + + @include ontablet { + flex-grow: 1; + flex-basis: 0; + } + } + + &_links { + @include ontablet { + flex-grow: 1; + flex-basis: 0; + } + } + + &_back_top { + display: flex; + justify-content: center; + align-items: center; + column-gap: 16px; + width: 100%; + + @include ontablet { + flex-grow: 1; + flex-basis: 0; + justify-content: flex-end; + } + } + } +} + +.back_top_text { + @extend %small-text; + + cursor: pointer; + color: var(--c-secondary); +} + +.icon { + &_container { + box-sizing: border-box; + + @extend %icon-container; + + width: 32px; + height: 32px; + background-color: var(--c-buttons-nav); + } + + &_upArrow { + @include icon-bg(var(--fls-up)); + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..0d003dea01 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,42 @@ +import classNames from 'classnames'; + +import style from './Footer.module.scss'; +import { FooterLinks } from './FooterLinks'; +import { SiteLogo } from '../SiteLogo'; + +export const Footer = () => { + const scrollToTop = () => { + window.scrollTo(0, 0); + }; + + return ( +
+
+ +
+ +
+ +
+ +
+
scrollToTop()} + > + Back to top +
+ +
scrollToTop()} + > +
+
+
+
+ ); +}; diff --git a/src/components/Footer/FooterLinks/FooterLinks.module.scss b/src/components/Footer/FooterLinks/FooterLinks.module.scss new file mode 100644 index 0000000000..d0775371df --- /dev/null +++ b/src/components/Footer/FooterLinks/FooterLinks.module.scss @@ -0,0 +1,23 @@ +@import '../../../styles/main'; + +.link_container { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + row-gap: 16px; + + @include ontablet { + height: 100%; + width: 100%; + flex-direction: row; + align-items: center; + gap: 14px; + } +} + +.link { + @extend %uppercase; + @extend %link; + @include hover; +} diff --git a/src/components/Footer/FooterLinks/FooterLinks.tsx b/src/components/Footer/FooterLinks/FooterLinks.tsx new file mode 100644 index 0000000000..3f114f7d64 --- /dev/null +++ b/src/components/Footer/FooterLinks/FooterLinks.tsx @@ -0,0 +1,42 @@ +import classNames from 'classnames'; +import style from './FooterLinks.module.scss'; +import { Link } from 'react-router-dom'; +import { CopyrightModal } from '../CopyrightModal'; +import { useState } from 'react'; + +export const FooterLinks: React.FC = () => { + const [isVisibleCopyright, setIsVisibleCopyright] = useState(false); + + const toogleModal = () => { + setIsVisibleCopyright(prev => !prev); + }; + + return ( +
+ {isVisibleCopyright && } + + + Github + + + + Contacts + + + toogleModal()} + > + Rights + +
+ ); +}; diff --git a/src/components/Footer/FooterLinks/index.ts b/src/components/Footer/FooterLinks/index.ts new file mode 100644 index 0000000000..fe667de823 --- /dev/null +++ b/src/components/Footer/FooterLinks/index.ts @@ -0,0 +1 @@ +export * from './FooterLinks'; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 0000000000..ddcc5a9cd1 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/GlobalProvider.tsx b/src/components/GlobalProvider.tsx new file mode 100644 index 0000000000..f304e795fa --- /dev/null +++ b/src/components/GlobalProvider.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useReducer } from 'react'; +import { Product } from '../types/Product'; +import { ProductItem } from '../types/ProductItem'; +import { accessLocalStorage } from '../utils/accessLocalStorage'; +import { getProducts } from '../utils/getProducts'; +import { LocalAccessKeys } from '../utils/LocalAccessKeys'; + +type State = { + showMenu: boolean; + showSearch: boolean; + inDarkMode: boolean; + products: Product[]; + phones: ProductItem[]; + tablets: ProductItem[]; + accessories: ProductItem[]; + inFavorites: Product[]; + inCart: Product[]; +}; + +type Action = + | { type: 'setShowMenu'; payload: boolean } + | { type: 'setShowSearch'; payload: boolean } + | { type: 'setInDarkMode'; payload: boolean } + | { type: 'setProducts'; payload: Product[] } + | { type: 'setPhones'; payload: ProductItem[] } + | { type: 'setTablets'; payload: ProductItem[] } + | { type: 'setAccessories'; payload: ProductItem[] } + | { type: 'toggleInFavorites'; payload: string } + | { type: 'toggleInCart'; payload: string } + | { type: 'setInFavotites'; payload: Product[] } + | { type: 'setInCart'; payload: Product[] }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setShowMenu': + return { ...state, showMenu: action.payload }; + + case 'setShowSearch': + return { ...state, showSearch: action.payload }; + + case 'setInDarkMode': + return { ...state, inDarkMode: action.payload }; + + case 'setProducts': + return { ...state, products: action.payload }; + + case 'setPhones': + return { ...state, phones: action.payload }; + + case 'setTablets': + return { ...state, tablets: action.payload }; + + case 'setAccessories': + return { ...state, accessories: action.payload }; + + case 'toggleInFavorites': + accessLocalStorage.toggle( + getProducts.getProductById(state.products, action.payload), + LocalAccessKeys.favorites, + ); + + return { + ...state, + inFavorites: accessLocalStorage.get(LocalAccessKeys.favorites), + }; + + case 'toggleInCart': + accessLocalStorage.toggle( + getProducts.getProductById(state.products, action.payload), + LocalAccessKeys.cart, + ); + + return { + ...state, + inCart: accessLocalStorage.get(LocalAccessKeys.cart), + }; + + case 'setInFavotites': + accessLocalStorage.set(action.payload, LocalAccessKeys.favorites); + + return { ...state, inFavorites: action.payload }; + + case 'setInCart': + accessLocalStorage.set(action.payload, LocalAccessKeys.cart); + + return { ...state, inCart: action.payload }; + + default: + return { ...state }; + } +} + +const initialState: State = { + showMenu: false, + showSearch: false, + inDarkMode: false, + products: [], + phones: [], + tablets: [], + accessories: [], + inFavorites: [], + inCart: [], +}; + +export const StateContext = React.createContext(initialState); + +export const DispatchContext = React.createContext>( + () => {}, +); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + dispatch({ + type: 'setInFavotites', + payload: accessLocalStorage.get(LocalAccessKeys.favorites), + }); + dispatch({ + type: 'setInCart', + payload: accessLocalStorage.get(LocalAccessKeys.cart), + }); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 0000000000..62f1ef38d5 --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,70 @@ +@import '../../styles/main'; + +.container { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + height: 48px; + padding: 0; + box-shadow: 0 1px 0 0 var(--c-elements); + + @include ondesktop { + height: 64px; + gap: 24px; + } +} + +.header { + &_logo { + display: flex; + box-sizing: border-box; + align-items: center; + height: 22px; + padding-inline: 16px; + min-width: 96px; + + @include ondesktop { + padding-inline: 24px; + } + } + + &_links { + display: none; + + @include ontablet { + display: block; + height: 100%; + } + } + + &_icons { + width: 100%; + height: 100%; + display: flex; + flex-direction: row-reverse; + + &_search { + @include ontablet { + display: block; + height: 100%; + } + } + + &_fav_cart { + display: none; + + @include ontablet { + display: block; + height: 100%; + } + } + + &_menu { + @include ontablet { + display: block; + height: 100%; + } + } + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..fbb683951f --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,45 @@ +import style from './Header.module.scss'; +import classNames from 'classnames'; +import { HeaderLinks } from '../HeaderLinks'; +import { FavCarIcons } from '../FavCarIcons'; +import { StateContext } from '../GlobalProvider'; +import { useContext } from 'react'; +import { MenuCloseIcons } from './MenuCloseIcons'; +import { SiteLogo } from '../SiteLogo'; +import { SearchModule } from './SearchModule'; + +export const Header = () => { + const { showMenu, showSearch } = useContext(StateContext); + + return ( +
+
+ +
+ + {!showMenu && ( +
+ +
+ )} + +
+ {!showMenu && ( +
+ +
+ )} + +
+ +
+ + {showSearch && !showMenu && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/components/Header/MenuCloseIcons/MenuCloseIcons.module.scss b/src/components/Header/MenuCloseIcons/MenuCloseIcons.module.scss new file mode 100644 index 0000000000..91f4e55ac8 --- /dev/null +++ b/src/components/Header/MenuCloseIcons/MenuCloseIcons.module.scss @@ -0,0 +1,46 @@ +@import '../../../styles/main'; + +.container { + display: flex; + justify-content: flex-end; + align-items: center; + height: 100%; + + &_menu { + display: flex; + justify-content: flex-end; + align-items: center; + height: 100%; + width: 100%; + + @include ontablet { + display: none; + } + } + + &_close { + display: flex; + justify-content: flex-end; + align-items: center; + height: 100%; + width: 100%; + } +} + +.icon { + &_container { + @extend %icon-container; + + width: 48px; + height: 48px; + } + + &_menu { + @include icon-bg(var(--fls-menu)); + + } + + &_close { + @include icon-bg(var(--fls-close)); + } +} diff --git a/src/components/Header/MenuCloseIcons/MenuCloseIcons.tsx b/src/components/Header/MenuCloseIcons/MenuCloseIcons.tsx new file mode 100644 index 0000000000..cedf425bfe --- /dev/null +++ b/src/components/Header/MenuCloseIcons/MenuCloseIcons.tsx @@ -0,0 +1,33 @@ +import classNames from 'classnames'; +import style from './MenuCloseIcons.module.scss'; +import { useContext } from 'react'; +import { DispatchContext, StateContext } from '../../GlobalProvider'; + +export const MenuCloseIcons = () => { + const { showMenu } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + return ( +
+ {!showMenu ? ( +
+
dispatch({ type: 'setShowMenu', payload: true })} + > +
+
+
+ ) : ( +
+
dispatch({ type: 'setShowMenu', payload: false })} + > +
+
+
+ )} +
+ ); +}; diff --git a/src/components/Header/MenuCloseIcons/index.ts b/src/components/Header/MenuCloseIcons/index.ts new file mode 100644 index 0000000000..f59aa1f445 --- /dev/null +++ b/src/components/Header/MenuCloseIcons/index.ts @@ -0,0 +1 @@ +export * from './MenuCloseIcons'; diff --git a/src/components/Header/SearchModule/SearchModule.module.scss b/src/components/Header/SearchModule/SearchModule.module.scss new file mode 100644 index 0000000000..658f11a2e1 --- /dev/null +++ b/src/components/Header/SearchModule/SearchModule.module.scss @@ -0,0 +1,51 @@ +@import '../../../styles/main'; + +.container { + box-sizing: border-box; + display: flex; + align-items: center; + height: 100%; + transition: border-color 0.3s; + border: 1px solid var(--c-icons); + + &:hover { + border-color: var(--c-primary); + } + + & .icon { + &_container { + @extend %icon-container; + + border: none; + + @include ontablet { + width: 48px; + height: 48px; + } + + @include ondesktop { + width: 64px; + height: 64px; + } + } + + &_search { + @include icon-bg(var(--fls-search)); + } + } + + & .input { + background-color: red; + all: unset; + padding: 8px 0; + margin: 0 8px; + border: none; + line-height: 11px; + width: 100%; + color: var(--c-primary); + + &::placeholder { + color: var(--c-secondary); + } + } +} diff --git a/src/components/Header/SearchModule/SearchModule.tsx b/src/components/Header/SearchModule/SearchModule.tsx new file mode 100644 index 0000000000..c0bbc1d570 --- /dev/null +++ b/src/components/Header/SearchModule/SearchModule.tsx @@ -0,0 +1,93 @@ +import classNames from 'classnames'; +import style from './SearchModule.module.scss'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { SearchParams } from '../../../types/SearchParams'; + +function debounce(callback: (query: string) => void, delay: number = 300) { + let timerId = 0; + + return (arg: string) => { + window.clearTimeout(timerId); + timerId = window.setTimeout(() => { + callback(arg); + }, delay); + }; +} + +export const SearchModule = () => { + const [showField, setShowField] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + setSearchQuery(() => { + const curQuery = searchParams.get(SearchParams.query); + + return curQuery ? curQuery : ''; + }); + }, [searchParams]); + + const updateSearchParams = useCallback( + (val: string) => { + const params = new URLSearchParams(searchParams); + + if (val) { + params.set(SearchParams.query, val); + } else { + params.delete(SearchParams.query); + } + + setSearchParams(() => params); + }, + [searchParams, setSearchParams], + ); + + const searchRef = useRef(null); + + const applyQuery = useMemo( + () => debounce(updateSearchParams), + [updateSearchParams], + ); + + const handleQueryChange = (event: React.ChangeEvent) => { + const val = event.target.value; + + setSearchQuery(() => val); + applyQuery(val); + }; + + const handleClicks = (eve: MouseEvent) => { + if (searchRef.current) { + if (!searchRef.current?.contains(eve.target as Node)) { + setShowField(false); + } + } + }; + + useEffect(() => document.addEventListener('click', handleClicks), []); + + return ( +
+
setShowField(prev => !prev)} + > +
+
+ + {showField && ( + + )} +
+ ); +}; diff --git a/src/components/Header/SearchModule/index.ts b/src/components/Header/SearchModule/index.ts new file mode 100644 index 0000000000..f9326d5786 --- /dev/null +++ b/src/components/Header/SearchModule/index.ts @@ -0,0 +1 @@ +export * from './SearchModule'; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 0000000000..266dec8a1b --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/HeaderLinks/HeaderLinks.module.scss b/src/components/HeaderLinks/HeaderLinks.module.scss new file mode 100644 index 0000000000..db050dbc7a --- /dev/null +++ b/src/components/HeaderLinks/HeaderLinks.module.scss @@ -0,0 +1,37 @@ + @import '../../styles/main'; + @import '../../styles/theme'; + + .container { + display: flex; + justify-content: space-between; + column-gap: 32px; + box-sizing: border-box; + + @include ondesktop { + column-gap: 64px; + } + + &_isMobileMenu { + flex-direction: column; + align-items: center; + row-gap: 16px; + } + } + + .link { + @extend %uppercase; + @extend %link; + + padding: 17px 0; + + + @include ondesktop { + padding: 25px 0; + } + + &_isMobileMenu { + padding-block: 8px; + } + + @include hover; + } diff --git a/src/components/HeaderLinks/HeaderLinks.tsx b/src/components/HeaderLinks/HeaderLinks.tsx new file mode 100644 index 0000000000..8898d4fc2b --- /dev/null +++ b/src/components/HeaderLinks/HeaderLinks.tsx @@ -0,0 +1,38 @@ +import classNames from 'classnames'; +import { NavLink } from 'react-router-dom'; +import React, { useContext } from 'react'; + +import style from './HeaderLinks.module.scss'; +import { MenuItems } from '../../types/MenuItems'; +import { DispatchContext, StateContext } from '../GlobalProvider'; + +export const HeaderLinks: React.FC = () => { + const { showMenu } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + return ( +
+ {['home', ...Object.values(MenuItems)].map(item => { + return ( + + classNames(style.link, { + isActive_link: isActive, + [style.link_isMobileMenu]: showMenu, + }) + } + onClick={() => dispatch({ type: 'setShowMenu', payload: false })} + > + {item} + + ); + })} +
+ ); +}; diff --git a/src/components/HeaderLinks/index.ts b/src/components/HeaderLinks/index.ts new file mode 100644 index 0000000000..1e4303a4a2 --- /dev/null +++ b/src/components/HeaderLinks/index.ts @@ -0,0 +1 @@ +export * from './HeaderLinks'; diff --git a/src/components/Loader/Loader.scss b/src/components/Loader/Loader.scss new file mode 100644 index 0000000000..1413677cb8 --- /dev/null +++ b/src/components/Loader/Loader.scss @@ -0,0 +1,26 @@ +.Loader { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + + &__content { + border-radius: 50%; + width: 10em; + height: 10em; + margin: 5em 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..b9a7378e88 --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,10 @@ +import './Loader.scss'; + +export const Loader = () => ( +
+
+
+); diff --git a/src/components/Loader/index.tsx b/src/components/Loader/index.tsx new file mode 100644 index 0000000000..d5ce981151 --- /dev/null +++ b/src/components/Loader/index.tsx @@ -0,0 +1 @@ +export * from './Loader'; diff --git a/src/components/MobileMenu/MobileMenu.module.scss b/src/components/MobileMenu/MobileMenu.module.scss new file mode 100644 index 0000000000..6e5f412dfc --- /dev/null +++ b/src/components/MobileMenu/MobileMenu.module.scss @@ -0,0 +1,37 @@ +@import '../../styles/main'; + +.wraper { + display: flex; + position: absolute; + width: 100%; + height: calc(100vh - 48px); + transition: all .3s; + transform: translate(100vw); + z-index: 1; + + &_up { + flex-grow: 1; + transform: translate(0%); + } +} + +.container { + display: flex; + flex-direction: column; + flex-grow: 1; + + &_links { + padding: 24px 16px 0; + flex-grow: 1; + } + + &_icons { + height: 64px; + width: auto; + } +} + +// .slide-pane { +// background-color: red; +// /* Change the background color */ +// } diff --git a/src/components/MobileMenu/MobileMenu.tsx b/src/components/MobileMenu/MobileMenu.tsx new file mode 100644 index 0000000000..c64ee79412 --- /dev/null +++ b/src/components/MobileMenu/MobileMenu.tsx @@ -0,0 +1,27 @@ +import classNames from 'classnames'; +import style from './MobileMenu.module.scss'; +import { HeaderLinks } from '../HeaderLinks'; +import { FavCarIcons } from '../FavCarIcons'; +import { useContext } from 'react'; +import { StateContext } from '../GlobalProvider'; + +export const MobileMenu = () => { + const { showMenu } = useContext(StateContext); + + return ( +
+
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/src/components/MobileMenu/index.ts b/src/components/MobileMenu/index.ts new file mode 100644 index 0000000000..298a8ff2b8 --- /dev/null +++ b/src/components/MobileMenu/index.ts @@ -0,0 +1 @@ +export * from './MobileMenu'; diff --git a/src/components/PhoneSlider/PhoneSlider.scss b/src/components/PhoneSlider/PhoneSlider.scss new file mode 100644 index 0000000000..f3b4dffc7e --- /dev/null +++ b/src/components/PhoneSlider/PhoneSlider.scss @@ -0,0 +1,85 @@ +@import '../../styles/main'; + +.phoneSlider.container { + box-sizing: border-box; + position: relative; + width: 100%; + +} + +.phoneSlider .title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 72px; + min-height: 41px; + padding-bottom: 24px; +} + +.phoneSlider.swiper { + &_container { + width: 100%; + height: 100%; + } + + width: 100%; + height: 100%; + + overflow: hidden; +} + +.phoneSlider .swiper-slide { + text-align: center; + font-size: 18px; + display: flex; + justify-content: center; + align-items: center; + height: 440px; + width: 212px; + + @include ontablet { + height: 506px; + width: 237px; + } + + @include ondesktop { + width: 272px; + } +} + +.phoneSlider .controls { + display: flex; + gap: 16px; + height: 32px; +} + +.phoneSlider .icon { + &_right { + @include icon-bg(var(--fls-right)); + } + + &_left { + @include icon-bg(var(--fls-left)); + } + + &_container { + @extend %icon-container; + + background-color: var(--c-buttons-nav); + + &.swiper-button-disabled { + background-color: var(--c-background); + + & .icon { + + &_left { + @include icon-bg(var(--fls-left-disabled)); + } + + &_right { + @include icon-bg(var(--fls-right-disabled)); + } + } + } + } +} diff --git a/src/components/PhoneSlider/PhoneSlider.tsx b/src/components/PhoneSlider/PhoneSlider.tsx new file mode 100644 index 0000000000..fa78037d48 --- /dev/null +++ b/src/components/PhoneSlider/PhoneSlider.tsx @@ -0,0 +1,83 @@ +import 'swiper/css'; +import 'swiper/css/pagination'; +import './PhoneSlider.scss'; + +import classNames from 'classnames'; +import 'swiper/css/navigation'; + +import React, { useRef } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Navigation } from 'swiper/modules'; +import { ProductCard } from '../ProductCard'; +import { Product } from '../../types/Product'; + +type Props = { + title: string; + products: Product[]; +}; + +export const PhoneSlider: React.FC = ({ title, products }) => { + const sliderRef = useRef>(null); + const prevRef = useRef(null); + const nextRef = useRef(null); + + return ( +
+
+

{title}

+ +
+
+
+
+ +
+
+
+
+
+ +
+ { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line no-param-reassign + swiper.params.navigation.prevEl = prevRef.current; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line no-param-reassign + swiper.params.navigation.nextEl = nextRef.current; + swiper.navigation.init(); + swiper.navigation.update(); + }} + > + {products.map((product: Product) => ( + + + + ))} + +
+
+ ); +}; diff --git a/src/components/PhoneSlider/index.ts b/src/components/PhoneSlider/index.ts new file mode 100644 index 0000000000..c48f164c9d --- /dev/null +++ b/src/components/PhoneSlider/index.ts @@ -0,0 +1 @@ +export * from './PhoneSlider'; diff --git a/src/components/PicturesSlider/PictureSlider.scss b/src/components/PicturesSlider/PictureSlider.scss new file mode 100644 index 0000000000..09b1163c73 --- /dev/null +++ b/src/components/PicturesSlider/PictureSlider.scss @@ -0,0 +1,93 @@ +@import '../../styles/main'; + +.photoSwiper { + &_container { + display: flex; + box-sizing: border-box; + width: 100%; + height: 100%; + gap: 19px; + + @include ontablet { + flex-direction: row; + } + + & .icon { + box-sizing: content-box; + + &_container { + @extend %icon-container; + + display: none; + flex-shrink: 0; + width: 32px; + height: 100%; + background-color: var(--c-buttons-nav); + + @include ontablet { + display: flex; + height: 189px; + } + + @include ondesktop { + height: 400px; + } + } + + &_right { + @include icon-bg(var(--fls-right)); + + &_disabled { + @include icon-bg(var(--fls-right-disabled)); + } + } + + &_left { + @include icon-bg(var(--fls-left)); + + &_disabled { + @include icon-bg(var(--fls-left-disabled)); + } + } + } + } + + &.swiper { + display: flex; + flex-direction: column; + width: 100%; + } + + & img { + height: 100%; + } + + & .swiper-slide { + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + height: 100vw; + + @include ontablet { + height: 189px; + } + + @include ondesktop { + height: 400px; + } + } + + & .swiper-pagination { + position: relative; + bottom: unset; + height: 24px; + + &-bullet { + height: 4px; + width: 14px; + border-radius: 0%; + background-color: var(--c-primary); + } + } +} diff --git a/src/components/PicturesSlider/PicturesSlider.tsx b/src/components/PicturesSlider/PicturesSlider.tsx new file mode 100644 index 0000000000..01e2e282ed --- /dev/null +++ b/src/components/PicturesSlider/PicturesSlider.tsx @@ -0,0 +1,71 @@ +import 'swiper/css'; +import 'swiper/css/pagination'; +import 'swiper/css/navigation'; + +import classNames from 'classnames'; + +import { useRef } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Autoplay, Navigation, Pagination } from 'swiper/modules'; +import type SwiperCore from 'swiper'; +import './PictureSlider.scss'; + +export const PicturesSlider = () => { + const swiperRef = useRef(); + + return ( +
+
swiperRef.current?.slidePrev()} + > +
+
+ + { + swiperRef.current = swiper; + }} + className="photoSwiper" + > + + banner-accessorie + + + + banner-phones + + + + banner-tablets + + + +
swiperRef.current?.slideNext()} + > +
+
+
+ ); +}; diff --git a/src/components/PicturesSlider/index.ts b/src/components/PicturesSlider/index.ts new file mode 100644 index 0000000000..81a373f3aa --- /dev/null +++ b/src/components/PicturesSlider/index.ts @@ -0,0 +1 @@ +export * from './PicturesSlider'; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 0000000000..63ac088091 --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,104 @@ +@import '../../styles/main'; +@import '../../styles/mixines'; + +.prod_card_container { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + height: 100%; + width: 100%; + border: 1px solid var(--c-elements); + padding: 32px; + + & .title { + cursor: default; + + @include hover; + + flex-grow: 1; + } + + & .price { + &_container { + width: 100%; + display: flex; + gap: 8px; + border-bottom: 1px solid var(--c-elements); + cursor: default; + } + + } + + & .specs { + &_container { + width: 100%; + display: flex; + flex-direction: column; + + .spec { + &_row { + width: 100%; + display: flex; + justify-content: space-between; + gap: 8px; + padding-block: 8px; + cursor: default; + } + + &_name { + @extend %small-text; + + color: var(--c-secondary); + } + + &_param { + @extend %small-text; + } + } + } + } + + & .bts { + &_container { + box-sizing: border-box; + display: flex; + width: 100%; + height: 40px; + } + } +} + +img.image { + width: 148px; + height: 130px; + object-fit: contain; + + @include hover; + + @include ontablet { + width: 173px; + height: 181px; + } + + @include ondesktop { + width: 208px; + height: 196px; + } +} + +h3.price { + &_discount { + color: var(--c-secondary); + text-decoration: line-through; + text-decoration-thickness: 2px; + } + +} + +.divider { + height: 1px; + width: 100%; + border: 1px solid var(--c-elements); +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000000..e52aff7947 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,68 @@ +import classNames from 'classnames'; +import style from './ProductCard.module.scss'; +import { Product } from '../../types/Product'; +import { ButtonsAddCardFav } from '../ButtonsAddCardFav'; +import { useNavigate } from 'react-router-dom'; + +type Props = { + product: Product; +}; + +export const ProductCard: React.FC = ({ product }) => { + const navigate = useNavigate(); + + const handleClick = () => { + navigate(`/${product.category}/${product.itemId}`); + }; + + return ( + <> + {product && ( +
+ product image + +

+ {product.name} +

+ +
+

${product.price}

+ +

+ ${product.fullPrice} +

+
+ +
+
+

Screen

+

{product.screen}

+
+ +
+

Capacity

+

{product.capacity}

+
+ +
+

RAM

+

{product.ram}

+
+
+ +
+ +
+
+ )} + + ); +}; diff --git a/src/components/ProductCard/index.ts b/src/components/ProductCard/index.ts new file mode 100644 index 0000000000..7ce031c382 --- /dev/null +++ b/src/components/ProductCard/index.ts @@ -0,0 +1 @@ +export * from './ProductCard'; diff --git a/src/components/SiteLogo/SiteLogo.module.scss b/src/components/SiteLogo/SiteLogo.module.scss new file mode 100644 index 0000000000..dee753dd6c --- /dev/null +++ b/src/components/SiteLogo/SiteLogo.module.scss @@ -0,0 +1,17 @@ +@import '../../styles/main'; + +.logo { + + &_img { + display: flex; + box-sizing: border-box; + align-items: flex-start; + height: 32px; + width: 90px; + background: no-repeat center/contain; + background-image: var(--fls-logo); + + @include hover; + + } +} diff --git a/src/components/SiteLogo/SiteLogo.tsx b/src/components/SiteLogo/SiteLogo.tsx new file mode 100644 index 0000000000..fe37026518 --- /dev/null +++ b/src/components/SiteLogo/SiteLogo.tsx @@ -0,0 +1,18 @@ +import { useContext } from 'react'; +import { StateContext } from '../GlobalProvider'; +import style from './SiteLogo.module.scss'; +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; + +export const SiteLogo = () => { + const { inDarkMode } = useContext(StateContext); + + return ( + + ); +}; diff --git a/src/components/SiteLogo/index.ts b/src/components/SiteLogo/index.ts new file mode 100644 index 0000000000..a2cc2164ff --- /dev/null +++ b/src/components/SiteLogo/index.ts @@ -0,0 +1 @@ +export * from './SiteLogo'; diff --git a/src/components/SortFilter/Selector/Selector.module.scss b/src/components/SortFilter/Selector/Selector.module.scss new file mode 100644 index 0000000000..c2f7b9a64a --- /dev/null +++ b/src/components/SortFilter/Selector/Selector.module.scss @@ -0,0 +1,93 @@ +@import '../../../styles/main'; + + +.selector { + &_container { + display: flex; + flex-direction: column; + + .selection_hidden { + transform: scaleY(1); + } + } + + &_button { + display: flex; + border: 1px solid var(--c-elements); + align-items: center; + justify-content: space-between; + padding-inline: 12px; + height: 40px; + + &_title { + cursor: default; + } + + & .icon { + display: flex; + align-items: center; + + &_container { + @extend %icon-container; + + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + + border: none; + + + } + + &_up { + @include icon-bg(var(--fls-up-disabled)); + + width: 16px; + height: 16px; + } + + &_down { + @include icon-bg(var(--fls-down-disabled)); + + width: 16px; + height: 16px; + } + } + } + + &_body { + display: flex; + flex-direction: column; + gap: 5px; + border: 1px solid var(--c-elements); + position: absolute; + top: 4px; + width: 100%; + background-color: var(--c-background); + padding-block: 8px; + transition: all 200ms 0ms ease-in-out; + transform: scaleY(0); + transform-origin: top; + z-index: 1; + + &_container { + position: relative; + } + + &__text { + cursor: default; + color: var(--c-secondary); + font-weight: 500; + text-decoration: none; + padding-inline: 12px; + + @include hover(transform, scale(1.02)); + } + } + + + +} diff --git a/src/components/SortFilter/Selector/Selector.tsx b/src/components/SortFilter/Selector/Selector.tsx new file mode 100644 index 0000000000..4eb75ec091 --- /dev/null +++ b/src/components/SortFilter/Selector/Selector.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import style from './Selector.module.scss'; +import { useSearchParams } from 'react-router-dom'; +import { SearchParams } from '../../../types/SearchParams'; + +type Props = { + searchParamName: string; + options: string[]; + defaultValue?: number; +}; +export const Selector: React.FC = ({ + searchParamName, + options, + defaultValue = 0, +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const [visible, setVisible] = useState(false); + const [selection, setSelection] = useState(defaultValue); + + const selectBodyRef = useRef(null); + const selectButtonRef = useRef(null); + + const handleSelect = (index: number) => { + setVisible(() => false); + setSelection(() => index); + + const params = new URLSearchParams(searchParams); + + params.set(searchParamName, options[index]); + params.delete(SearchParams.page); + setSearchParams(params); + }; + + const handleClicks = (eve: MouseEvent) => { + if (selectBodyRef.current && selectButtonRef.current) { + if (selectButtonRef.current?.contains(eve.target as Node)) { + setVisible(prev => !prev); + } else if (!selectBodyRef.current?.contains(eve.target as Node)) { + setVisible(false); + } + } + }; + + useEffect(() => document.addEventListener('click', handleClicks), []); + + useEffect(() => { + const curIndex = options.findIndex( + opt => opt === searchParams.get(searchParamName), + ); + + if (curIndex >= 0) { + setSelection(curIndex); + + return; + } + + setSelection(defaultValue); + }, [searchParams, defaultValue, options, searchParamName]); + + return ( +
+
+

+ {options[selection]} +

+ +
+
+
+
+ +
+
+ {options.map((opt, index) => { + return ( +

{ + handleSelect(index); + }} + > + {opt} +

+ ); + })} +
+
+
+ ); +}; diff --git a/src/components/SortFilter/Selector/index.ts b/src/components/SortFilter/Selector/index.ts new file mode 100644 index 0000000000..c27310e5d8 --- /dev/null +++ b/src/components/SortFilter/Selector/index.ts @@ -0,0 +1 @@ +export * from './Selector'; diff --git a/src/components/SortFilter/SortFilter.module.scss b/src/components/SortFilter/SortFilter.module.scss new file mode 100644 index 0000000000..5f2038649a --- /dev/null +++ b/src/components/SortFilter/SortFilter.module.scss @@ -0,0 +1,35 @@ +@import '../../styles/main'; + +.search_filter_container { + @include page-grid; + + & .container { + &_title { + color: var(--c-secondary); + } + + &_item { + display: flex; + flex-direction: column; + gap: 4px; + justify-content: space-between; + } + + &_sort { + grid-column: 1/ span 2; + + @include ontablet { + grid-column: 1/ span 4; + } + + } + + &_perPage { + grid-column: 3/ span 2; + + @include ontablet { + grid-column: 5/ span 3; + } + } + } +} diff --git a/src/components/SortFilter/SortFilter.tsx b/src/components/SortFilter/SortFilter.tsx new file mode 100644 index 0000000000..7823226fab --- /dev/null +++ b/src/components/SortFilter/SortFilter.tsx @@ -0,0 +1,33 @@ +import classNames from 'classnames'; +import style from './SortFilter.module.scss'; +import { Selector } from './Selector'; +import { SearchParams } from '../../types/SearchParams'; +import { SortingTypes } from '../../types/SortFilter'; + +const perPage = ['4', '8', '16', 'All']; +const sorting = Object.values(SortingTypes); + +export const SortFilter = () => { + return ( +
+
+

Sort by

+ +
+ +
+

Items on page

+ +
+
+ ); +}; diff --git a/src/components/SortFilter/index.ts b/src/components/SortFilter/index.ts new file mode 100644 index 0000000000..5434a0427d --- /dev/null +++ b/src/components/SortFilter/index.ts @@ -0,0 +1 @@ +export * from './SortFilter'; diff --git a/src/index.tsx b/src/index.tsx index 50470f1508..b3bee57714 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,9 @@ import { createRoot } from 'react-dom/client'; -import { App } from './App'; +import { Root } from './modules/Root'; +import { GlobalProvider } from './components/GlobalProvider'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + , +); diff --git a/src/modules/AccessoriesPage/AccessoriesPage.module.scss b/src/modules/AccessoriesPage/AccessoriesPage.module.scss new file mode 100644 index 0000000000..c3b9bf1178 --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.module.scss @@ -0,0 +1,6 @@ +@import '../../styles/main'; + +.tabletPage_container { + width: 100%; + height: 100%; +} diff --git a/src/modules/AccessoriesPage/AccessoriesPage.tsx b/src/modules/AccessoriesPage/AccessoriesPage.tsx new file mode 100644 index 0000000000..2863e4cd3c --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.tsx @@ -0,0 +1,40 @@ +import style from './AccessoriesPage.module.scss'; +import { useContext, useEffect, useMemo } from 'react'; +import { DispatchContext, StateContext } from '../../components/GlobalProvider'; +import { Catalog } from '../../components/Catalog/Catalog'; +import { getProducts } from '../../utils/getProducts'; +import { MenuItems } from '../../types/MenuItems'; +import { SearchParams } from '../../types/SearchParams'; +import { useSearchParams } from 'react-router-dom'; + +export const AccessoriesPage = () => { + const { products, showSearch } = useContext(StateContext); + const [searchParams] = useSearchParams(); + const dispatch = useContext(DispatchContext); + + const accessories = useMemo(() => { + const allAccessories = getProducts.getProductByCategory( + products, + MenuItems.accessories, + ); + + return getProducts.getFilteredByQuery( + allAccessories, + searchParams.get(SearchParams.query), + ); + }, [products, searchParams]); + + useEffect( + () => dispatch({ type: 'setShowSearch', payload: true }), + [dispatch, showSearch], + ); + + return ( +
+ +
+ ); +}; diff --git a/src/modules/AccessoriesPage/index.ts b/src/modules/AccessoriesPage/index.ts new file mode 100644 index 0000000000..486474aa0b --- /dev/null +++ b/src/modules/AccessoriesPage/index.ts @@ -0,0 +1 @@ +export * from './AccessoriesPage'; diff --git a/src/modules/App/App.module.scss b/src/modules/App/App.module.scss new file mode 100644 index 0000000000..b36b664596 --- /dev/null +++ b/src/modules/App/App.module.scss @@ -0,0 +1,44 @@ +@import '../../styles/main'; + +.container { + display: flex; + flex-direction: column; + min-height: 100vh; + overflow: hidden; + + &_header { + background-color: var(--c-background); + display: flex; + justify-content: center; + position: sticky; + top: 0; + } + + &_mobile_menu { + position: relative; + background-color: var(--c-background); + display: flex; + flex-direction: column; + + } + + &_body { + display: flex; + justify-content: center; + width: 100%; + max-width: 1200px; + flex-grow: 1; + + + @include ondesktop { + margin: 0 auto; + } + } + + &_footer { + background-color: var(--c-background); + display: flex; + justify-content: center; + box-shadow: 0 -1px 0 0 #E2E6E9; + } +} diff --git a/src/modules/App/App.tsx b/src/modules/App/App.tsx new file mode 100644 index 0000000000..8e6fd35d5e --- /dev/null +++ b/src/modules/App/App.tsx @@ -0,0 +1,76 @@ +import { Outlet, useLocation } from 'react-router-dom'; +import classNames from 'classnames'; + +import style from './App.module.scss'; +import '../../styles/theme.scss'; +import '../../styles/main.scss'; +import { Footer } from '../../components/Footer'; +import { Header } from '../../components/Header'; +import { useContext, useEffect, useState } from 'react'; +import { MobileMenu } from '../../components/MobileMenu'; +import { DispatchContext, StateContext } from '../../components/GlobalProvider'; +import { Loader } from '../../components/Loader'; +import { getProducts } from '../../utils/getProducts'; + +const ScrollToTop = () => { + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return <>; +}; + +export const App = () => { + const { showMenu } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(() => true); + getProducts + .fetchProducts() + .then(res => { + dispatch({ type: 'setProducts', payload: res }); + }) + .finally(() => setLoading(() => false)); + }, [dispatch]); + + return ( +
+ + +

Product Catalog

+ +
+
+
+ +
+ +
+ + {!showMenu && ( + <> + {loading ? ( +
+ +
+ ) : ( +
+ +
+ )} + +
+
+
+ + )} +
+ ); +}; diff --git a/src/modules/CartPage/CartItem/CartItem.module.scss b/src/modules/CartPage/CartItem/CartItem.module.scss new file mode 100644 index 0000000000..818a9bab2e --- /dev/null +++ b/src/modules/CartPage/CartItem/CartItem.module.scss @@ -0,0 +1,129 @@ +@import '../../../styles/main'; + +.cartItem_container { + display: flex; + flex-direction: column; + justify-content: space-between; + border: 1px solid var(--c-elements); + padding: 16px; + gap: 16px; + + @include ontablet { + flex-direction: row; + gap: 24px; + } + + & .icon { + display: flex; + align-items: center; + width: 16px; + height: 16px; + + &_container { + @extend %icon-container; + + background-color: var(--c-buttons-nav); + + &_disabled { + border-color: var(--c-elements); + background-color: var(--c-background); + + &:hover { + border-color: var(--c-elements); + } + } + } + } + + & .top { + display: flex; + align-items: center; + gap: 16px; + + @include ontablet { + gap: 24px; + } + + & .icon { + &_container { + cursor: pointer; + width: 16px; + height: 16px; + border: none; + flex-shrink: 0; + + &_close { + background-color: var(--c-background); + } + } + + &_close { + @include icon-bg(var(--fls-close)); + } + } + + & .container_image { + width: 80px; + height: 80px; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + } + + & .image { + width: 100%; + height: 100%; + object-fit: contain; + } + + & .name { + text-decoration: none; + color: (var(--c-primary)); + cursor: pointer; + + @include hover; + } + } + + & .bottom { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + + @include ontablet { + gap: 24px; + } + + & .counter_container { + display: flex; + align-items: center; + + & .icon { + &_container { + width: 32px; + height: 32px; + flex-shrink: 0; + } + + &_plus { + @include icon-bg(var(--fls-plus)); + } + + &_minus { + @include icon-bg(var(--fls-minus)); + + &_disabled { + @include icon-bg(var(--fls-minus-disabled)); + } + } + } + + & .count_text { + width: 32px; + text-align: center; + } + } + } +} diff --git a/src/modules/CartPage/CartItem/CartItem.tsx b/src/modules/CartPage/CartItem/CartItem.tsx new file mode 100644 index 0000000000..2fe5ca0cf5 --- /dev/null +++ b/src/modules/CartPage/CartItem/CartItem.tsx @@ -0,0 +1,82 @@ +import classNames from 'classnames'; +import { Product } from '../../../types/Product'; +import style from './CartItem.module.scss'; +import { Link } from 'react-router-dom'; + +type Props = { + product: Product; + count: number; + onIncrease: (id: string) => void; + onDecrease: (id: string) => void; + onClear: (id: string) => void; +}; +export const CartItem: React.FC = ({ + product, + count, + onIncrease = () => {}, + onDecrease = () => {}, + onClear = () => {}, +}) => { + return ( +
+ {product && ( + <> +
+
onClear(product.itemId)} + > +
+
+ +
+ phone-image +
+ + + {product.name} + +
+ +
+
+
onDecrease(product.itemId)} + > +
+
+ +

{count}

+ +
onIncrease(product.itemId)} + > +
+
+
+ +

${product.price}

+
+ + )} +
+ ); +}; diff --git a/src/modules/CartPage/CartItem/index.ts b/src/modules/CartPage/CartItem/index.ts new file mode 100644 index 0000000000..37a0553540 --- /dev/null +++ b/src/modules/CartPage/CartItem/index.ts @@ -0,0 +1 @@ +export * from './CartItem'; diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 0000000000..96c3f966f6 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,161 @@ +@import '../../styles/main'; + +.cart { + &_container { + width: 100%; + + @include inline-padding; + + padding-block: 24px 56px; + display: flex; + flex-direction: column; + gap: 32px; + + @include ontablet { + padding-block: 40px 64px; + } + + @include ondesktop { + padding-block: 40px 80px; + } + + & .container_nav { + display: flex; + align-items: center; + + & .icon { + display: flex; + align-items: center; + width: 16px; + height: 16px; + + + &_container { + @extend %icon-container; + + width: 16px; + height: 16px; + border: none; + } + + &_left { + @include icon-bg(var(--fls-left)); + } + } + + & p { + color: var(--c-secondary); + cursor: pointer; + + &:hover { + color: var(--c-primary) + } + } + } + + & .container_body { + display: flex; + flex-direction: column; + gap: 32px; + + & .container { + &_cartItems { + display: flex; + flex-direction: column; + gap: 16px; + } + + &_summary { + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px; + border: 1px solid var(--c-elements); + + @include ondesktop { + width: 368px; + } + + & .seperator { + border: 1px solid var(--c-elements); + width: 100%; + } + } + + &_priceTotal { + display: flex; + flex-direction: column; + align-items: center; + + & p { + color: var(--c-secondary); + } + } + + &_checkout { + height: 48px; + width: 100%; + padding: 0; + } + } + } + + & .modal_container { + align-items: center; + background-color: rgba(0, 0, 0, .9); + display: flex; + inset: 0; + justify-content: center; + position: fixed; + z-index: 1000; + + & .modal { + background-color: var(--c-background); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + } + + & .modal_buttons { + display: flex; + justify-content: space-between; + height: 48px; + width: 100%; + + & .modal_bt { + width: 100px; + } + + } + + } + + & .container_noproducts { + display: flex; + align-items: center; + flex-direction: column; + + & .empty_image { + height: 150px; + } + } + + & .container_main { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 32px; + width: 100%; + + @include ondesktop { + flex-direction: row; + align-items: flex-start; + gap: 16px; + } + + } + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 0000000000..aceb8c2f66 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,172 @@ +import style from './CartPage.module.scss'; +import { CartItem } from './CartItem'; +import { Product } from '../../types/Product'; +import { v4 as uuidv4 } from 'uuid'; +import { useContext, useEffect, useState } from 'react'; +import { getProducts } from '../../utils/getProducts'; +import classNames from 'classnames'; +import { useNavigate } from 'react-router-dom'; +import { DispatchContext, StateContext } from '../../components/GlobalProvider'; + +export const CartPage = () => { + const navigate = useNavigate(); + const { inCart: products } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const [uniqueProducts, setUniqueProducts] = useState([]); + const [checkOutActive, setCheckoutActive] = useState(false); + + useEffect(() => { + const uniqueArr: Product[] = []; + + for (let i = 0; i < products.length; i++) { + const prod = products[i]; + + if (uniqueArr.every(uprod => prod.itemId !== uprod.itemId)) { + uniqueArr.push(prod); + } + } + + setUniqueProducts(() => [...uniqueArr].sort((a, b) => b.price - a.price)); + }, [products]); + + const countProducts = (itemId: string) => + products.filter((prod: Product) => itemId === prod.itemId).length; + + const handleCountIncrease = (id: string) => { + const newProd = getProducts.getProductById(products, id); + + if (newProd) { + dispatch({ type: 'setInCart', payload: [...products, newProd] }); + } else { + dispatch({ type: 'setInCart', payload: [...products] }); + } + }; + + const handleCountDecrease = (id: string) => { + if (countProducts(id) - 1 > 0) { + const delIndex = products.findIndex(prod => prod.itemId === id); + const newArr = [...products]; + + newArr.splice(delIndex, 1); + dispatch({ type: 'setInCart', payload: newArr }); + } + }; + + const handleClearItem = (id: string) => { + const clearedProds = products.filter(prod => prod.itemId !== id); + + dispatch({ type: 'setInCart', payload: clearedProds }); + }; + + const getTotalProce = () => { + return products.reduce((acc, cur) => acc + cur.price, 0); + }; + + const clearCart = () => dispatch({ type: 'setInCart', payload: [] }); + + return ( +
+
+
+
+
+ +

navigate(-1)}>Back

+
+ + {products.length > 0 ? ( +
+
+

Cart

+
+
+
+ {uniqueProducts.map(prod => { + return ( + + ); + })} +
+ +
+
+

${getTotalProce()}

+ +

{`Total for ${products.length === 1 ? `${products.length} item` : `${products.length} items`}`}

+
+ +
+ +
setCheckoutActive(true)} + > +
+ Checkout +
+
+
+
+
+ ) : ( +
+

Your Cart is Empty

+ +
+ )} + + {checkOutActive && ( +
+
+

Checkout is not implemented yet.

+ +

Do you want to clear the Cart?

+ +
+
{ + clearCart(); + setCheckoutActive(false); + }} + > +
Clear
+
+ +
setCheckoutActive(() => false)} + > +
Cancel
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 0000000000..90c010237a --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export * from './CartPage'; diff --git a/src/modules/FavotitePage/FavoritePage.module.scss b/src/modules/FavotitePage/FavoritePage.module.scss new file mode 100644 index 0000000000..d5263822c8 --- /dev/null +++ b/src/modules/FavotitePage/FavoritePage.module.scss @@ -0,0 +1,6 @@ +@import '../../styles/main'; + +.favoritePage_container { + width: 100%; + height: 100%; +} diff --git a/src/modules/FavotitePage/FavoritePage.tsx b/src/modules/FavotitePage/FavoritePage.tsx new file mode 100644 index 0000000000..a46bf9d400 --- /dev/null +++ b/src/modules/FavotitePage/FavoritePage.tsx @@ -0,0 +1,27 @@ +import style from './FavoritePage.module.scss'; +import { Catalog } from '../../components/Catalog/Catalog'; +import { useContext, useEffect, useState } from 'react'; +import { DispatchContext, StateContext } from '../../components/GlobalProvider'; + +export const FavoritePage = () => { + const { inFavorites } = useContext(StateContext); + const [favorites, setFavorites] = useState(inFavorites); + const { showSearch } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + useEffect( + () => dispatch({ type: 'setShowSearch', payload: true }), + [dispatch, showSearch], + ); + useEffect(() => setFavorites(() => inFavorites), [inFavorites]); + + return ( +
+ +
+ ); +}; diff --git a/src/modules/FavotitePage/index.ts b/src/modules/FavotitePage/index.ts new file mode 100644 index 0000000000..e3ca0d0fcb --- /dev/null +++ b/src/modules/FavotitePage/index.ts @@ -0,0 +1 @@ +export * from './FavoritePage'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 0000000000..4e14b0681c --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,75 @@ +@import '../../styles/main'; + +.title { + @include page-grid; + + &_text { + grid-column: 1/-1; + + } +} + +.container { + width: 100%; + display: flex; + flex-direction: column; + gap: 24px; + padding-block: 24px 64px; + + @include ontablet { + gap: 32px; + } + + @include ondesktop { + gap: 56px; + } + + & .title { + @include inline-padding; + } + + &_homepage { + box-sizing: border-box; + display: flex; + flex-direction: column; + row-gap: 56px; + + width: 100%; + + @include ontablet { + row-gap: 64px; + } + + @include ondesktop { + row-gap: 80px; + } + } +} + +.photoSlide_wraper { + box-sizing: border-box; + + + @include ontablet { + padding-inline: 24px; + min-height: 221px; + max-height: 432px; + } +} + +.slider_container { + box-sizing: border-box; + width: 100%; + + @include inline-padding; + +} + +.container_categories { + @include inline-padding; + @include page-grid; + + &_body { + grid-column: 1/-1; + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 0000000000..7be3dcc9ae --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,66 @@ +import classNames from 'classnames'; + +import style from './HomePage.module.scss'; +import { PhoneSlider } from '../../components/PhoneSlider'; +import { useContext, useEffect, useMemo } from 'react'; +import { DispatchContext, StateContext } from '../../components/GlobalProvider'; +import { Categories } from '../../components/Categories'; +import { PicturesSlider } from '../../components/PicturesSlider'; +import { getProducts } from '../../utils/getProducts'; + +export const HomePage = () => { + const { products } = useContext(StateContext); + const { showSearch } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + useEffect( + () => dispatch({ type: 'setShowSearch', payload: false }), + [dispatch, showSearch], + ); + + const newPhone = useMemo( + () => getProducts.getNewProducts(products), + [products], + ); + + const hotDealsProducts = useMemo( + () => getProducts.getHotDealsProducts(products), + [products], + ); + + return ( +
+
+

+ Welcome to Nice Gadgets store! +

+
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+
+
+ ); +}; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 0000000000..11e53da674 --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export * from './HomePage'; diff --git a/src/modules/PageNotFoundPage/PageNotFoundPage.module.scss b/src/modules/PageNotFoundPage/PageNotFoundPage.module.scss new file mode 100644 index 0000000000..efe2fa9b5f --- /dev/null +++ b/src/modules/PageNotFoundPage/PageNotFoundPage.module.scss @@ -0,0 +1,16 @@ +@import '../../styles/main'; + +.page_not_found_container{ + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 20px; + + & .img{ + flex-grow: 1; + width: 30vw; + aspect-ratio: 1/1; + background: url(../../../public/img/page-not-found.png) no-repeat center/contain;; + } +} diff --git a/src/modules/PageNotFoundPage/PageNotFoundPage.tsx b/src/modules/PageNotFoundPage/PageNotFoundPage.tsx new file mode 100644 index 0000000000..30e318025f --- /dev/null +++ b/src/modules/PageNotFoundPage/PageNotFoundPage.tsx @@ -0,0 +1,10 @@ +import style from './PageNotFoundPage.module.scss'; + +export const PageNotFoundPage = () => { + return ( +
+

Sorry Page Not Found

+
+
+ ); +}; diff --git a/src/modules/PageNotFoundPage/index.ts b/src/modules/PageNotFoundPage/index.ts new file mode 100644 index 0000000000..dcae515118 --- /dev/null +++ b/src/modules/PageNotFoundPage/index.ts @@ -0,0 +1 @@ +export * from './PageNotFoundPage'; diff --git a/src/modules/PhonePage/PhonePage.module.scss b/src/modules/PhonePage/PhonePage.module.scss new file mode 100644 index 0000000000..d661c194af --- /dev/null +++ b/src/modules/PhonePage/PhonePage.module.scss @@ -0,0 +1,6 @@ +@import '../../styles/main'; + +.phonePage_container { + width: 100%; + height: 100%; +} diff --git a/src/modules/PhonePage/PhonePage.tsx b/src/modules/PhonePage/PhonePage.tsx new file mode 100644 index 0000000000..a0b729dac8 --- /dev/null +++ b/src/modules/PhonePage/PhonePage.tsx @@ -0,0 +1,40 @@ +import style from './PhonePage.module.scss'; +import { useContext, useEffect, useMemo } from 'react'; +import { DispatchContext, StateContext } from '../../components/GlobalProvider'; +import { Catalog } from '../../components/Catalog/Catalog'; +import { getProducts } from '../../utils/getProducts'; +import { MenuItems } from '../../types/MenuItems'; +import { useSearchParams } from 'react-router-dom'; +import { SearchParams } from '../../types/SearchParams'; + +export const PhonePage = () => { + const { products, showSearch } = useContext(StateContext); + const [searchParams] = useSearchParams(); + const dispatch = useContext(DispatchContext); + + const phones = useMemo(() => { + const allPhones = getProducts.getProductByCategory( + products, + MenuItems.phones, + ); + + return getProducts.getFilteredByQuery( + allPhones, + searchParams.get(SearchParams.query), + ); + }, [products, searchParams]); + + useEffect( + () => dispatch({ type: 'setShowSearch', payload: true }), + [dispatch, showSearch], + ); + + return ( +
+ +
+ ); +}; diff --git a/src/modules/PhonePage/index.ts b/src/modules/PhonePage/index.ts new file mode 100644 index 0000000000..b5a582aec3 --- /dev/null +++ b/src/modules/PhonePage/index.ts @@ -0,0 +1 @@ +export * from './PhonePage'; diff --git a/src/modules/ProductDetailsPage/ItemPhoto/ItemPhoto.tsx b/src/modules/ProductDetailsPage/ItemPhoto/ItemPhoto.tsx new file mode 100644 index 0000000000..7ca6db47db --- /dev/null +++ b/src/modules/ProductDetailsPage/ItemPhoto/ItemPhoto.tsx @@ -0,0 +1,57 @@ +import './styles.scss'; +import 'swiper/css'; +import 'swiper/css/free-mode'; +import 'swiper/css/navigation'; +import 'swiper/css/thumbs'; +import React, { useState } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { SwiperClass } from 'swiper/react'; +import { FreeMode, Navigation, Thumbs } from 'swiper/modules'; +import { ProductItem } from '../../../types/ProductItem'; + +type Props = { + item: ProductItem; +}; + +export const ItemPhoto: React.FC = ({ item }) => { + const [thumbsSwiper, setThumbsSwiper] = useState(null); + + if (!item) { + return

Somthing went wrong

; + } + + const swiperSlideSet = item.images.map((imageLink: string) => { + return ( + + + + ); + }); + + return ( +
+ + {swiperSlideSet} + + + + {swiperSlideSet} + +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/ItemPhoto/index.ts b/src/modules/ProductDetailsPage/ItemPhoto/index.ts new file mode 100644 index 0000000000..cc62838565 --- /dev/null +++ b/src/modules/ProductDetailsPage/ItemPhoto/index.ts @@ -0,0 +1 @@ +export * from './ItemPhoto'; diff --git a/src/modules/ProductDetailsPage/ItemPhoto/styles.scss b/src/modules/ProductDetailsPage/ItemPhoto/styles.scss new file mode 100644 index 0000000000..b2ccfd7275 --- /dev/null +++ b/src/modules/ProductDetailsPage/ItemPhoto/styles.scss @@ -0,0 +1,63 @@ +@import '../../../styles/main'; + +.ItemPhoto_container { + height: 100%; + display: flex; + flex-direction: column; + gap: 16px; + + @include ontablet { + flex-direction: row-reverse; + } + + & .ItemPhoto { + &_thumbs { + // width: 100%; + + & .swiper-wrapper { + @include ontablet { + flex-direction: column; + } + } + + & .swiper-slide { + box-sizing: border-box; + overflow: hidden; + height: 49px; + width: 49px; + display: flex; + justify-content: center; + gap: 8px; + padding: 4px; + border: 1px solid var(--c-elements); + + } + + & .swiper-slide-thumb-active { + border: 1px solid var(--c-primary) + } + } + + &_main { + width: 100%; + height: 288px; + + + & .swiper-wrapper { + height: 100%; + } + + & .swiper-slide { + box-sizing: border-box; + display: flex; + justify-content: center; + height: 100%; + overflow: hidden; + aspect-ratio: 1/1; + + + } + + } + } +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 0000000000..f91f2acba2 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,312 @@ +@import '../../styles/main'; + +.prod_page_container { + box-sizing: border-box; + display: flex; + width: 100%; + max-width: 1200px; + padding-block: 24px 56px; + + @include inline-padding; + + @include ontablet { + padding-block: 24px 64px; + } + + @include ondesktop { + padding-block: 24px 81px; + } + + & .container { + &_body { + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 100%; + + @include ondesktop { + @include page-grid; + + padding-block: 0; + } + } + + &_breadcrumbs { + width: 100%; + padding-bottom: 24px; + + @include ontablet { + padding-bottom: 40px; + } + + @include ondesktop { + grid-row: 1/1; + grid-column: 1/-1; + } + } + + &_back_bt { + display: flex; + position: relative; + align-items: flex-end; + padding-bottom: 16px; + + @include ondesktop { + grid-row: 2/2; + grid-column: 1/-1; + } + + & .icon { + display: flex; + align-items: center; + + &_container { + @extend %icon-container; + + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + } + + &_right { + @include icon-bg(var(--fls-left)); + + width: 16px; + height: 16px; + } + } + + &_text { + cursor: pointer; + + @extend %small-text; + + color: var(--c-secondary); + line-height: 100%; + + &:hover { + color: var(--c-primary); + } + + } + } + + &_title { + padding-bottom: 32px; + + @include ontablet { + padding-bottom: 40px; + } + + @include ondesktop { + grid-row: 3/3; + grid-column: 1/-1; + } + } + + &_photo_option { + width: 100%; + + @include ontablet { + padding-bottom: 64px; + + @include page-grid; + } + + @include ondesktop { + padding-bottom: 80px; + grid-row: 4/4; + grid-column: 1/-1; + } + + & .photo_slider { + padding-bottom: 40px; + + @include ontablet { + padding-bottom: 0; + grid-column: 1/7; + } + + @include ondesktop { + grid-column: 1/13; + } + + } + + & .options_addCart { + display: flex; + flex-direction: column; + gap: 24px; + padding-bottom: 56px; + + @include ontablet { + padding-bottom: 0; + grid-column: 7/-1; + } + + @include ondesktop { + grid-column: 14/ span 7; + } + + & .container { + &_colors_selection { + display: flex; + flex-direction: column; + gap: 24px; + } + + &_capacity_selection { + display: flex; + flex-direction: column; + gap: 24px; + } + + &_cart_fav_price { + padding-top: 6px; + display: flex; + flex-direction: column; + gap: 16px; + } + + &_price { + display: flex; + gap: 8px; + + & .price_crossout { + color: var(--c-secondary); + text-decoration: line-through; + } + } + + &_cart_favorite { + display: flex; + justify-content: space-between; + width: 100%; + height: 48px; + } + + &_specs { + display: flex; + flex-direction: column; + gap: 8px; + + & .spec_line { + display: flex; + justify-content: space-between; + + & .spec_item { + color: var(--c-secondary); + } + } + } + + } + } + } + + &_about { + display: flex; + flex-direction: column; + gap: 32px; + padding-bottom: 56px; + + @include ontablet { + padding-bottom: 64px; + } + + @include ondesktop { + padding-bottom: 0; + grid-row: 5/5; + grid-column: 1/13; + } + + h3 { + padding-bottom: 16px; + } + + & .sections { + + & h4 { + padding-bottom: 16px; + } + + & p { + color: var(--c-secondary); + } + } + } + + &_full_specs { + padding-bottom: 56px; + + @include ontablet { + padding-bottom: 64px; + } + + @include ondesktop { + padding-bottom: 0; + grid-row: 5/5; + grid-column: 14/-1; + } + + & h3 { + padding-bottom: 16px; + } + + & .full_spec_seperator { + margin-bottom: 30px; + } + + & .full_spec_container { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0; + } + + & .full_spec_line { + display: flex; + justify-content: space-between; + } + + + & .full_spec_item { + color: var(--c-secondary); + } + } + + &_other_like { + overflow: hidden; + + @include ondesktop { + grid-row: 6/6; + grid-column: 1/-1; + } + } + } + + & .container_seperator { + border: 1px solid var(--c-elements); + } + + & .product_not_found_container{ + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 20px; + + & .img{ + flex-grow: 1; + width: 30vw; + aspect-ratio: 1/1; + background: url(../../../public/img/product-not-found.png) no-repeat center/contain;; + } +} + +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 0000000000..48776588ac --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,331 @@ +import { Location, useLocation, useNavigate } from 'react-router-dom'; +import style from './ProductDetailsPage.module.scss'; +import { useContext, useEffect, useState } from 'react'; +import { ProductItem } from '../../types/ProductItem'; +import { getProducts } from '../../utils/getProducts'; +import { Loader } from '../../components/Loader'; +import { BreadCrumbs } from '../../components/BreadCrumbs'; +import classNames from 'classnames'; +import { ColorSelector } from '../../components/ColorSelector'; +import { CapacitySelector } from '../../components/CapacitySelector'; +import { getProductItems } from '../../utils/getProductItems'; +import { ButtonsAddCardFav } from '../../components/ButtonsAddCardFav'; +import { PhoneSlider } from '../../components/PhoneSlider'; +import { DispatchContext, StateContext } from '../../components/GlobalProvider'; +import { Product } from '../../types/Product'; +import { MenuItems } from '../../types/MenuItems'; +import { ItemPhoto } from './ItemPhoto/ItemPhoto'; + +const getIdFromURL = (location: Location) => { + return location.pathname.split('/').slice(-1)[0]; +}; + +type Props = { + category: MenuItems; +}; + +export const ProductDetailsPage: React.FC = ({ category }) => { + const location = useLocation(); + const navigate = useNavigate(); + const { products, showSearch } = useContext(StateContext); + const [item, setItem] = useState(); + const [allCatalogProducts, setAllCatalogProducts] = useState( + [], + ); + const [similarProduct, setSimilarProducts] = useState([]); + const [loading, setLoading] = useState(false); + + const curItemId = getIdFromURL(location); + const dispatch = useContext(DispatchContext); + + useEffect( + () => dispatch({ type: 'setShowSearch', payload: false }), + [dispatch, showSearch], + ); + + useEffect(() => { + setLoading(true); + getProductItems + .fetchByCategory(category) + .then(res => setAllCatalogProducts(() => res)) + .finally(() => setLoading(false)); + }, [category]); + + useEffect(() => { + if (!products.length) { + return; + } + + const currentItem = allCatalogProducts.find( + (prod: ProductItem) => prod.id === curItemId, + ); + + setItem(() => currentItem); + + if (currentItem) { + const simItems: ProductItem[] = allCatalogProducts.filter( + itm => itm.namespaceId === currentItem.namespaceId, + ); + + const simProducts: Product[] = []; + + simItems.forEach((itm: ProductItem) => { + const found = getProducts.getProductById(products, itm.id); + + if (found) { + simProducts.push(found); + } + }); + + setSimilarProducts(() => simProducts); + } + }, [products, allCatalogProducts, curItemId]); + + const handleColorChange = (selectedColor: string) => { + if (selectedColor && item && allCatalogProducts.length > 0) { + const newItem = getProductItems.getColorVariant( + allCatalogProducts, + item, + selectedColor, + ); + + if (newItem) { + setItem(() => newItem); + navigate(`/${item?.category}/${newItem.id}`); + } + } + }; + + const handleCapacityChange = (selectedCapacity: string) => { + if (selectedCapacity && item && allCatalogProducts.length > 0) { + const newItem = getProductItems.getCapacityVariant( + allCatalogProducts, + item, + selectedCapacity, + ); + + if (newItem) { + setItem(() => newItem); + navigate(`/${item?.category}/${newItem.id}`); + } + } + }; + + return ( +
+ {loading ? ( +
+ +
+ ) : ( + <> + {item ? ( +
+
+ +
+ +
+
navigate(-1)} + > +
navigate(-1)} + /> +
+ +

navigate(-1)} + > + Back +

+
+ +
+

{item.name}

+
+ +
+
+ +
+ +
+
+ + +
+
+ +
+ + +
+
+ +
+
+

${item.priceDiscount}

+ +

+ ${item.priceRegular} +

+
+ +
+ +
+
+ +
+
+

Screen

+ +

{item.screen}

+
+ +
+

Resolution

+ +

{item.resolution}

+
+ +
+

Processor

+ +

{item.processor}

+
+ +
+

RAM

+ +

{item.ram}

+
+
+
+
+ +
+
+

About

+ +
+
+ + {item.description.map(section => { + return ( +
+

{section.title}

+ + {section.text.map((parag, i) => { + return

{parag}

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

Tech specs

+ +
+ +
    +
  • +

    Screen

    + +

    {item.screen}

    +
  • + +
  • +

    Resolution

    + +

    + {item.resolution} +

    +
  • + +
  • +

    Processor

    + +

    {item.processor}

    +
  • + +
  • +

    RAM

    + +

    {item.ram}

    +
  • + +
  • +

    Built in memory

    + +

    {item.capacity}

    +
  • + +
  • +

    Camera

    + +

    {item.camera}

    +
  • + +
  • +

    Zoom

    + +

    {item.zoom}

    +
  • + +
  • +

    Cell

    + +

    + {item.cell.map((channel, i) => ( + {channel}, + ))} +

    +
  • +
+
+ +
+ +
+
+ ) : ( +
+

Sorry Product Not Found

+
+
+ )} + + )} +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 0000000000..6615089e5e --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductDetailsPage'; diff --git a/src/modules/Root/Root.tsx b/src/modules/Root/Root.tsx new file mode 100644 index 0000000000..3c3385b2d9 --- /dev/null +++ b/src/modules/Root/Root.tsx @@ -0,0 +1,93 @@ +import { + HashRouter as Router, + Navigate, + Route, + Routes, +} from 'react-router-dom'; + +import { App } from '../App/App'; +import { HomePage } from '../HomePage/HomePage'; +import { PhonePage } from '../PhonePage'; +import { FavoritePage } from '../FavotitePage'; +import { CartPage } from '../CartPage'; +import { TabletPage } from '../TabletPage'; +import { AccessoriesPage } from '../AccessoriesPage'; +import { ProductDetailsPage } from '../ProductDetailsPage'; +import { MenuItems } from '../../types/MenuItems'; +import { PageNotFoundPage } from '../PageNotFoundPage'; + +export const Root = () => { + return ( + + + } + > + } + /> + + + } + /> + + + } + /> + } + /> + + + + } + /> + } + /> + + + + } + /> + } + /> + + + } + /> + + } + /> + + } + /> + + + + ); +}; diff --git a/src/modules/Root/index.ts b/src/modules/Root/index.ts new file mode 100644 index 0000000000..c78e011529 --- /dev/null +++ b/src/modules/Root/index.ts @@ -0,0 +1 @@ +export * from './Root'; diff --git a/src/modules/TabletPage/TabletPage.module.scss b/src/modules/TabletPage/TabletPage.module.scss new file mode 100644 index 0000000000..c3b9bf1178 --- /dev/null +++ b/src/modules/TabletPage/TabletPage.module.scss @@ -0,0 +1,6 @@ +@import '../../styles/main'; + +.tabletPage_container { + width: 100%; + height: 100%; +} diff --git a/src/modules/TabletPage/TabletPage.tsx b/src/modules/TabletPage/TabletPage.tsx new file mode 100644 index 0000000000..14990fa418 --- /dev/null +++ b/src/modules/TabletPage/TabletPage.tsx @@ -0,0 +1,40 @@ +import style from './TabletPage.module.scss'; +import { useContext, useEffect, useMemo } from 'react'; +import { DispatchContext, StateContext } from '../../components/GlobalProvider'; +import { Catalog } from '../../components/Catalog/Catalog'; +import { getProducts } from '../../utils/getProducts'; +import { MenuItems } from '../../types/MenuItems'; +import { useSearchParams } from 'react-router-dom'; +import { SearchParams } from '../../types/SearchParams'; + +export const TabletPage = () => { + const { products, showSearch } = useContext(StateContext); + const [searchParams] = useSearchParams(); + const dispatch = useContext(DispatchContext); + + const tablets = useMemo(() => { + const allTablets = getProducts.getProductByCategory( + products, + MenuItems.tablets, + ); + + return getProducts.getFilteredByQuery( + allTablets, + searchParams.get(SearchParams.query), + ); + }, [products, searchParams]); + + useEffect( + () => dispatch({ type: 'setShowSearch', payload: true }), + [dispatch, showSearch], + ); + + return ( +
+ +
+ ); +}; diff --git a/src/modules/TabletPage/index.ts b/src/modules/TabletPage/index.ts new file mode 100644 index 0000000000..74893971d9 --- /dev/null +++ b/src/modules/TabletPage/index.ts @@ -0,0 +1 @@ +export * from './TabletPage'; diff --git a/src/modules/constants/index.ts b/src/modules/constants/index.ts new file mode 100644 index 0000000000..bf232d8ffe --- /dev/null +++ b/src/modules/constants/index.ts @@ -0,0 +1 @@ +export * from './productColors'; diff --git a/src/modules/constants/productColors.ts b/src/modules/constants/productColors.ts new file mode 100644 index 0000000000..46e854c17b --- /dev/null +++ b/src/modules/constants/productColors.ts @@ -0,0 +1,45 @@ +type ProductColors = { + black: string; + blue: string; + coral: string; + gold: string; + graphite: string; + green: string; + midnight: string; + midnightgreen: string; + pink: string; + purple: string; + red: string; + rosegold: string; + sierrablue: string; + silver: string; + skyblue: string; + spaceblack: string; + spacegray: string; + starlight: string; + white: string; + yellow: string; +}; + +export const productColors: ProductColors = { + black: '#3C4042', + blue: '#CED5D9', + coral: '#FF6E5A', + gold: '#F4E8CE', + graphite: '#54524F', + green: '#576856', + midnight: '#232A31', + midnightgreen: '#394C38', + pink: '#FADDD7', + purple: '#594F63', + red: '#FC0324', + rosegold: '#F7E8DD', + sierrablue: '#A7C1D9', + silver: '#F1F2ED', + skyblue: '#276787', + spaceblack: '#403E3D', + spacegray: '#535150', + starlight: '#FAF6F2', + white: '#F6F2EF', + yellow: '#FFE681', +}; diff --git a/src/notes.txt b/src/notes.txt new file mode 100644 index 0000000000..c36cde3133 --- /dev/null +++ b/src/notes.txt @@ -0,0 +1,7 @@ +Progress: +stopeped: +- greated grid with media and mixins +- finished scss typography + +Next: +- start builwing header diff --git a/src/styles/buttons.scss b/src/styles/buttons.scss new file mode 100644 index 0000000000..608bd81567 --- /dev/null +++ b/src/styles/buttons.scss @@ -0,0 +1,38 @@ +.buttons { + &_container { + background-color: var(--c-buttons-background); + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-inline: 5px; + cursor: pointer; + transition: all 0.3s; + + &:hover { + box-shadow: 0 3px 13px 0 #17203166; + background-color: var(--c-buttons-background-hover); + } + + &_selected { + background-color: var(--c-buttons-background-selected); + border: 1px solid var(--c-elements); + + &:hover { + box-shadow: 0 3px 13px 0 #17203166; + background-color: var(--c-buttons-background-selected); + } + } + } +} + +.buttons_text { + @extend %buttons; + + cursor: pointer; + color: var(--c-buttons-text); + + &_selected { + color: var(--c-buttons-text-selected); + } +} diff --git a/src/styles/icons.scss b/src/styles/icons.scss new file mode 100644 index 0000000000..92d22632b6 --- /dev/null +++ b/src/styles/icons.scss @@ -0,0 +1,18 @@ +@import './mixines'; + +%icon-container { + cursor: pointer; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + transition: border-color 0.3s; + border: 1px solid var(--c-icons); + width: 32px; + height: 32px; + + &:hover { + border-color: var(--c-primary); + } +} diff --git a/src/styles/main.scss b/src/styles/main.scss new file mode 100644 index 0000000000..0fa223222f --- /dev/null +++ b/src/styles/main.scss @@ -0,0 +1,72 @@ +@import './mixines'; +@import './icons'; +@import './buttons'; +@import './typography'; +@import './variables'; +@import './theme'; + +@font-face { + font-family: Mont; + src: url('../fonts/Mont-Regular.otf'); + font-weight: 500; +} + +@font-face { + font-family: Mont; + src: url('../fonts/Mont-SemiBold.otf'); + font-weight: 600; +} + +@font-face { + font-family: Mont; + src: url('../fonts/Mont-Bold.otf'); + font-weight: bold; +} + +body { + margin: 0; + padding: 0; + background-color: var(--c-background); + min-height: 100vh; + box-sizing: border-box; +} + +html { + font-family: Mont, sans-serif; + scroll-behavior: smooth; + box-sizing: border-box; +} + +p { + margin: 0; + color: var(--c-primary); +} + +h1 { + margin: 0; +} + +h2 { + margin: 0; +} + +h3 { + margin: 0; +} + +%link { + text-decoration: none; + color: var(--c-secondary); +} + +.isActive_link { + @include is-active; +} + +.hidden { + display: none; +} + +.loader_container { + flex-grow: 1; +} diff --git a/src/styles/mixines.scss b/src/styles/mixines.scss new file mode 100644 index 0000000000..9d4be51162 --- /dev/null +++ b/src/styles/mixines.scss @@ -0,0 +1,76 @@ +@import './variables'; +@import './theme'; + +@mixin ondesktop { + @media (min-width: $desktop-min-width) { + @content; + } +} + +@mixin ontablet { + @media (min-width: $tablet-min-width) { + @content; + } +} + +@mixin page-grid { + box-sizing: border-box; + + --columns: 4; + + display: grid; + column-gap: 16px; + + grid-template-columns: repeat(var(--columns), 1fr); + + @include ontablet { + --columns: 12; + } + + @include ondesktop { + --columns: 24; + + grid-template-columns: repeat(var(--columns), 32px); + } +} + +@mixin inline-padding { + padding-inline: $mobil-padding-inline; + + @include ontablet { + padding-inline: $tablet-padding-inline; + } +} + +@mixin headings { + h1, + h2, + h3, + h4, + h5, + h6 { + @content; + } +} + +@mixin hover($property: transform, $toValue: scale(1.1)) { + transition: #{$property} 0.3s; + + &:hover { + color: var(--c-primary); + #{$property}: $toValue; + } +} + +@mixin is-active { + border-bottom: 3px solid var(--c-primary); + color: var(--c-primary); +} + +@mixin icon-bg($url, $bgColor: var(--c-primary)) { + background-image: #{$url}; + width: 16px; + height: 16px; + background-repeat: no-repeat; + background-size: cover; +} diff --git a/src/styles/theme.scss b/src/styles/theme.scss new file mode 100644 index 0000000000..c836b6ebc6 --- /dev/null +++ b/src/styles/theme.scss @@ -0,0 +1,73 @@ +.theme { + --c-white: #FFF; + --c-background: var(--c-white); + --c-accent: #F86800; + --c-accent-secondary: #476DF4; + --c-primary: #313237; + --c-secondary: #89939A; + --c-icons: #B4BDC3; + --c-elements: #E2E6E9; + --c-green: #27ae60; + --c-buttons-background: var(--c-primary); + --c-buttons-background-selected: var(--c-white); + --c-buttons-background-hover: var(--c-primary); + --c-buttons-text: var(--c-white); + --c-buttons-text-selected: var(--c-green); + --c-buttons-nav: var(--c-background); + --fls-logo: url('../img/icons/Logo.png'); + --fls-right: url('../img/icons/arrowRight.svg'); + --fls-right-disabled: url('../img/icons/arrowRight_disabled.svg'); + --fls-left: url('../img/icons/arrowLeft.svg'); + --fls-left-disabled: url('../img/icons/arrowLeft_disabled.svg'); + --fls-up: url('../img/icons/arrowUp.svg'); + --fls-up-disabled: url('../img/icons/arrowUp_disabled.svg'); + --fls-down-disabled: url('../img/icons/arrowDown_disabled.svg'); + --fls-home: url('../img/icons/home.svg'); + --fls-favorite: url('../img/icons/favorite.svg'); + --fls-favorite-selected: url('../img/icons/favorite_selected.svg'); + --fls-cart: url('../img/icons/cart.svg'); + --fls-dark-mode: url('../img/icons/dark_mode.svg'); + --fls-menu: url('../img/icons/menu.svg'); + --fls-close: url('../img/icons/close.svg'); + --fls-plus: url('../img/icons/plus.svg'); + --fls-minus: url('../img/icons/minus.svg'); + --fls-minus-disabled: url('../img/icons/minus_disabled.svg'); + --fls-search: url('../img/icons/Search.svg'); + + &_dark { + --c-accent: #905BFF; + --c-accent-secondary: #476DF4; + --c-primary: #fff; + --c-secondary: #75767F; + --c-icons: #4A4D58; + --c-elements: #3B3E4A; + --c-background: #0F1121; + --c-white: #F1F2F9; + --c-green: #27ae60; + --c-buttons-background: var(--c-accent); + --c-buttons-background-selected: #323542; + --c-buttons-background-hover: #A378FF; + --c-buttons-text: var(--c-white); + --c-buttons-text-selected: #F1F2F9; + --c-buttons-nav: #323542; + --fls-logo: url('../img/icons/darkMode/Logo-dark.png'); + --fls-right: url('../img/icons/darkMode/arrowRight.svg'); + --fls-right-disabled: url('../img/icons/arrowRight_disabled.svg'); + --fls-left: url('../img/icons/darkMode/arrowLeft.svg'); + --fls-left-disabled: url('../img/icons/arrowLeft_disabled.svg'); + --fls-up: url('../img/icons/darkMode/arrowUp.svg'); + --fls-up-disabled: url('../img/icons/arrowUp_disabled.svg'); + --fls-down-disabled: url('../img/icons/arrowDown_disabled.svg'); + --fls-home: url('../img/icons/darkMode/home.svg'); + --fls-favorite: url('../img/icons/darkMode/favorite.svg'); + --fls-favorite-selected: url('../img/icons/favorite_selected.svg'); + --fls-cart: url('../img/icons/darkMode/cart.svg'); + --fls-dark-mode: url('../img/icons/darkMode/dark_mode.svg'); + --fls-menu: url('../img/icons/darkMode/menu.svg'); + --fls-close: url('../img/icons/darkMode/close.svg'); + --fls-plus: url('../img/icons/darkMode/plus.svg'); + --fls-minus: url('../img/icons/darkMode/minus.svg'); + --fls-minus-disabled: url('../img/icons/minus_disabled.svg'); + --fls-search: url('../img/icons/darkMode/Search.svg'); + } +} diff --git a/src/styles/typography.scss b/src/styles/typography.scss new file mode 100644 index 0000000000..22b5a9fde9 --- /dev/null +++ b/src/styles/typography.scss @@ -0,0 +1,100 @@ +@import './theme'; +@import './mixines'; + +@include headings { + font-family: Mont, sans-serif;; + letter-spacing: 0; + color: var(--c-primary); +} + +body { + font-family: Mont, sans-serif;; + margin: 0; +} + +h1 { + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + margin: 0; + text-align: left; + + @include ontablet { + font-size: 48px; + line-height: 56px; + } +} + +h2 { + font-size: 22px; + font-weight: 800; + line-height: 30.8px; + margin: 0; + + @include ontablet { + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + } +} + +h3 { + font-size: 20px; + font-weight: 700; + line-height: 25.56px; + margin: 0; + + @include ontablet { + font-size: 22px; + font-weight: 800; + line-height: 30.8px; + } +} + +h4 { + font-size: 16px; + font-weight: 700; + line-height: 20.45px; + margin: 0; + + @include ontablet { + font-size: 20px; + font-weight: 700; + line-height: 25.56px; + } +} + +p { + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: var(--c-primary); + margin: 0; +} + + +%uppercase { + font-size: 12px; + font-weight: 800; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + margin: 0; +} + +%buttons { + font-size: 12px; + font-weight: 800; + line-height: 11px; + letter-spacing: 0.04em; + margin: 0; + text-align: center; +} + +%small-text { + font-size: 12px; + font-weight: 700; + line-height: 15.34px; + margin: 0; +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss new file mode 100644 index 0000000000..b6bb225209 --- /dev/null +++ b/src/styles/variables.scss @@ -0,0 +1,5 @@ +$desktop-min-width: 1200px; +$tablet-min-width: 640px; +$mobil-padding-inline: 16px; +$tablet-padding-inline: 24px; +$desktop-padding-inline: 32px; diff --git a/src/types/MenuItems.ts b/src/types/MenuItems.ts new file mode 100644 index 0000000000..11316969a1 --- /dev/null +++ b/src/types/MenuItems.ts @@ -0,0 +1,5 @@ +export enum MenuItems { + 'phones' = 'phones', + 'tablets' = 'tablets', + 'accessories' = 'accessories', +} diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 0000000000..04549884e1 --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,16 @@ +export interface Product { + id: number; + category: string; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; + discountPrice?: number; + discount?: number; +} diff --git a/src/types/ProductItem.ts b/src/types/ProductItem.ts new file mode 100644 index 0000000000..1872c27409 --- /dev/null +++ b/src/types/ProductItem.ts @@ -0,0 +1,21 @@ +export interface ProductItem { + 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/SearchParams.ts b/src/types/SearchParams.ts new file mode 100644 index 0000000000..23e4f513d6 --- /dev/null +++ b/src/types/SearchParams.ts @@ -0,0 +1,8 @@ +export enum SearchParams { + 'order' = 'order', + 'perPage' = 'perPage', + 'page' = 'page', + 'productColor' = 'productColor', + 'capacity' = 'capacity', + 'query' = 'query', +} diff --git a/src/types/SortFilter.ts b/src/types/SortFilter.ts new file mode 100644 index 0000000000..f445ad3560 --- /dev/null +++ b/src/types/SortFilter.ts @@ -0,0 +1,5 @@ +export enum SortingTypes { + 'Newest' = 'Newest', + 'Alphabetical' = 'Alphabetical', + 'Cheapest' = 'Cheapest', +} diff --git a/src/utils/LocalAccessKeys.ts b/src/utils/LocalAccessKeys.ts new file mode 100644 index 0000000000..ca0c27314f --- /dev/null +++ b/src/utils/LocalAccessKeys.ts @@ -0,0 +1,4 @@ +export enum LocalAccessKeys { + 'favorites' = 'favorites', + 'cart' = 'cart', +} diff --git a/src/utils/accessLocalStorage.ts b/src/utils/accessLocalStorage.ts new file mode 100644 index 0000000000..cc14530075 --- /dev/null +++ b/src/utils/accessLocalStorage.ts @@ -0,0 +1,77 @@ +import { Product } from '../types/Product'; +import { LocalAccessKeys } from './LocalAccessKeys'; + +function addProduct(data: Product[], target: Product) { + return [...data, target]; +} + +function removeProduct(data: Product[], target: Product) { + return [...data.filter(item => item.itemId !== target.itemId)]; +} + +export const accessLocalStorage = { + get(key: LocalAccessKeys) { + const data = localStorage.getItem(key); + + try { + return data ? JSON.parse(data) : []; + } catch { + return []; + } + }, + + set(data: Product[], key: LocalAccessKeys) { + try { + localStorage.setItem(key, JSON.stringify(data)); + + return this.get(key); + } catch { + return []; + } + }, + + toggle(product: Product | undefined, key: LocalAccessKeys) { + const inMemory = this.get(key); + let newList = []; + + if (product) { + if (!inMemory.find((prod: Product) => prod.itemId === product.itemId)) { + newList = addProduct(inMemory, product); + } else { + newList = removeProduct(inMemory, product); + } + + this.set(newList, key); + } + + return this.get(key); + }, + + append(product: Product | undefined, key: LocalAccessKeys) { + if (product) { + const newList = addProduct(this.get(key), product); + + this.set(newList, key); + + return newList; + } + + return; + }, + + remove(product: Product | undefined, key: LocalAccessKeys) { + if (product) { + const newList = removeProduct(this.get(key), product); + + this.set(newList, key); + + return newList; + } + + return; + }, + + clearKey(key: LocalAccessKeys) { + localStorage.removeItem(key); + }, +}; diff --git a/src/utils/catalogHelper.ts b/src/utils/catalogHelper.ts new file mode 100644 index 0000000000..f586f9f8e1 --- /dev/null +++ b/src/utils/catalogHelper.ts @@ -0,0 +1,50 @@ +import { Product } from '../types/Product'; +import { SearchParams } from '../types/SearchParams'; +import { SortingTypes } from '../types/SortFilter'; + +export const catalogHelper = { + sort(order: string | null = null, products: Product[] = []): Product[] { + const sortedProds = [...products]; + + switch (order) { + case SortingTypes.Alphabetical: + default: + sortedProds.sort((a, b) => a.name.localeCompare(b.name)); + break; + + case SortingTypes.Cheapest: + sortedProds.sort((a, b) => a.price - b.price); + break; + + case SortingTypes.Newest: + sortedProds.sort((a, b) => b.year - a.year); + break; + } + + return sortedProds ? sortedProds : []; + }, + + perPage(sizeStr: string | null = '', products: Product[] = []) { + if (!sizeStr || isNaN(parseInt(sizeStr))) { + return [products]; + } + + const size = parseInt(sizeStr); + const chunk = Array.from( + { length: Math.ceil(products.length / size) }, + (_, i) => products.slice(i * size, i * size + size), + ); + + return chunk; + }, + + getCurrenPageParam(searchParams: URLSearchParams) { + const curPgParam = searchParams.get(SearchParams.page); + + if (!curPgParam || isNaN(parseInt(curPgParam))) { + return 1; + } + + return parseInt(curPgParam); + }, +}; diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts new file mode 100644 index 0000000000..009201f898 --- /dev/null +++ b/src/utils/fetch.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +export const BASE_API_URL = + 'https://syavayki.github.io/react_phone-catalog/api/'; + +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, +): Promise { + const options: RequestInit = { method }; + + if (data) { + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + return wait(1000) + .then(() => fetch(BASE_API_URL + url, options)) + .then(response => { + if (!response.ok) { + throw new Error('Error in Fetch'); + } + + return response.json(); + }); +} + +export const client = { + get: (url: string) => request(url), + post: (url: string, data: any) => request(url, 'POST', data), + patch: (url: string, data: any) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +}; diff --git a/src/utils/getProductItems.ts b/src/utils/getProductItems.ts new file mode 100644 index 0000000000..899a468e8c --- /dev/null +++ b/src/utils/getProductItems.ts @@ -0,0 +1,62 @@ +import { MenuItems } from '../types/MenuItems'; +import { ProductItem } from '../types/ProductItem'; +import { client } from './fetch'; + +export const getProductItems = { + fetchByCategory(category: string): Promise { + return client.get(`${category}.json`); + }, + + fetchAllCategories() { + const allPromises: Promise[] = []; + + Object.values(MenuItems).forEach(category => { + allPromises.push(getProductItems.fetchByCategory(category)); + }); + + return Promise.allSettled(allPromises); + }, + + fetchDetails(category: string, id: string): Promise { + return this.fetchByCategory(category).then((result: ProductItem[]) => + this.getById(result, id), + ); + }, + + getById( + products: ProductItem[] | undefined, + id: string, + ): ProductItem | undefined { + if (products) { + return products.find(product => product.id === id); + } + + return; + }, + + getColorVariant( + products: ProductItem[], + item: ProductItem, + selectedColor: string, + ): ProductItem | undefined { + const { namespaceId, capacity } = { ...item }; + + return products + .filter(itm => itm.namespaceId === namespaceId) + .filter(itm2 => itm2.capacity === capacity) + .find(itm3 => itm3.color === selectedColor); + }, + + getCapacityVariant( + products: ProductItem[], + item: ProductItem, + selectedCapacity: string, + ): ProductItem | undefined { + const { namespaceId, color } = { ...item }; + + return products + .filter(itm => itm.namespaceId === namespaceId) + .filter(itm3 => itm3.color === color) + .find(itm2 => itm2.capacity === selectedCapacity); + }, +}; diff --git a/src/utils/getProducts.ts b/src/utils/getProducts.ts new file mode 100644 index 0000000000..6854d6d7d4 --- /dev/null +++ b/src/utils/getProducts.ts @@ -0,0 +1,61 @@ +import { MenuItems } from '../types/MenuItems'; +import { Product } from '../types/Product'; +import { client } from './fetch'; + +export const getProducts = { + fetchProducts(): Promise { + return client.get('products.json'); + }, + + getDiscount(item: Product) { + return item.fullPrice - item.price; + }, + + getHotDealsProducts(products: Product[]) { + const sorted = products.sort((a, b) => { + return this.getDiscount(b) - this.getDiscount(a); + }); + + return sorted; + }, + + getNewProducts(products: Product[]) { + const maxYear: number = products + .map(prod => prod.year) + .reduce((prev, cur) => (cur > prev ? cur : prev), 0); + + return products.filter(product => product.year >= maxYear); + }, + + getProductById(products: Product[], itemId: string) { + if (products) { + return products.find(product => product.itemId === itemId); + } + + return; + }, + + getProductByCategory( + products: Product[], + category: MenuItems, + ): Product[] | undefined { + if (products) { + return products.filter(product => product.category === category); + } + + return; + }, + + getFilteredByQuery( + products: Product[] | undefined, + query: string | null, + ): Product[] | [] { + if (products && query) { + return products.filter(product => + product.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()), + ); + } + + return products; + }, +}; diff --git a/src/utils/sentenseFormating.ts b/src/utils/sentenseFormating.ts new file mode 100644 index 0000000000..147d4f9537 --- /dev/null +++ b/src/utils/sentenseFormating.ts @@ -0,0 +1,3 @@ +export const sentenseFormating = (text: string) => { + return text.charAt(0).toUpperCase() + text.slice(1); +};