diff --git a/.stylelintrc.js b/.stylelintrc.js index f3a4e74272a..7942cce0712 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,4 +1,6 @@ module.exports = { extends: "@mate-academy/stylelint-config", - rules: {} + rules: { + "scss/at-mixin-pattern": null, + } }; diff --git a/index.html b/index.html index 095fb3a4537..186d6184833 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,8 @@ - Vite + React + TS + Nice Gadgets Store +
diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..6cebd392cd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,16 +11,18 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "@reduxjs/toolkit": "^2.11.2", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-redux": "^9.2.0", "react-router-dom": "^6.25.1", "react-transition-group": "^4.4.5" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1184,10 +1186,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -1875,6 +1878,32 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz", @@ -2126,6 +2155,18 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2216,13 +2257,13 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2258,6 +2299,12 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -5936,6 +5983,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", @@ -8734,6 +8791,29 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -8893,6 +8973,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -8976,6 +9071,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -10438,6 +10539,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index ae251685c8b..62864f805ea 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,23 @@ { "name": "react_phone-catalog", - "homepage": "react_phone-catalog", "version": "0.1.0", "keywords": [], "author": "Mate Academy", "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "@reduxjs/toolkit": "^2.11.2", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-redux": "^9.2.0", "react-router-dom": "^6.25.1", "react-transition-group": "^4.4.5" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -55,7 +56,7 @@ "format": "prettier --write './src/**/*.{ts,tsx}'", "lint": "npm run style-format && npm run format && npm run lint-js && npm run lint-css", "update": "mate-scripts update", - "postinstall": "npm run update && cypress verify", + "postinstall": "npm run update", "predeploy": "npm run build", "deploy": "mate-scripts deploy" }, diff --git a/src/App.scss b/src/App.scss index 71bc413aade..aced522fd01 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,12 @@ -// not empty +// not empty +.App { + min-height: 100vh; + display: flex; + flex-direction: column; + +} + +.main { + flex: 1; + padding-inline: 32px; +} diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..d4ad883927c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,32 @@ +import { Route, Routes } from 'react-router-dom'; import './App.scss'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { HomePage } from './modules/HomePage'; +import { ProductsPage } from './modules/ProductsPage'; +import { ProductDetailsPage } from './modules/ProductDetailsPage'; +import { FavoritesPage } from './modules/FavoritesPage'; +import { CartPage } from './modules/CartPage'; +import { NotFoundPage } from './modules/NotFoundPage'; export const App = () => (
-

Product Catalog

+
+
+ + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + +
+
); diff --git a/src/api/phone.ts b/src/api/phone.ts new file mode 100644 index 00000000000..a9490b735fb --- /dev/null +++ b/src/api/phone.ts @@ -0,0 +1,8 @@ +import { PhoneType } from '../features/types/phoneType'; +import { getData } from '../features/utils/client'; + +export async function getPhones(): Promise { + const phones = await getData('/api/phones.json'); + + return phones; +} diff --git a/src/api/products.ts b/src/api/products.ts new file mode 100644 index 00000000000..2494a60547b --- /dev/null +++ b/src/api/products.ts @@ -0,0 +1,35 @@ +import { ProductDetails } from '../features/types/productDetailsType'; +import { Product, ProductCategory } from '../features/types/productType'; +import { getData } from '../features/utils/client'; + +const DETAILS_FILE_BY_CATEGORY: Record = { + phones: '/api/phones.json', + tablets: '/api/tablets.json', + accessories: '/api/accessories.json', +}; + +export function getProducts(): Promise { + return getData('/api/products.json'); +} + +export async function getProductDetails( + category: ProductCategory, + productId: string, +): Promise { + const url = DETAILS_FILE_BY_CATEGORY[category]; + + const res = await fetch(url); + + if (!res.ok) { + throw new Error('Failed to load details'); + } + + const all: ProductDetails[] = await res.json(); + const found = all.find(p => p.id === productId); + + if (!found) { + throw new Error('Product not found'); + } + + return found; +} diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/store.ts b/src/app/store.ts new file mode 100644 index 00000000000..90f416ea1a2 --- /dev/null +++ b/src/app/store.ts @@ -0,0 +1,26 @@ +import { configureStore, Middleware } from '@reduxjs/toolkit'; +import favoritesReducer from '../features/slices/favorites/favoritesSlice'; +import cartListReducer from '../features/slices/cartSlice/cartSlice'; + +const localStorageMiddleware: Middleware = store => next => action => { + const result = next(action); + + const state = store.getState(); + + localStorage.setItem('favorites', JSON.stringify(state.favorites.items)); + localStorage.setItem('cartList', JSON.stringify(state.cartList.items)); + + return result; +}; + +export const store = configureStore({ + reducer: { + favorites: favoritesReducer, + cartList: cartListReducer, + }, + middleware: getDefaultMiddleware => + getDefaultMiddleware().concat(localStorageMiddleware), +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/assets/icons/Favourites.svg b/src/assets/icons/Favourites.svg new file mode 100644 index 00000000000..ca57cfedd8a --- /dev/null +++ b/src/assets/icons/Favourites.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/Home.svg b/src/assets/icons/Home.svg new file mode 100644 index 00000000000..474476cb027 --- /dev/null +++ b/src/assets/icons/Home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/Logo.svg b/src/assets/icons/Logo.svg new file mode 100644 index 00000000000..fc41060097b --- /dev/null +++ b/src/assets/icons/Logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/ShoppingBag.svg b/src/assets/icons/ShoppingBag.svg new file mode 100644 index 00000000000..6030970f2e9 --- /dev/null +++ b/src/assets/icons/ShoppingBag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/Union.svg b/src/assets/icons/Union.svg new file mode 100644 index 00000000000..0a33fa8d98e --- /dev/null +++ b/src/assets/icons/Union.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/BannerSlider/BannerSlider.module.scss b/src/components/BannerSlider/BannerSlider.module.scss new file mode 100644 index 00000000000..2ffa8a626de --- /dev/null +++ b/src/components/BannerSlider/BannerSlider.module.scss @@ -0,0 +1,137 @@ +@import '../../styles/global'; + +$arrow-size: 32px; +$slider-min: 189px; +$slider-max: 400px; + +.slider { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 16px; + + + &__frame { + width: 100%; + aspect-ratio: 1 / 1; + + @include onTablet { + height: clamp($slider-min, 35vw, $slider-max); + aspect-ratio: auto; + + @include innerGrid; + } + } + + &__viewport { + position: relative; + width: 100%; + height: 100%; + overflow: auto hidden; + background-color: $colors-primary; + scrollbar-width: none; + scroll-snap-type: x mandatory; + + @include onTablet { + grid-column: 2 / -2; + overflow: hidden; + border-radius: 16px; + scroll-snap-type: none; + } + } + + &__viewport::-webkit-scrollbar { + display: none; + } + + &__track { + display: flex; + width: 100%; + height: 100%; + + @include onTablet { + transition: transform 0.6s ease; + will-change: transform; + } + } + + &__slide { + position: relative; + min-width: 100%; + height: 100%; + overflow: hidden; + scroll-snap-align: start; + } + + &__image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + } + + &__arrow { + display: none; + + @include onTablet { + width: 100%; + height: 100%; + padding: 0; + border: 1px solid $colors-icons; + background: none; + display: grid; + place-items: center; + place-self: stretch stretch; + stroke: $colors-primary; + cursor: pointer; + transition: + border-color 0.3s ease, + transform 0.3s ease; + + &:hover { + border-color: $colors-primary; + } + + &:active { + transform: scale(0.98); + } + + &:first-child { + grid-column: 1 / 2; + } + + &:last-child { + grid-column: -2 / -1; + } + } + } + + &__dots { + display: flex; + justify-content: center; + gap: 8px; + } + + &__dot { + width: 18px; + height: 4px; + padding: 0; + border: none; + border-radius: 999px; + background: $colors-elements; + cursor: pointer; + transition: + background-color 0.3s ease, + transform 0.3s ease; + + &:hover { + background: $colors-secondary; + } + } + + &__dotActive { + background: $colors-primary; + transform: scaleX(1.15); + } +} diff --git a/src/components/BannerSlider/BannerSlider.tsx b/src/components/BannerSlider/BannerSlider.tsx new file mode 100644 index 00000000000..b2bf107428c --- /dev/null +++ b/src/components/BannerSlider/BannerSlider.tsx @@ -0,0 +1,148 @@ +import { useEffect, useRef, useState } from 'react'; +import styles from './BannerSlider.module.scss'; +import { Chevron } from '../icons/Chevron'; + +const banners = [ + { + id: 0, + img: '/img/banner-accessories.png', + title: 'Top accessories', + }, + { + id: 1, + img: '/img/banner-phones.png', + title: 'Latest smartphones', + }, + { + id: 2, + img: '/img/banner-tablets.png', + title: 'Tablets for work and play', + }, +]; + +export const BannerSlider = () => { + const [index, setIndex] = useState(0); + const [isMobile, setIsMobile] = useState(window.innerWidth < 640); + const viewportRef = useRef(null); + + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < 640); + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + useEffect(() => { + const id = window.setInterval(() => { + setIndex(current => (current + 1) % banners.length); + }, 5000); + + return () => window.clearInterval(id); + }, []); + + useEffect(() => { + const viewport = viewportRef.current; + + if (!viewport) { + return; + } + + if (isMobile) { + viewport.scrollTo({ + left: viewport.clientWidth * index, + behavior: 'smooth', + }); + } + }, [index, isMobile]); + + const prev = () => + setIndex(current => (current - 1 + banners.length) % banners.length); + + const next = () => setIndex(current => (current + 1) % banners.length); + + const handleDotClick = (nextIndex: number) => { + setIndex(nextIndex); + }; + + const handleMobileScroll = () => { + const viewport = viewportRef.current; + + if (!viewport || window.innerWidth >= 640) { + return; + } + + const nextIndex = Math.round(viewport.scrollLeft / viewport.clientWidth); + + if (nextIndex !== index) { + setIndex(nextIndex); + } + }; + + return ( +
+
+ + +
+
+ {banners.map(banner => ( +
+ {banner.title} +
+ ))} +
+
+ + +
+ +
+ {banners.map((banner, i) => ( +
+
+ ); +}; diff --git a/src/components/BannerSlider/index.ts b/src/components/BannerSlider/index.ts new file mode 100644 index 00000000000..c29cbd6c894 --- /dev/null +++ b/src/components/BannerSlider/index.ts @@ -0,0 +1 @@ +export * from './BannerSlider'; diff --git a/src/components/Breadcrumbs/Breadcrumbs.module.scss b/src/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 00000000000..a5d56a5722f --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,56 @@ +@import '../../styles/global'; + +.breadcrumbs { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 6px; + margin-top: 24px; + min-height: 16px; + + &__home, + &__chevron, + &__category, + &__product { + display: flex; + align-items: center; + text-decoration: none; + } + + &__homeImg { + display: block; + width: 16px; + height: 16px; + transform: translateY(-0.5px); + } + + &__chevron { + stroke: $colors-secondary; + } + + &__category, + &__product { + font-weight: 600; + font-size: 12px; + text-decoration: none; + line-height: 1; + margin: 0; + transform: translateY(1px); + } + + &__categoryActive { + color: $colors-primary; + } + + &__categoryMuted { + color: $colors-secondary; + } + + &__product { + max-width: 380px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: $colors-secondary; + } +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..4cf6f8f8c9b --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,61 @@ +import { NavLink, useLocation } from 'react-router-dom'; +import classNames from 'classnames'; +import styles from './Breadcrumbs.module.scss'; +import home from '../../assets/icons/Home.svg'; +import { Chevron } from '../icons/Chevron'; + +type Props = { + productName?: string; +}; + +export const Breadcrumbs: React.FC = ({ productName }) => { + const location = useLocation(); + const parts = location.pathname.split('/').filter(Boolean); + + const [category, productSlug] = parts; + + const formattedCategory = category + ? category.replace(/\b\w/g, char => char.toUpperCase()) + : ''; + + if (!category) { + return null; + } + + return ( + + ); +}; diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts new file mode 100644 index 00000000000..ce977548b14 --- /dev/null +++ b/src/components/Breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from './Breadcrumbs'; diff --git a/src/components/CartItemCard/CartItemCard.module.scss b/src/components/CartItemCard/CartItemCard.module.scss new file mode 100644 index 00000000000..d9f4c6d9ac0 --- /dev/null +++ b/src/components/CartItemCard/CartItemCard.module.scss @@ -0,0 +1,129 @@ +@import '../../styles/global'; + +.cartItem { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + border: 1px solid $colors-elements; + justify-content: space-between; + + @include onTablet { + flex-direction: row; + align-items: center; + gap: 0; + } + + &__part { + display: flex; + flex-direction: row; + align-items: center; + + + &:first-child { + flex: 1; + min-width: 0; + } + + &:nth-child(2) { + justify-content: space-between; + + @include onTablet { + justify-content: flex-start; + } + } + + @include onDesktop { + gap: 24px; + } + } + + &__removeButton { + border: none; + background: none; + color: $colors-icons; + transition: all 0.3s ease; + + &:hover { + cursor: pointer; + color: $colors-primary; + transform: scale(1.1); + } + } + + &__link { + display: flex; + flex-direction: row; + gap: 24px; + text-decoration: none; + align-items: center; + min-width: 0; + transition: transform 0.3s ease; + + &:hover { + transform: scale(1.01); + } + } + + &__imageWrapper { + width: 66px; + height: 66px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + @include onDesktop { + height: 80px; + width: 80px; + } + } + + &__image { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; + } + + &__name { + color: $colors-primary; + min-width: 128px; + max-width: 336px; + + @include body-text; + + @include onTablet { + width: 176px; + } + + @include onDesktop { + width: 336px; + } + } + + &__quantity { + display: flex; + flex-direction: row; + gap: 14px; + align-items: center; + } + + &__quantityValue { + min-width: 2ch; + text-align: center; + + @include body-text; + } + + &__price { + color: $colors-primary; + min-width: 80px; + text-align: right; + flex-shrink: 0; + + @include h3-text; + } +} diff --git a/src/components/CartItemCard/CartItemCard.tsx b/src/components/CartItemCard/CartItemCard.tsx new file mode 100644 index 00000000000..16a1460a911 --- /dev/null +++ b/src/components/CartItemCard/CartItemCard.tsx @@ -0,0 +1,72 @@ +import styles from './CartItemCard.module.scss'; +import React from 'react'; +import { Product } from '../../features/types/productType'; +import { Link } from 'react-router-dom'; +import { SecondaryButton } from '../SecondaryButton'; + +type Props = { + product: Product; + quantity: number; + onRemove: () => void; + onDecrease: () => void; + onIncrease: () => void; +}; + +export const CartItemCard: React.FC = ({ + product, + quantity, + onRemove, + onDecrease, + onIncrease, +}) => { + return ( +
+
+ + + +
+ {product.name} +
+ +

{product.name}

+ +
+ +
+
+ + - + + +

{quantity}

+ + + +
+ +

${product.price}

+
+
+ ); +}; diff --git a/src/components/CartItemCard/index.ts b/src/components/CartItemCard/index.ts new file mode 100644 index 00000000000..7488f63aac8 --- /dev/null +++ b/src/components/CartItemCard/index.ts @@ -0,0 +1 @@ +export * from './CartItemCard'; diff --git a/src/components/CheckoutModal/CheckoutModal.module.scss b/src/components/CheckoutModal/CheckoutModal.module.scss new file mode 100644 index 00000000000..6722e98412c --- /dev/null +++ b/src/components/CheckoutModal/CheckoutModal.module.scss @@ -0,0 +1,62 @@ +@import '../../styles/global'; + +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background-color: rgba(0, 0, 0, 0.45); + + &__content { + width: 100%; + max-width: 420px; + padding: 24px; + border: 1px solid $colors-elements; + background-color: $colors-white; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + } + + &__title { + margin: 0 0 12px; + color: $colors-primary; + + @include h3-text; + } + + &__text { + margin: 0 0 24px; + color: $colors-secondary; + + @include body-text; + } + + &__actions { + display: flex; + gap: 12px; + justify-content: flex-end; + } + + &__buttonSecondary, + &__buttonPrimary { + height: 40px; + padding: 0 40px; + border: none; + cursor: pointer; + + @include button-text; + } + + &__buttonSecondary { + background-color: $colors-white; + border: 1px solid $colors-elements; + color: $colors-primary; + } + + &__buttonPrimary { + background-color: $colors-primary; + color: $colors-white; + } +} diff --git a/src/components/CheckoutModal/CheckoutModal.tsx b/src/components/CheckoutModal/CheckoutModal.tsx new file mode 100644 index 00000000000..a0bc666c8b2 --- /dev/null +++ b/src/components/CheckoutModal/CheckoutModal.tsx @@ -0,0 +1,39 @@ +import styles from './CheckoutModal.module.scss'; + +type Props = { + onClose: () => void; + onConfirm: () => void; +}; + +export const CheckoutModal: React.FC = ({ onClose, onConfirm }) => { + return ( +
+
event.stopPropagation()} + > +

Checkout is not implemented yet

+ +

Do you want to clear the Cart?

+ +
+ + + +
+
+
+ ); +}; diff --git a/src/components/CheckoutModal/index.ts b/src/components/CheckoutModal/index.ts new file mode 100644 index 00000000000..df7c4cd8c2f --- /dev/null +++ b/src/components/CheckoutModal/index.ts @@ -0,0 +1 @@ +export * from './CheckoutModal'; diff --git a/src/components/DropDown/DropDown.module.scss b/src/components/DropDown/DropDown.module.scss new file mode 100644 index 00000000000..fbf1ca0c5b8 --- /dev/null +++ b/src/components/DropDown/DropDown.module.scss @@ -0,0 +1,102 @@ +@import '../../styles/global'; + +.dropdown { + position: relative; + width: 100%; + box-sizing: border-box; + + &__title { + margin: 0 0 4px; + + @include small-text; + + color: $colors-secondary; + } + + &__button { + display: flex; + align-items: center; + position: relative; + width: 100%; + height: 40px; + border: 1px solid $colors-icons; + background: none; + + padding: 0 40px 0 12px; + + @include button-text; + + color: $colors-primary; + + &:hover { + cursor: pointer; + border: 1px solid $colors-secondary; + } + + } + + &__buttonActive { + border: 1px solid $colors-primary; + } + + &__value { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__chevron { + position: absolute; + stroke: $colors-icons; + right: 12px; + top: 50%; + transform: translateY(-50%); + } + + &__content { + position: absolute; + top: calc(100% + 6px); + left: 0; + width: 100%; + box-sizing: border-box; + margin: 0; + padding: 0; + list-style: none; + + border: 1px solid $colors-elements; + background: $colors-white; + box-shadow: 0 2px 15px rgba(0, 0, 0, 0.05); + + transform-origin: top; + transition: + opacity 160ms ease, + transform 160ms ease; + } + + &__option { + padding: 8px 12px; + color: $colors-secondary; + transition: all 0.3s ease; + + &:hover { + cursor: pointer; + color: $colors-primary; + background-color: $colors-hover-bg; + } + } + +} + + +.open { + opacity: 1; + transform: translateY(0) scaleY(1); + pointer-events: auto; +} + +.closed { + opacity: 0; + transform: translateY(-6px) scaleY(0.98); + pointer-events: none; +} diff --git a/src/components/DropDown/DropDown.tsx b/src/components/DropDown/DropDown.tsx new file mode 100644 index 00000000000..9ed813a06a8 --- /dev/null +++ b/src/components/DropDown/DropDown.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react'; +import styles from './DropDown.module.scss'; +import classNames from 'classnames'; +import { Chevron } from '../icons/Chevron'; + +const ANIMATION_MS = 160; + +export type DropdownOption = { + value: T; + label: string; +}; + +type Props = { + title: string; + value: T; + options: readonly DropdownOption[]; + onChange: (value: T) => void; +}; + +export function Dropdown({ + title, + value, + options, + onChange, +}: Props) { + const [isOpen, setIsOpen] = useState(false); + const [isRender, setIsRender] = useState(false); + + const dropdownRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setIsRender(true); + + return; + } + + const t = window.setTimeout(() => setIsRender(false), ANIMATION_MS); + + return () => window.clearTimeout(t); + }, [isOpen]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!dropdownRef.current) { + return; + } + + if (!dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const selected = options.find(o => o.value === value); + const buttonLabel = selected ? selected.label : value; + const listboxId = `${title.replace(/\s+/g, '-').toLowerCase()}-listbox`; + + const handleSelect = (nextValue: T) => { + onChange(nextValue); + setIsOpen(false); + }; + + return ( +
+

{title}

+ + {isRender && ( +
    + {options.map(option => ( +
  • handleSelect(option.value)} + className={styles.dropdown__option} + > + {option.label} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/DropDown/index.ts b/src/components/DropDown/index.ts new file mode 100644 index 00000000000..69092242f78 --- /dev/null +++ b/src/components/DropDown/index.ts @@ -0,0 +1 @@ +export * from './DropDown'; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..b96a9784ec2 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,87 @@ +@import '../../styles/global'; + +.footer { + background-color: $colors-white; + border-top: 1px solid $colors-elements; + margin-top: 64px; + min-height: 257px; + + display: flex; + align-items: stretch; + + @include onTablet { + min-height: 96px; + } + + @include onDesktop { + margin-top: 80px; + } +} + +.container { + min-height: inherit; + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 32px; + + display: flex; + flex-direction: column; + justify-content: center; + gap: 32px; + + @include onTablet { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.logo { + display: inline-flex; + transition: transform 0.3s ease; +} + +.logo:hover { + transform: scale(1.1); +} + +.mainLinks { + display: flex; + flex-direction: column; + gap: 16px; + + @include onTablet { + flex-flow: row wrap; + gap: clamp(24px, 6vw, 106px); + } + + &__link { + @include uppercase-text; + + text-decoration: none; + color: $colors-secondary; + + transition: transform 0.3s ease; + + &:hover { + color: #6c6c6c; + transform: translateY(-1px) scale(1.01); + } + } +} + +.backToTop { + display: flex; + flex-direction: row; + gap: 16px; + align-self: center; + align-items: center; + + &__text { + @include small-text; + + color: $colors-secondary; + } + +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..afc48f0e056 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,60 @@ +import { Link } from 'react-router-dom'; +import styles from './Footer.module.scss'; +import logo from '../../assets/icons/Logo.svg'; +import { Chevron } from '../icons/Chevron'; +import { SecondaryButton } from '../SecondaryButton'; + +export const Footer = () => { + const handleBackToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 00000000000..ddcc5a9cd18 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..cd427ac740c --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,230 @@ +@import '../../styles/global'; + +.header { + position: sticky; + top: 0; + z-index: 1100; + height: var(--header-height); + background-color: $colors-white; + border-bottom: 1px solid $colors-elements; + + @include onTablet { + height: var(--header-height); + } + + &__container { + margin: 0 auto; + padding: 0 24px; + + display: flex; + align-items: center; + justify-content: space-between; + + height: 100%; + padding-right: 0; + } + + &__left { + display: flex; + align-items: center; + height: 100%; + gap: 48px; + + @include onTablet { + gap: 32px; + } + } + + &__logo { + display: flex; + transition: transform 0.3s ease; + width: 80px; + height: auto; + + @include onTablet { + width: 64px; + } + + &:hover { + transform: scale(1.1); + } + + img { + width: 100%; + height: auto; + display: block; + } + } + + &__nav { + display: none; + height: 100%; + + @include onTablet { + display: flex; + gap: 32px; + } + + @include onDesktop { + display: flex; + gap: 64px; + } + } + + &__navLink { + position: relative; + height: 100%; + display: flex; + align-items: center; + + @include uppercase-text; + + text-decoration: none; + color: $colors-secondary; + + transition: color 0.3s ease; + + &::after { + content: ''; + position: absolute; + left: -2px; + bottom: 0; + + width: 110%; + height: 3px; + background-color: $colors-primary; + + transform: scaleX(0); + transform-origin: left; + transition: transform 0.3s ease; + } + + &:hover { + color: $colors-primary; + } + + &:hover::after { + transform: scaleX(1); + } + } + + &__navLinkActive { + color: $colors-primary; + text-decoration: none; + + &::after { + transform: scaleX(1); + } + } + + &__right { + display: none; + align-items: center; + + @include onTablet { + display: flex; + } + } + + &__iconLink { + position: relative; + width: var(--header-height); + height: var(--header-height); + + display: flex; + align-items: center; + justify-content: center; + + border-left: 1px solid $colors-elements; + text-decoration: none; + + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + + width: 100%; + height: 3px; + background-color: $colors-primary; + + transform: scaleX(0); + transform-origin: left; + transition: transform 0.3s ease; + } + + &>* { + transform: scale(1.05); + } + + &:hover>* { + transform: scale(1.1); + } + + &:hover::after { + transform: scaleX(1); + } + } + + &__iconLinkActive { + &::after { + transform: scaleX(1); + } + } + + &__burgerContainer { + display: flex; + align-items: center; + justify-content: center; + + width: auto; + height: 100%; + aspect-ratio: 1 / 1; + box-shadow: -1px 0 0 $colors-elements; + + @include onTablet { + display: none; + } + + @include onDesktop { + display: none; + } + } + + &__burger { + width: 16px; + height: 14px; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0; + background: none; + border: none; + cursor: pointer; + + span { + height: 2px; + width: 100%; + background: $colors-primary; + border-radius: 2px; + + transition: transform 0.3s ease, opacity 0.3s ease; + } + + } + + &__burgerOpen { + span:nth-child(1) { + transform: translateY(6px) rotate(45deg); + } + + span:nth-child(2) { + opacity: 0; + } + + span:nth-child(3) { + transform: translateY(-6px) rotate(-45deg); + } + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..5a725938185 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,95 @@ +import { Link, NavLink } from 'react-router-dom'; +import styles from './Header.module.scss'; +import logo from '../../assets/icons/Logo.svg'; +import favorites from '../../assets/icons/Favourites.svg'; +import shoppingBag from '../../assets/icons/ShoppingBag.svg'; +import { useState } from 'react'; +import { MobileMenu } from './MobileMenu/MobileMenu'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../app/store'; +import { IconWithBadge } from '../icons/IconWithBadge'; +import classNames from 'classnames'; +import { cartItemsCount } from '../../features/utils/selectors'; + +export const Header = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const getNavClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.header__navLink, { + [styles.header__navLinkActive]: isActive, + }); + + const getIconClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.header__iconLink, { + [styles.header__iconLinkActive]: isActive, + }); + + const favoriteItems = useSelector( + (state: RootState) => state.favorites.items, + ); + + const cartItems = useSelector(cartItemsCount); + + return ( + <> +
+
+
+ + Nice Gadgets + + + +
+ +
+ + + + + + +
+
+ +
+
+
+ setIsMenuOpen(false)} /> + + ); +}; diff --git a/src/components/Header/MobileMenu/MobileMenu.module.scss b/src/components/Header/MobileMenu/MobileMenu.module.scss new file mode 100644 index 00000000000..883e9a4e5a9 --- /dev/null +++ b/src/components/Header/MobileMenu/MobileMenu.module.scss @@ -0,0 +1,76 @@ +@import '../../../styles/global'; + +.mobileMenu { + position: fixed; + inset: var(--header-height) 0 0; + z-index: 1000; + background: $colors-white; + display: flex; + flex-direction: column; + + &__nav { + display: flex; + flex-direction: column; + align-items: center; + gap: 32px; + margin-top: 24px; + } + + &__bottom { + margin-top: auto; + display: flex; + } + + &__navLink { + text-decoration: none; + color: $colors-secondary; + + @include uppercase-text; + } + + &__navLinkActive { + position: relative; + color: $colors-primary; + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: -11px; + width: 100%; + height: 2px; + background: $colors-primary; + } + } + + &__iconLink { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 50%; + height: 64px; + text-decoration: none; + border-top: 1px solid $colors-elements; + + &:not(:first-child) { + border-left: 1px solid $colors-elements; + } + } + + &__iconLinkActive { + &>* { + transform: scale(1.05); + } + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 2px; + background: $colors-primary; + } + } +} diff --git a/src/components/Header/MobileMenu/MobileMenu.tsx b/src/components/Header/MobileMenu/MobileMenu.tsx new file mode 100644 index 00000000000..837c949f7b7 --- /dev/null +++ b/src/components/Header/MobileMenu/MobileMenu.tsx @@ -0,0 +1,87 @@ +import styles from './MobileMenu.module.scss'; +import favorites from '../../../assets/icons/Favourites.svg'; +import shoppingBag from '../../../assets/icons/ShoppingBag.svg'; +import { NavLink } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../../app/store'; +import { IconWithBadge } from '../../icons/IconWithBadge'; +import classNames from 'classnames'; +import { cartItemsCount } from '../../../features/utils/selectors'; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +export const MobileMenu: React.FC = ({ isOpen, onClose }) => { + const favoriteItems = useSelector( + (state: RootState) => state.favorites.items, + ); + + const cartItems = useSelector(cartItemsCount); + + const getNavClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.mobileMenu__navLink, { + [styles.mobileMenu__navLinkActive]: isActive, + }); + + const getIconClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.mobileMenu__iconLink, { + [styles.mobileMenu__iconLinkActive]: isActive, + }); + + useEffect(() => { + if (!isOpen) { + return; + } + + const prev = document.body.style.overflow; + + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = prev; + }; + }, [isOpen]); + + if (!isOpen) { + return null; + } + + return ( +
+ + +
+ + + + + + +
+
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 00000000000..266dec8a1bc --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 00000000000..040c96200b3 --- /dev/null +++ b/src/components/Loader/Loader.module.scss @@ -0,0 +1,25 @@ +.Loader { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + + &__content { + border-radius: 50%; + width: 2em; + height: 2em; + margin: 1em auto; + border: 0.3em solid #ddd; + border-left-color: #000; + animation: load8 1.2s infinite linear; + } +} + +@keyframes load8 { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx new file mode 100644 index 00000000000..a35e5763fd3 --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,7 @@ +import styles from './Loader.module.scss'; + +export const Loader = () => ( +
+
+
+); diff --git a/src/components/Loader/index.tsx b/src/components/Loader/index.tsx new file mode 100644 index 00000000000..d5ce981151f --- /dev/null +++ b/src/components/Loader/index.tsx @@ -0,0 +1 @@ +export * from './Loader'; diff --git a/src/components/ProductActions/ProductActions.module.scss b/src/components/ProductActions/ProductActions.module.scss new file mode 100644 index 00000000000..7a5eb95a55d --- /dev/null +++ b/src/components/ProductActions/ProductActions.module.scss @@ -0,0 +1,73 @@ +@import '../../styles/global'; + +.productActions { + display: flex; + flex-direction: row; + gap: 8px; + + &__add { + padding: 0; + flex: 1; + height: 40px; + background-color: #313237; + border: none; + color: #fff; + transition: all 0.3s ease; + font: inherit; + font-weight: 600; + font-size: 14px; + line-height: 21px; + + &:hover { + box-shadow: 0 3px 13px rgba(0, 0, 0, 0.4); + cursor: pointer; + } + + &:disabled { + background: #fff; + border: 1px solid #E2E6E9; + color: $colors-green; + cursor: default; + box-shadow: none; + transform: none; + } + } + + .iconImg { + width: 15px; + height: auto; + display: block; + transition: transform 120ms ease; + transform: translateZ(0); + will-change: transform; + } + + + .iconPop { + animation: pop 550ms cubic-bezier(0.34, 1.56, 0.64, 1); + } + + @keyframes pop { + 0% { + transform: scale(1) rotate(0deg); + } + + 30% { + transform: scale(1.25) rotate(-8deg); + } + + 55% { + transform: scale(0.95) rotate(4deg); + } + + 75% { + transform: scale(1.08) rotate(-2deg); + } + + 100% { + transform: scale(1) rotate(0deg); + } + } + + +} diff --git a/src/components/ProductActions/ProductActions.tsx b/src/components/ProductActions/ProductActions.tsx new file mode 100644 index 00000000000..a28bf2b2ece --- /dev/null +++ b/src/components/ProductActions/ProductActions.tsx @@ -0,0 +1,58 @@ +import styles from './ProductActions.module.scss'; +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { AppDispatch, RootState } from '../../app/store'; +import { addInCart } from '../../features/slices/cartSlice/cartSlice'; +import { toggleFavorite } from '../../features/slices/favorites/favoritesSlice'; +import union from '../../assets/icons/Union.svg'; +import favorites from '../../assets/icons/Favourites.svg'; +import { SecondaryButton } from '../SecondaryButton'; +type Props = { + itemId: string; +}; + +export const ProductActions: React.FC = ({ itemId }) => { + const [pop, setPop] = useState(false); + + const dispatch = useDispatch(); + const favoriteItems = useSelector( + (state: RootState) => state.favorites.items, + ); + const cartItems = useSelector((state: RootState) => state.cartList.items); + + const isFavorite = favoriteItems.includes(itemId); + const isInCart = cartItems.some(item => item.itemId === itemId); + + const handleToggleCart = () => { + dispatch(addInCart(itemId)); + }; + + const handleToggleFavorite = () => { + dispatch(toggleFavorite(itemId)); + setPop(false); + requestAnimationFrame(() => setPop(true)); + }; + + return ( +
+ + + Hearts + +
+ ); +}; diff --git a/src/components/ProductActions/index.ts b/src/components/ProductActions/index.ts new file mode 100644 index 00000000000..4ae950d1697 --- /dev/null +++ b/src/components/ProductActions/index.ts @@ -0,0 +1 @@ +export * from './ProductActions'; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..666fc26f9b7 --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,188 @@ +@import '../../styles/global'; + +.productCard { + box-sizing: border-box; + border: 1px solid $colors-elements; + padding: 32px; + background: $colors-white; + display: flex; + flex-direction: column; + transition: all 0.3s ease; + gap: 8px; + min-width: 0; + min-height: 440px; + justify-content: space-between; + + @include onTablet { + min-height: 512px; + max-width: 288px; + } + + @include onDesktop { + min-height: 506px; + max-width: 272px; + } + + &:hover { + cursor: pointer; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.1); + } + + &__link { + display: flex; + flex-direction: column; + text-decoration: none; + color: $colors-primary; + justify-content: space-between; + height: 100%; + } + + &__image { + width: 100%; + height: 196px; + object-fit: contain; + display: block; + } + + &__title { + @include body-text; + + margin-top: 24px; + color: $colors-primary; + + min-height: calc(21px * 2); + display: -webkit-box; + -webkit-box-orient: vertical; + line-clamp: 2; + -webkit-line-clamp: 2; + overflow: hidden; + } + + &__price { + display: flex; + flex-direction: row; + gap: 8px; + } + + &__priceCurrent { + color: $colors-primary; + margin-top: 0; + + @include h3-text; + } + + &__priceOld { + font-weight: 600; + font-size: 22px; + line-height: 1.4; + color: $colors-secondary; + text-decoration-line: line-through; + padding-left: 0; + text-decoration-color: $colors-secondary; + text-decoration-thickness: 2px; + } + + &__specs { + border-top: 1px solid $colors-elements; + display: flex; + padding-top: 16px; + flex-direction: column; + gap: 8px; + + div { + display: flex; + justify-content: space-between; + font-size: 12px; + + span { + color: $colors-secondary; + } + } + } + + &__actions { + display: flex; + flex-direction: row; + gap: 8px; + } + + &__add { + padding: 0; + flex: 1; + height: 40px; + background-color: $colors-primary; + border: none; + color: $colors-white; + transition: all 0.3s ease; + + @include button-text; + + &:hover { + box-shadow: 0 3px 13px rgba(0, 0, 0, 0.4); + cursor: pointer; + } + + &:disabled { + background: #fff; + border: 1px solid #E2E6E9; + color: $colors-green; + cursor: default; + box-shadow: none; + transform: none; + } + } + + + .iconImg { + width: 15px; + height: auto; + display: block; + transition: transform 120ms ease; + transform: translateZ(0); + will-change: transform; + } + + + .iconPop { + animation: pop 550ms cubic-bezier(0.34, 1.56, 0.64, 1); + } + + @keyframes pop { + 0% { + transform: scale(1) rotate(0deg); + } + + 30% { + transform: scale(1.25) rotate(-8deg); + } + + 55% { + transform: scale(0.95) rotate(4deg); + } + + 75% { + transform: scale(1.08) rotate(-2deg); + } + + 100% { + transform: scale(1) rotate(0deg); + } + } + + &__fav { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + width: 40px; + height: 40px; + background: none; + border: 1px solid #B4BDC3; + transition: border 0.3s ease; + + &:hover { + border: 1px solid #313237; + cursor: pointer; + } + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..be57b003bb7 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,57 @@ +import { Product } from '../../features/types/productType'; +import styles from './ProductCard.module.scss'; +import { Link } from 'react-router-dom'; +import { ProductActions } from '../ProductActions'; + +type Props = { + product: Product; + showDiscount?: boolean; +}; + +export const ProductCard: React.FC = ({ + product, + showDiscount = false, +}) => { + const { name, fullPrice, price, image, screen, capacity, ram } = product; + + const imgSrc = image.startsWith('/') ? image : `/${image}`; + + return ( +
+ + {name} + +

{name}

+ +
+ + ${showDiscount ? price : fullPrice} + + + {showDiscount && ( + ${fullPrice} + )} +
+ +
+
+ Screen + {screen} +
+
+ Capacity + {capacity} +
+
+ RAM + {ram} +
+
+ + +
+ ); +}; diff --git a/src/components/ProductCard/index.tsx b/src/components/ProductCard/index.tsx new file mode 100644 index 00000000000..7ce031c3820 --- /dev/null +++ b/src/components/ProductCard/index.tsx @@ -0,0 +1 @@ +export * from './ProductCard'; diff --git a/src/components/ProductGallery/ProductGallery.module.scss b/src/components/ProductGallery/ProductGallery.module.scss new file mode 100644 index 00000000000..d4b6dafb3a3 --- /dev/null +++ b/src/components/ProductGallery/ProductGallery.module.scss @@ -0,0 +1,84 @@ +@import '../../styles/global'; + +.gallery { + display: flex; + flex-direction: column; + gap: 16px; + + @include onTablet { + flex-direction: row; + align-items: flex-start; + } + + &__thumbnails { + display: flex; + flex-direction: row; + gap: 16px; + order: 2; + + overflow: auto hidden; + max-width: 100%; + + @include onTablet { + flex-direction: column; + order: -1; + + overflow: hidden auto; + } + } + + &__thumbnailButton { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 1 / 1; + padding: 0; + border: 1px solid $colors-elements; + background: none; + cursor: pointer; + transition: border-color 0.3s ease; + + &:hover { + cursor: pointer; + border-color: $colors-secondary; + } + + @include onTablet { + width: clamp(35px, 4vw + 10px, 80px); + height: clamp(35px, 4vw + 10px, 80px); + } + + @include onDesktop { + width: 80px; + height: 80px; + } + } + + &__thumbnailButtonActive { + border-color: $colors-primary; + } + + + &__thumbnailImage { + width: 100%; + height: 100%; + object-fit: contain; + } + + &__main { + flex: 1; + + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1 / 1; + } + + &__mainImage { + width: 100%; + height: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; + } +} diff --git a/src/components/ProductGallery/ProductGallery.tsx b/src/components/ProductGallery/ProductGallery.tsx new file mode 100644 index 00000000000..e398cafa568 --- /dev/null +++ b/src/components/ProductGallery/ProductGallery.tsx @@ -0,0 +1,51 @@ +import styles from './ProductGallery.module.scss'; +import classNames from 'classnames'; + +type Props = { + images: string[]; + productName: string; + mainImage: string; + onImageSelect: (image: string) => void; +}; + +export const ProductGallery: React.FC = ({ + images, + productName, + mainImage, + onImageSelect, +}) => { + return ( +
+
+ {images.map(img => { + const imageSrc = `/${img}`; + const isActive = mainImage === imageSrc; + + return ( + + ); + })} +
+
+ {productName} +
+
+ ); +}; diff --git a/src/components/ProductGallery/index.ts b/src/components/ProductGallery/index.ts new file mode 100644 index 00000000000..b74eb50e7e5 --- /dev/null +++ b/src/components/ProductGallery/index.ts @@ -0,0 +1 @@ +export * from './ProductGallery'; diff --git a/src/components/ProductOptions/ProductOptions.module.scss b/src/components/ProductOptions/ProductOptions.module.scss new file mode 100644 index 00000000000..4dd941a8489 --- /dev/null +++ b/src/components/ProductOptions/ProductOptions.module.scss @@ -0,0 +1,96 @@ +@import '../../styles/global'; + +.options { + display: flex; + flex-direction: column; + gap: 24px; + + &__group { + display: flex; + flex-direction: column; + gap: 24px; + } + + &__colors { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__radio { + position: absolute; + opacity: 0; + pointer-events: none; + } + + &__colorLabel { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 2px; + border: 1px solid $colors-elements; + border-radius: 50%; + cursor: pointer; + transition: border-color 0.3s ease; + + &:hover { + border-color: $colors-secondary; + } + + &:has(input:checked) { + border-color: $colors-primary; + } + } + + &__colorCircle { + width: 100%; + height: 100%; + border-radius: 50%; + } + + &__capacities { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__capacityLabel { + display: inline-flex; + } + + &__capacityValue { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 48px; + padding: 8px 12px; + border: 1px solid $colors-secondary; + color: $colors-primary; + + @include body-text; + + transition: + background-color 0.3s ease, + color 0.3s ease, + border-color 0.3s ease; + + &:hover { + cursor: pointer; + border-color: $colors-primary; + } + } + + &__capacityLabel:has(input:checked) &__capacityValue { + background-color: $colors-primary; + border-color: $colors-primary; + color: $colors-white; + } + + &__divider { + width: 100%; + height: 1px; + background-color: $colors-elements; + } +} diff --git a/src/components/ProductOptions/ProductOptions.tsx b/src/components/ProductOptions/ProductOptions.tsx new file mode 100644 index 00000000000..ce4c291ce2e --- /dev/null +++ b/src/components/ProductOptions/ProductOptions.tsx @@ -0,0 +1,80 @@ +import { getProductColor } from '../../features/constants/productColors'; +import styles from './ProductOptions.module.scss'; + +type Props = { + colors: string[]; + selectedColor: string; + capacities: string[]; + selectedCapacity: string; + onColorChange: (color: string) => void; + onCapacityChange: (capacity: string) => void; +}; + +export const ProductOptions: React.FC = ({ + colors, + selectedColor, + capacities, + selectedCapacity, + onColorChange, + onCapacityChange, +}) => { + return ( +
+
+
+ {colors.map(color => { + const isChecked = selectedColor === color; + + return ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control + + ); + })} +
+ +
+ +
+ {capacities.map(capacity => { + const isChecked = selectedCapacity === capacity; + + return ( + + ); + })} +
+ +
+
+
+ ); +}; diff --git a/src/components/ProductOptions/index.ts b/src/components/ProductOptions/index.ts new file mode 100644 index 00000000000..14e8c012fb7 --- /dev/null +++ b/src/components/ProductOptions/index.ts @@ -0,0 +1 @@ +export * from './ProductOptions'; diff --git a/src/components/ProductsCarousel/ProductsCarousel.module.scss b/src/components/ProductsCarousel/ProductsCarousel.module.scss new file mode 100644 index 00000000000..8bd8b3779b3 --- /dev/null +++ b/src/components/ProductsCarousel/ProductsCarousel.module.scss @@ -0,0 +1,42 @@ +@import '../../styles/global'; + +.carousel { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 24px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__title { + margin: 0; + color: $colors-primary; + + @include h2-text; + } + + &__actions { + display: flex; + gap: 16px; + } + + &__track { + display: flex; + overflow: auto hidden; + gap: 16px; + scroll-snap-type: x mandatory; + } + + &__item { + flex: 0 0 229px; + scroll-snap-align: start; + + @include onDesktop { + flex: 0 0 calc((100% - 48px) / 4); + } + } +} diff --git a/src/components/ProductsCarousel/ProductsCarousel.tsx b/src/components/ProductsCarousel/ProductsCarousel.tsx new file mode 100644 index 00000000000..c97c03d3737 --- /dev/null +++ b/src/components/ProductsCarousel/ProductsCarousel.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Product } from '../../features/types/productType'; +import styles from './ProductsCarousel.module.scss'; +import { Chevron } from '../icons/Chevron'; +import { ProductCard } from '../ProductCard'; +import { SecondaryButton } from '../SecondaryButton'; + +type Props = { + title: string; + products: Product[]; + showDiscount?: boolean; +}; + +export const ProductsCarousel: React.FC = ({ + title, + products, + showDiscount = false, +}) => { + const carouselRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateArrows = useCallback(() => { + const el = carouselRef.current; + + if (!el) { + return; + } + + const left = el.scrollLeft; + const maxScrollLeft = el.scrollWidth - el.clientWidth; + + setCanScrollLeft(left > 1); + setCanScrollRight(left < maxScrollLeft - 1); + }, []); + + useEffect(() => { + updateArrows(); + }, [products, updateArrows]); + + useEffect(() => { + const el = carouselRef.current; + + if (!el) { + return; + } + + const onScroll = () => updateArrows(); + const onResize = () => updateArrows(); + + el.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onResize); + + return () => { + el.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onResize); + }; + }, [updateArrows]); + + const scrollByStep = (direction: 'left' | 'right') => { + const el = carouselRef.current; + + if (!el) { + return; + } + + const step = el.clientWidth; + + el.scrollBy({ + left: direction === 'left' ? -step : step, + behavior: 'smooth', + }); + }; + + return ( +
+
+

{title}

+
+ scrollByStep('left')} + disabled={!canScrollLeft} + aria-label="Scroll left" + > + + + scrollByStep('right')} + disabled={!canScrollRight} + aria-label="Scroll right" + > + + +
+
+
+ {products.map(product => ( +
+ +
+ ))} +
+
+ ); +}; diff --git a/src/components/ProductsCarousel/index.ts b/src/components/ProductsCarousel/index.ts new file mode 100644 index 00000000000..3689b5b3a56 --- /dev/null +++ b/src/components/ProductsCarousel/index.ts @@ -0,0 +1 @@ +export * from './ProductsCarousel'; diff --git a/src/components/ProductsList/ProductsList.module.scss b/src/components/ProductsList/ProductsList.module.scss new file mode 100644 index 00000000000..9b8a0dc8844 --- /dev/null +++ b/src/components/ProductsList/ProductsList.module.scss @@ -0,0 +1,11 @@ +@import '../../styles/global'; + +.productList { + display: grid; + gap: 40px 16px; + grid-template-columns: repeat(auto-fill, minmax(229px, 1fr)); + + @include onDesktop { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/src/components/ProductsList/ProductsList.tsx b/src/components/ProductsList/ProductsList.tsx new file mode 100644 index 00000000000..0f338f99786 --- /dev/null +++ b/src/components/ProductsList/ProductsList.tsx @@ -0,0 +1,17 @@ +import { Product } from '../../features/types/productType'; +import { ProductCard } from '../ProductCard'; +import styles from './ProductsList.module.scss'; + +type Props = { + products: Product[]; +}; + +export const ProductList: React.FC = ({ products }) => { + return ( +
+ {products.map(product => ( + + ))} +
+ ); +}; diff --git a/src/components/ProductsList/index.ts b/src/components/ProductsList/index.ts new file mode 100644 index 00000000000..09f9887f278 --- /dev/null +++ b/src/components/ProductsList/index.ts @@ -0,0 +1 @@ +export * from './ProductsList'; diff --git a/src/components/SecondaryButton/SecondaryButton.module.scss b/src/components/SecondaryButton/SecondaryButton.module.scss new file mode 100644 index 00000000000..43daacf6100 --- /dev/null +++ b/src/components/SecondaryButton/SecondaryButton.module.scss @@ -0,0 +1,45 @@ +@import '../../styles/global'; + +.button { + background: none; + border: 1px solid $colors-icons; + display: flex; + align-items: center; + justify-content: center; + color: $colors-primary; + stroke: currentColor; + padding: 0; + + &:disabled { + color: $colors-icons; + border-color: $colors-elements; + cursor: auto; + stroke: currentColor; + } + + &:hover:not(:disabled, .active) { + border: 1px solid $colors-primary; + cursor: pointer; + } +} + +.active { + color: $colors-white; + background-color: $colors-primary; + border-color: $colors-primary; +} + +.s { + width: 32px; + height: 32px; +} + +.m { + width: 40px; + height: 40px; +} + +.l { + width: 48px; + height: 48px; +} diff --git a/src/components/SecondaryButton/SecondaryButton.tsx b/src/components/SecondaryButton/SecondaryButton.tsx new file mode 100644 index 00000000000..311b5d00544 --- /dev/null +++ b/src/components/SecondaryButton/SecondaryButton.tsx @@ -0,0 +1,38 @@ +import React, { ButtonHTMLAttributes, ReactNode } from 'react'; +import styles from './SecondaryButton.module.scss'; +import classNames from 'classnames'; + +type ButtonSize = 's' | 'm' | 'l'; + +type Props = { + children: ReactNode; + className?: string; + isActive?: boolean; + size?: ButtonSize; +} & ButtonHTMLAttributes; + +export const SecondaryButton: React.FC = ({ + children, + className = '', + isActive = false, + size = 's', + type = 'button', + ...rest +}) => { + return ( + + ); +}; diff --git a/src/components/SecondaryButton/index.ts b/src/components/SecondaryButton/index.ts new file mode 100644 index 00000000000..af0b58cd868 --- /dev/null +++ b/src/components/SecondaryButton/index.ts @@ -0,0 +1 @@ +export * from './SecondaryButton'; diff --git a/src/components/categoryCard/CategoryCard.module.scss b/src/components/categoryCard/CategoryCard.module.scss new file mode 100644 index 00000000000..e100d7c8327 --- /dev/null +++ b/src/components/categoryCard/CategoryCard.module.scss @@ -0,0 +1,99 @@ +@import '../../styles/global'; + +.categoryCard { + display: flex; + flex-direction: column; + width: 100%; + text-decoration: none; + color: inherit; + gap: 24px; + + transition: transform 0.3s ease; + + @include onTablet { + width: auto; + flex: 1 1 0; + min-width: 0; + } + + &__square { + width: 100%; + aspect-ratio: 1; + max-width: none; + overflow: hidden; + position: relative; + transition: box-shadow 0.3s ease; + } + + &__image { + position: absolute; + right: 0; + bottom: 0; + width: auto; + display: block; + pointer-events: none; + max-width: none; + } + + &__textBlock { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__title { + color: $colors-primary; + margin: 0; + + @include h4-text; + } + + &__count { + margin: 0; + + @include body-text; + + color: $colors-secondary; + } +} + +.phones { + .categoryCard__square { + background-color: #6D6474; + } + + .categoryCard__image { + height: 100%; + transform: translate(20%, 10%); + } +} + +.tablets { + .categoryCard__square { + background-color: #8D8D92; + } + + .categoryCard__image { + height: 140%; + transform: translate(40%, 30%); + } +} + +.accessories { + .categoryCard__square { + background-color: #973D5F; + } + + .categoryCard__image { + height: 85%; + transform: translate(50%, 0%); + } +} + +.categoryCard:hover { + transform: translateY(-2px) scale(1.01); +} + +.categoryCard:hover .categoryCard__square { + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2); +} diff --git a/src/components/categoryCard/CategoryCard.tsx b/src/components/categoryCard/CategoryCard.tsx new file mode 100644 index 00000000000..ecb84eaa149 --- /dev/null +++ b/src/components/categoryCard/CategoryCard.tsx @@ -0,0 +1,25 @@ +import { Link } from 'react-router-dom'; +import { CategoryConfig } from '../../features/types/category'; +import styles from './CategoryCard.module.scss'; +import classNames from 'classnames'; + +type Props = { + category: CategoryConfig; + count: number; +}; + +export const CategoryCard = ({ category, count }: Props) => { + const { key, title, link, image } = category; + + return ( + +
+ {title} +
+
+
{title}
+

{count} models

+
+ + ); +}; diff --git a/src/components/categoryCard/index.ts b/src/components/categoryCard/index.ts new file mode 100644 index 00000000000..a72d92435c8 --- /dev/null +++ b/src/components/categoryCard/index.ts @@ -0,0 +1 @@ +export * from './CategoryCard'; diff --git a/src/components/icons/Chevron/Chevron.module.scss b/src/components/icons/Chevron/Chevron.module.scss new file mode 100644 index 00000000000..110fc8cb6c5 --- /dev/null +++ b/src/components/icons/Chevron/Chevron.module.scss @@ -0,0 +1,28 @@ +.icon { + width: 6px; + height: 10px; + + path { + fill: none; + stroke: inherit; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + } +} + +.right { + transform: rotate(0deg); +} + +.left { + transform: rotate(180deg); +} + +.up { + transform: rotate(-90deg); +} + +.down { + transform: rotate(90deg); +} diff --git a/src/components/icons/Chevron/Chevron.tsx b/src/components/icons/Chevron/Chevron.tsx new file mode 100644 index 00000000000..d080d0b5197 --- /dev/null +++ b/src/components/icons/Chevron/Chevron.tsx @@ -0,0 +1,19 @@ +import styles from './Chevron.module.scss'; + +type Props = { + direction: 'left' | 'right' | 'up' | 'down'; + className?: string; +}; + +export const Chevron = ({ direction, className }: Props) => { + return ( + + ); +}; diff --git a/src/components/icons/Chevron/index.ts b/src/components/icons/Chevron/index.ts new file mode 100644 index 00000000000..fae5d54c933 --- /dev/null +++ b/src/components/icons/Chevron/index.ts @@ -0,0 +1 @@ +export * from './Chevron'; diff --git a/src/components/icons/IconWithBadge/IconWithBadge.module.scss b/src/components/icons/IconWithBadge/IconWithBadge.module.scss new file mode 100644 index 00000000000..45158f8176f --- /dev/null +++ b/src/components/icons/IconWithBadge/IconWithBadge.module.scss @@ -0,0 +1,35 @@ +@import '../../../styles/global'; + +.wrapper { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.3s ease; +} + +.icon { + display: block; +} + +.badge { + position: absolute; + top: -6px; + right: -6px; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: $colors-red; + border: 1px solid $colors-white; + + display: flex; + align-items: center; + justify-content: center; + + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 9px; + line-height: 1; + color: $colors-white; + z-index: 1; +} diff --git a/src/components/icons/IconWithBadge/IconWithBadge.tsx b/src/components/icons/IconWithBadge/IconWithBadge.tsx new file mode 100644 index 00000000000..a3aff1a396e --- /dev/null +++ b/src/components/icons/IconWithBadge/IconWithBadge.tsx @@ -0,0 +1,22 @@ +import styles from './IconWithBadge.module.scss'; +import React from 'react'; + +type Props = { + icon: string; + alt: string; + badgeCount?: number; +}; + +export const IconWithBadge: React.FC = ({ + icon, + alt, + badgeCount = 0, +}) => { + return ( +
+ {alt} + + {badgeCount > 0 && {badgeCount}} +
+ ); +}; diff --git a/src/components/icons/IconWithBadge/index.ts b/src/components/icons/IconWithBadge/index.ts new file mode 100644 index 00000000000..aa66ca0bf79 --- /dev/null +++ b/src/components/icons/IconWithBadge/index.ts @@ -0,0 +1 @@ +export * from './IconWithBadge'; diff --git a/src/features/constants/categories.ts b/src/features/constants/categories.ts new file mode 100644 index 00000000000..bca02ee7f23 --- /dev/null +++ b/src/features/constants/categories.ts @@ -0,0 +1,31 @@ +import { CategoryConfig } from '../types/category'; + +export const categories: CategoryConfig[] = [ + { + key: 'phones', + title: 'Mobile phones', + link: '/phones', + color: '#6D6474', + image: '/img/category-phones.webp', + height: '100%', + transform: 'translate(20%, 10%)', + }, + { + key: 'tablets', + title: 'Tablets', + link: '/tablets', + color: '#8D8D92', + image: '/img/category-tablets.png', + height: '140%', + transform: 'translate(40%, 30%)', + }, + { + key: 'accessories', + title: 'Accessories', + link: '/accessories', + color: '#D53C51', + image: '/img/category-accessories.png', + height: '60%', + transform: 'translate(40%, 30%)', + }, +]; diff --git a/src/features/constants/productColors.ts b/src/features/constants/productColors.ts new file mode 100644 index 00000000000..2f6b8ea0838 --- /dev/null +++ b/src/features/constants/productColors.ts @@ -0,0 +1,27 @@ +export const productColors: Record = { + black: '#1f2020', + white: '#f5f5f0', + yellow: '#f4d06f', + green: '#5f8f72', + purple: '#b8afe6', + red: '#c91c1c', + gold: '#f3d6a3', + silver: '#e3e5e8', + spacegray: '#4c4c4c', + rosegold: '#f6c7b5', + midnightgreen: '#4e5f58', + coral: '#ff7f6e', + midnight: '#171e27', + starlight: '#f5f1e6', + blue: '#4f7cae', + pink: '#f5b8c8', + graphite: '#4a4a4d', + sierrablue: '#9bb5ce', + skyblue: '#b7d7ee', +}; + +export const getProductColor = (color: string) => { + const normalizedColor = color.toLowerCase().replace(/[\s-]/g, ''); + + return productColors[normalizedColor] || color; +}; diff --git a/src/features/slices/cartSlice/cartSlice.ts b/src/features/slices/cartSlice/cartSlice.ts new file mode 100644 index 00000000000..8e3dcfc9854 --- /dev/null +++ b/src/features/slices/cartSlice/cartSlice.ts @@ -0,0 +1,83 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export type CartItem = { + itemId: string; + quantity: number; +}; + +export interface CartListState { + items: CartItem[]; +} + +const initialState: CartListState = { + items: JSON.parse(localStorage.getItem('cartList') || '[]'), +}; + +const cartListSlice = createSlice({ + name: 'cartList', + initialState, + reducers: { + addInCart: (state, action: PayloadAction) => { + const itemId = action.payload; + + const existingItem = state.items.find(item => item.itemId === itemId); + + if (!existingItem) { + state.items.push({ + itemId, + quantity: 1, + }); + } + }, + increaseQuantity: (state, action: PayloadAction) => { + const itemId = action.payload; + + const cartItem = state.items.find(item => item.itemId === itemId); + + if (cartItem) { + cartItem.quantity += 1; + } + }, + minusQuantity: (state, action: PayloadAction) => { + const itemId = action.payload; + + const cartItem = state.items.find(item => item.itemId === itemId); + + if (!cartItem) { + return; + } + + if (cartItem.quantity > 1) { + cartItem.quantity -= 1; + } else { + const index = state.items.findIndex(item => item.itemId === itemId); + + if (index !== -1) { + state.items.splice(index, 1); + } + } + }, + removeInCart: (state, action: PayloadAction) => { + const itemId = action.payload; + + const index = state.items.findIndex(item => item.itemId === itemId); + + if (index !== -1) { + state.items.splice(index, 1); + } + }, + clearCart: () => { + return { items: [] }; + }, + }, +}); + +export const { + addInCart, + increaseQuantity, + minusQuantity, + removeInCart, + clearCart, +} = cartListSlice.actions; + +export default cartListSlice.reducer; diff --git a/src/features/slices/favorites/favoritesSlice.ts b/src/features/slices/favorites/favoritesSlice.ts new file mode 100644 index 00000000000..d3eaa9d0c99 --- /dev/null +++ b/src/features/slices/favorites/favoritesSlice.ts @@ -0,0 +1,29 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface FavoritesState { + items: string[]; +} + +const initialState: FavoritesState = { + items: JSON.parse(localStorage.getItem('favorites') || '[]'), +}; + +const favoritesSlice = createSlice({ + name: 'favorites', + initialState, + reducers: { + toggleFavorite: (state, action: PayloadAction) => { + const itemId = action.payload; + const index = state.items.indexOf(itemId); + + if (index !== -1) { + state.items.splice(index, 1); + } else { + state.items.push(itemId); + } + }, + }, +}); + +export const { toggleFavorite } = favoritesSlice.actions; +export default favoritesSlice.reducer; diff --git a/src/features/types/category.ts b/src/features/types/category.ts new file mode 100644 index 00000000000..bd440ade0cb --- /dev/null +++ b/src/features/types/category.ts @@ -0,0 +1,11 @@ +export type CategoryKey = 'phones' | 'tablets' | 'accessories'; + +export type CategoryConfig = { + key: CategoryKey; + title: string; + link: string; + image: string; + color: string; + height: string; + transform: string; +}; diff --git a/src/features/types/phoneType.ts b/src/features/types/phoneType.ts new file mode 100644 index 00000000000..891ef68eeef --- /dev/null +++ b/src/features/types/phoneType.ts @@ -0,0 +1,10 @@ +export type PhoneType = { + id: string; + name: string; + priceRegular: number; + priceDiscount: number | null; + images: string[]; + screen: string; + capacity: string; + ram: string; +}; diff --git a/src/features/types/productDetailsType.ts b/src/features/types/productDetailsType.ts new file mode 100644 index 00000000000..08a31c091eb --- /dev/null +++ b/src/features/types/productDetailsType.ts @@ -0,0 +1,29 @@ +import { ProductCategory } from './productType'; + +export type ProductDetails = { + id: string; + category: ProductCategory; + namespaceId: string; + name: string; + + capacityAvailable: string[]; + capacity: string; + + priceRegular: number; + priceDiscount: number; + + colorsAvailable: string[]; + color: string; + + images: string[]; + + description: { title: string; text: string[] }[]; + + screen: string; + resolution: string; + processor: string; + ram: string; + camera: string; + zoom: string; + cell: string[]; +}; diff --git a/src/features/types/productType.ts b/src/features/types/productType.ts new file mode 100644 index 00000000000..d136c4ea7b1 --- /dev/null +++ b/src/features/types/productType.ts @@ -0,0 +1,16 @@ +export type ProductCategory = 'phones' | 'tablets' | 'accessories'; + +export type Product = { + id: number; + category: ProductCategory; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +}; diff --git a/src/features/utils/client.ts b/src/features/utils/client.ts new file mode 100644 index 00000000000..51530d0685a --- /dev/null +++ b/src/features/utils/client.ts @@ -0,0 +1,9 @@ +export async function getData(url: string): Promise { + const res = await fetch(url); + + if (!res.ok) { + throw new Error(`Request failed: ${res.status} ${res.statusText}`); + } + + return res.json() as Promise; +} diff --git a/src/features/utils/selectors.ts b/src/features/utils/selectors.ts new file mode 100644 index 00000000000..21f8e702af4 --- /dev/null +++ b/src/features/utils/selectors.ts @@ -0,0 +1,4 @@ +import { RootState } from '../../app/store'; + +export const cartItemsCount = (state: RootState) => + state.cartList.items.reduce((acc, item) => acc + item.quantity, 0); diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..e0d2056a2cb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,14 @@ import { createRoot } from 'react-dom/client'; import { App } from './App'; +import { BrowserRouter } from 'react-router-dom'; +import './styles/global.scss'; +import { Provider } from 'react-redux'; +import { store } from './app/store'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + + + , +); diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..7e6bef5b428 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,150 @@ +@import '../../styles/global'; + +.cart { + @include pageGrid; + + &__header { + margin-top: 40px; + display: flex; + flex-direction: column; + gap: 16px; + grid-column: 1 / -1; + margin-bottom: 32px; + } + + &__back { + display: inline-flex; + width: fit-content; + flex-direction: row; + gap: 9px; + align-items: baseline; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + } + + &__backIcon { + stroke: $colors-primary; + } + + &__title { + margin: 0; + color: $colors-primary; + + @include h1-text; + } + + &__products { + display: flex; + flex-direction: column; + gap: 16px; + + grid-column: 1 / -1; + + @include onDesktop { + grid-column: 1 / 17; + + } + } + + &__summary { + display: flex; + flex-direction: column; + align-items: center; + + grid-column: 1 / -1; + padding: 24px; + border: 1px solid $colors-elements; + align-self: start; + box-sizing: border-box; + margin-top: 32px; + + @include onDesktop { + grid-column: 17 / -1; + margin-top: 0; + } + } + + &__summaryPrice { + color: $colors-primary; + margin: 0; + + @include h2-text; + } + + &__summaryItem { + color: $colors-secondary; + + @include body-text; + } + + &__summaryLine { + width: 100%; + height: 1px; + background-color: $colors-elements; + margin-top: 25px; + margin-bottom: 25px; + } + + &__checkoutButton { + padding: 0; + height: 40px; + background-color: $colors-primary; + border: none; + color: $colors-white; + transition: box-shadow 0.3s ease, background-color 0.3s ease, color 0.3s ease; + + @include button-text; + + width: 100%; + + &:hover { + box-shadow: 0 3px 13px rgba(0, 0, 0, 0.4); + cursor: pointer; + } + + &:disabled { + background: $colors-white; + border: 1px solid $colors-elements; + color: $colors-green; + cursor: default; + box-shadow: none; + transform: none; + } + } +} + +.cartEmpty { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + justify-content: center; + margin-top: 150px; + + &__img { + width: 200px; + height: auto; + } + + &__title { + color: $colors-primary; + margin: 0; + + @include h2-text; + } + + &__link { + color: $colors-secondary; + margin: 0; + + @include body-text; + + transition: color 0.3s ease; + + &:hover { + color: $colors-primary; + } + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..16cddd76f80 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Product } from '../../features/types/productType'; +import { getProducts } from '../../api/products'; +import { useDispatch, useSelector } from 'react-redux'; +import { AppDispatch, RootState } from '../../app/store'; +import { Loader } from '../../components/Loader'; +import styles from './CartPage.module.scss'; +import { + clearCart, + increaseQuantity, + minusQuantity, + removeInCart, +} from '../../features/slices/cartSlice/cartSlice'; +import { NavLink, useNavigate } from 'react-router-dom'; +import { Chevron } from '../../components/icons/Chevron'; +import { CartItemCard } from '../../components/CartItemCard'; +import { CheckoutModal } from '../../components/CheckoutModal'; +import { cartItemsCount } from '../../features/utils/selectors'; + +export const CartPage = () => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [, setError] = useState(false); + const [isCheckoutModalOpen, setIsCheckoutModalOpen] = useState(false); + + const navigate = useNavigate(); + const dispatch = useDispatch(); + const cartListItems = useSelector((state: RootState) => state.cartList.items); + + const cartItemsMap = useMemo(() => { + return Object.fromEntries( + cartListItems.map(item => [item.itemId, item.quantity]), + ) as Record; + }, [cartListItems]); + + const cartProducts = useMemo(() => { + return products.filter(product => cartItemsMap[product.itemId]); + }, [products, cartItemsMap]); + + const totalSum = useMemo(() => { + return cartProducts.reduce((acc, product) => { + return acc + product.price * cartItemsMap[product.itemId]; + }, 0); + }, [cartProducts, cartItemsMap]); + + const cartItems = useSelector(cartItemsCount); + + useEffect(() => { + setLoading(true); + + getProducts() + .then(setProducts) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ; + } + + if (!cartItems) { + return ( +
+ Empty cart illustration +

Your cart is empty

+ + Back to Home + +
+ ); + } + + return ( +
+
+ +

Cart

+
+ +
+ {cartProducts.map(product => ( + dispatch(removeInCart(product.itemId))} + onDecrease={() => dispatch(minusQuantity(product.itemId))} + onIncrease={() => dispatch(increaseQuantity(product.itemId))} + /> + ))} +
+ +
+

${totalSum}

+

Total for {cartItems} items

+
+ +
+ {isCheckoutModalOpen && ( + setIsCheckoutModalOpen(false)} + onConfirm={() => { + dispatch(clearCart()); + setIsCheckoutModalOpen(false); + }} + /> + )} +
+ ); +}; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 00000000000..90c010237a0 --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export * from './CartPage'; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..955596497c0 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,33 @@ +@import '../../styles/global'; + +.favoritesPage { + margin-top: 24px; + + @include pageGrid; + + &__wrapper { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 40px; + } + + &__textBlock { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__title { + margin: 0; + color: $colors-primary; + + @include h1-text; + } + + &__count { + @include body-text; + + color: $colors-secondary; + } +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..40ff93d001d --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../app/store'; +import { Product } from '../../features/types/productType'; +import { getProducts } from '../../api/products'; +import styles from './FavoritesPage.module.scss'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { Loader } from '../../components/Loader'; +import { ProductList } from '../../components/ProductsList'; + +export const FavoritesPage = () => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const favoriteItems = useSelector( + (state: RootState) => state.favorites.items, + ); + + useEffect(() => { + setLoading(true); + + getProducts() + .then(setProducts) + .catch(() => setError('Failed to load products')) + .finally(() => setLoading(false)); + }, []); + + const favoriteProducts = products.filter(product => + favoriteItems.includes(product.itemId), + ); + + if (loading) { + return ; + } + + if (error) { + return

{error}

; + } + + return ( +
+
+ +
+

Favorites

+

+ {favoriteItems.length} items +

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

Nothing here yet

+ ) : ( + + )} +
+
+ ); +}; diff --git a/src/modules/FavoritesPage/index.ts b/src/modules/FavoritesPage/index.ts new file mode 100644 index 00000000000..b3a884b1889 --- /dev/null +++ b/src/modules/FavoritesPage/index.ts @@ -0,0 +1 @@ +export * from './FavoritesPage'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..cc6f8fc85d0 --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,67 @@ +@import '../../styles/global'; + +.home { + margin-top: 56px; + + @include pageGrid; + + &__titleHiden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + margin: 0; + } + + &__title { + grid-column: 1 / -1; + color: $colors-primary; + + @include h1-text; + } + + &__content { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 56px; + + @include onTablet { + gap: 64px; + } + + @include onDesktop { + gap: 80px; + } + } + + &__banner { + @include pageGrid; + } + + &__categories { + display: flex; + flex-direction: column; + gap: 24px; + } + + &__categoriesTitle { + margin: 0; + color: $colors-primary; + + @include h2-text; + } + + &__categoriesList { + display: flex; + flex-direction: column; + gap: 16px; + + @include onTablet { + flex-direction: row; + align-items: stretch; + } + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..b49587273b6 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,108 @@ +import styles from './HomePage.module.scss'; +import { BannerSlider } from '../../components/BannerSlider'; +import { useEffect, useMemo, useState } from 'react'; +import { Product } from '../../features/types/productType'; +import { getProducts } from '../../api/products'; +import { Loader } from '../../components/Loader'; +import { ProductsCarousel } from '../../components/ProductsCarousel'; +import { CategoryCard } from '../../components/categoryCard'; +import { categories } from '../../features/constants/categories'; + +export const HomePage = () => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const loadProducts = () => { + setLoading(true); + setError(false); + + getProducts() + .then(setProducts) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + loadProducts(); + }, []); + + const categoryCounts = useMemo(() => { + return products.reduce( + (acc, p) => ({ + ...acc, + [p.category]: acc[p.category] + 1, + }), + { phones: 0, tablets: 0, accessories: 0 }, + ); + }, [products]); + + const newestProducts = useMemo(() => { + return [...products].sort((a, b) => b.year - a.year).slice(0, 30); + }, [products]); + + const bestDeals = useMemo(() => { + return [...products] + .sort((a, b) => { + const discountA = a.fullPrice - a.price; + const discountB = b.fullPrice - b.price; + + return discountB - discountA; + }) + .slice(0, 30); + }, [products]); + + return ( +
+

Product Catalog

+

Welcome to Nice Gadgets store!

+
+
+ +
+ {loading && } + + {error && !loading && ( +
+

Something went wrong

+ +
+ )} + + {!loading && !error && ( + <> +
+ +
+ +
+

Shop by category

+
+ {categories.map(category => ( + + ))} +
+
+ +
+ +
+ + )} +
+
+ ); +}; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 00000000000..11e53da674c --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export * from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000000..c398da655b8 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,35 @@ +@import '../../styles/global'; + +.page { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + justify-content: center; + margin-top: 150px; + + &__img { + width: 250px; + height: auto; + } + + &__title { + color: $colors-primary; + margin: 0; + + @include h2-text; + } + + &__link { + color: $colors-secondary; + margin: 0; + + @include body-text; + + transition: color 0.3s ease; + + &:hover { + color: $colors-primary; + } + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..1bcb1d97e25 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,18 @@ +import styles from './NotFoundPage.module.scss'; +import { NavLink } from 'react-router-dom'; + +export const NotFoundPage = () => { + return ( +
+ Empty cart illustration +

Page not found

+ + Back to Home page + +
+ ); +}; diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 00000000000..6197aa75aa8 --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export * from './NotFoundPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..9f7d7ea576f --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,292 @@ +@import '../../styles/global'; + +.productDetails { + margin-top: 24px; + row-gap: 40px; + + @include pageGrid; + + &__wrapper { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 56px; + + @include onTablet { + gap: 64px; + } + + @include onDesktop { + gap: 80px; + } + } + + &__header { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 40px; + } + + &__headerText { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__back { + display: inline-flex; + align-items: center; + gap: 9px; + width: fit-content; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + } + + &__backIcon { + display: inline-flex; + stroke: $colors-primary; + } + + &__backText { + margin: 0; + + @include body-text; + + color: $colors-primary; + } + + &__title { + margin: 0; + color: $colors-primary; + + @include h1-text; + } + + &__hero { + row-gap: 16px; + + @include pageGrid; + } + + &__gallery { + min-width: 0; + grid-column: 1 / -1; + + + @include onTablet { + grid-column: 1 / 7; + } + + @include onDesktop { + grid-column: 1 / 12; + } + } + + &__sidebar { + min-width: 0; + grid-column:1 / -1; + + @include onTablet { + grid-column: 8 / -1; + } + + @include onDesktop { + grid-column: 14 / 20; + } + } + + &__options { + display: flex; + flex-direction: column; + gap: 32px; + } + + &__summary { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__priceBlock { + display: flex; + align-items: center; + gap: 8px; + } + + &__priceDiscount { + color: $colors-primary; + + @include h3-text; + } + + &__priceRegular { + color: $colors-secondary; + font-weight: 600; + font-size: 22px; + line-height: 1.4; + text-decoration: line-through; + text-decoration-color: $colors-secondary; + text-decoration-thickness: 2px; + } + + &__shortSpecs { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__shortSpec { + display: flex; + justify-content: space-between; + gap: 16px; + } + + &__shortSpecLabel { + margin: 0; + color: $colors-secondary; + + @include body-text; + } + + &__shortSpecValue { + margin: 0; + color: $colors-primary; + text-align: right; + + @include body-text; + } + + &__content { + display: grid; + grid-template-columns: 1fr; + gap: 40px; + + @include onDesktop { + grid-template-columns: minmax(0, 1fr) minmax(320px, 1fr); + gap: 64px; + } + } + + &__about, + &__tech { + display: flex; + flex-direction: column; + gap: 24px; + } + + &__divider { + width: 100%; + height: 1px; + background-color: $colors-elements; + } + + &__sectionTitle { + margin: 0; + color: $colors-primary; + + @include h2-text; + } + + &__descriptionBlock { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__descriptionTitle { + margin: 0; + color: $colors-primary; + + @include h3-text; + } + + &__descriptionText { + margin: 0; + color: $colors-secondary; + + @include body-text; + } + + &__techList { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__techRow { + display: flex; + justify-content: space-between; + gap: 16px; + } + + &__techLabel { + margin: 0; + color: $colors-secondary; + + @include body-text; + } + + &__techValue { + margin: 0; + color: $colors-primary; + text-align: right; + + @include body-text; + } + + &__recommended { + display: flex; + flex-direction: column; + } + + &__state { + display: flex; + justify-content: center; + align-items: center; + min-height: 240px; + } + + &__stateText { + margin: 0; + color: $colors-primary; + + @include h3-text; + } +} + +.notFound { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + justify-content: center; + margin-top: 150px; + + &__img { + width: 200px; + height: auto; + } + + &__title { + color: $colors-primary; + margin: 0; + + @include h2-text; + } + + &__link { + color: $colors-secondary; + margin: 0; + + @include body-text; + + transition: color 0.3s ease; + + &:hover { + color: $colors-primary; + } + } +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..821688021e0 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,295 @@ +import { NavLink, useNavigate, useParams } from 'react-router-dom'; +import { Product, ProductCategory } from '../../features/types/productType'; +import { useEffect, useMemo, useState } from 'react'; +import { ProductDetails } from '../../features/types/productDetailsType'; +import { getProductDetails, getProducts } from '../../api/products'; +import { Loader } from '../../components/Loader'; +import styles from './ProductDetailsPage.module.scss'; +import { ProductGallery } from '../../components/ProductGallery'; +import { ProductsCarousel } from '../../components/ProductsCarousel'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { Chevron } from '../../components/icons/Chevron'; +import { ProductActions } from '../../components/ProductActions'; +import { ProductOptions } from '../../components/ProductOptions'; + +const isProductCategory = (value: string): value is ProductCategory => + value === 'phones' || value === 'tablets' || value === 'accessories'; + +const normalizeColorForUrl = (color: string) => + color.toLowerCase().replace(/\s+/g, '-'); + +export const ProductDetailsPage = () => { + const { category, productId } = useParams<{ + category: string; + productId: string; + }>(); + + const [products, setProducts] = useState([]); + const [product, setProduct] = useState(null); + + const [detailsLoading, setDetailsLoading] = useState(true); + const [recommendedLoading, setRecommendedLoading] = useState(true); + + const [detailsError, setDetailsError] = useState(''); + const [recommendedError, setRecommendedError] = useState(''); + + const [mainImage, setMainImage] = useState(''); + const [isFirstRender, setIsFirstRender] = useState(true); + + const navigate = useNavigate(); + + useEffect(() => { + if (!category || !productId) { + setDetailsLoading(false); + + return; + } + + if (!isProductCategory(category)) { + setDetailsError('Unknown category'); + setDetailsLoading(false); + + return; + } + + setDetailsLoading(true); + setDetailsError(''); + + getProductDetails(category, productId) + .then(data => { + setProduct(data); + setMainImage(data.images[0] ? `/${data.images[0]}` : ''); + }) + .catch(error => { + if (error instanceof Error && error.message === 'Product not found') { + setProduct(null); + + return; + } + + setDetailsError('Something went wrong'); + }) + .finally(() => setDetailsLoading(false)); + }, [category, productId]); + + useEffect(() => { + setRecommendedLoading(true); + setRecommendedError(''); + + getProducts() + .then(setProducts) + .catch(() => setRecommendedError('Failed to load recommended products')) + .finally(() => setRecommendedLoading(false)); + }, []); + + useEffect(() => { + if (isFirstRender) { + setIsFirstRender(false); + + return; + } + + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }, [productId, isFirstRender]); + + const recommended = useMemo(() => { + if (!category || !product) { + return []; + } + + return products.filter( + item => + item.category === category && + item.itemId !== product.id && + item.name !== product.name, + ); + }, [products, category, product]); + + if (detailsLoading && !product) { + return ; + } + + if (detailsError) { + return ( +
+
+
+

{detailsError}

+
+
+
+ ); + } + + if (!product || !category) { + return ( +
+ Product not found +

Product not found

+ + Back to Home + +
+ ); + } + + const specs = [ + { label: 'Screen', value: product.screen, isShort: true }, + { label: 'Resolution', value: product.resolution, isShort: true }, + { label: 'Processor', value: product.processor, isShort: true }, + { label: 'RAM', value: product.ram, isShort: true }, + { label: 'Camera', value: product.camera, isShort: false }, + { label: 'Zoom', value: product.zoom, isShort: false }, + { label: 'Cell', value: product.cell.join(', '), isShort: false }, + ]; + + const shortSpecs = specs.filter(spec => spec.isShort); + + const buildVariantLink = ({ + color = product.color, + capacity = product.capacity, + }: { + color?: string; + capacity?: string; + }) => { + return `/${category}/${product.namespaceId}-${capacity.toLowerCase()}-${normalizeColorForUrl(color)}`; + }; + + return ( +
+
+ + +
+ + +

{product.name}

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

About

+
+ + {product.description.map(section => ( +
+

+ {section.title} +

+

+ {section.text} +

+
+ ))} +
+ +
+

Tech specs

+
+ +
+ {specs.map(spec => ( +
+

+ {spec.label} +

+

+ {spec.value} +

+
+ ))} +
+
+
+ + {!recommendedLoading && !recommendedError && recommended.length > 0 && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 00000000000..6615089e5ec --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductDetailsPage'; diff --git a/src/modules/ProductsPage/ProductsPage.module.scss b/src/modules/ProductsPage/ProductsPage.module.scss new file mode 100644 index 00000000000..68817129abd --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.module.scss @@ -0,0 +1,88 @@ +@import '../../styles/global'; + +.productPage { + margin-top: 24px; + min-height: 100vh; + + @include pageGrid; + + &__wrapper { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 40px; + flex: 1; + } + + &__textBlock { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__title { + margin: 0; + + @include h1-text; + } + + &__count { + margin: 0; + + @include body-text; + + color: $colors-secondary; + } + + &__main { + display: flex; + flex-direction: column; + gap: 24px; + flex: 1; + } + + &__controls { + @include innerGrid; + } + + &__controlsSort { + grid-column: span 2; + + + @include onTablet { + grid-column: span 4; + } + } + + &__controlsPerPage { + grid-column: span 2; + + @include onTablet { + grid-column: span 3; + } + } + + &__pagination { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 16px; + } + + &__paginationButtons { + display: flex; + flex-direction: row; + gap: 8px; + min-width: 0; + } + + &__dots { + width: 20px; + height: 32px; + display: flex; + align-items: flex-end; + justify-content: center; + font-size: 12px; + } +} diff --git a/src/modules/ProductsPage/ProductsPage.tsx b/src/modules/ProductsPage/ProductsPage.tsx new file mode 100644 index 00000000000..b1c0428363a --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.tsx @@ -0,0 +1,276 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Product, ProductCategory } from '../../features/types/productType'; +import { getProducts } from '../../api/products'; +import styles from './ProductsPage.module.scss'; +import { useSearchParams } from 'react-router-dom'; +import { Chevron } from '../../components/icons/Chevron'; +import { Dropdown } from '../../components/DropDown'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { SecondaryButton } from '../../components/SecondaryButton'; +import { Loader } from '../../components/Loader'; +import { ProductList } from '../../components/ProductsList'; + +type Props = { + category: ProductCategory; +}; + +type Sort = 'age' | 'title' | 'price'; + +type PerPage = '4' | '8' | '16' | 'all'; + +const DEFAULT_PER_PAGE: PerPage = 'all'; + +const categoryTitles: Record = { + phones: 'Mobile Phones', + tablets: 'Tablets', + accessories: 'Accessories', +}; + +const emptyMessages: Record = { + phones: 'There are no phones yet', + tablets: 'There are no tablets yet', + accessories: 'There are no accessories yet', +}; + +const SORT_OPTIONS = [ + { value: 'age', label: 'Newest' }, + { value: 'title', label: 'Alphabetically' }, + { value: 'price', label: 'Cheapest' }, +] as const; + +const PER_PAGE_OPTIONS = [ + { value: '4', label: '4' }, + { value: '8', label: '8' }, + { value: '16', label: '16' }, + { value: 'all', label: 'all' }, +] as const; + +export const ProductsPage: React.FC = ({ category }) => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [searchParams, setSearchParams] = useSearchParams(); + + const perPage = (searchParams.get('perPage') as PerPage) || DEFAULT_PER_PAGE; + const page = Number(searchParams.get('page') || '1'); + + const handlePerPageChange = (value: PerPage) => { + const next = new URLSearchParams(searchParams); + + if (value === 'all') { + next.delete('perPage'); + } else { + next.set('perPage', value); + } + + next.delete('page'); + + setSearchParams(next); + }; + + const sort = (searchParams.get('sort') as Sort) || 'age'; + + const handleSortChange = (value: Sort) => { + const next = new URLSearchParams(searchParams); + + next.set('sort', value); + next.delete('page'); + + setSearchParams(next); + }; + + const loadProducts = () => { + setLoading(true); + setError(''); + + getProducts() + .then(data => setProducts(data)) + .catch(() => setError('Something went wrong')) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + loadProducts(); + }, []); + + const categoryProducts = useMemo(() => { + return [...products].filter(p => p.category === category); + }, [products, category]); + + const title = categoryTitles[category]; + + const sortedProducts = useMemo(() => { + const list = [...categoryProducts]; + + switch (sort) { + case 'title': + return list.sort((a, b) => a.name.localeCompare(b.name)); + case 'price': + return list.sort((a, b) => a.price - b.price); + case 'age': + default: + return list.sort((a, b) => b.year - a.year); + } + }, [categoryProducts, sort]); + + const totalItems = sortedProducts.length; + + const perPageNumber = perPage === 'all' ? totalItems : Number(perPage); + const totalPages = + perPage === 'all' ? 1 : Math.max(1, Math.ceil(totalItems / perPageNumber)); + + const safePage = Math.min(Math.max(page, 1), totalPages); + + const visibleProducts = useMemo(() => { + if (perPage === 'all') { + return sortedProducts; + } + + const start = (safePage - 1) * perPageNumber; + + return sortedProducts.slice(start, start + perPageNumber); + }, [sortedProducts, perPage, perPageNumber, safePage]); + + const setPage = (nextPage: number) => { + const next = new URLSearchParams(searchParams); + + if (nextPage === 1) { + next.delete('page'); + } else { + next.set('page', String(nextPage)); + } + + setSearchParams(next); + }; + + const visiblePages = useMemo<(number | string)[]>(() => { + if (totalPages <= 5) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + if (safePage <= 2) { + return [1, 2, 3, '...', totalPages]; + } + + if (safePage >= totalPages - 1) { + return [1, '...', totalPages - 2, totalPages - 1, totalPages]; + } + + return [1, '...', safePage - 1, safePage, safePage + 1, '...', totalPages]; + }, [safePage, totalPages]); + + const showPagination = perPage !== 'all' && totalPages > 1; + + const count = categoryProducts.length; + + if (loading) { + return ; + } + + if (error) { + return ( +
+

Something went wrong

+ +
+ ); + } + + if (!categoryProducts.length) { + return ( +
+
+ +
+

{title}

+

0 models

+
+ +

+ {emptyMessages[category]} +

+
+
+ ); + } + + return ( +
+
+ +
+

{title}

+

{count} models

+
+ +
+
+
+ +
+
+ +
+
+ + +
+ + {showPagination && ( +
+ setPage(safePage - 1)} + disabled={safePage === 1} + > + + + +
+ {visiblePages.map((item, index) => + typeof item === 'string' ? ( + + ... + + ) : ( + setPage(item)} + isActive={item === safePage} + > + {item} + + ), + )} +
+ + setPage(safePage + 1)} + disabled={safePage === totalPages} + > + + +
+ )} +
+
+ ); +}; diff --git a/src/modules/ProductsPage/index.ts b/src/modules/ProductsPage/index.ts new file mode 100644 index 00000000000..8e350f20bf9 --- /dev/null +++ b/src/modules/ProductsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductsPage'; diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss new file mode 100644 index 00000000000..90e5c7b3481 --- /dev/null +++ b/src/styles/fonts.scss @@ -0,0 +1,31 @@ +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Regular.otf') format('opentype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Bold.otf') format('opentype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Bold.otf') format('opentype'); + font-weight: 800; + font-style: normal; + font-display: swap; +} diff --git a/src/styles/global.scss b/src/styles/global.scss new file mode 100644 index 00000000000..9b2c2044f6f --- /dev/null +++ b/src/styles/global.scss @@ -0,0 +1,13 @@ +@import './fonts'; +@import './utils/vars'; +@import './utils/mixins'; +@import './utils/typography'; + +html, body { + height: 100%; +} + +body { + font-family: Mont, sans-serif; + margin: 0; +} diff --git a/src/styles/utils/_mixins.scss b/src/styles/utils/_mixins.scss new file mode 100644 index 00000000000..5944c1ebe6f --- /dev/null +++ b/src/styles/utils/_mixins.scss @@ -0,0 +1,41 @@ +@mixin pageGrid { + display: grid; + column-gap: 16px; + grid-template-columns: repeat(4, 1fr); + + @include onTablet { + grid-template-columns: repeat(12, 1fr); + } + + @include onDesktop { + grid-template-columns: repeat(24, 1fr); + max-width: 1200px; + margin: 0 auto; + } +} + +@mixin innerGrid { + display: grid; + column-gap: 16px; + grid-template-columns: repeat(4, 1fr); + + @include onTablet { + grid-template-columns: repeat(12, 1fr); + } + + @include onDesktop { + grid-template-columns: repeat(24, 1fr); + } +} + +@mixin onTablet { + @media (min-width: $tablet-min-width) { + @content; + } +} + +@mixin onDesktop { + @media (min-width: $desktop-min-width) { + @content; + } +} diff --git a/src/styles/utils/typography.scss b/src/styles/utils/typography.scss new file mode 100644 index 00000000000..09e6f6852aa --- /dev/null +++ b/src/styles/utils/typography.scss @@ -0,0 +1,88 @@ +@import './mixins'; + +@mixin h1-text { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + + @include onTablet { + font-size: 48px; + line-height: 56px; + } +} + +@mixin h2-text { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 22px; + line-height: 31px; + letter-spacing: 0; + + @include onTablet { + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + } +} + +@mixin h3-text { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 20px; + line-height: 26px; + letter-spacing: 0; + + @include onTablet { + font-weight: 800; + font-size: 22px; + line-height: 31px; + } +} + +@mixin h4-text { + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 16px; + line-height: 20px; + letter-spacing: 0; + + @include onTablet { + font-size: 20px; + line-height: 26px; + } +} + +@mixin uppercase-text { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 12px; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +@mixin button-text { + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0; +} + +@mixin body-text { + font-family: Mont, sans-serif; + font-weight: 500; + font-size: 14px; + line-height: 21px; + letter-spacing: 0; +} + +@mixin small-text { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 12px; + line-height: 15px; + letter-spacing: 0; +} diff --git a/src/styles/utils/vars.scss b/src/styles/utils/vars.scss new file mode 100644 index 00000000000..c59722c973b --- /dev/null +++ b/src/styles/utils/vars.scss @@ -0,0 +1,22 @@ +@import './mixins'; + +$tablet-min-width: 640px; +$desktop-min-width: 1199px; +$colors-primary: #313237; +$colors-secondary: #89939A; +$colors-icons: #B4BDC3; +$colors-elements: #E2E6E9; +$colors-hover-bg: #FAFBFC; +$colors-white: #fff; +$colors-green: #27AE60; +$colors-red: #EB5757; + +:root { + --header-height: 48px; +} + +@include onDesktop { + :root { + --header-height: 64px; + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000000..1323cdac34c --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +}