diff --git a/package-lock.json b/package-lock.json index 98392dc382..6c5d476953 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1566,6 +1566,24 @@ } } }, + "@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "requires": { + "@emotion/memoize": "^0.8.1" + } + }, + "@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, "@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2421,9 +2439,9 @@ } }, "@mate-academy/scripts": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.7.9.tgz", - "integrity": "sha512-TDtSLf9BVwkaib4xpMB8r8VA18N6ABRpePGxpqk+aYOHcXq1DFwrzqCbOW9LyrOxWbqLVJBhP5exEgFXiaWhfw==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.1.tgz", + "integrity": "sha512-zRhcI3uiflwWXeODuyELMqh1j4s438nTRiXXQ+v6Cs95kWMrfUDuzmg6ZqqJuIPZtmpg0KTLghGBf38vKalW/g==", "dev": true, "requires": { "@octokit/rest": "^17.11.2", @@ -3468,6 +3486,11 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "@types/stylis": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==" + }, "@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4719,6 +4742,11 @@ } } }, + "camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==" + }, "caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -5100,6 +5128,11 @@ "postcss-selector-parser": "^6.0.9" } }, + "css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==" + }, "css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", @@ -5210,6 +5243,16 @@ "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" }, + "css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "requires": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "css-tree": { "version": "1.0.0-alpha.37", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", @@ -12998,6 +13041,22 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-loader-spinner": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/react-loader-spinner/-/react-loader-spinner-6.1.6.tgz", + "integrity": "sha512-x5h1Jcit7Qn03MuKlrWcMG9o12cp9SNDVHVJTNRi9TgtGPKcjKiXkou4NRfLAtXaFB3+Z8yZsVzONmPzhv2ErA==", + "requires": { + "react-is": "^18.2.0", + "styled-components": "^6.1.2" + }, + "dependencies": { + "react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + } + } + }, "react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -14044,6 +14103,11 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -14569,6 +14633,44 @@ "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", "dev": true }, + "styled-components": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.13.tgz", + "integrity": "sha512-M0+N2xSnAtwcVAQeFEsGWFFxXDftHUD7XrKla06QbpUMmbmtFBMMTcKWvFXtWxuD5qQkB8iU5gk6QASlx2ZRMw==", + "requires": { + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.38", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" + }, + "dependencies": { + "postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "requires": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + } + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + } + } + }, "stylehacks": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", @@ -14788,6 +14890,11 @@ "postcss-value-parser": "^4.2.0" } }, + "stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" + }, "sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -14921,9 +15028,9 @@ }, "dependencies": { "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", "dev": true } } diff --git a/package.json b/package.json index 222c88a139..ea45891c8c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "classnames": "^2.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-loader-spinner": "^6.1.6", "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", "react-transition-group": "^4.4.5" @@ -20,7 +21,7 @@ "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@mate-academy/eslint-config-react-typescript": "latest", - "@mate-academy/scripts": "^1.7.9", + "@mate-academy/scripts": "^1.8.1", "@mate-academy/students-ts-config": "latest", "@mate-academy/stylelint-config": "latest", "@types/node": "^16.18.80", diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000..8f1a49d8f0 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/img/category-accessories.png b/public/img/category-accessories.png index 67c5bfdb35..9ac6ee6318 100644 Binary files a/public/img/category-accessories.png and b/public/img/category-accessories.png differ diff --git a/public/img/category-phones.png b/public/img/category-phones.png index fd7616042f..0cb88ba4ee 100644 Binary files a/public/img/category-phones.png and b/public/img/category-phones.png differ diff --git a/public/img/category-tablets.png b/public/img/category-tablets.png index 57e33c5807..e5ccbf65a6 100644 Binary files a/public/img/category-tablets.png and b/public/img/category-tablets.png differ diff --git a/public/img/favicons/apple.png b/public/img/favicons/apple.png new file mode 100644 index 0000000000..db0136d8ea Binary files /dev/null and b/public/img/favicons/apple.png differ diff --git a/public/img/favicons/favicon.svg b/public/img/favicons/favicon.svg new file mode 100644 index 0000000000..fa86701a2a --- /dev/null +++ b/public/img/favicons/favicon.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/public/img/picthree.bdd2e0fc.png b/public/img/picthree.bdd2e0fc.png deleted file mode 100644 index 28b5c4c99a..0000000000 Binary files a/public/img/picthree.bdd2e0fc.png and /dev/null differ diff --git a/public/index.html b/public/index.html index 4b622dad39..f2f174329a 100644 --- a/public/index.html +++ b/public/index.html @@ -3,6 +3,9 @@ + + + Phone catalog diff --git a/src/App.module.scss b/src/App.module.scss new file mode 100644 index 0000000000..f7ae09493d --- /dev/null +++ b/src/App.module.scss @@ -0,0 +1,15 @@ +@import './styles/utils'; + +.app { + min-height: 100vh; + display: grid; + grid-template-rows: min-content 1fr min-content; + font-family: Mont, Arial, sans-serif; + color: var(--primary); + + @include body-text; + + &__main { + background-color: var(--hover-bg); + } +} 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 index 372e4b4206..e87426cfef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,26 @@ -import './App.scss'; +import { Outlet, useLocation } from 'react-router-dom'; +import { Footer } from './modules/Footer'; +import { Header } from './modules/Header'; -export const App = () => ( -
-

Product Catalog

-
-); +import './styles/globals.scss'; +import styles from './App.module.scss'; +import { useEffect } from 'react'; +import { scrollToTop } from './utils/utility'; + +export const App = () => { + const location = useLocation(); + + useEffect(() => { + scrollToTop(); + }, [location.pathname]); + + return ( +
+
+
+ +
+
+ ); +}; diff --git a/src/Root.tsx b/src/Root.tsx new file mode 100644 index 0000000000..3ecd205112 --- /dev/null +++ b/src/Root.tsx @@ -0,0 +1,47 @@ +import { Route, Routes } from 'react-router-dom'; + +import { App } from './App'; +import { HomePage } from './modules/HomePage'; +import { PhonesPage } from './modules/PhonesPage'; +import { MainNavigation } from './utils/constants'; +import { TabletsPage } from './modules/TabletsPage'; +import { AccessoriesPage } from './modules/AccessoriesPage'; +import { ProductPage } from './modules/ProductPage'; +import { FavouritesPage } from './modules/FavouritesPage'; +import { CartPage } from './modules/CartPage'; +import { NotFoundPage } from './modules/NotFoundPage'; + +export const Root = () => { + return ( + + }> + } /> + + } /> + } + /> + + } /> + } + /> + + } + /> + } + /> + + } /> + } /> + } /> + + + ); +}; diff --git a/public/fonts/Mont-Bold.otf b/src/assets/fonts/Mont-Bold.otf old mode 100755 new mode 100644 similarity index 100% rename from public/fonts/Mont-Bold.otf rename to src/assets/fonts/Mont-Bold.otf diff --git a/public/fonts/Mont-Regular.otf b/src/assets/fonts/Mont-Regular.otf old mode 100755 new mode 100644 similarity index 100% rename from public/fonts/Mont-Regular.otf rename to src/assets/fonts/Mont-Regular.otf diff --git a/public/fonts/Mont-SemiBold.otf b/src/assets/fonts/Mont-SemiBold.otf old mode 100755 new mode 100644 similarity index 100% rename from public/fonts/Mont-SemiBold.otf rename to src/assets/fonts/Mont-SemiBold.otf diff --git a/src/assets/images/icons/arrow.svg b/src/assets/images/icons/arrow.svg new file mode 100644 index 0000000000..02568eec7f --- /dev/null +++ b/src/assets/images/icons/arrow.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/assets/images/icons/cart.svg b/src/assets/images/icons/cart.svg new file mode 100644 index 0000000000..d76e7eb922 --- /dev/null +++ b/src/assets/images/icons/cart.svg @@ -0,0 +1,11 @@ + + + + + diff --git a/src/assets/images/icons/close.svg b/src/assets/images/icons/close.svg new file mode 100644 index 0000000000..44fe5e9c28 --- /dev/null +++ b/src/assets/images/icons/close.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/assets/images/icons/heart-like.svg b/src/assets/images/icons/heart-like.svg new file mode 100644 index 0000000000..d6f5122dee --- /dev/null +++ b/src/assets/images/icons/heart-like.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/icons/heart.svg b/src/assets/images/icons/heart.svg new file mode 100644 index 0000000000..35afa4bb10 --- /dev/null +++ b/src/assets/images/icons/heart.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/assets/images/icons/home.svg b/src/assets/images/icons/home.svg new file mode 100644 index 0000000000..5737c25522 --- /dev/null +++ b/src/assets/images/icons/home.svg @@ -0,0 +1,8 @@ + + + + diff --git a/src/assets/images/icons/menu.svg b/src/assets/images/icons/menu.svg new file mode 100644 index 0000000000..d729715935 --- /dev/null +++ b/src/assets/images/icons/menu.svg @@ -0,0 +1,11 @@ + + + + + diff --git a/src/assets/images/icons/moon.svg b/src/assets/images/icons/moon.svg new file mode 100644 index 0000000000..74ff5d6b89 --- /dev/null +++ b/src/assets/images/icons/moon.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/assets/images/icons/search-dark.svg b/src/assets/images/icons/search-dark.svg new file mode 100644 index 0000000000..1760357193 --- /dev/null +++ b/src/assets/images/icons/search-dark.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/src/assets/images/icons/search-light.svg b/src/assets/images/icons/search-light.svg new file mode 100644 index 0000000000..929556143b --- /dev/null +++ b/src/assets/images/icons/search-light.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/src/assets/images/icons/sun.svg b/src/assets/images/icons/sun.svg new file mode 100644 index 0000000000..aa68592a72 --- /dev/null +++ b/src/assets/images/icons/sun.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/assets/images/logo-dark.svg b/src/assets/images/logo-dark.svg new file mode 100644 index 0000000000..26676b7233 --- /dev/null +++ b/src/assets/images/logo-dark.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/logo-light.svg b/src/assets/images/logo-light.svg new file mode 100644 index 0000000000..b38b893097 --- /dev/null +++ b/src/assets/images/logo-light.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Actions/Actions.module.scss b/src/components/Actions/Actions.module.scss new file mode 100644 index 0000000000..b82a0e2a68 --- /dev/null +++ b/src/components/Actions/Actions.module.scss @@ -0,0 +1,56 @@ +@import '../../styles/utils'; + +.actions { + display: flex; + justify-content: space-between; + gap: 8px; + width: 100%; + height: 40px; + + &__btn-cart { + border: none; + flex: 1 1; + background-color: var(--accent); + color: var(--white); + border-radius: 8px; + display: flex; + justify-content: center; + align-items: center; + padding: 9px; + + &--active { + background-color: inherit; + color: var(--accent); + box-shadow: 0 0 0 1px var(--elements); + } + } + + &__btn-favourites { + height: 100%; + aspect-ratio: 1 / 1; + display: flex; + justify-content: center; + align-items: center; + border: 1px var(--icon-secondary) solid; + border-radius: 50%; + background-color: inherit; + + @include transition; + + svg path { + fill: var(--icon-primary); + } + + &:hover { + border-color: var(--icon-primary); + } + + &--active { + border-color: var(--elements); + + svg path { + fill: var(--secondary-accent); + } + } + } +} diff --git a/src/components/Actions/Actions.tsx b/src/components/Actions/Actions.tsx new file mode 100644 index 0000000000..372dd9e7cd --- /dev/null +++ b/src/components/Actions/Actions.tsx @@ -0,0 +1,65 @@ +import cn from 'classnames'; +import styles from './Actions.module.scss'; +import { useContext } from 'react'; +import { SvgIcon } from '../SvgIcon'; +import { DispatchContext, StateContext } from '../../contex/State'; +import { Product } from '../../types'; + +interface Props { + product: Product; + className?: string; +} + +export const Actions: React.FC = ({ className, product }) => { + const { favourites, cart } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const { itemId } = product; + + const isFavourite = favourites.find(p => p.itemId === itemId); + const isInCart = cart.find(p => p.id === itemId); + + const changeFavouriteStatus = () => { + if (isFavourite) { + dispatch({ type: 'removeFavourites', payload: itemId }); + } else { + dispatch({ type: 'addFavourites', payload: product }); + } + }; + + const addToCart = () => { + if (!isInCart) { + dispatch({ + type: 'addCart', + payload: { product: product, quantity: 1, id: product.itemId }, + }); + } + }; + + return ( +
+ + + +
+ ); +}; diff --git a/src/components/Actions/index.ts b/src/components/Actions/index.ts new file mode 100644 index 0000000000..ac0eab8d42 --- /dev/null +++ b/src/components/Actions/index.ts @@ -0,0 +1 @@ +export { Actions } from './Actions'; diff --git a/src/components/BackLink/BackLink.module.scss b/src/components/BackLink/BackLink.module.scss new file mode 100644 index 0000000000..b4067103c0 --- /dev/null +++ b/src/components/BackLink/BackLink.module.scss @@ -0,0 +1,24 @@ +@import '../../styles/utils'; + +.back-link { + width: 65px; + display: flex; + gap: 4px; + align-items: center; + height: 16px; + + &__icon { + transform: rotate(-90deg); + } + + &__text { + @include small-text; + + color: var(--secondary); + line-height: 15px; + } + + svg { + fill: var(--icon-primary); + } +} diff --git a/src/components/BackLink/BackLink.tsx b/src/components/BackLink/BackLink.tsx new file mode 100644 index 0000000000..b3d59320b3 --- /dev/null +++ b/src/components/BackLink/BackLink.tsx @@ -0,0 +1,31 @@ +import styles from './BackLink.module.scss'; +import { Link, useNavigate } from 'react-router-dom'; +import { SvgIcon } from '../SvgIcon'; +import React from 'react'; +import cn from 'classnames'; + +interface Props { + className?: string; +} + +export const BackLink: React.FC = ({ className }) => { + const navigate = useNavigate(); + + const onClickHandler = ( + evt: React.MouseEvent, + ) => { + evt.preventDefault(); + navigate(-1); + }; + + return ( + + +

Back

+ + ); +}; diff --git a/src/components/BackLink/index.ts b/src/components/BackLink/index.ts new file mode 100644 index 0000000000..907b5bb3c8 --- /dev/null +++ b/src/components/BackLink/index.ts @@ -0,0 +1 @@ +export { BackLink } from './BackLink'; diff --git a/src/components/Grid/Grid.module.scss b/src/components/Grid/Grid.module.scss new file mode 100644 index 0000000000..24caadc076 --- /dev/null +++ b/src/components/Grid/Grid.module.scss @@ -0,0 +1,29 @@ +@import '../../styles/utils'; + +.grid { + display: flex; + flex-direction: column; + gap: 40px; + + @include on-tablet { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 40px 16px; + } + + @media (min-width: 768px) { + grid-template-columns: repeat(3, 1fr); + } + + @include on-desktop { + grid-template-columns: repeat(4, 1fr); + } + + &__item { + height: 440px; + + @include on-tablet { + height: 506px; + } + } +} diff --git a/src/components/Grid/Grid.tsx b/src/components/Grid/Grid.tsx new file mode 100644 index 0000000000..5fb2e8f32d --- /dev/null +++ b/src/components/Grid/Grid.tsx @@ -0,0 +1,21 @@ +import styles from './Grid.module.scss'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard'; + +interface Props { + products: Product[]; +} + +export const Grid: React.FC = ({ products }) => { + return ( +
    + {products.map(product => { + return ( +
  • + +
  • + ); + })} +
+ ); +}; diff --git a/src/components/Grid/index.ts b/src/components/Grid/index.ts new file mode 100644 index 0000000000..66c39561da --- /dev/null +++ b/src/components/Grid/index.ts @@ -0,0 +1 @@ +export { Grid } from './Grid'; diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 0000000000..4fa829ae76 --- /dev/null +++ b/src/components/Loader/Loader.module.scss @@ -0,0 +1,14 @@ +@import '../../styles/utils'; + +.loader { + margin: 32px auto; + justify-content: center; + + svg { + stroke: var(--accent); + } + + svg circle { + stroke: rgba($color: var(--primary), $alpha: 0.5); + } +} diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx new file mode 100644 index 0000000000..a0b186d485 --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,20 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { Oval } from 'react-loader-spinner'; + +import styles from './Loader.module.scss'; +import React from 'react'; +import classNames from 'classnames'; + +interface Props { + className?: string; +} + +export const Loader: React.FC = ({ className }) => { + return ( + + ); +}; diff --git a/src/components/Loader/index.ts b/src/components/Loader/index.ts new file mode 100644 index 0000000000..d702788525 --- /dev/null +++ b/src/components/Loader/index.ts @@ -0,0 +1 @@ +export { Loader } from './Loader'; diff --git a/src/components/Logo/Logo.styles.scss b/src/components/Logo/Logo.styles.scss new file mode 100644 index 0000000000..6838d347d5 --- /dev/null +++ b/src/components/Logo/Logo.styles.scss @@ -0,0 +1,3 @@ +.logo { + display: block; +} diff --git a/src/components/Logo/Logo.tsx b/src/components/Logo/Logo.tsx new file mode 100644 index 0000000000..f5b82bbb0f --- /dev/null +++ b/src/components/Logo/Logo.tsx @@ -0,0 +1,20 @@ +import cn from 'classnames'; +import logoLight from '../../assets/images/logo-light.svg'; +import logoDark from '../../assets/images/logo-dark.svg'; +import { useContext } from 'react'; +import { ThemeContext } from '../../contex/Theme'; +import { Theme } from '../../utils/constants'; + +import styles from './Logo.styles.scss'; + +interface Props { + className?: string; +} + +export const Logo: React.FC = ({ className }) => { + const { theme } = useContext(ThemeContext); + + const logo = theme === Theme.LIGTH ? logoLight : logoDark; + + return logo; +}; diff --git a/src/components/Logo/index.ts b/src/components/Logo/index.ts new file mode 100644 index 0000000000..33af505338 --- /dev/null +++ b/src/components/Logo/index.ts @@ -0,0 +1 @@ +export { Logo } from './Logo'; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 0000000000..1c2409995c --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,118 @@ +@import '../../styles/utils'; + +.product-card { + width: 100%; + height: 100%; + padding: 32px; + border-radius: 8px; + box-shadow: inset 0 0 0 1px var(--elements); + display: flex; + gap: 24px; + flex-direction: column; + justify-content: space-between; + background-color: var(--bg-secondary); + + @include transition; + + &:hover { + box-shadow: inset 0 0 0 1px var(--active-link); + } + + &__photo-link { + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 1; + flex-grow: 1; + min-height: 80px; + + @include transition; + + &:hover { + transform: scale(1.1); + } + } + + &__photo { + display: block; + object-fit: contain; + height: 100%; + width: 100%; + } + + &__wrapper { + flex-shrink: 0; + } + + &__link { + &:hover { + color: var(--link); + + @include transition; + } + } + + &__title { + margin: 0; + margin-bottom: 8px; + font-size: inherit; + font-weight: 600; + } + + &__price-wrapper { + position: relative; + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-bottom: 8px; + line-height: 31px; + margin-bottom: 16px; + + @include h3-tablet-desktop; + + &::after { + position: absolute; + display: block; + content: ''; + width: 100%; + height: 1px; + background-color: var(--elements); + bottom: 0; + } + } + + &__full-price { + color: var(--secondary); + text-decoration: line-through; + font-weight: 600; + } + + &__properties-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + } + + &__specs-list { + display: flex; + flex-direction: column; + } + + &__definition-list { + margin: 0; + display: flex; + gap: 8px; + justify-content: space-between; + + @include small-text; + } + + &__definition-term { + color: var(--secondary); + } + + &__definition-desc { + margin: 0; + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000000..b39254b5c3 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,80 @@ +import styles from './ProductCard.module.scss'; +import cn from 'classnames'; +import { Product } from '../../types/Product'; +import { Link } from 'react-router-dom'; +import { Actions } from '../Actions'; +import { scrollToTop } from '../../utils/utility'; + +interface Props { + product: Product; +} + +export const ProductCard: React.FC = ({ product }) => { + const { + name, + image, + price, + fullPrice, + screen, + capacity, + ram, + category, + itemId, + } = product; + + const properties = [ + { term: 'Screen', desc: screen }, + { term: 'Capacity', desc: capacity }, + { term: 'RAM', desc: ram }, + ]; + + const pathLink = `/${category}/${itemId}`; + + return ( +
+ + {name} + + +
+ +

{name}

+ + +
+

${price}

+

${fullPrice}

+
+ +
    + {properties.map(property => ( +
  • +
    +
    + {property.term} +
    +
    + {property.desc} +
    +
    +
  • + ))} +
+ + +
+
+ ); +}; diff --git a/src/components/ProductCard/index.ts b/src/components/ProductCard/index.ts new file mode 100644 index 0000000000..c4f2778191 --- /dev/null +++ b/src/components/ProductCard/index.ts @@ -0,0 +1 @@ +export { ProductCard } from './ProductCard'; diff --git a/src/components/ProductsSlider/ProductsSlider.module.scss b/src/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 0000000000..619905991c --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,87 @@ +@import '../../styles/utils'; + +.products-slider { + display: grid; + grid-template-columns: repeat(12, 1fr); + + &__header { + grid-column: 1 / -1; + display: flex; + gap: 10px; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + + @include page-padding-inline; + } + + &__title { + margin: 0; + + @include h2-mobile; + + @include on-tablet { + @include h2-tablet-desktop; + } + } + + &__controls { + display: flex; + gap: 16px; + } + + &__btn { + width: 32px; + height: 32px; + + &--prev { + transform: rotate(-90deg); + } + &--next { + transform: rotate(90deg); + } + + &:disabled { + svg path { + fill: var(--elements); + } + } + } + + &__wrapper { + grid-column: 1 / -1; + padding-left: $mobile-padding-inline; + + @include on-tablet { + padding-left: $tablet-padding-inline; + } + + @include on-desktop { + padding-inline: $desktop-padding-inline; + } + } + + &__list { + display: flex; + overflow: hidden; + height: auto; + gap: 16px; + } + + &__slide { + flex: 0 0 212px; + height: 440px; + + transition: transform 0.5s ease-in-out; + + @include on-tablet { + flex: 0 0 237px; + height: 512px; + } + + @include on-desktop { + flex: 0 0 272px; + height: 506px; + } + } +} diff --git a/src/components/ProductsSlider/ProductsSlider.tsx b/src/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 0000000000..a5782c22c1 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,103 @@ +import styles from './ProductsSlider.module.scss'; +import cn from 'classnames'; +import { useEffect, useState } from 'react'; +import { Product } from '../../types'; +import { RoundButton } from '../RoundButton'; +import { SvgIcon } from '../SvgIcon'; +import { ProductCard } from '../ProductCard'; + +interface Props { + title: string; + products: Product[]; +} + +export const ProductsSlider: React.FC = ({ title, products }) => { + const [currentSlideIndex, setCurrentSlideIndex] = useState(0); + const [visibleCardsCount, setVisibleCardsCount] = useState(3); + + const updateVisibleCardsCount = () => { + const width = window.innerWidth; + + switch (true) { + case width < 440: + setVisibleCardsCount(1); + break; + case width < 767: + setVisibleCardsCount(2); + break; + case width < 1200: + setVisibleCardsCount(3); + break; + default: + setVisibleCardsCount(4); + break; + } + }; + + useEffect(() => { + updateVisibleCardsCount(); + window.addEventListener('resize', updateVisibleCardsCount); + + return () => { + window.removeEventListener('resize', updateVisibleCardsCount); + }; + }, []); + + const previousSlide = () => { + setCurrentSlideIndex(index => Math.max(index - 1, 0)); + }; + + const nextSlide = () => { + setCurrentSlideIndex(index => + Math.min(index + 1, products.length - visibleCardsCount), + ); + }; + + return ( +
+
+

{title}

+
+ + + + + + +
+
+ +
+
    + {products.map(product => { + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +}; diff --git a/src/components/ProductsSlider/index.ts b/src/components/ProductsSlider/index.ts new file mode 100644 index 0000000000..0a5bb98662 --- /dev/null +++ b/src/components/ProductsSlider/index.ts @@ -0,0 +1 @@ +export { ProductsSlider } from './ProductsSlider'; diff --git a/src/components/RoundButton/RoundButton.module.scss b/src/components/RoundButton/RoundButton.module.scss new file mode 100644 index 0000000000..9e40be423f --- /dev/null +++ b/src/components/RoundButton/RoundButton.module.scss @@ -0,0 +1,30 @@ +@import '../../styles/utils'; + +.round-button { + width: 100%; + background-color: inherit; + border-radius: 50%; + border: 1px solid var(--icon-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + aspect-ratio: 1 / 1; + color: inherit; + + @include transition; + + &:hover { + border-color: var(--round-btn-hover-border); + background-color: var(--round-btn-hover); + } + + &:disabled { + border-color: var(--elements); + background-color: inherit; + } + + svg path { + fill: var(--icon-primary); + } +} diff --git a/src/components/RoundButton/RoundButton.tsx b/src/components/RoundButton/RoundButton.tsx new file mode 100644 index 0000000000..79678d5626 --- /dev/null +++ b/src/components/RoundButton/RoundButton.tsx @@ -0,0 +1,20 @@ +import cn from 'classnames'; +import React from 'react'; + +import styles from './RoundButton.module.scss'; + +interface Props extends React.ButtonHTMLAttributes { + children: React.ReactNode; +} + +export const RoundButton: React.FC = ({ + className, + children, + ...rest +}) => { + return ( + + ); +}; diff --git a/src/components/RoundButton/index.ts b/src/components/RoundButton/index.ts new file mode 100644 index 0000000000..6d06eb8935 --- /dev/null +++ b/src/components/RoundButton/index.ts @@ -0,0 +1 @@ +export { RoundButton } from './RoundButton'; diff --git a/src/components/SvgIcon/SvgIcon.tsx b/src/components/SvgIcon/SvgIcon.tsx new file mode 100644 index 0000000000..93a4da5103 --- /dev/null +++ b/src/components/SvgIcon/SvgIcon.tsx @@ -0,0 +1,190 @@ +/* eslint-disable max-len */ +import React from 'react'; + +type Type = + | 'arrow' + | 'heart' + | 'heart-like' + | 'cart' + | 'menu' + | 'close' + | 'search' + | 'home'; + +interface Props { + className?: string; + type: Type; +} + +export const SvgIcon: React.FC = ({ className, type }) => { + switch (type) { + case 'arrow': + return ( + + + + ); + case 'heart': + return ( + + + + ); + case 'heart-like': + return ( + + + + ); + case 'cart': + return ( + + + + + + ); + + case 'menu': + return ( + + + + + + ); + case 'close': + return ( + + + + ); + case 'search': + return ( + + + + + + + + ); + case 'home': + return ( + + + + + ); + default: + return null; + } +}; diff --git a/src/components/SvgIcon/index.ts b/src/components/SvgIcon/index.ts new file mode 100644 index 0000000000..bd502a01b6 --- /dev/null +++ b/src/components/SvgIcon/index.ts @@ -0,0 +1 @@ +export { SvgIcon } from './SvgIcon'; diff --git a/src/components/breadcrumbs/breadcrumbs.module.scss b/src/components/breadcrumbs/breadcrumbs.module.scss new file mode 100644 index 0000000000..1d7d9346a9 --- /dev/null +++ b/src/components/breadcrumbs/breadcrumbs.module.scss @@ -0,0 +1,54 @@ +@import '../../styles/utils'; + +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + + &__icon { + display: block; + } + + &__item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 700; + line-height: 15px; + color: var(--primary); + + &:not(:last-child)::after { + display: block; + content: ''; + width: 16px; + height: 16px; + background-image: url('../../assets/images/icons/arrow.svg'); + background-size: cover; + transform: rotate(90deg); + } + + &:last-child { + flex: 1; + font-weight: 600; + color: var(--secondary); + width: 50px; + } + } + + &__link { + display: flex; + align-items: center; + + svg path{ + fill: var(--icon-primary); + } + } + + &__last-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + } +} diff --git a/src/components/breadcrumbs/breadcrumbs.tsx b/src/components/breadcrumbs/breadcrumbs.tsx new file mode 100644 index 0000000000..56dc4b8258 --- /dev/null +++ b/src/components/breadcrumbs/breadcrumbs.tsx @@ -0,0 +1,48 @@ +import { Link, useLocation } from 'react-router-dom'; +import styles from './Breadcrumbs.module.scss'; +import cn from 'classnames'; +import { firstLetterCap } from '../../utils/utility'; +import { SvgIcon } from '../SvgIcon'; + +interface Props { + className?: string; +} + +export const Breadcrumbs: React.FC = ({ className }) => { + const location = useLocation(); + + const crumbs = location.pathname + .replaceAll('#', '') + .split('/') + .filter(crumb => !!crumb.length); + + let currentLink = ''; + + return ( +
    +
  • + + + +
  • + + {crumbs.map((crumb, i, arr) => { + currentLink += `/${crumb}`; + const title = firstLetterCap(crumb).replaceAll('-', ' '); + const isLast = i === arr.length - 1; + + return ( +
  • + {isLast ? ( +

    {title}

    + ) : ( + + {title} + + )} +
  • + ); + })} +
+ ); +}; diff --git a/src/components/breadcrumbs/index.ts b/src/components/breadcrumbs/index.ts new file mode 100644 index 0000000000..28140a257f --- /dev/null +++ b/src/components/breadcrumbs/index.ts @@ -0,0 +1 @@ +export { Breadcrumbs } from './Breadcrumbs'; diff --git a/src/contex/State.tsx b/src/contex/State.tsx new file mode 100644 index 0000000000..b6ff3bdd63 --- /dev/null +++ b/src/contex/State.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useReducer } from 'react'; +import { Product } from '../types'; +import { CartProduct } from '../types/Product'; + +type Action = + | { type: 'addFavourites'; payload: Product } + | { type: 'removeFavourites'; payload: string } + | { type: 'addCart'; payload: CartProduct } + | { type: 'removeCart'; payload: string } + | { type: 'clearCart' } + | { type: 'increaseCountInCart'; payload: string } + | { type: 'decreaseCountInCart'; payload: string }; + +interface State { + favourites: Product[]; + cart: CartProduct[]; +} + +const initialState: State = { + favourites: [], + cart: [], +}; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'addCart': + return { ...state, cart: [...state.cart, action.payload] }; + + case 'removeCart': + const filteredCart = state.cart.filter( + product => product.id !== action.payload, + ); + + return { ...state, cart: filteredCart }; + + case 'clearCart': + return { ...state, cart: [] }; + + case 'addFavourites': + return { ...state, favourites: [...state.favourites, action.payload] }; + + case 'removeFavourites': + const filteredFavousrites = state.favourites.filter( + product => product.itemId !== action.payload, + ); + + return { ...state, favourites: filteredFavousrites }; + + case 'increaseCountInCart': + return { + ...state, + cart: state.cart.map(p => + p.id === action.payload ? { ...p, quantity: p.quantity + 1 } : p, + ), + }; + + case 'decreaseCountInCart': + return { + ...state, + cart: state.cart.map(p => + p.id === action.payload ? { ...p, quantity: p.quantity - 1 } : p, + ), + }; + + default: + return { ...state }; + } +}; + +export const StateContext = React.createContext(initialState); +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const DispatchContext = React.createContext((_action: Action) => {}); + +interface Props { + children: React.ReactNode; +} + +export const GlobalStateProvider: React.FC = ({ children }) => { + const getInitialState = (): State => { + try { + const savedFavourites = JSON.parse( + localStorage.getItem('favourites') || '[]', + ); + const savedCart = JSON.parse(localStorage.getItem('cart') || '[]'); + + return { + favourites: savedFavourites, + cart: savedCart, + }; + } catch (_) { + return { + favourites: [], + cart: [], + }; + } + }; + + const [state, dispatch] = useReducer(reducer, getInitialState()); + + useEffect(() => { + localStorage.setItem('favourites', JSON.stringify(state.favourites)); + localStorage.setItem('cart', JSON.stringify(state.cart)); + }, [state]); + + return ( + + {children} + + ); +}; diff --git a/src/contex/Theme.tsx b/src/contex/Theme.tsx new file mode 100644 index 0000000000..15f0ddb500 --- /dev/null +++ b/src/contex/Theme.tsx @@ -0,0 +1,31 @@ +import { createContext, useEffect, useState } from 'react'; +import { Theme } from '../utils/constants'; + +const initialState = { + theme: Theme.LIGTH, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setTheme: (_theme: Theme) => {}, +}; + +export const ThemeContext = createContext(initialState); + +interface Props { + children: React.ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + const initialTheme = (localStorage.getItem('theme') as Theme) || Theme.LIGTH; + + const [theme, setTheme] = useState(initialTheme); + + useEffect(() => { + document.body.className = theme; + localStorage.setItem('theme', theme); + }, [theme]); + + return ( + + {children} + + ); +}; diff --git a/src/declaration.d.ts b/src/declaration.d.ts new file mode 100644 index 0000000000..7b2a58cfcc --- /dev/null +++ b/src/declaration.d.ts @@ -0,0 +1,3 @@ +declare module '*.scss'; +declare module '*.svg'; +declare module '*.png'; diff --git a/src/helpers/colorMap.ts b/src/helpers/colorMap.ts new file mode 100644 index 0000000000..7e76de6335 --- /dev/null +++ b/src/helpers/colorMap.ts @@ -0,0 +1,26 @@ +interface ColorMap { + [key: string]: string; +} + +export const colorMap: ColorMap = { + black: '#000', + gold: '#FFD700', + yellow: '#FFFF00', + green: '#008000', + midnightgreen: '#004953', + silver: '#c0c0c0', + spacegray: '#717378', + red: '#ff0000', + white: '#FFF', + purple: '#800080', + coral: '#ff7f50', + rosegold: '#B76E79', + midnight: '#191970', + spaceblack: '#505150', + blue: '#0000ff', + pink: '#ffc0cb', + sierrablue: '#BFDAF7', + graphite: '#41424C', + skyblue: '#87CEEB', + starlight: '#F8F9EC', +}; diff --git a/src/helpers/index.ts b/src/helpers/index.ts new file mode 100644 index 0000000000..2ee4854e13 --- /dev/null +++ b/src/helpers/index.ts @@ -0,0 +1 @@ +export { colorMap } from './colorMap'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000000..4be657d117 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export { useUpdateSearchParams } from './useUpdateSearchParams'; diff --git a/src/hooks/useUpdateSearchParams.tsx b/src/hooks/useUpdateSearchParams.tsx new file mode 100644 index 0000000000..334871cf44 --- /dev/null +++ b/src/hooks/useUpdateSearchParams.tsx @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +export const useUpdateSearchParams = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const updateSearchParams = useCallback( + (key: string, value: string | number) => { + const params = new URLSearchParams(searchParams); + + params.set(key, value.toString()); + setSearchParams(params); + }, + [searchParams, setSearchParams], + ); + + const deleteSearchParam = useCallback( + (key: string) => { + const newSearchParams = new URLSearchParams(searchParams); + + newSearchParams.delete(key); + setSearchParams(newSearchParams); + }, + [searchParams, setSearchParams], + ); + + return { searchParams, updateSearchParams, deleteSearchParam }; +}; diff --git a/src/index.tsx b/src/index.tsx index 50470f1508..ee4a60d280 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,15 @@ import { createRoot } from 'react-dom/client'; -import { App } from './App'; +import { HashRouter as Router } from 'react-router-dom'; +import { Root } from './Root'; +import { GlobalStateProvider } from './contex/State'; +import { ThemeProvider } from './contex/Theme'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + , +); diff --git a/src/modules/AccessoriesPage/AccessoriesPage.tsx b/src/modules/AccessoriesPage/AccessoriesPage.tsx new file mode 100644 index 0000000000..a4d0de7c2f --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { Product } from '../../types/Product'; +import { getProductsByCategory } from '../../servises/products'; +import { ProductsCatalog } from '../ProductsCatalog'; + +export const AccessoriesPage = () => { + const [accessories, setAccessories] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + setLoading(true); + getProductsByCategory('accessories') + .then(setAccessories) + .catch(() => setError('Something went wrong')) + .finally(() => setLoading(false)); + }, []); + + return ( + + ); +}; diff --git a/src/modules/AccessoriesPage/index.ts b/src/modules/AccessoriesPage/index.ts new file mode 100644 index 0000000000..83dcf696d1 --- /dev/null +++ b/src/modules/AccessoriesPage/index.ts @@ -0,0 +1 @@ +export { AccessoriesPage } from './AccessoriesPage'; diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 0000000000..c446278c12 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,109 @@ +@import '../../styles/utils'; + +.cart-page { + max-width: $desktop-width; + margin: 0 auto; + padding-top: 24px; + padding-bottom: 56px; + + @include page-padding-inline; + + @include on-tablet { + padding-bottom: 64px; + } + + @include on-desktop { + padding-bottom: 80px; + } + + &__back { + margin-bottom: 24px; + + @include on-tablet { + margin-bottom: 40px; + } + } + + &__title { + margin: 0; + margin-bottom: 32px; + + @include h1-mobile; + + @include on-tablet { + @include h1-tablet-desktop; + } + } + + &__container { + @include on-desktop { + display: grid; + grid-template-columns: 752px 368px; + gap: 16px; + } + } + + &__text { + color: var(--secondary); + margin-bottom: 32px; + + @include on-tablet { + margin-bottom: 40px; + } + } + + &__list { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 32px; + + @include on-desktop { + margin: 0; + } + } + + &__total { + text-align: center; + padding: 24px; + border: 1px solid var(--elements); + border-radius: 16px; + + @include on-desktop { + align-self: start; + } + } + + &__total-price { + @include h2-tablet-desktop; + } + + &__total-desc { + display: flex; + flex-direction: column; + gap: 15px; + color: var(--secondary); + margin-bottom: 16px; + + &::after { + display: block; + content: ''; + width: 100%; + height: 1px; + background-color: var(--elements); + } + } + + &__checkout { + height: 48px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 13px; + border: none; + background-color: var(--accent); + color: var(--white); + border-radius: 8px; + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 0000000000..de65a3386e --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,61 @@ +import { useContext, useState } from 'react'; +import { StateContext } from '../../contex/State'; +import styles from './CartPage.module.scss'; +import { BackLink } from '../../components/BackLink'; +import { CartItem } from './components/CartItem'; +import { EmptyCart } from './components/EmptyCart'; +import { Modal } from './components/Modal'; + +export const CartPage = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + const { cart } = useContext(StateContext); + + const totalPrice = cart.reduce( + (total, p) => total + p.product.price * p.quantity, + 0, + ); + + const totalQuantity = cart.reduce((count, p) => count + p.quantity, 0); + + return ( +
+ +

Cart

+ {cart.length ? ( +
+
    + {cart.map(product => { + return ( +
  • + +
  • + ); + })} +
+
+

${totalPrice}

+

+ Total for {totalQuantity} item{cart.length > 1 ? 's' : ''} +

+ +
+
+ ) : ( + + )} + + {isModalOpen && ( + { + setIsModalOpen(false); + }} + /> + )} +
+ ); +}; diff --git a/src/modules/CartPage/components/CartItem/CartItem.module.scss b/src/modules/CartPage/components/CartItem/CartItem.module.scss new file mode 100644 index 0000000000..b206423cfd --- /dev/null +++ b/src/modules/CartPage/components/CartItem/CartItem.module.scss @@ -0,0 +1,138 @@ +@import '../../../../styles/utils'; + +.cart-item { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + border: 1px solid var(--elements); + border-radius: 16px; + background-color: var(--bg-secondary); + + @include on-tablet { + flex-direction: row; + justify-content: space-between; + gap: 24px; + padding: 24px; + } + + &__container { + display: flex; + gap: 16px; + align-items: center; + + @include on-tablet { + gap: 24px; + } + } + + &__del { + flex-shrink: 0; + position: relative; + width: 16px; + height: 16px; + align-self: center; + border: none; + background-color: inherit; + padding: 0; + + &::before, + &::after { + position: absolute; + content: ''; + width: 12px; + height: 1px; + background-color: var(--icon-secondary); + border-radius: 1px; + top: 50%; + left: 50%; + } + + @include transition; + + &::before { + transform: translate(-50%, -50%) rotate(45deg); + } + + &::after { + transform: translate(-50%, -50%) rotate(-45deg); + } + + &:hover { + scale: 1.2; + } + } + + &__img { + width: 80px; + object-fit: contain; + aspect-ratio: 1 / 1; + } + + &__link { + @include transition; + + &:hover { + color: var(--icon-secondary); + } + } + + &__count { + display: flex; + align-items: center; + gap: 14px; + } + + &__count-btn { + position: relative; + border-radius: 50%; + width: 30px; + height: 30px; + border: 1px solid var(--icon-secondary); + background-color: inherit; + + &::before, + &::after { + position: absolute; + content: ''; + width: 11px; + height: 1px; + background-color: var(--primary); + border-radius: 1px; + top: 50%; + left: 50%; + } + + &--minus::before, + &--minus::after { + transform: translate(-50%); + } + + &--plus::after { + transform: translate(-50%); + } + &--plus::before { + transform: translate(-50%) rotate(90deg); + } + + &:disabled { + border-color: var(--elements); + + &::before, + &::after { + background-color: var(--icon-secondary); + } + } + } + + &__price { + margin-left: auto; + + @include h3-tablet-desktop; + + @include on-tablet { + width: 80px; + text-align: end; + } + } +} diff --git a/src/modules/CartPage/components/CartItem/CartItem.tsx b/src/modules/CartPage/components/CartItem/CartItem.tsx new file mode 100644 index 0000000000..a9f250be1f --- /dev/null +++ b/src/modules/CartPage/components/CartItem/CartItem.tsx @@ -0,0 +1,70 @@ +import classNames from 'classnames'; +import { CartProduct } from '../../../../types'; +import styles from './CartItem.module.scss'; +import { useContext } from 'react'; +import { DispatchContext } from '../../../../contex/State'; +import { Link } from 'react-router-dom'; + +interface Props { + product: CartProduct; +} + +export const CartItem: React.FC = ({ product }) => { + const { name, image, price, itemId, category } = product.product; + const { quantity } = product; + const dispatch = useContext(DispatchContext); + + const deleteGood = () => { + dispatch({ type: 'removeCart', payload: itemId }); + }; + + const increaseQuantity = () => { + dispatch({ type: 'increaseCountInCart', payload: itemId }); + }; + + const decreaseQuantity = () => { + dispatch({ type: 'decreaseCountInCart', payload: itemId }); + }; + + const path = `/${category}/${itemId}`; + + return ( +
+
+ + {name} + + {name} + +
+ +
+
+ +

{quantity}

+ +
+

${price * quantity}

+
+
+ ); +}; diff --git a/src/modules/CartPage/components/CartItem/index.ts b/src/modules/CartPage/components/CartItem/index.ts new file mode 100644 index 0000000000..186b364ebd --- /dev/null +++ b/src/modules/CartPage/components/CartItem/index.ts @@ -0,0 +1 @@ +export { CartItem } from './CartItem'; diff --git a/src/modules/CartPage/components/EmptyCart/EmptyCart.module.scss b/src/modules/CartPage/components/EmptyCart/EmptyCart.module.scss new file mode 100644 index 0000000000..6cc850e7eb --- /dev/null +++ b/src/modules/CartPage/components/EmptyCart/EmptyCart.module.scss @@ -0,0 +1,26 @@ +@import '../../../../styles/utils'; + +.empty-cart { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + + &__text { + @include h2-mobile; + + @include on-tablet { + @include h2-tablet-desktop; + } + } + + &__img { + width: 100%; + max-width: 450px; + background: radial-gradient(circle, var(--accent) 0, var(--hover-bg) 70%); + padding: 60px; + object-fit: contain; + aspect-ratio: 1 / 1; + } + +} diff --git a/src/modules/CartPage/components/EmptyCart/EmptyCart.tsx b/src/modules/CartPage/components/EmptyCart/EmptyCart.tsx new file mode 100644 index 0000000000..b1b01a9555 --- /dev/null +++ b/src/modules/CartPage/components/EmptyCart/EmptyCart.tsx @@ -0,0 +1,14 @@ +import styles from './EmptyCart.module.scss'; + +export const EmptyCart: React.FC = () => { + return ( +
+

Your cart is empty

+ Empty cart +
+ ); +}; diff --git a/src/modules/CartPage/components/EmptyCart/index.ts b/src/modules/CartPage/components/EmptyCart/index.ts new file mode 100644 index 0000000000..a0a65c39fc --- /dev/null +++ b/src/modules/CartPage/components/EmptyCart/index.ts @@ -0,0 +1 @@ +export { EmptyCart } from './EmptyCart'; diff --git a/src/modules/CartPage/components/Modal/Modal.module.scss b/src/modules/CartPage/components/Modal/Modal.module.scss new file mode 100644 index 0000000000..b77d8c2127 --- /dev/null +++ b/src/modules/CartPage/components/Modal/Modal.module.scss @@ -0,0 +1,67 @@ +@import '../../../../styles/utils'; + +.modal { + position: fixed; + z-index: 3; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + padding: 40px; + border: 2px solid var(--accent); + border-radius: 16px; + width: 80%; + max-width: 600px; + background-color: var(--bg-secondary); + + display: flex; + flex-direction: column; + gap: 16px; + + &__close { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + position: absolute; + width: 24px; + height: 24px; + top: 10px; + right: 10px; + border: none; + background-color: inherit; + + @include transition; + + &:hover { + transform: scale(1.2); + opacity: 0.6; + } + + svg path { + fill: var(--icon-primary); + } + } + + &__text { + color: var(--primary); + text-align: center; + + @include h4-mobile; + + @include on-tablet { + @include h4-tablet-desktop; + } + } + + &__confirm { + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--accent); + border: none; + color: var(--white); + border-radius: 8px; + } +} diff --git a/src/modules/CartPage/components/Modal/Modal.tsx b/src/modules/CartPage/components/Modal/Modal.tsx new file mode 100644 index 0000000000..c010151675 --- /dev/null +++ b/src/modules/CartPage/components/Modal/Modal.tsx @@ -0,0 +1,32 @@ +import { useContext } from 'react'; +import styles from './Modal.module.scss'; +import { DispatchContext } from '../../../../contex/State'; +import { SvgIcon } from '../../../../components/SvgIcon'; + +interface Props { + close: () => void; +} + +export const Modal: React.FC = ({ close }) => { + const dispatch = useContext(DispatchContext); + + const onConfirmClick = () => { + dispatch({ type: 'clearCart' }); + close(); + }; + + return ( + + +

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

+ +
+ ); +}; diff --git a/src/modules/CartPage/components/Modal/index.ts b/src/modules/CartPage/components/Modal/index.ts new file mode 100644 index 0000000000..8deb0a3dff --- /dev/null +++ b/src/modules/CartPage/components/Modal/index.ts @@ -0,0 +1 @@ +export { Modal } from './Modal'; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 0000000000..203fb0ea4b --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export { CartPage } from './CartPage'; diff --git a/src/modules/FavouritesPage/FavouritesPage.module.scss b/src/modules/FavouritesPage/FavouritesPage.module.scss new file mode 100644 index 0000000000..be6ad1b612 --- /dev/null +++ b/src/modules/FavouritesPage/FavouritesPage.module.scss @@ -0,0 +1,45 @@ +@import '../../styles/utils'; + +.favourites-page { + max-width: $desktop-width; + margin: 0 auto; + padding-top: 24px; + padding-bottom: 56px; + + @include page-padding-inline; + + @include on-tablet { + padding-bottom: 64px; + } + + @include on-desktop { + padding-bottom: 80px; + } + + &__breadcrumbs { + margin-bottom: 24px; + + @include on-tablet { + margin-bottom: 40px; + } + } + + &__title { + margin-bottom: 8px; + + @include h1-mobile; + + @include on-tablet { + @include h1-tablet-desktop; + } + } + + &__text { + color: var(--secondary); + margin-bottom: 32px; + + @include on-tablet { + margin-bottom: 40px; + } + } +} diff --git a/src/modules/FavouritesPage/FavouritesPage.tsx b/src/modules/FavouritesPage/FavouritesPage.tsx new file mode 100644 index 0000000000..027148ea3b --- /dev/null +++ b/src/modules/FavouritesPage/FavouritesPage.tsx @@ -0,0 +1,26 @@ +import { useContext } from 'react'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { StateContext } from '../../contex/State'; +import styles from './FavouritesPage.module.scss'; +import { Grid } from '../../components/Grid'; + +export const FavouritesPage = () => { + const { favourites } = useContext(StateContext); + + return ( +
+ +

Favourites

+ {favourites.length ? ( + <> +

+ {favourites.length} item{favourites.length === 1 ? '' : 's'} +

+ + + ) : ( +

No items

+ )} +
+ ); +}; diff --git a/src/modules/FavouritesPage/index.ts b/src/modules/FavouritesPage/index.ts new file mode 100644 index 0000000000..0a16512c05 --- /dev/null +++ b/src/modules/FavouritesPage/index.ts @@ -0,0 +1 @@ +export { FavouritesPage } from './FavouritesPage'; diff --git a/src/modules/Footer/Footer.module.scss b/src/modules/Footer/Footer.module.scss new file mode 100644 index 0000000000..c0e4f33851 --- /dev/null +++ b/src/modules/Footer/Footer.module.scss @@ -0,0 +1,62 @@ +@import '../../styles/utils'; + +.footer { + display: flex; + flex-direction: column; + gap: 32px; + padding: 32px; + color: var(--text-footer); + box-shadow: 0 -1px 0 0 var(--elements); + background-color: var(--bg-header-footer); + + @include page-padding-inline; + + @include on-tablet { + flex-direction: row; + gap: 15px; + justify-content: space-between; + } + + &__logo { + width: 89px; + height: 32px; + } + + &__list { + display: flex; + flex-direction: column; + gap: 16px; + + @include uppercase; + + @include on-tablet { + flex-flow: row wrap; + gap: 13.5px; + } + } + + &__item { + display: flex; + align-items: center; + } + + &__link { + color: var(--active-link); + + @include transition; + + &:hover { + color: var(--link); + } + } + + &__container { + color: var(--link); + display: grid; + grid-template-columns: 1fr 32px; + align-items: center; + gap: 16px; + + @include small-text; + } +} diff --git a/src/modules/Footer/Footer.tsx b/src/modules/Footer/Footer.tsx new file mode 100644 index 0000000000..ea40f0eb2e --- /dev/null +++ b/src/modules/Footer/Footer.tsx @@ -0,0 +1,38 @@ +import { Link } from 'react-router-dom'; +import { SvgIcon } from '../../components/SvgIcon'; + +import styles from './Footer.module.scss'; +import { RoundButton } from '../../components/RoundButton'; +import { scrollToTop } from '../../utils/utility'; +import { Logo } from '../../components/Logo'; + +export const Footer = () => { + const links = [ + { title: 'Github', path: '#' }, + { title: 'Contacts', path: '#' }, + { title: 'Rights', path: '#' }, + ]; + + return ( +
+ + +
    + {links.map(link => ( +
  • + + {link.title} + +
  • + ))} +
+ +
+

Back to top

+ + + +
+
+ ); +}; diff --git a/src/modules/Footer/index.ts b/src/modules/Footer/index.ts new file mode 100644 index 0000000000..65e2506faf --- /dev/null +++ b/src/modules/Footer/index.ts @@ -0,0 +1 @@ +export { Footer } from './Footer'; diff --git a/src/modules/Header/Header.module.scss b/src/modules/Header/Header.module.scss new file mode 100644 index 0000000000..9b03edfe8d --- /dev/null +++ b/src/modules/Header/Header.module.scss @@ -0,0 +1,116 @@ +@import '../../styles/utils'; + +.header { + border-bottom: 1px solid var(--elements); + position: sticky; + top: 0; + z-index: 2; + background-color: var(--bg-header-footer); + display: flex; + gap: 16px; + justify-content: space-between; + height: 48px; + + @include on-tablet { + display: grid; + grid-template-columns: min-content 1fr; + } + + @include on-desktop { + height: 64px; + } + + &__link-logo { + display: flex; + width: min-content; + align-items: center; + justify-content: center; + padding: 13px 16px; + + @include transition; + + &:hover { + scale: 1.1; + } + } + + &__logo { + display: block; + width: 64px; + height: 22px; + + @include on-desktop { + width: 80px; + height: 28px; + } + } + + &__menu { + position: fixed; + z-index: 1; + inset: 0; + background-color: var(--bg-header-footer); + padding-top: 72px; + + @include on-tablet { + display: none; + } + } + + &__wrapper { + display: flex; + gap: 8px; + + &--mobile { + @include on-tablet { + display: none; + } + } + } + + &__theme-switcher { + width: 48px; + height: 48px; + + @include on-desktop { + width: 64px; + height: 64px; + } + } + + &__menu-btn { + height: 100%; + width: 48px; + border: none; + border-left: 1px solid var(--elements); + background-color: inherit; + + display: flex; + align-items: center; + justify-content: center; + + svg path { + fill: var(--icon-primary); + } + } + + &__nav-bar { + display: none; + + @include on-tablet { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + } + } + + &__actions { + display: flex; + height: 48px; + + @include on-desktop { + height: 64px; + } + } +} diff --git a/src/modules/Header/Header.tsx b/src/modules/Header/Header.tsx new file mode 100644 index 0000000000..6404fe3d84 --- /dev/null +++ b/src/modules/Header/Header.tsx @@ -0,0 +1,68 @@ +import { NavLink, useLocation } from 'react-router-dom'; +import styles from './Header.module.scss'; +import { useEffect, useState } from 'react'; +import { Menu } from './components/Menu'; +import { Navigation } from './components/Navigation'; +import { Actions } from './components/Actions'; +import { MainNavigation } from '../../utils/constants'; +import { disableScroll, enableScroll } from '../../utils/utility'; +import { ThemeSwitcher } from './components/ThemeSwitcher'; +import { Logo } from '../../components/Logo'; +import { SvgIcon } from '../../components/SvgIcon'; +import classNames from 'classnames'; + +export const Header = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const location = useLocation(); + + useEffect(() => { + setIsMenuOpen(false); + }, [location]); + + useEffect(() => { + if (isMenuOpen) { + disableScroll(); + } else { + enableScroll(); + } + }, [isMenuOpen]); + + return ( + <> +
+ + + + +
+ + +
+ +
+ +
+ + +
+
+
+ + {isMenuOpen && } + + ); +}; diff --git a/src/modules/Header/components/Actions/Actions.module.scss b/src/modules/Header/components/Actions/Actions.module.scss new file mode 100644 index 0000000000..631d62829a --- /dev/null +++ b/src/modules/Header/components/Actions/Actions.module.scss @@ -0,0 +1,72 @@ +@import '../../../../styles/utils'; + +.actions { + &__item { + position: relative; + width: 100%; + height: 100%; + box-shadow: -1px 0 0 0 var(--elements); + display: flex; + align-items: center; + justify-content: center; + + @include on-tablet { + width: 48px; + } + + @include on-desktop { + width: 64px; + } + + &::after { + position: absolute; + content: ''; + width: 100%; + height: 2px; + background-color: var(--active-link); + bottom: 0; + left: 0; + + transition: transform 0.3s; + transform: scale(0); + transform-origin: left; + } + + &:hover { + &::after { + transform: scale(1); + } + } + + &--active { + cursor: auto; + + &::after { + transform: scale(1); + } + } + + svg path { + fill: var(--icon-primary); + } + } + + &__count { + position: absolute; + border-radius: 50%; + background-color: var(--secondary-accent); + color: var(--white); + width: 14px; + height: 14px; + + font-size: 9px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + bottom: 50%; + left: 50%; + + border: 1px solid var(--bg-header-footer); + } +} diff --git a/src/modules/Header/components/Actions/Actions.tsx b/src/modules/Header/components/Actions/Actions.tsx new file mode 100644 index 0000000000..b93319c4dd --- /dev/null +++ b/src/modules/Header/components/Actions/Actions.tsx @@ -0,0 +1,53 @@ +import { NavLink } from 'react-router-dom'; +import cn from 'classnames'; +import styles from './Actions.module.scss'; +import { useContext } from 'react'; +import { StateContext } from '../../../../contex/State'; +import { SvgIcon } from '../../../../components/SvgIcon'; + +interface Props { + className?: string; +} + +interface Action { + name: string; + count: number; + icon: 'heart' | 'cart'; +} + +export const Actions: React.FC = ({ className }) => { + const { favourites, cart } = useContext(StateContext); + + const actions: Action[] = [ + { name: 'favourites', count: favourites.length, icon: 'heart' }, + { + name: 'cart', + count: cart.reduce((count, p) => count + p.quantity, 0), + icon: 'cart', + }, + ]; + + return ( +
+ {actions.map(action => { + const { name, count, icon } = action; + + return ( + + cn(styles.actions__item, { + [styles['actions__item--active']]: isActive, + }) + } + to={`/${name}`} + > + + {!!count &&

{count}

} + {name} +
+ ); + })} +
+ ); +}; diff --git a/src/modules/Header/components/Actions/index.ts b/src/modules/Header/components/Actions/index.ts new file mode 100644 index 0000000000..ac0eab8d42 --- /dev/null +++ b/src/modules/Header/components/Actions/index.ts @@ -0,0 +1 @@ +export { Actions } from './Actions'; diff --git a/src/modules/Header/components/Menu/Menu.module.scss b/src/modules/Header/components/Menu/Menu.module.scss new file mode 100644 index 0000000000..7eb522e6d5 --- /dev/null +++ b/src/modules/Header/components/Menu/Menu.module.scss @@ -0,0 +1,22 @@ +@import '../../../../styles/utils'; + +.menu { + display: flex; + flex-direction: column; + justify-content: space-between; + + &__navigation { + @include page-padding-inline; + } + + &__actions { + display: grid; + grid-template-columns: repeat(2, 1fr); + border-top: 1px solid var(--elements); + height: 64px; + } + + @include on-tablet { + display: none; + } +} diff --git a/src/modules/Header/components/Menu/Menu.tsx b/src/modules/Header/components/Menu/Menu.tsx new file mode 100644 index 0000000000..df046b823f --- /dev/null +++ b/src/modules/Header/components/Menu/Menu.tsx @@ -0,0 +1,19 @@ +import { Navigation } from '../Navigation'; + +import styles from './Menu.module.scss'; +import React from 'react'; +import { Actions } from '../Actions'; + +interface Props { + className?: string; +} + +export const Menu: React.FC = ({ className }) => { + return ( + + ); +}; diff --git a/src/modules/Header/components/Menu/index.ts b/src/modules/Header/components/Menu/index.ts new file mode 100644 index 0000000000..81b93712f1 --- /dev/null +++ b/src/modules/Header/components/Menu/index.ts @@ -0,0 +1 @@ +export { Menu } from './Menu'; diff --git a/src/modules/Header/components/Navigation/Navigation.module.scss b/src/modules/Header/components/Navigation/Navigation.module.scss new file mode 100644 index 0000000000..2b08f074d6 --- /dev/null +++ b/src/modules/Header/components/Navigation/Navigation.module.scss @@ -0,0 +1,65 @@ +@import '../../../../styles/utils'; + +.navigation { + &__list { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + + @include on-tablet { + flex-direction: row; + } + } + + &__link { + position: relative; + display: block; + text-transform: uppercase; + font-size: 12px; + font-weight: 800; + line-height: 27px; + letter-spacing: 0.04em; + color: var(--link); + + @include transition; + + @include on-tablet { + line-height: 48px; + } + + @include on-desktop { + line-height: 64px; + } + + &::after { + position: absolute; + content: ''; + width: 100%; + height: 2px; + background-color: var(--active-link); + bottom: 0; + left: 0; + + transition: transform 0.3s; + transform: scale(0); + transform-origin: left; + } + + &:hover { + color: var(--active-link); + + &::after { + transform: scale(1); + } + } + + &--active { + color: var(--active-link); + + &::after { + transform: scale(1); + } + } + } +} diff --git a/src/modules/Header/components/Navigation/Navigation.tsx b/src/modules/Header/components/Navigation/Navigation.tsx new file mode 100644 index 0000000000..0c4fafa6af --- /dev/null +++ b/src/modules/Header/components/Navigation/Navigation.tsx @@ -0,0 +1,42 @@ +import { NavLink } from 'react-router-dom'; + +import styles from './Navigation.module.scss'; +import React from 'react'; +import cn from 'classnames'; +import { MainNavigation } from '../../../../utils/constants'; + +const mainNav = [ + { title: 'Home', path: MainNavigation.HOME }, + { title: 'Phones', path: MainNavigation.PHONES }, + { title: 'Tablets', path: MainNavigation.TABLETS }, + { title: 'Accessories', path: MainNavigation.ACCESSORIES }, +]; + +interface Props { + className?: string; +} + +export const Navigation: React.FC = ({ className }) => { + return ( + + ); +}; diff --git a/src/modules/Header/components/Navigation/index.ts b/src/modules/Header/components/Navigation/index.ts new file mode 100644 index 0000000000..61f39d15c9 --- /dev/null +++ b/src/modules/Header/components/Navigation/index.ts @@ -0,0 +1 @@ +export { Navigation } from './Navigation'; diff --git a/src/modules/Header/components/ThemeSwitcher/ThemeSwitcher.module.scss b/src/modules/Header/components/ThemeSwitcher/ThemeSwitcher.module.scss new file mode 100644 index 0000000000..21ddbaa114 --- /dev/null +++ b/src/modules/Header/components/ThemeSwitcher/ThemeSwitcher.module.scss @@ -0,0 +1,22 @@ +@import '../../../../styles/utils'; + +.theme-switcher { + &__btn { + padding: 0; + border: none; + background-color: inherit; + height: 100%; + width: 100%; + background-size: 24px; + background-position: center; + background-repeat: no-repeat; + + &--light { + background-image: url('../../../../assets/images/icons/sun.svg'); + } + + &--dark { + background-image: url('../../../../assets/images/icons/moon.svg'); + } + } +} diff --git a/src/modules/Header/components/ThemeSwitcher/ThemeSwitcher.tsx b/src/modules/Header/components/ThemeSwitcher/ThemeSwitcher.tsx new file mode 100644 index 0000000000..32fbd27546 --- /dev/null +++ b/src/modules/Header/components/ThemeSwitcher/ThemeSwitcher.tsx @@ -0,0 +1,38 @@ +import { useContext } from 'react'; +import { ThemeContext } from '../../../../contex/Theme'; +import { Theme } from '../../../../utils/constants'; + +import styles from './ThemeSwitcher.module.scss'; +import cn from 'classnames'; + +interface Props { + className?: string; +} + +export const ThemeSwitcher: React.FC = ({ className }) => { + const { theme, setTheme } = useContext(ThemeContext); + + return ( +
+ {theme === Theme.LIGTH ? ( +
+ ); +}; diff --git a/src/modules/Header/components/ThemeSwitcher/index.ts b/src/modules/Header/components/ThemeSwitcher/index.ts new file mode 100644 index 0000000000..5c112587e9 --- /dev/null +++ b/src/modules/Header/components/ThemeSwitcher/index.ts @@ -0,0 +1 @@ +export { ThemeSwitcher } from './ThemeSwitcher'; diff --git a/src/modules/Header/index.ts b/src/modules/Header/index.ts new file mode 100644 index 0000000000..29429dc97e --- /dev/null +++ b/src/modules/Header/index.ts @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 0000000000..fdfef7c6e2 --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,52 @@ +@import '../../styles/utils'; + +.home-page { + max-width: $desktop-width; + margin: 24px auto 64px; + + display: flex; + flex-direction: column; + gap: 56px; + + @include on-tablet { + gap: 64px; + margin-top: 32px; + } + + @include on-desktop { + gap: 80px; + margin-top: 56px; + margin-bottom: 80px; + } + + &__wrapper { + display: flex; + flex-direction: column; + gap: 24px; + + @include on-tablet { + gap: 32px; + } + + @include on-desktop { + gap: 56px; + } + } + + &__title { + margin: 0; + + @include h1-mobile; + @include page-padding-inline; + + @include on-tablet { + @include h1-tablet-desktop; + } + } + + &__title-break { + @include on-desktop { + display: none; + } + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 0000000000..cdd0047a14 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; +import styles from './HomePage.module.scss'; +import { Categories } from './components/Categories'; +import { MainSlider } from './components/MainSlider'; +import { getHotPriceProducts, getNewProducts } from '../../servises/products'; +import { Product } from '../../types/Product'; +import { ProductsSlider } from '../../components/ProductsSlider'; + +export const HomePage = () => { + const [newModels, setNewModels] = useState([]); + const [hotPrices, setHotPrices] = useState([]); + + useEffect(() => { + getHotPriceProducts().then(setHotPrices); + getNewProducts().then(setNewModels); + }, []); + + return ( +
+
+

Product Catalog

+

+ Welcome to Nice
+ Gadgets store! +

+ + +
+ + + + + +
+ ); +}; diff --git a/src/modules/HomePage/components/Categories/Categories.module.scss b/src/modules/HomePage/components/Categories/Categories.module.scss new file mode 100644 index 0000000000..7849eb6ae1 --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.module.scss @@ -0,0 +1,63 @@ +@import '../../../../styles/utils'; + +.categories { + @include page-padding-inline; + + &__title { + margin-bottom: 24px; + + @include h2-mobile; + + @include on-tablet { + @include h2-tablet-desktop; + } + } + + &__list { + display: flex; + flex-direction: column; + gap: 32px; + + @include on-tablet { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + } + } + + &__item { + display: flex; + flex-direction: column; + } + + &__item-title { + margin: 0; + margin-bottom: 4px; + + @include h4-tablet-desktop; + } + + &__text { + color: var(--secondary); + + @include body-text; + } + + &__img { + display: block; + width: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; + border-radius: 8px; + margin-bottom: 24px; + overflow: hidden; + + @include transition; + } + + &__link { + &:hover .categories__img { + transform: scale(1.05); + } + } +} diff --git a/src/modules/HomePage/components/Categories/Categories.tsx b/src/modules/HomePage/components/Categories/Categories.tsx new file mode 100644 index 0000000000..29f408f78d --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.tsx @@ -0,0 +1,79 @@ +import { Link } from 'react-router-dom'; +import styles from './Categories.module.scss'; +import { MainNavigation } from '../../../../utils/constants'; +import { useEffect, useState } from 'react'; +import { getAllProducts } from '../../../../servises/products'; +import { Category } from '../../../../types'; + +export const Categories = () => { + const initialCategories = [ + { + title: 'Mobile phones', + count: 0, + category: 'phones', + img: 'img/category-phones.png', + path: MainNavigation.PHONES, + }, + { + title: 'Tablets', + count: 0, + category: 'tablets', + img: 'img/category-tablets.png', + path: MainNavigation.TABLETS, + }, + { + title: 'Accessories', + count: 0, + category: 'accessories', + img: 'img/category-accessories.png', + path: MainNavigation.ACCESSORIES, + }, + ]; + const [categories, setCategories] = useState(initialCategories); + + useEffect(() => { + getAllProducts.then(products => { + const counts: Record = { + phones: 0, + tablets: 0, + accessories: 0, + }; + + products.forEach(p => { + counts[p.category] += 1; + }); + + setCategories(current => + current.map(category => ({ + ...category, + count: counts[category.category as Category], + })), + ); + }); + }, []); + + return ( +
+

Shop by category

+
    + {categories.map(category => { + const { path, img, count, title } = category; + + return ( +
  • + + {title} +

    {title}

    +

    {count} models

    + +
  • + ); + })} +
+
+ ); +}; diff --git a/src/modules/HomePage/components/Categories/index.ts b/src/modules/HomePage/components/Categories/index.ts new file mode 100644 index 0000000000..8ae5932a87 --- /dev/null +++ b/src/modules/HomePage/components/Categories/index.ts @@ -0,0 +1 @@ +export { Categories } from './Categories'; diff --git a/src/modules/HomePage/components/MainSlider/MainSlider.module.scss b/src/modules/HomePage/components/MainSlider/MainSlider.module.scss new file mode 100644 index 0000000000..d957eab49b --- /dev/null +++ b/src/modules/HomePage/components/MainSlider/MainSlider.module.scss @@ -0,0 +1,120 @@ +@import '../../../../styles/utils'; + +.main-slider { + @include on-tablet { + padding-inline: $tablet-padding-inline; + } + + @include on-desktop { + padding-inline: desktop-padding-inline; + } + + &__container { + display: flex; + width: 100%; + height: 320px; + gap: 16px; + + @include on-tablet { + height: 360px; + } + + @include on-desktop { + height: 400px; + } + } + + &__controller { + display: none; + + &:hover { + animation: squish 200ms ease-in-out; + } + + @include on-tablet { + display: block; + width: 32px; + border-radius: 48px; + border: 1px solid var(--icon-secondary); + background-color: inherit; + padding: 8px; + + svg { + path { + fill: var(--icon-primary); + } + } + } + + &--next { + svg { + transform: rotate(90deg); + } + } + + &--prev { + svg { + transform: rotate(-90deg); + } + } + } + + &__list { + display: flex; + overflow: hidden; + width: 100%; + height: 100%; + + @include on-tablet { + border-radius: 8px; + } + } + + &__slide { + width: 100%; + height: 100%; + flex: 0 0 auto; + transition: transform 0.5s ease-in-out; + background: var(--bg-secondary); + } + + &__img { + display: block; + object-fit: cover; + width: 100%; + height: 100%; + } + + &__dots { + display: flex; + gap: 4px; + justify-content: center; + } + + &__dot { + width: 24px; + height: 24px; + border: none; + background-color: inherit; + + &::after { + content: ''; + display: block; + width: 14px; + height: 4px; + background-color: var(--elements); + } + + &--active { + &::after { + background-color: var(--primary); + } + } + } +} + +@keyframes squish { + 50% { + scale: 1.2 0.9; + } +} diff --git a/src/modules/HomePage/components/MainSlider/MainSlider.tsx b/src/modules/HomePage/components/MainSlider/MainSlider.tsx new file mode 100644 index 0000000000..b6b8db408f --- /dev/null +++ b/src/modules/HomePage/components/MainSlider/MainSlider.tsx @@ -0,0 +1,93 @@ +import cn from 'classnames'; +import styles from './MainSlider.module.scss'; +import { SvgIcon } from '../../../../components/SvgIcon'; +import { useEffect, useState } from 'react'; + +const slides = [ + 'img/banner-accessories.png', + 'img/banner-phones.png', + 'img/banner-tablets.png', +]; + +interface Props { + className: string; +} + +export const MainSlider: React.FC = ({ className = '' }) => { + const [currentSlide, setCurrentSlide] = useState(0); + + const handleNext = () => { + setCurrentSlide(prev => (prev + 1) % slides.length); + }; + + const handlePrev = () => { + setCurrentSlide(prev => (prev - 1 + slides.length) % slides.length); + }; + + useEffect(() => { + const intervalId = setInterval(handleNext, 5000); + + return () => clearInterval(intervalId); + }, []); + + return ( +
+
+ + +
    + {slides.map(slide => { + return ( +
  • + +
  • + ); + })} +
+ + +
+
+ {slides.map((_, index) => { + const isActive = index === currentSlide; + + return ( +
+
+ ); +}; diff --git a/src/modules/HomePage/components/MainSlider/index.ts b/src/modules/HomePage/components/MainSlider/index.ts new file mode 100644 index 0000000000..c1dbaddb82 --- /dev/null +++ b/src/modules/HomePage/components/MainSlider/index.ts @@ -0,0 +1 @@ +export { MainSlider } from './MainSlider'; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 0000000000..0799f479a2 --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export { HomePage } from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 0000000000..a81d3b497a --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,31 @@ +@import '../../styles/utils'; + +.not-found-page { + max-width: $desktop-width; + margin: 0 auto; + padding-top: 24px; + padding-bottom: 32px; + display: flex; + flex-direction: column; + gap: 24px; + + @include page-padding-inline; + + &__title { + margin: 0; + + @include h1-mobile; + + @include on-tablet { + @include h1-tablet-desktop; + } + } + + &__img { + width: 100%; + height: 100%; + object-fit: contain; + max-width: 600px; + align-self: center; + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 0000000000..745124c09f --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,16 @@ +import { BackLink } from '../../components/BackLink'; +import styles from './NotFoundPage.module.scss'; + +export const NotFoundPage = () => { + return ( +
+ +

Page not found

+ Page not found +
+ ); +}; diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 0000000000..642c600088 --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export { NotFoundPage } from './NotFoundPage'; diff --git a/src/modules/PhonesPage/PhonesPage.tsx b/src/modules/PhonesPage/PhonesPage.tsx new file mode 100644 index 0000000000..c5982134e1 --- /dev/null +++ b/src/modules/PhonesPage/PhonesPage.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { Product } from '../../types/Product'; +import { getProductsByCategory } from '../../servises/products'; +import { ProductsCatalog } from '../ProductsCatalog'; + +export const PhonesPage = () => { + const [phones, setPhones] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + setLoading(true); + getProductsByCategory('phones') + .then(setPhones) + .catch(() => setError('Something went wrong')) + .finally(() => setLoading(false)); + }, []); + + return ( + + ); +}; diff --git a/src/modules/PhonesPage/index.ts b/src/modules/PhonesPage/index.ts new file mode 100644 index 0000000000..6054067fc6 --- /dev/null +++ b/src/modules/PhonesPage/index.ts @@ -0,0 +1 @@ +export { PhonesPage } from './PhonesPage'; diff --git a/src/modules/ProductPage/ProductPage.module.scss b/src/modules/ProductPage/ProductPage.module.scss new file mode 100644 index 0000000000..2944ca5ec1 --- /dev/null +++ b/src/modules/ProductPage/ProductPage.module.scss @@ -0,0 +1,59 @@ +@import '../../styles/utils'; + +.product-page { + max-width: $desktop-width; + margin: 0 auto; + padding-top: 24px; + padding-bottom: 56px; + + @include on-tablet { + padding-bottom: 64px; + } + + @include on-desktop { + padding-bottom: 80px; + } + + &__nav-wrapper { + display: flex; + flex-direction: column; + gap: 24px; + margin-bottom: 16px; + + @include page-padding-inline; + + @include on-tablet { + gap: 40px; + } + } + + &__gadget { + margin-bottom: 64px; + + @include on-tablet { + margin-bottom: 80px; + } + } + + &__not-found { + @include page-padding-inline; + } + + &__not-found-text { + margin-bottom: 24px; + + @include h1-mobile; + + @include on-tablet { + @include h1-tablet-desktop; + } + } + + &__not-found-img { + display: block; + width: 100%; + height: 100%; + max-width: 600px; + margin: 0 auto; + } +} diff --git a/src/modules/ProductPage/ProductPage.tsx b/src/modules/ProductPage/ProductPage.tsx new file mode 100644 index 0000000000..dea676c762 --- /dev/null +++ b/src/modules/ProductPage/ProductPage.tsx @@ -0,0 +1,82 @@ +import styles from './ProductPage.module.scss'; +import { BackLink } from '../../components/BackLink'; +import { useLocation, useParams } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { getGadgetById } from '../../servises/gadgets'; +import { Category, Product, GadgetType } from '../../types'; + +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { ProductsSlider } from '../../components/ProductsSlider'; +import { getProductById, getSuggestedProducts } from '../../servises/products'; +import { Gadget } from './components/Gadget'; +import { Loader } from '../../components/Loader'; + +export const ProductPage = () => { + const [currentGadget, setCurrentGadget] = useState(null); + const [currentProduct, setCurrentProduct] = useState(null); + const [suggestedProducts, setSuggestedProducts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const params = useParams(); + const location = useLocation(); + + const category = location.pathname.split('/')[1] as Category; + + const id = params.id as string; + + useEffect(() => { + const fetchData = async () => { + try { + setIsLoading(true); + const gadget = await getGadgetById(category, id); + const product = await getProductById(id); + const suggested = await getSuggestedProducts(); + + setCurrentGadget(gadget); + setCurrentProduct(product); + setSuggestedProducts(suggested); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [category, id]); + + if (isLoading) { + return ; + } + + return ( +
+
+ + +
+ {!currentGadget || !currentProduct ? ( +
+

+ Product was not found +

+ product not found +
+ ) : ( + <> + + + + )} +
+ ); +}; diff --git a/src/modules/ProductPage/components/About/About.module.scss b/src/modules/ProductPage/components/About/About.module.scss new file mode 100644 index 0000000000..7112775d3d --- /dev/null +++ b/src/modules/ProductPage/components/About/About.module.scss @@ -0,0 +1,47 @@ +@import '../../../../styles/utils'; + +.about { + &__title { + margin: 0; + margin-bottom: 32px; + + @include h3-mobile; + + @include on-tablet { + @include h3-tablet-desktop; + } + + &::after { + display: block; + content: ''; + height: 1px; + background-color: var(--elements); + margin-top: 16px; + } + } + + &__list { + display: flex; + flex-direction: column; + gap: 32px; + } + + &__subtitle { + margin: 0; + margin-bottom: 16px; + + @include h4-mobile; + + @include on-tablet { + @include h4-tablet-desktop; + } + } + + &__text { + color: var(--secondary); + + &:not(:last-child) { + margin-bottom: 21px; + } + } +} diff --git a/src/modules/ProductPage/components/About/About.tsx b/src/modules/ProductPage/components/About/About.tsx new file mode 100644 index 0000000000..5829a63e1f --- /dev/null +++ b/src/modules/ProductPage/components/About/About.tsx @@ -0,0 +1,36 @@ +import cn from 'classnames'; +import { Description } from '../../../../types/Gadget'; + +import styles from './About.module.scss'; + +interface Props { + description: Description[]; + className?: string; +} + +export const About: React.FC = ({ description, className }) => { + return ( +
+

About

+ +
    + {description.map(desc => { + const { title, text } = desc; + + return ( +
  • +

    {title}

    + {text.map(t => { + return ( +

    + {t} +

    + ); + })} +
  • + ); + })} +
+
+ ); +}; diff --git a/src/modules/ProductPage/components/About/index.ts b/src/modules/ProductPage/components/About/index.ts new file mode 100644 index 0000000000..b749bad6d5 --- /dev/null +++ b/src/modules/ProductPage/components/About/index.ts @@ -0,0 +1 @@ +export { About } from './About'; diff --git a/src/modules/ProductPage/components/AvailableCapacity/AvailableCapacity.module.scss b/src/modules/ProductPage/components/AvailableCapacity/AvailableCapacity.module.scss new file mode 100644 index 0000000000..769bce95ed --- /dev/null +++ b/src/modules/ProductPage/components/AvailableCapacity/AvailableCapacity.module.scss @@ -0,0 +1,50 @@ +@import '../../../../styles/utils'; + +.available-capacity { + padding-bottom: 24px; + position: relative; + + &::after { + display: block; + position: absolute; + content: ''; + width: 100%; + height: 1px; + background-color: var(--elements); + bottom: 0; + } + + &__title { + @include small-text; + + color: var(--secondary); + margin-bottom: 8px; + font-weight: 600; + } + + &__list { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__link { + display: block; + border: 1px solid var(--icon-secondary); + padding: 5px 8px 4px; + border-radius: 4px; + + @include transition; + + &--active { + background-color: var(--primary); + border-color: var(--primary); + color: var(--text-active); + cursor: auto; + } + + &:not(&--active):hover { + border-color: var(--primary); + } + } +} diff --git a/src/modules/ProductPage/components/AvailableCapacity/AvailableCapacity.tsx b/src/modules/ProductPage/components/AvailableCapacity/AvailableCapacity.tsx new file mode 100644 index 0000000000..2fd219d290 --- /dev/null +++ b/src/modules/ProductPage/components/AvailableCapacity/AvailableCapacity.tsx @@ -0,0 +1,61 @@ +import { Link, useLocation } from 'react-router-dom'; +import styles from './AvailableCapacity.module.scss'; +import cn from 'classnames'; + +interface Props { + capacity: string[]; + activeCapacity: string; + className?: string; +} + +export const AvailableCapacity: React.FC = ({ + capacity, + activeCapacity, + className, +}) => { + const location = useLocation(); + + const normalizeCapacity = (cap: string) => { + let res = ''; + + for (let i = 0; i < cap.length; i++) { + if (cap[i].toLowerCase() === cap[i].toUpperCase()) { + res += cap[i]; + } else { + res += ' ' + cap.slice(i); + break; + } + } + + return res; + }; + + const path = location.pathname.toLowerCase(); + + return ( +
+

Select capacity

+
    + {capacity.map(cap => { + const isActive = activeCapacity === cap; + const currentPath = path + .split(activeCapacity.toLowerCase()) + .join(cap.toLowerCase()); + + return ( +
  • + + {normalizeCapacity(cap)} + +
  • + ); + })} +
+
+ ); +}; diff --git a/src/modules/ProductPage/components/AvailableCapacity/index.ts b/src/modules/ProductPage/components/AvailableCapacity/index.ts new file mode 100644 index 0000000000..738402b710 --- /dev/null +++ b/src/modules/ProductPage/components/AvailableCapacity/index.ts @@ -0,0 +1 @@ +export { AvailableCapacity } from './AvailableCapacity'; diff --git a/src/modules/ProductPage/components/AvailableColors/AvailableColors.module.scss b/src/modules/ProductPage/components/AvailableColors/AvailableColors.module.scss new file mode 100644 index 0000000000..abf325ac39 --- /dev/null +++ b/src/modules/ProductPage/components/AvailableColors/AvailableColors.module.scss @@ -0,0 +1,52 @@ +@import '../../../../styles/utils'; + +.available-colors { + padding-bottom: 24px; + position: relative; + + &::after { + display: block; + position: absolute; + content: ''; + width: 100%; + height: 1px; + background-color: var(--elements); + bottom: 0; + } + + &__title { + @include small-text; + + color: var(--secondary); + margin-bottom: 8px; + font-weight: 600; + } + + &__list { + display: flex; + gap: 8px; + } + + &__item { + border-radius: 50%; + width: 32px; + height: 32px; + border: 1px solid var(--elements); + padding: 2px; + + &:not(&--active):hover { + border-color: var(--icon-secondary); + } + + &--active { + border-color: var(--primary); + } + } + + &__link { + display: block; + width: 100%; + height: 100%; + border-radius: 50%; + } +} diff --git a/src/modules/ProductPage/components/AvailableColors/AvailableColors.tsx b/src/modules/ProductPage/components/AvailableColors/AvailableColors.tsx new file mode 100644 index 0000000000..ff679baaf2 --- /dev/null +++ b/src/modules/ProductPage/components/AvailableColors/AvailableColors.tsx @@ -0,0 +1,51 @@ +import { Link, useLocation } from 'react-router-dom'; +import styles from './AvailableColors.module.scss'; +import cn from 'classnames'; +import { colorMap } from '../../../../helpers'; + +interface Props { + colors: string[]; + currentColor: string; + className?: string; +} + +export const AvailableColors: React.FC = ({ + colors, + currentColor, + className, +}) => { + const location = useLocation(); + + const path = location.pathname.toLowerCase(); + + return ( +
+

Available colors

+
    + {colors.map(c => { + const color = c.split(' ').join('-'); + + const isActive = c === currentColor; + const currentPath = path + .split(currentColor.split(' ').join('-')) + .join(color); + + return ( +
  • + +
  • + ); + })} +
+
+ ); +}; diff --git a/src/modules/ProductPage/components/AvailableColors/index.ts b/src/modules/ProductPage/components/AvailableColors/index.ts new file mode 100644 index 0000000000..ec9b7b3461 --- /dev/null +++ b/src/modules/ProductPage/components/AvailableColors/index.ts @@ -0,0 +1 @@ +export { AvailableColors } from './AvailableColors'; diff --git a/src/modules/ProductPage/components/Gadget/Gadget.module.scss b/src/modules/ProductPage/components/Gadget/Gadget.module.scss new file mode 100644 index 0000000000..1ad368ff6d --- /dev/null +++ b/src/modules/ProductPage/components/Gadget/Gadget.module.scss @@ -0,0 +1,44 @@ +@import '../../../../styles/utils'; + +.gadget { + @include page-padding-inline; + + &__title { + margin: 0; + margin-bottom: 32px; + + @include h2-mobile; + + @include on-tablet { + margin-bottom: 40px; + + @include h2-tablet-desktop; + } + } + + &__details { + display: flex; + flex-direction: column; + gap: 40px; + + @include on-tablet { + display: grid; + grid-template-columns: 113fr 79fr; + gap: 16px; + } + + @include on-desktop { + grid-template-columns: 35fr 32fr; + gap: 64px; + } + } + + &__about, + &__tech-specs { + grid-column: 1 / -1; + + @include on-desktop { + grid-column: auto; + } + } +} diff --git a/src/modules/ProductPage/components/Gadget/Gadget.tsx b/src/modules/ProductPage/components/Gadget/Gadget.tsx new file mode 100644 index 0000000000..4832882a4e --- /dev/null +++ b/src/modules/ProductPage/components/Gadget/Gadget.tsx @@ -0,0 +1,78 @@ +import cn from 'classnames'; +import { About } from '../About'; +import { ProductPhotos } from '../ProductPhotos'; +import { TechSpecs } from '../TechSpecs'; +import styles from './Gadget.module.scss'; +import { MainControls } from '../MainControls'; +import { Product, GadgetType } from '../../../../types'; + +interface Props { + gadget: GadgetType; + className?: string; + product: Product; +} + +export const Gadget: React.FC = ({ gadget, className, product }) => { + const { + name, + images, + description, + screen, + resolution, + processor, + ram, + capacity, + cell, + } = gadget; + + const techSpecs = [ + { + title: 'Screen', + value: screen, + }, + { + title: 'Resolution', + value: resolution, + }, + { + title: 'Processor', + value: processor, + }, + { + title: 'RAM', + value: ram, + }, + { + title: 'Built in memory', + value: capacity, + }, + 'camera' in gadget && { + title: 'Camera', + value: gadget.camera, + }, + 'zoom' in gadget && { + title: 'Zoom', + value: gadget.zoom, + }, + { + title: 'Cell', + value: cell.join(', '), + }, + ].filter(Boolean) as { title: string; value: string }[]; + + return ( +
+

{name}

+ +
+ + + + +
+
+ ); +}; diff --git a/src/modules/ProductPage/components/Gadget/index.ts b/src/modules/ProductPage/components/Gadget/index.ts new file mode 100644 index 0000000000..ea503cfb32 --- /dev/null +++ b/src/modules/ProductPage/components/Gadget/index.ts @@ -0,0 +1 @@ +export { Gadget } from './Gadget'; diff --git a/src/modules/ProductPage/components/MainControls/MainControls.module.scss b/src/modules/ProductPage/components/MainControls/MainControls.module.scss new file mode 100644 index 0000000000..166468e258 --- /dev/null +++ b/src/modules/ProductPage/components/MainControls/MainControls.module.scss @@ -0,0 +1,82 @@ +@import '../../../../styles/utils'; + +.main-controls { + position: relative; + + @include on-desktop { + display: grid; + grid-template-columns: 320px 1fr; + gap: 16px; + } + + &__colors { + margin-bottom: 24px; + } + + &__capacity { + margin-bottom: 32px; + } + + &__price { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + } + + &__price-discount { + font-size: 32px; + font-weight: 800; + line-height: 41px; + } + + &__price-full { + font-size: 22px; + font-weight: 500; + line-height: 28px; + text-decoration: line-through; + color: var(--secondary); + } + + &__actions { + height: 48px; + margin-bottom: 32px; + } + + &__prop-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__prop-item { + display: flex; + justify-content: space-between; + gap: 8px; + + @include small-text; + } + + &__term { + color: var(--secondary); + font-weight: 600; + } + + &__desc { + margin: 0; + } + + &__id { + @include small-text; + + color: var(--icon-secondary); + position: absolute; + top: 0; + right: 0; + + @include on-desktop { + position: static; + text-align: right; + } + } +} diff --git a/src/modules/ProductPage/components/MainControls/MainControls.tsx b/src/modules/ProductPage/components/MainControls/MainControls.tsx new file mode 100644 index 0000000000..8e3d17f3bb --- /dev/null +++ b/src/modules/ProductPage/components/MainControls/MainControls.tsx @@ -0,0 +1,78 @@ +import cn from 'classnames'; + +import styles from './MainControls.module.scss'; +import { Actions } from '../../../../components/Actions'; +import { AvailableColors } from '../AvailableColors'; +import { AvailableCapacity } from '../AvailableCapacity'; +import { Product, GadgetType } from '../../../../types'; + +interface Props { + gadget: GadgetType; + className?: string; + product: Product; +} + +export const MainControls: React.FC = ({ + className, + gadget, + product, +}) => { + const { + colorsAvailable, + color, + priceDiscount, + priceRegular, + screen, + resolution, + processor, + ram, + capacityAvailable, + capacity, + } = gadget; + + const properties = [ + { term: 'Screen', desc: screen }, + { term: 'Resolution', desc: resolution }, + { term: 'Processor', desc: processor }, + { term: 'RAM', desc: ram }, + ]; + + return ( +
+
+ + +
+

+ {priceDiscount}$ +

+

{priceRegular}$

+
+ +
    + {properties.map(property => ( +
  • +
    {property.term}
    +
    {property.desc}
    +
  • + ))} +
+
+

ID: {product.id}

+
+ ); +}; diff --git a/src/modules/ProductPage/components/MainControls/index.ts b/src/modules/ProductPage/components/MainControls/index.ts new file mode 100644 index 0000000000..48877cc344 --- /dev/null +++ b/src/modules/ProductPage/components/MainControls/index.ts @@ -0,0 +1 @@ +export { MainControls } from './MainControls'; diff --git a/src/modules/ProductPage/components/ProductPhotos/ProductPhotos.module.scss b/src/modules/ProductPage/components/ProductPhotos/ProductPhotos.module.scss new file mode 100644 index 0000000000..8de61560ea --- /dev/null +++ b/src/modules/ProductPage/components/ProductPhotos/ProductPhotos.module.scss @@ -0,0 +1,72 @@ +@import '../../../../styles/utils'; + +.product-photos { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + + @include on-tablet { + display: grid; + grid-template-columns: repeat(7, 1fr); + align-items: start; + } + + @include on-desktop { + grid-template-columns: 5fr 29fr; + } + + &__current { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 1 / 1; + overflow: hidden; + + @include on-tablet { + grid-column: 2 / -1; + } + } + + &__list { + display: flex; + gap: 8px; + max-width: 100%; + + @include on-tablet { + order: -1; + display: flex; + flex-direction: column; + } + } + + &__item { + border: 1px solid var(--elements); + border-radius: 8px; + padding: 3px; + width: 51px; + height: 49px; + + @include on-tablet { + width: 100%; + height: 100%; + aspect-ratio: 1 / 1; + } + } + + &__btn { + border: none; + background-color: inherit; + width: 100%; + height: 100%; + padding: 0; + } + + &__img { + display: block; + object-fit: contain; + width: 100%; + height: 100%; + } +} diff --git a/src/modules/ProductPage/components/ProductPhotos/ProductPhotos.tsx b/src/modules/ProductPage/components/ProductPhotos/ProductPhotos.tsx new file mode 100644 index 0000000000..57b105c93d --- /dev/null +++ b/src/modules/ProductPage/components/ProductPhotos/ProductPhotos.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; +import styles from './ProductPhotos.module.scss'; +import cn from 'classnames'; + +interface Props { + photos: string[]; + className?: string; +} + +export const ProductPhotos: React.FC = ({ photos, className }) => { + const [currentImage, setCurrentImage] = useState(photos[0]); + + useEffect(() => { + setCurrentImage(photos[0]); + }, [photos]); + + return ( +
+
+ +
+
    + {photos.map(img => { + return ( +
  • setCurrentImage(img)} + > + +
  • + ); + })} +
+
+ ); +}; diff --git a/src/modules/ProductPage/components/ProductPhotos/index.ts b/src/modules/ProductPage/components/ProductPhotos/index.ts new file mode 100644 index 0000000000..c200dd5eb1 --- /dev/null +++ b/src/modules/ProductPage/components/ProductPhotos/index.ts @@ -0,0 +1 @@ +export { ProductPhotos } from './ProductPhotos'; diff --git a/src/modules/ProductPage/components/TechSpecs/TechSpecs.module.scss b/src/modules/ProductPage/components/TechSpecs/TechSpecs.module.scss new file mode 100644 index 0000000000..d4dab62cb4 --- /dev/null +++ b/src/modules/ProductPage/components/TechSpecs/TechSpecs.module.scss @@ -0,0 +1,45 @@ +@import '../../../../styles/utils'; + +.tech-specs { + &__title { + margin: 0; + margin-bottom: 32px; + + @include h3-mobile; + + @include on-tablet { + @include h3-tablet-desktop; + } + + &::after { + display: block; + content: ''; + height: 1px; + background-color: var(--elements); + margin-top: 16px; + } + } + + &__list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + } + + &__term { + font-weight: 500; + color: var(--secondary); + } + + &__desc { + margin: 0; + font-weight: 600; + } +} diff --git a/src/modules/ProductPage/components/TechSpecs/TechSpecs.tsx b/src/modules/ProductPage/components/TechSpecs/TechSpecs.tsx new file mode 100644 index 0000000000..b21dbdee14 --- /dev/null +++ b/src/modules/ProductPage/components/TechSpecs/TechSpecs.tsx @@ -0,0 +1,31 @@ +import cn from 'classnames'; +import styles from './TechSpecs.module.scss'; + +interface Props { + className?: string; + techSpecs: { + title: string; + value: string; + }[]; +} + +export const TechSpecs: React.FC = ({ techSpecs, className }) => { + return ( +
+

Tech specs

+ +
    + {techSpecs.map(spec => { + const { title, value } = spec; + + return ( +
  • +
    {title}
    +
    {value}
    +
  • + ); + })} +
+
+ ); +}; diff --git a/src/modules/ProductPage/components/TechSpecs/index.ts b/src/modules/ProductPage/components/TechSpecs/index.ts new file mode 100644 index 0000000000..fced6b5881 --- /dev/null +++ b/src/modules/ProductPage/components/TechSpecs/index.ts @@ -0,0 +1 @@ +export { TechSpecs } from './TechSpecs'; diff --git a/src/modules/ProductPage/index.ts b/src/modules/ProductPage/index.ts new file mode 100644 index 0000000000..93f6975c68 --- /dev/null +++ b/src/modules/ProductPage/index.ts @@ -0,0 +1 @@ +export { ProductPage } from './ProductPage'; diff --git a/src/modules/ProductsCatalog/ProductsCatalog.module.scss b/src/modules/ProductsCatalog/ProductsCatalog.module.scss new file mode 100644 index 0000000000..a276d20c98 --- /dev/null +++ b/src/modules/ProductsCatalog/ProductsCatalog.module.scss @@ -0,0 +1,73 @@ +@import '../../styles/utils'; + +.products-catalog { + max-width: $desktop-width; + padding-top: 24px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 24px; + padding-bottom: 64px; + + @include page-padding-inline; + + @include on-tablet { + gap: 40px; + } + + &__title { + margin: 0; + margin-bottom: 8px; + + @include h1-mobile; + + @include on-tablet { + @include h1-tablet-desktop; + } + } + + &__text { + color: var(--secondary); + margin-bottom: 8px; + + @include on-tablet { + margin: 0; + } + } + + &__controllers { + margin-bottom: 24px; + + @include on-tablet { + display: flex; + gap: 16px; + } + } + + &__selectors-wrapper { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 16px; + + @include on-tablet { + grid-template-columns: 187px 136px; + } + + @include on-desktop { + grid-template-columns: 176px 128px; + } + } + + &__no-found { + @include h2-mobile; + + @include on-tablet { + @include h2-tablet-desktop; + } + } + + &__pagination { + margin: 0 auto; + } +} diff --git a/src/modules/ProductsCatalog/ProductsCatalog.tsx b/src/modules/ProductsCatalog/ProductsCatalog.tsx new file mode 100644 index 0000000000..15fa4de782 --- /dev/null +++ b/src/modules/ProductsCatalog/ProductsCatalog.tsx @@ -0,0 +1,143 @@ +import { Product } from '../../types/Product'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { Pagination } from './components/Pagination'; +import styles from './ProductsCatalog.module.scss'; +import { Selector } from './components/Selector'; +import { Grid } from '../../components/Grid'; +import { useUpdateSearchParams } from '../../hooks'; +import { Loader } from '../../components/Loader'; +import { Search } from './components/Search'; + +interface Props { + title: string; + products: Product[]; + loading: boolean; + error: string; +} + +export const ProductsCatalog: React.FC = ({ + title, + products, + loading, + error, +}) => { + const { searchParams, updateSearchParams } = useUpdateSearchParams(); + + const SORT_OPTIONS = [ + { name: 'newest', value: 'age' }, + { name: 'alphabetically', value: 'title' }, + { name: 'cheapest', value: 'price' }, + ]; + + const PER_PAGE_OPTIONS = [ + { name: '4', value: '4' }, + { name: '8', value: '8' }, + { name: '16', value: '16' }, + { name: 'all', value: 'all' }, + ]; + + const perPage = + PER_PAGE_OPTIONS.find(item => item.value === searchParams.get('perPage')) + ?.value || PER_PAGE_OPTIONS[0].value; + + const sort = searchParams.get('sort') || SORT_OPTIONS[0].value; + const query = searchParams.get('query'); + + const refineProducts = (p: Product[]) => { + let currentProducts = p.slice(); + + if (query) { + currentProducts = currentProducts.filter(a => + a.name.toLowerCase().includes(query.toLowerCase()), + ); + } + + return currentProducts.sort((a, b) => { + switch (sort) { + case 'age': + return b.year - a.year; + case 'title': + return a.name.localeCompare(b.name); + case 'price': + return b.price - a.price; + default: + return 0; + } + }); + }; + + const refinedProducts = refineProducts(products); + + const getCurrentPage = () => { + const page = Number(searchParams.get('page')) || 1; + const maxPages = Math.ceil(refinedProducts.length / +perPage); + + return page > maxPages ? maxPages : page; + }; + + const currentPage = getCurrentPage(); + + const getVisibleProducts = (p: Product[]) => { + if (perPage === 'all') { + return p; + } + + const startIndex = (currentPage - 1) * +perPage; + const endIndex = + currentPage * +perPage > p.length ? p.length : currentPage * +perPage; + + return p.slice(startIndex, endIndex); + }; + + const visibleProducts = getVisibleProducts(refinedProducts); + + if (loading) { + return ; + } + + return ( +
+ + + {error ? ( +

{error}

+ ) : ( + <> +
+

{title}

+

+ {refinedProducts.length} models +

+
+
+
+
+ + +
+ +
+ {refinedProducts.length ? ( + + ) : ( +

+ No matches found +

+ )} +
+ updateSearchParams('page', page)} + className={styles['products-catalog__pagination']} + /> + + )} +
+ ); +}; diff --git a/src/modules/ProductsCatalog/components/Pagination/Pagination.module.scss b/src/modules/ProductsCatalog/components/Pagination/Pagination.module.scss new file mode 100644 index 0000000000..6bf3f0f334 --- /dev/null +++ b/src/modules/ProductsCatalog/components/Pagination/Pagination.module.scss @@ -0,0 +1,27 @@ +@import '../../../../styles/utils'; + +.pagination { + display: flex; + gap: 16px; + + &__container { + display: flex; + gap: 8px; + } + + &__item { + width: 32px; + height: 32px; + + &--next { + transform: rotate(90deg); + } + &--prev { + transform: rotate(-90deg); + } + } + + &__dots { + align-self: end; + } +} diff --git a/src/modules/ProductsCatalog/components/Pagination/Pagination.tsx b/src/modules/ProductsCatalog/components/Pagination/Pagination.tsx new file mode 100644 index 0000000000..ac3ab4e9fb --- /dev/null +++ b/src/modules/ProductsCatalog/components/Pagination/Pagination.tsx @@ -0,0 +1,139 @@ +import React, { useCallback, useMemo } from 'react'; +import cn from 'classnames'; +import styles from './Pagination.module.scss'; +import { RoundButton } from '../../../../components/RoundButton'; +import { SvgIcon } from '../../../../components/SvgIcon'; +import { scrollToTop } from '../../../../utils/utility'; +import { PaginationItem } from '../PaginationItem'; + +interface Props { + perPage: string; + currentPage: number; + total: number; + onPageChange: (page: number) => void; + className?: string; +} + +export const Pagination: React.FC = ({ + perPage, + currentPage, + total, + onPageChange, + className, +}) => { + const perPageNumber = perPage === 'all' ? total : Number(perPage); + + const pages = useMemo( + () => Array.from({ length: Math.ceil(total / +perPage) }, (_, i) => i + 1), + [perPage, total], + ); + + const getMiddlePages = useCallback(() => { + const pagesCount = pages.length; + + if (currentPage <= 2) { + return [2, 3, 4]; + } + + if (currentPage >= pagesCount - 2) { + return [pagesCount - 3, pagesCount - 2, pagesCount - 1]; + } + + return [currentPage - 1, currentPage, currentPage + 1]; + }, [currentPage, pages.length]); + + const isFirstPage = currentPage === 1; + const isLastPage = pages.length === currentPage; + + const onClickPrevPage = () => { + onPageChange(Math.max(currentPage - 1, 1)); + scrollToTop(); + }; + + const onClickNextPage = () => { + onPageChange(Math.max(currentPage + 1, 1)); + scrollToTop(); + }; + + if (total <= perPageNumber) { + return null; + } + + return ( +
+
+ + + +
+ +
+ {pages.length <= 5 ? ( + pages.map(page => ( + + )) + ) : ( + <> + + {currentPage > 3 && ( +

+ ... +

+ )} + + {getMiddlePages().map(page => { + return ( + + ); + })} + + {currentPage < pages.length - 3 && ( +

+ ... +

+ )} + + + )} +
+ +
+ + + +
+
+ ); +}; diff --git a/src/modules/ProductsCatalog/components/Pagination/index.ts b/src/modules/ProductsCatalog/components/Pagination/index.ts new file mode 100644 index 0000000000..0a1fd4dad6 --- /dev/null +++ b/src/modules/ProductsCatalog/components/Pagination/index.ts @@ -0,0 +1 @@ +export { Pagination } from './Pagination'; diff --git a/src/modules/ProductsCatalog/components/PaginationItem/PaginationItem.module.scss b/src/modules/ProductsCatalog/components/PaginationItem/PaginationItem.module.scss new file mode 100644 index 0000000000..cc01518b8e --- /dev/null +++ b/src/modules/ProductsCatalog/components/PaginationItem/PaginationItem.module.scss @@ -0,0 +1,15 @@ +@import '../../../../styles/utils'; + +.pagination-item { + display: block; + + &__btn { + &--active, + &--active:hover { + cursor: auto; + background-color: var(--primary); + color: var(--text-active); + border-color: var(--primary); + } + } +} diff --git a/src/modules/ProductsCatalog/components/PaginationItem/PaginationItem.tsx b/src/modules/ProductsCatalog/components/PaginationItem/PaginationItem.tsx new file mode 100644 index 0000000000..dab37cde9c --- /dev/null +++ b/src/modules/ProductsCatalog/components/PaginationItem/PaginationItem.tsx @@ -0,0 +1,38 @@ +import cn from 'classnames'; +import { RoundButton } from '../../../../components/RoundButton'; +import { scrollToTop } from '../../../../utils/utility'; +import styles from './PaginationItem.module.scss'; + +interface Props { + page: number; + onPageChange: (page: number) => void; + isCurrent: boolean; + className?: string; +} + +export const PaginationItem: React.FC = ({ + page, + onPageChange, + isCurrent, + className, +}) => { + const onBtnClickHandler = () => { + if (!isCurrent) { + onPageChange(page); + scrollToTop(); + } + }; + + return ( +
+ + {page} + +
+ ); +}; diff --git a/src/modules/ProductsCatalog/components/PaginationItem/index.ts b/src/modules/ProductsCatalog/components/PaginationItem/index.ts new file mode 100644 index 0000000000..c070a870df --- /dev/null +++ b/src/modules/ProductsCatalog/components/PaginationItem/index.ts @@ -0,0 +1 @@ +export { PaginationItem } from './PaginationItem'; diff --git a/src/modules/ProductsCatalog/components/Search/Search.module.scss b/src/modules/ProductsCatalog/components/Search/Search.module.scss new file mode 100644 index 0000000000..4e0f330036 --- /dev/null +++ b/src/modules/ProductsCatalog/components/Search/Search.module.scss @@ -0,0 +1,62 @@ +@import '../../../../styles/utils'; + +.search { + width: 100%; + + &__title { + display: block; + color: var(--secondary); + margin-bottom: 4px; + + @include small-text; + } + + &__input-wrapper { + position: relative; + } + + &__input { + width: 100%; + height: 40px; + border-radius: 8px; + border: 1px solid var(--icon-secondary); + background-color: var(--bg-secondary); + color: var(--primary); + padding: 0 30px; + background-image: var(--search-img); + background-repeat: no-repeat; + background-position: 8px center; + + @include transition; + + &:focus { + border-color: var(--primary); + outline: none; + } + } + + &__clear { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + top: 50%; + right: 4px; + transform: translateY(-50%); + width: 24px; + height: 24px; + background-color: inherit; + border: none; + + @include transition; + + &:hover { + opacity: 0.5; + } + + svg path { + fill: var(--icon-primary); + } + } +} diff --git a/src/modules/ProductsCatalog/components/Search/Search.tsx b/src/modules/ProductsCatalog/components/Search/Search.tsx new file mode 100644 index 0000000000..49d965ab3a --- /dev/null +++ b/src/modules/ProductsCatalog/components/Search/Search.tsx @@ -0,0 +1,59 @@ +import cn from 'classnames'; +import styles from './Search.module.scss'; +import { useUpdateSearchParams } from '../../../../hooks'; +import { useEffect, useState } from 'react'; +import { SvgIcon } from '../../../../components/SvgIcon'; + +interface Props { + className?: string; +} + +export const Search: React.FC = ({ className }) => { + const { updateSearchParams, deleteSearchParam, searchParams } = + useUpdateSearchParams(); + const [value, setValue] = useState(searchParams.get('query') || ''); + + useEffect(() => { + const timerId = setTimeout(() => { + if (value) { + updateSearchParams('query', value); + } else { + deleteSearchParam('query'); + } + }, 500); + + return () => { + clearTimeout(timerId); + }; + }, [deleteSearchParam, updateSearchParams, value]); + + const onInputChange = (evt: React.ChangeEvent) => { + setValue(evt.target.value); + }; + + const clear = () => { + setValue(''); + }; + + return ( +
+ +
+ + {value && ( + + )} +
+
+ ); +}; diff --git a/src/modules/ProductsCatalog/components/Search/index.ts b/src/modules/ProductsCatalog/components/Search/index.ts new file mode 100644 index 0000000000..6860ea7e14 --- /dev/null +++ b/src/modules/ProductsCatalog/components/Search/index.ts @@ -0,0 +1 @@ +export { Search } from './Search'; diff --git a/src/modules/ProductsCatalog/components/Selector/Selector.module.scss b/src/modules/ProductsCatalog/components/Selector/Selector.module.scss new file mode 100644 index 0000000000..444de27d49 --- /dev/null +++ b/src/modules/ProductsCatalog/components/Selector/Selector.module.scss @@ -0,0 +1,95 @@ +@import '../../../../styles/utils'; + +.selector { + &__title { + color: var(--secondary); + margin-bottom: 4px; + + @include small-text; + } + + &__dropdown { + position: relative; + width: 100%; + } + + &__btn { + width: 100%; + height: 40px; + border-radius: 8px; + border: 1px solid var(--icon-secondary); + padding-inline: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background-color: inherit; + + &:hover { + border-color: var(--secondary); + } + + &:focus { + border-color: var(--primary); + outline: none; + } + } + + &__text { + font-weight: 700; + color: var(--primary); + } + + &__icon { + width: 16px; + height: 16px; + transform: rotate(180deg); + + svg { + fill: var(--icon-secondary); + } + } + + &__menu { + width: 100%; + display: none; + position: absolute; + z-index: 2; + padding-top: 4px; + + &--active { + display: block; + } + } + + &__content { + display: flex; + flex-direction: column; + border-radius: 8px; + border: 1px solid var(--elements); + background-color: var(--bg-secondary); + } + + &__item { + text-align: start; + line-height: 32px; + cursor: pointer; + font-weight: 600; + color: var(--secondary); + padding-inline: 12px; + background-color: inherit; + border: none; + + &:hover, + &:focus, + &--active { + background-color: var(--hover-bg); + border-radius: 8px; + color: var(--primary); + } + + &--active { + cursor: default; + } + } +} diff --git a/src/modules/ProductsCatalog/components/Selector/Selector.tsx b/src/modules/ProductsCatalog/components/Selector/Selector.tsx new file mode 100644 index 0000000000..cc6d84f9a8 --- /dev/null +++ b/src/modules/ProductsCatalog/components/Selector/Selector.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; + +import styles from './Selector.module.scss'; +import { useSearchParams } from 'react-router-dom'; +import { firstLetterCap } from '../../../../utils/utility'; +import { SvgIcon } from '../../../../components/SvgIcon'; + +interface Props { + title: string; + type: string; + items: { name: string; value: string }[]; + className?: string; +} + +export const Selector: React.FC = ({ + title, + type, + items, + className, +}) => { + const [expanded, setExpanded] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + + const dropdownRef = useRef(null); + + const selectedValue = + items.find(item => item.value === searchParams.get(type)) || items[0]; + + const onChangeValue = (value: string) => { + const params = new URLSearchParams(searchParams); + + params.set(type, value); + params.set('page', '1'); + setSearchParams(params); + }; + + const handleDocumentClick = (evt: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(evt.target as Node) + ) { + setExpanded(false); + } + }; + + useEffect(() => { + if (expanded) { + document.addEventListener('click', handleDocumentClick); + } else { + document.removeEventListener('click', handleDocumentClick); + } + + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [expanded]); + + return ( +
+

{title}

+
+
+ +
+ + +
+
+ ); +}; diff --git a/src/modules/ProductsCatalog/components/Selector/index.ts b/src/modules/ProductsCatalog/components/Selector/index.ts new file mode 100644 index 0000000000..90d25f2bf2 --- /dev/null +++ b/src/modules/ProductsCatalog/components/Selector/index.ts @@ -0,0 +1 @@ +export { Selector } from './Selector'; diff --git a/src/modules/ProductsCatalog/index.ts b/src/modules/ProductsCatalog/index.ts new file mode 100644 index 0000000000..be4f0c9e98 --- /dev/null +++ b/src/modules/ProductsCatalog/index.ts @@ -0,0 +1 @@ +export { ProductsCatalog } from './ProductsCatalog'; diff --git a/src/modules/TabletsPage/TabletsPage.tsx b/src/modules/TabletsPage/TabletsPage.tsx new file mode 100644 index 0000000000..d71997f5cd --- /dev/null +++ b/src/modules/TabletsPage/TabletsPage.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { Product } from '../../types/Product'; +import { getProductsByCategory } from '../../servises/products'; +import { ProductsCatalog } from '../ProductsCatalog'; + +export const TabletsPage = () => { + const [tablets, setTablets] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + setLoading(true); + getProductsByCategory('tablets') + .then(setTablets) + .catch(() => setError('Something went wrong')) + .finally(() => setLoading(false)); + }, []); + + return ( + + ); +}; diff --git a/src/modules/TabletsPage/index.ts b/src/modules/TabletsPage/index.ts new file mode 100644 index 0000000000..5f5d7eb9d6 --- /dev/null +++ b/src/modules/TabletsPage/index.ts @@ -0,0 +1 @@ +export { TabletsPage } from './TabletsPage'; diff --git a/src/servises/gadgets.ts b/src/servises/gadgets.ts new file mode 100644 index 0000000000..acc714573c --- /dev/null +++ b/src/servises/gadgets.ts @@ -0,0 +1,27 @@ +import { Category, Accessory, Phone, Tablet } from '../types'; +import { getData } from '../utils/httpClient'; + +export const getPhones = getData('/api/phones.json'); +export const getAccessories = getData('/api/accessories.json'); +export const getTablets = getData('/api/tablets.json'); + +export const getGadgetById = async (category: Category, id: string) => { + switch (category) { + case 'phones': + const phones = await getPhones; + + return phones.find(phone => phone.id === id.toLowerCase()) || null; + case 'tablets': + const tablets = await getTablets; + + return tablets.find(tablet => tablet.id === id.toLowerCase()) || null; + case 'accessories': + const accessories = await getAccessories; + + return ( + accessories.find(accessory => accessory.id === id.toLowerCase()) || null + ); + default: + return null; + } +}; diff --git a/src/servises/products.ts b/src/servises/products.ts new file mode 100644 index 0000000000..e6c1bbfac3 --- /dev/null +++ b/src/servises/products.ts @@ -0,0 +1,43 @@ +import { Product } from '../types/Product'; +import { getData } from '../utils/httpClient'; + +export const getAllProducts = getData('/api/products.json'); + +export const getProductsByCategory = async (category: string) => { + const products = await getAllProducts; + + return products.filter(product => product.category === category); +}; + +export const getHotPriceProducts = async () => { + const products = await getAllProducts; + + return products.sort( + (a, b) => b.fullPrice - b.price - (a.fullPrice - a.price), + ); +}; + +export const getNewProducts = async () => { + const products = await getAllProducts; + const latestYear = Math.max(...products.map(product => product.year)); + + return products + .filter(p => p.year === latestYear) + .sort((a, b) => b.fullPrice - a.fullPrice); +}; + +export const getProductById = async (id: string) => { + const products = await getAllProducts; + + return ( + products.find(p => { + return p.itemId.toLowerCase() === id.toLowerCase(); + }) || null + ); +}; + +export const getSuggestedProducts = async () => { + const products = await getAllProducts; + + return products.filter(() => Math.random() > 0.5); +}; diff --git a/src/styles/_fonts.scss b/src/styles/_fonts.scss new file mode 100644 index 0000000000..eb01589020 --- /dev/null +++ b/src/styles/_fonts.scss @@ -0,0 +1,17 @@ +@font-face { + font-family: Mont; + src: url('../assets/fonts/Mont-Regular.otf') format('opentype'); + font-weight: normal; +} + +@font-face { + font-family: Mont; + src: url('../assets/fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: 600; +} + +@font-face { + font-family: Mont; + src: url('../assets/fonts/Mont-Bold.otf') format('opentype'); + font-weight: bold; +} diff --git a/src/styles/_normalize.scss b/src/styles/_normalize.scss new file mode 100644 index 0000000000..2d553f3cfd --- /dev/null +++ b/src/styles/_normalize.scss @@ -0,0 +1,516 @@ +// ============================================================================= +// Normalize.scss based on Nicolas Gallagher and Jonathan Neal's +// normalize.css v2.1.3 | MIT License | git.io/normalize +// ============================================================================= + +// ============================================================================= +// Normalize.scss settings +// ============================================================================= + +// Set to true if you want to add support for IE6 and IE7 +// Notice: setting to true might render some elements +// slightly differently than when set to false +$legacy_support_for_ie: false !default; // Used also in Compass + +// Set the default font family here so you don't have to override it later +$normalized_font_family: sans-serif !default; +$normalize_headings: true !default; +$h1_font_size: 2em !default; +$h2_font_size: 1.5em !default; +$h3_font_size: 1.17em !default; +$h4_font_size: 1em !default; +$h5_font_size: 0.83em !default; +$h6_font_size: 0.75em !default; +$h1_margin: 0.67em 0 !default; +$h2_margin: 0.83em 0 !default; +$h3_margin: 1em 0 !default; +$h4_margin: 1.33em 0 !default; +$h5_margin: 1.67em 0 !default; +$h6_margin: 2.33em 0 !default; +$background: #fff !default; +$color: #000 !default; + +// ============================================================================= +// HTML5 display definitions +// ============================================================================= + +// Corrects block display not defined in IE6/7/8/9 & FF3 + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +nav, +section, +summary { + display: block; +} + +// Corrects inline-block display not defined in IE6/7/8/9 & FF3 + +audio, +canvas, +video { + display: inline-block; + + @if $legacy_support_for_ie { + *display: inline; + *zoom: 1; + } +} + +// 1. Prevents modern browsers from displaying 'audio' without controls +// 2. Remove excess height in iOS5 devices + +audio:not([controls]) { + display: none; // 1 + height: 0; // 2 +} + +// Address `[hidden]` styling not present in IE 8/9. +// Hide the `template` element in IE, Safari, and Firefox < 22. + +[hidden], +template { + display: none; +} + +// ============================================================================= +// Base +// ============================================================================= + +// 1. Corrects text resizing oddly in IE6/7 when body font-size is set using em units +// http://clagnut.com/blog/348/#c790 +// 2. Prevents iOS text size adjust after orientation change, without disabling user zoom +// www.456bereastreet.com/archive/201012/controlling_text_size_in_safari_for_ios_without_disabling_user_zoom/ + +html { + @if $legacy_support_for_ie { + font-size: 100%; // 1 + } + + background: $background; + color: $color; + -webkit-text-size-adjust: 100%; // 2 + -ms-text-size-adjust: 100%; // 2 +} + +// Addresses font-family inconsistency between 'textarea' and other form elements. + +html, +button, +input, +select, +textarea { + font-family: $normalized_font_family; +} + +// Addresses margins handled incorrectly in IE6/7 + +body { + margin: 0; +} + +// ============================================================================= +// Links +// ============================================================================= + +// 1. Remove the gray background color from active links in IE 10. +// 2. Addresses outline displayed oddly in Chrome +// 3. Improves readability when focused and also mouse hovered in all browsers +// people.opera.com/patrickl/experiments/keyboard/test + +a { + // 1 + + background: transparent; + + // 2 + + &:focus { + outline: thin dotted; + } + + // 3 + + &:hover, + &:active { + outline: 0; + } +} + +// ============================================================================= +// Typography +// ============================================================================= + +// Addresses font sizes and margins set differently in IE6/7 +// Addresses font sizes within 'section' and 'article' in FF4+, Chrome, S5 + +@if $normalize_headings == true { + h1 { + font-size: $h1_font_size; + margin: $h1_margin; + } + + h2 { + font-size: $h2_font_size; + margin: $h2_margin; + } + + h3 { + font-size: $h3_font_size; + margin: $h3_margin; + } + + h4 { + font-size: $h4_font_size; + margin: $h4_margin; + } + + h5 { + font-size: $h5_font_size; + margin: $h5_margin; + } + + h6 { + font-size: $h6_font_size; + margin: $h6_margin; + } +} + +// Addresses styling not present in IE 8/9, S5, Chrome + +abbr[title] { + border-bottom: 1px dotted; +} + +// Addresses style set to 'bolder' in FF3+, S4/5, Chrome + +b, +strong { + font-weight: bold; +} + +@if $legacy_support_for_ie { + blockquote { + margin: 1em 40px; + } +} + +// Addresses styling not present in S5, Chrome + +dfn { + font-style: italic; +} + +// Addresses styling not present in IE6/7/8/9 + +mark { + background: #ff0; + color: #000; +} + +// Addresses margins set differently in IE6/7 +@if $legacy_support_for_ie { + p, + pre { + margin: 1em 0; + } +} + +// Corrects font family set oddly in IE6, S4/5, Chrome +// en.wikipedia.org/wiki/User:Davidgothberg/Test59 + +code, +kbd, +pre, +samp { + font-family: monospace, serif; + + @if $legacy_support_for_ie { + _font-family: 'courier new', monospace; + } + + font-size: 1em; +} + +// Improves readability of pre-formatted text in all browsers +pre { + white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; +} + +// Set consistent quote types. + +q { + quotes: '\201C' '\201D' '\2018' '\2019'; + + &::before, + &::after { + content: ''; + content: none; + } +} + +// 1. Addresses CSS quotes not supported in IE6/7 +@if $legacy_support_for_ie { + q { + quotes: none; + } +} + +// Address inconsistent and variable font size in all browsers. + +small { + font-size: 80%; +} + +// Prevents sub and sup affecting line-height in all browsers +// gist.github.com/413930 + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +// ============================================================================= +// Lists +// ============================================================================= + +// Addresses margins set differently in IE6/7 +@if $legacy_support_for_ie { + dl, + menu, + ol, + ul { + margin: 1em 0; + } +} + +@if $legacy_support_for_ie { + dd { + margin: 0 0 0 40px; + } +} + +// Addresses paddings set differently in IE6/7 +@if $legacy_support_for_ie { + menu, + ol, + ul { + padding: 0 0 0 40px; + } +} + +// Corrects list images handled incorrectly in IE7 + +nav { + ul, + ol { + @if $legacy_support_for_ie { + list-style-image: none; + } + } +} + +// ============================================================================= +// Embedded content +// ============================================================================= + +// 1. Removes border when inside 'a' element in IE6/7/8/9, FF3 +// 2. Improves image quality when scaled in IE7 +// code.flickr.com/blog/2008/11/12/on-ui-quality-the-little-things-client-side-image-resizing/ + +img { + border: 0; // 1 + + @if $legacy_support_for_ie { + -ms-interpolation-mode: bicubic; // 2 + } +} + +// Corrects overflow displayed oddly in IE9 + +svg:not(:root) { + overflow: hidden; +} + +// ============================================================================= +// Figures +// ============================================================================= + +// Addresses margin not present in IE6/7/8/9, S5, O11 + +figure { + margin: 0; +} + +// ============================================================================= +// Forms +// ============================================================================= + +// Corrects margin displayed oddly in IE6/7 +@if $legacy_support_for_ie { + form { + margin: 0; + } +} + +// Define consistent border, margin, and padding + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.63em 0.75em; +} + +// 1. Corrects color not being inherited in IE6/7/8/9 +// 2. Remove padding so people aren't caught out if they zero out fieldsets. +// 3. Corrects text not wrapping in FF3 +// 4. Corrects alignment displayed oddly in IE6/7 + +legend { + border: 0; // 1 + padding: 0; // 2 + white-space: normal; // 3 + + @if $legacy_support_for_ie { + *margin-left: -7px; // 4 + } +} + +// 1. Correct font family not being inherited in all browsers. +// 2. Corrects font size not being inherited in all browsers +// 3. Addresses margins set differently in IE6/7, FF3+, S5, Chrome +// 4. Improves appearance and consistency in all browsers + +button, +input, +select, +textarea { + font-family: inherit; // 1 + font-size: 100%; // 2 + margin: 0; // 3 + vertical-align: baseline; // 4 + + @if $legacy_support_for_ie { + *vertical-align: middle; // 4 + } +} + +// Addresses FF3/4 setting line-height on 'input' using !important in the UA stylesheet + +button, +input { + line-height: normal; + + &::-moz-focus-inner { + border: 0; + padding: 0; + } +} + +// Address inconsistent `text-transform` inheritance for `button` and `select`. +// All other form control elements do not inherit `text-transform` values. +// Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. +// Correct `select` style inheritance in Firefox 4+ and Opera. + +button, +select { + text-transform: none; +} + +// 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` +// and `video` controls +// 2. Corrects inability to style clickable 'input' types in iOS +// 3. Improves usability and consistency of cursor style between image-type +// 'input' and others +// 4. Removes inner spacing in IE7 without affecting normal text inputs +// Known issue: inner spacing remains in IE6 + +button, +html input[type="button"], // 1 +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; // 2 + cursor: pointer; // 3 + + @if $legacy_support_for_ie { + *overflow: visible; // 4 + } +} + +// Re-set default cursor for disabled elements + +button[disabled], +input[disabled] { + cursor: default; +} + +// 1. Removes default vertical scrollbar in IE6/7/8/9 +// 2. Improves readability and alignment in all browsers + +textarea { + overflow: auto; // 1 + vertical-align: top; // 2 +} + +// ============================================================================= +// Tables +// ============================================================================= + +// Remove most spacing between table cells + +table { + border-collapse: collapse; + border-spacing: 0; +} + +input { + // 1. Addresses appearance set to searchfield in S5, Chrome + // 2. Addresses box-sizing set to border-box in S5, Chrome (include -moz to future-proof) + &[type='search'] { + -webkit-appearance: textfield; // 1 + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; // 2 + box-sizing: content-box; + + // Remove inner padding and search cancel button in Safari 5 and Chrome + // on OS X. + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + } + + // 1. Address box sizing set to `content-box` in IE 8/9/10. + // 2. Remove excess padding in IE 8/9/10. + // 3. Removes excess padding in IE7 + // Known issue: excess padding remains in IE6 + &[type='checkbox'], + &[type='radio'] { + box-sizing: border-box; // 1 + padding: 0; // 2 + + @if $legacy_support_for_ie { + *height: 13px; // 3 + *width: 13px; // 3 + } + } +} diff --git a/src/styles/_typography.scss b/src/styles/_typography.scss new file mode 100644 index 0000000000..115fc310dd --- /dev/null +++ b/src/styles/_typography.scss @@ -0,0 +1,75 @@ +@mixin small-text { + font-size: 12px; + font-weight: 700; + line-height: 15.34px; +} + +@mixin body-text { + font-size: 14px; + font-weight: 600; + line-height: 21px; +} + +@mixin buttons { + font-size: 14px; + font-weight: 700; + line-height: 21px; +} + +@mixin uppercase { + font-size: 12px; + font-weight: 800; + line-height: 11px; + letter-spacing: 0.04em; +} + +@mixin h1-mobile { + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; +} + +@mixin h2-mobile { + font-size: 22px; + font-weight: 800; + line-height: 30.8px; +} + +@mixin h3-mobile { + font-size: 20px; + font-weight: 700; + line-height: 25.56px; +} + +@mixin h4-mobile { + font-size: 16px; + font-weight: 700; + line-height: 20.45px; +} + +@mixin h1-tablet-desktop { + font-size: 48px; + font-weight: 800; + line-height: 56px; + letter-spacing: -0.01em; +} + +@mixin h2-tablet-desktop { + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; +} + +@mixin h3-tablet-desktop { + font-size: 22px; + font-weight: 800; + line-height: 30.8px; +} + +@mixin h4-tablet-desktop { + font-size: 20px; + font-weight: 700; + line-height: 25.56px; +} diff --git a/src/styles/_utility.scss b/src/styles/_utility.scss new file mode 100644 index 0000000000..a2715cdbc2 --- /dev/null +++ b/src/styles/_utility.scss @@ -0,0 +1,13 @@ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + border: 0; + padding: 0; + + white-space: nowrap; + clip-path: inset(100%); + clip: rect(0 0 0 0); + overflow: hidden; +} diff --git a/src/styles/globals.scss b/src/styles/globals.scss new file mode 100644 index 0000000000..f5bddf30fc --- /dev/null +++ b/src/styles/globals.scss @@ -0,0 +1,30 @@ +@import './theme/dark'; +@import './theme/light'; +@import './normalize'; +@import './fonts'; +@import './utility'; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; +} + +ul { + margin: 0; + padding: 0; + list-style: none; +} + +p { + margin: 0; +} + +a { + text-decoration: none; + color: inherit; +} diff --git a/src/styles/theme/dark.scss b/src/styles/theme/dark.scss new file mode 100644 index 0000000000..ca725c40e1 --- /dev/null +++ b/src/styles/theme/dark.scss @@ -0,0 +1,24 @@ +@import '../utils'; + +body.dark-theme { + --green: #{$green}; + --red: #{$red}; + --primary: #{$dark-white}; + --secondary: #{$dark-secondary}; + --white: #{$dark-white}; + --elements: #{$dark-elements}; + --hover-bg: #{$dark-black}; + --accent: #{$dark-accent}; + --secondary-accent: #{$red}; + --text-footer: #{$dark-white}; + --bg-header-footer: #{$dark-black}; + --link: #{$dark-secondary}; + --active-link: #{$dark-white}; + --icon-primary: #{$dark-white}; + --icon-secondary: #{$dark-icons}; + --round-btn-hover: #{$dark-icons}; + --round-btn-hover-border: #{$dark-icons}; + --bg-secondary: #{$dark-surface-1}; + --text-active: #{$dark-black}; + --search-img: url('../../assets/images/icons/search-dark.svg'); +} diff --git a/src/styles/theme/light.scss b/src/styles/theme/light.scss new file mode 100644 index 0000000000..e5e8396118 --- /dev/null +++ b/src/styles/theme/light.scss @@ -0,0 +1,24 @@ +@import '../utils'; + +body.light-theme { + --green: #{$green}; + --red: #{$red}; + --primary: #{$light-primary}; + --secondary: #{$light-secondary}; + --white: #{$light-white}; + --elements: #{$light-elements}; + --hover-bg: #{$light-hover-bg}; + --accent: #{$light-accent}; + --secondary-accent: #{$light-secondary-accent}; + --text-footer: #{$light-secondary}; + --bg-header-footer: #{$light-white}; + --link: #{$light-secondary}; + --active-link: #{$light-primary}; + --icon-primary: #{$light-primary}; + --icon-secondary: #{$light-icons}; + --round-btn-hover: #{$light-hover-bg}; + --round-btn-hover-border: #{$light-primary}; + --bg-secondary: #{$light-white}; + --text-active: #{$light-white}; + --search-img: url('../../assets/images/icons/search-light.svg'); +} diff --git a/src/styles/utils.scss b/src/styles/utils.scss new file mode 100644 index 0000000000..bb90f70234 --- /dev/null +++ b/src/styles/utils.scss @@ -0,0 +1,3 @@ +@import './utils/variables'; +@import './utils/mixins'; +@import './typography'; diff --git a/src/styles/utils/_mixins.scss b/src/styles/utils/_mixins.scss new file mode 100644 index 0000000000..d705b7fd6a --- /dev/null +++ b/src/styles/utils/_mixins.scss @@ -0,0 +1,33 @@ +@mixin on-tablet { + @media (min-width: $tablet-width) { + @content; + } +} + +@mixin on-desktop { + @media (min-width: $desktop-width) { + @content; + } +} + +@mixin only-mobile { + @media (max-width: calc($tablet-width - 1px)) { + @content; + } +} + +@mixin page-padding-inline { + padding-inline: $mobile-padding-inline; + + @include on-tablet { + padding-inline: $tablet-padding-inline; + } + + @include on-desktop { + padding-inline: $desktop-padding-inline; + } +} + +@mixin transition { + transition: 0.4s; +} diff --git a/src/styles/utils/_variables.scss b/src/styles/utils/_variables.scss new file mode 100644 index 0000000000..002ce6f880 --- /dev/null +++ b/src/styles/utils/_variables.scss @@ -0,0 +1,34 @@ +// viewports +$mobile-width: 320px; +$tablet-width: 640px; +$desktop-width: 1200px; + +// inline paddings +$mobile-padding-inline: 16px; +$tablet-padding-inline: 24px; +$desktop-padding-inline: 32px; + +// colors +$green: #27ae60; +$red: #eb5757; + +// colors light theme +$light-accent: #f86800; +$light-secondary-accent: #476df4; +$light-primary: #0f0f11; +$light-secondary: #89939a; +$light-icons: #89939a; +$light-elements: #e2e6e9; +$light-hover-bg: #fafbfc; +$light-white: #fff; + +// colors dark theme +$dark-secondary: #75767f; +$dark-icons: #75767f; +$dark-elements: #3b3e4a; +$dark-surface-1: #161827; +$dark-surface-2: #323542; +$dark-black: #0f1121; +$dark-white: #f1f2f9; +$dark-accent: #905bff; + diff --git a/src/types/Category.ts b/src/types/Category.ts new file mode 100644 index 0000000000..45f85d6f95 --- /dev/null +++ b/src/types/Category.ts @@ -0,0 +1 @@ +export type Category = 'phones' | 'tablets' | 'accessories'; diff --git a/src/types/Gadget.ts b/src/types/Gadget.ts new file mode 100644 index 0000000000..5363de5d3c --- /dev/null +++ b/src/types/Gadget.ts @@ -0,0 +1,35 @@ +export interface Description { + title: string; + text: string[]; +} + +export interface BaseGadget { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: Description[]; + screen: string; + resolution: string; + processor: string; + ram: string; + cell: string[]; +} + +export type Accessory = BaseGadget; + +export interface Phone extends BaseGadget { + camera: string; + zoom: string; +} + +export type Tablet = Phone; + +export type GadgetType = Phone | Tablet | Accessory; diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 0000000000..26f15f406f --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,22 @@ +import { Category } from './Category'; + +export interface Product { + id: number; + category: Category; + itemId: string; + name: string; + capacity: string; + fullPrice: number; + price: number; + color: string; + image: string; + screen: string; + ram: string; + year: number; +} + +export interface CartProduct { + quantity: number; + id: string; + product: Product; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000000..dc2efb6276 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,3 @@ +export type { Category } from './Category'; +export type { Product, CartProduct } from './Product'; +export type { Phone, Tablet, Accessory, GadgetType } from './Gadget'; diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000000..04f39ab821 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,14 @@ +export const MainNavigation = { + HOME: '/', + PHONES: '/phones', + TABLETS: '/tablets', + ACCESSORIES: '/accessories', + FAVOURITES: '/favourites', + CART: '/cart', + NOT_FOUND: '*', +}; + +export enum Theme { + LIGTH = 'light-theme', + DARK = 'dark-theme', +} diff --git a/src/utils/httpClient.ts b/src/utils/httpClient.ts new file mode 100644 index 0000000000..3ef361e785 --- /dev/null +++ b/src/utils/httpClient.ts @@ -0,0 +1,7 @@ +const BASE_URL = `https://andriy-kostiuk.github.io/react_phone-catalog/`; + +export const getData = async (url: string): Promise => { + const responce = await fetch(BASE_URL + url); + + return responce.json(); +}; diff --git a/src/utils/utility.ts b/src/utils/utility.ts new file mode 100644 index 0000000000..6755faafbb --- /dev/null +++ b/src/utils/utility.ts @@ -0,0 +1,18 @@ +export const firstLetterCap = (word: string) => { + return word[0].toUpperCase() + word.slice(1); +}; + +export const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); +}; + +export const disableScroll = () => { + document.body.style.overflow = 'hidden'; +}; + +export const enableScroll = () => { + document.body.style.overflow = ''; +};