diff --git a/README.md b/README.md index 3e1213ef5f7..df23d6a9056 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # React Product Catalog +- [DEMO] (https://andriyts1234.github.io/react_phone-catalog/) Implement the catalog with a shopping cart and favorites page according to one of the next designs: @@ -141,3 +142,4 @@ Show `input:search` in the header when a page contains a `ProductList` to search 1. Save the `Search` value in the URL as a `?query=value` to apply on page load. 2. Show `There are no phones/tablets/accessories/products matching the query` instead of `ProductList` when needed. 3. Add `debounce` to the search field. + diff --git a/index.html b/index.html index 095fb3a4537..32f4b0b90af 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,9 @@ - Vite + React + TS + Nice Gadgets + +
diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..5d2ace399ad 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-router-dom": "^6.25.1", + "react-redux": "^9.2.0", + "react-router-dom": "^6.30.3", "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,10 +1878,37 @@ "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", - "integrity": "sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -2126,6 +2156,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 +2258,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 +2300,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", @@ -3265,7 +3313,8 @@ "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" }, "node_modules/clean-stack": { "version": "2.2.0", @@ -5936,6 +5985,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "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 +8793,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", @@ -8744,11 +8826,12 @@ } }, "node_modules/react-router": { - "version": "6.25.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.25.1.tgz", - "integrity": "sha512-u8ELFr5Z6g02nUtpPAggP73Jigj1mRePSwhS/2nkTrlPU5yEkH1vYzWNyvSnSzeeE2DNqWdH+P8OhIh9wuXhTw==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -8758,12 +8841,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.25.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.25.1.tgz", - "integrity": "sha512-0tUDpbFvk35iv+N89dWNrJp+afLgd+y4VtorJZuOCXK0kkCWjEvb3vTJM++SYvMEpbVwXKf3FjeVveVEb6JpDQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0", - "react-router": "6.25.1" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -8893,6 +8977,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 +9075,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 +10543,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..72a4c65c120 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,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-router-dom": "^6.25.1", + "react-redux": "^9.2.0", + "react-router-dom": "^6.30.3", "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", diff --git a/public/img/arrow-left.png b/public/img/arrow-left.png new file mode 100644 index 00000000000..28affdda9cd Binary files /dev/null and b/public/img/arrow-left.png differ diff --git a/public/img/arrow-right.png b/public/img/arrow-right.png new file mode 100644 index 00000000000..175e0fb3a29 Binary files /dev/null and b/public/img/arrow-right.png differ diff --git a/public/img/arrow-up.png b/public/img/arrow-up.png new file mode 100644 index 00000000000..6ed660ad6e8 Binary files /dev/null and b/public/img/arrow-up.png differ diff --git a/public/img/bag.png b/public/img/bag.png new file mode 100644 index 00000000000..96a2ea26acf Binary files /dev/null and b/public/img/bag.png differ diff --git a/public/img/close.png b/public/img/close.png new file mode 100644 index 00000000000..0a30e214f99 Binary files /dev/null and b/public/img/close.png differ diff --git a/public/img/heart-filled.png b/public/img/heart-filled.png new file mode 100644 index 00000000000..c7bab7a5534 Binary files /dev/null and b/public/img/heart-filled.png differ diff --git a/public/img/heart.png b/public/img/heart.png new file mode 100644 index 00000000000..f318e7fb17a Binary files /dev/null and b/public/img/heart.png differ diff --git a/public/img/home.png b/public/img/home.png new file mode 100644 index 00000000000..56d817af16f Binary files /dev/null and b/public/img/home.png differ diff --git a/public/img/logo.svg b/public/img/logo.svg new file mode 100644 index 00000000000..7ad0b0efc03 --- /dev/null +++ b/public/img/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/menu.png b/public/img/menu.png new file mode 100644 index 00000000000..0f19532f0da Binary files /dev/null and b/public/img/menu.png differ diff --git a/public/img/minus.png b/public/img/minus.png new file mode 100644 index 00000000000..0af8ec766e9 Binary files /dev/null and b/public/img/minus.png differ diff --git a/public/img/plus.png b/public/img/plus.png new file mode 100644 index 00000000000..2fbd287c4ed Binary files /dev/null and b/public/img/plus.png differ diff --git a/src/App.scss b/src/App.scss index 71bc413aade..d72916b542d 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,55 @@ -// not empty +@import './styles/variables'; + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Regular.otf') format('opentype'); + font-weight: 400; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: 600; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Bold.otf') format('opentype'); + font-weight: 700; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: $c-bg-dark; + color: $c-text-primary; + font-family: $font-family; + -webkit-font-smoothing: antialiased; +} + +.App { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.main-content { + flex: 1; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + border: 0; + padding: 0; + white-space: nowrap; + clip-path: inset(100%); + clip: rect(0 0 0 0); + overflow: hidden; +} diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..2d037989157 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,52 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { HomePage } from './modules/HomePage'; +import { CatalogPage } from './modules/CatalogPage'; +import { ProductDetailsPage } from './modules/ProductDetailsPage'; +import { CartPage } from './modules/CartPage'; +import { FavoritesPage } from './modules/FavoritesPage'; +import { NotFoundPage } from './modules/NotFoundPage/NotFoundPage'; import './App.scss'; +import { Header } from './components/Header/Header'; +import { Footer } from './components/Footer/Footer'; +import { ContactsPage } from './modules/ContactsPage/ContactsPage'; +import { RightsPage } from './modules/RightsPage/RightsPage'; -export const App = () => ( -
-

Product Catalog

-
-); +export const App = () => { + return ( +
+
+ +
+ + } /> + } /> + + + } /> + } /> + + + + } /> + } /> + + + + } /> + } /> + + + } /> + } /> + + } /> + } /> + + } /> + +
+ +
+ ); +}; diff --git a/src/api/fetchClient.ts b/src/api/fetchClient.ts new file mode 100644 index 00000000000..df84b838350 --- /dev/null +++ b/src/api/fetchClient.ts @@ -0,0 +1,15 @@ +// src/api/fetchClient.ts +const BASE_URL = './api'; + +export const client = { + // Дженерик означає, що функція поверне дані того типу, який ми попросимо + async get(url: string): Promise { + const response = await fetch(`${BASE_URL}${url}`); + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + + return response.json(); + }, +}; diff --git a/src/api/getImageUrl.ts b/src/api/getImageUrl.ts new file mode 100644 index 00000000000..313f2850c38 --- /dev/null +++ b/src/api/getImageUrl.ts @@ -0,0 +1,10 @@ +export const getImageUrl = (path: string) => { + if (!path) { + return ''; + } + + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + + // Встав сюди назву свого репозиторію + return `/react_phone-catalog/${cleanPath}`; +}; diff --git a/src/api/products.ts b/src/api/products.ts new file mode 100644 index 00000000000..a5737f13e32 --- /dev/null +++ b/src/api/products.ts @@ -0,0 +1,69 @@ +// src/api/products.ts +import { client } from './fetchClient'; +import { Product, ProductDetails } from '../types/Product'; + +// Отримати всі продукти (з products.json) +export const getProducts = () => { + return client.get('/products.json'); +}; + +// ДОДАНО: Отримати продукти за конкретною категорією +export const getProductsByCategory = (category: string) => { + // category буде 'phones', 'tablets' або 'accessories' + // Тому ми динамічно формуємо шлях: /phones.json + return client.get(`/${category}.json`); +}; + +// Пізніше ми додамо сюди функції: +// - getProductsByCategory (читатиме phones.json, tablets.json) +// - getProductById (для сторінки деталей) +// - getSuggestedProducts (для рандомних товарів) + +export const getProductDetails = async (productId: string) => { + // Завантажуємо всі файли категорій паралельно + const [phones, tablets, accessories] = await Promise.all([ + client.get('/phones.json').catch(() => []), + client.get('/tablets.json').catch(() => []), + client.get('/accessories.json').catch(() => []), + ]); + + // Об'єднуємо їх в один великий масив + const allProducts = [...phones, ...tablets, ...accessories]; + + // Шукаємо товар за його ID + const product = allProducts.find( + p => p.id === productId || p.itemId === productId, + ); + + if (!product) { + throw new Error('Product not found'); + } + + return product; +}; + +// Функція для рекомендованих товарів (просто беремо всі і мішаємо) +export const getSuggestedProducts = async () => { + const allProducts = await client.get('/products.json'); + + return [...allProducts].sort(() => Math.random() - 0.5).slice(0, 8); +}; + +export const getHotProducts = async () => { + const products = await client.get('/products.json'); + + return products.sort((a, b) => { + // Рахуємо суму знижки для кожного товару + const discountA = (a.fullPrice || 0) - (a.price || 0); + const discountB = (b.fullPrice || 0) - (b.price || 0); + + return discountB - discountA; // Від найбільшої знижки до найменшої + }); +}; + +export const getBrandNewProducts = async () => { + const products = await client.get('/products.json'); + + // Сортуємо за роком (або ціною, якщо рік однаковий) + return products.sort((a, b) => (b.year || 0) - (a.year || 0)); +}; diff --git a/src/components/BannerSlider/BannerSlider.module.scss b/src/components/BannerSlider/BannerSlider.module.scss new file mode 100644 index 00000000000..cc5262636ef --- /dev/null +++ b/src/components/BannerSlider/BannerSlider.module.scss @@ -0,0 +1,93 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.banner { + width: 100%; + margin-bottom: 80px; + display: flex; + flex-direction: column; + align-items: center; + + &__container { + display: flex; + width: 100%; + height: 200px; + margin-bottom: 16px; + gap: 16px; + + @include tablet { + height: 400px; + } + } + + &__btn { + display: none; + + @include tablet { + display: flex; + width: 32px; + height: 100%; + background-color: $c-selected; + border: 1px solid transparent; + justify-content: center; + align-items: center; + cursor: pointer; + flex-shrink: 0; + transition: border-color 0.3s, background-color 0.3s; + + img { + width: 16px; + height: 16px; + opacity: 0.5; + transition: opacity 0.3s; + } + + &:hover { + border-color: $c-border; + background-color: #4A4D58; + + img { + opacity: 1; + } + } + } + } + + &__window { + flex: 1; + height: 100%; + overflow: hidden; + } + + &__track { + display: flex; + height: 100%; + transition: transform 0.5s ease-in-out; + } + + &__image { + width: 100%; + height: 100%; + flex-shrink: 0; + object-fit: cover; + } + + &__dots { + display: flex; + gap: 16px; + } + + &__dot { + width: 14px; + height: 4px; + background-color: $c-border; + border: none; + cursor: pointer; + transition: all 0.3s; + + &--active { + width: 24px; + background-color: $c-text-primary; + } + } +} diff --git a/src/components/BannerSlider/BannerSlider.tsx b/src/components/BannerSlider/BannerSlider.tsx new file mode 100644 index 00000000000..7e9a4d0f3dc --- /dev/null +++ b/src/components/BannerSlider/BannerSlider.tsx @@ -0,0 +1,71 @@ +import { useState, useEffect } from 'react'; +import cn from 'classnames'; +import styles from './BannerSlider.module.scss'; + +const BANNERS = [ + './img/banner-phones.png', + './img/banner-tablets.png', + './img/banner-accessories.png', +]; + +export const BannerSlider = () => { + const [currentIndex, setCurrentIndex] = useState(0); + + const goNext = () => { + setCurrentIndex(prev => (prev === BANNERS.length - 1 ? 0 : prev + 1)); + }; + + const goPrev = () => { + setCurrentIndex(prev => (prev === 0 ? BANNERS.length - 1 : prev - 1)); + }; + + useEffect(() => { + const interval = setInterval(goNext, 5000); + + return () => clearInterval(interval); + }, []); + + return ( +
+
+ + +
+
+ {BANNERS.map((banner, index) => ( + {`Banner + ))} +
+
+ + +
+ +
+ {BANNERS.map((_, index) => ( +
+
+ ); +}; diff --git a/src/components/Breadcrumbs/Breadcrumbs.module.scss b/src/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 00000000000..221bdf90f90 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,65 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; + height: 16px; + + &__link { + display: flex; + align-items: center; + text-decoration: none; + color: $c-text-secondary; + font-family: inherit; + font-size: 12px; + font-weight: 700; + transition: color 0.3s ease; + + &:hover { + color: $c-text-primary; + + img { + opacity: 1; + } + } + } + + &__current { + color: $c-text-secondary; + font-family: inherit; + font-size: 12px; + font-weight: 700; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; + + @include tablet { + max-width: 300px; + } + + @include desktop { + max-width: 500px; + } + } + + &__icon { + width: 16px; + height: 16px; + display: block; + object-fit: contain; + transition: opacity 0.3s ease; + } + + &__divider { + width: 16px; + height: 16px; + display: block; + object-fit: contain; + opacity: 0.5; + } +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..c411af73d67 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,62 @@ +import { Link } from 'react-router-dom'; +import styles from './Breadcrumbs.module.scss'; + +interface Props { + category: string; + productName?: string; +} + +export const Breadcrumbs = ({ category, productName }: Props) => { + const formatCategory = (str: string) => { + if (!str) { + return ''; + } + + return str.charAt(0).toUpperCase() + str.slice(1); + }; + + return ( + + ); +}; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..d23be7c0370 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,113 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.footer { + border-top: 1px solid $c-border; + background-color: $c-bg-dark; +} + +.content { + max-width: 1200px; + margin: 0 auto; + padding: 32px 16px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 32px; + + @include tablet { + flex-direction: row; + align-items: center; + justify-content: space-between; + height: 96px; + padding: 0 16px; + } +} + +.logo { + display: flex; + align-items: center; +} + +.links { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + + @include tablet { + flex-direction: row; + align-items: center; + } + + @include desktop { + gap: 100px; + } +} + +.link { + font-family: inherit; + font-weight: 800; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: $c-text-secondary; + text-decoration: none; + transition: color 0.3s ease; + + &:hover { + color: $c-text-primary; + } +} + +.back_text { + font-family: inherit; + font-size: 12px; + font-weight: 600; + color: $c-text-secondary; + transition: color 0.3s ease; +} + +.back_btn { + width: 32px; + height: 32px; + background-color: #323542; + border: 1px solid transparent; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.3s ease, background-color 0.3s ease; + + img { + width: 16px; + height: 16px; + opacity: 0.5; + transition: opacity 0.3s ease; + } +} + +.back { + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + align-self: center; + + @include tablet { + align-self: auto; + } + + &:hover .back_btn { + background-color: #4A4D58; + border-color: $c-border; + + img { + opacity: 1; + } + } + + &:hover .back_text { + color: $c-text-primary; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..7c62cb880b6 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,46 @@ +import { Link } from 'react-router-dom'; +import styles from './Footer.module.scss'; + +export const Footer = () => { + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+
+ + Nice Gadgets Logo + + + + +
+ Back to top + +
+
+
+ ); +}; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..679c4d8433c --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,164 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.header { + position: sticky; + top: 0; + z-index: 100; + background-color: $c-bg-dark; + height: 48px; + border-bottom: 1px solid $c-border; + display: flex; + align-items: center; + justify-content: space-between; + + @include desktop { + height: 64px; + } + + &__left { + display: flex; + align-items: center; + height: 100%; + padding-left: 16px; + + @include tablet { + padding-left: 24px; + } + + @include desktop { + padding-left: 32px; + } + } + + &__logo { + display: flex; + align-items: center; + height: 100%; + margin-right: 32px; + + @include desktop { + margin-right: 64px; + } + + img { + height: 28px; + } + } + + &__nav { + display: none; + height: 100%; + + @include tablet { + display: flex; + gap: 32px; + } + } + + &__link { + text-decoration: none; + color: $c-text-secondary; + font-weight: 800; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + display: flex; + align-items: center; + height: 100%; + border-bottom: 3px solid transparent; + padding-top: 3px; + transition: color 0.3s ease; + + &:hover { + color: $c-text-primary; + } + + &--active { + color: $c-text-primary; + border-bottom: 3px solid $c-text-primary; + } + } + + &__right { + display: none; + height: 100%; + + @include tablet { + display: flex; + } + } + + &__icon-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 100%; + border-left: 1px solid $c-border; + color: $c-text-primary; + text-decoration: none; + transition: background-color 0.3s; + + @include desktop { + width: 64px; + } + + &:hover { + background-color: $c-selected; + } + + img { + width: 16px; + height: 16px; + } + } + + &__badge { + position: absolute; + top: 10px; + right: 10px; + background-color: $c-accent-red; + color: #fff; + font-size: 10px; + font-weight: bold; + width: 14px; + height: 14px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + @include desktop { + top: 16px; + right: 16px; + } + } + + &__menu-btn { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 100%; + background: transparent; + border: none; + border-left: 1px solid $c-border; + cursor: pointer; + transition: background-color 0.3s; + + @include tablet { + display: none; + } + + &:hover { + background-color: $c-selected; + } + + img { + width: 16px; + height: 16px; + } + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..7296fd456b0 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; +import { Link, NavLink, useLocation } from 'react-router-dom'; +import cn from 'classnames'; +import { useAppSelector } from '../../store/hooks'; +import styles from './Header.module.scss'; +import { MobileMenu } from '../MobileMenu/MobileMenu'; + +const NAV_LINKS = [ + { to: '/', text: 'Home' }, + { to: '/phones', text: 'Phones' }, + { to: '/tablets', text: 'Tablets' }, + { to: '/accessories', text: 'Accessories' }, +]; + +const getLinkClass = ({ isActive }: { isActive: boolean }) => + cn(styles.header__link, { + [styles['header__link--active']]: isActive, + }); + +export const Header = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const location = useLocation(); + + const cartItems = useAppSelector(state => state.cart.items); + const favoritesItems = useAppSelector(state => state.favorites.items); + + const cartTotalQuantity = cartItems.reduce( + (total, item) => total + item.quantity, + 0, + ); + const favTotalQuantity = favoritesItems.length; + + useEffect(() => { + if (isMenuOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + + return () => { + document.body.style.overflow = ''; + }; + }, [isMenuOpen]); + + useEffect(() => { + setIsMenuOpen(false); + }, [location.pathname]); + + return ( +
+
+ + Nice Gadgets + + + +
+ +
+ + Favorites + {favTotalQuantity > 0 && ( + {favTotalQuantity} + )} + + + + Cart + {cartTotalQuantity > 0 && ( + {cartTotalQuantity} + )} + +
+ + + + setIsMenuOpen(false)} /> +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/MobileMenu/MobileMenu.module.scss b/src/components/MobileMenu/MobileMenu.module.scss new file mode 100644 index 00000000000..873d5801849 --- /dev/null +++ b/src/components/MobileMenu/MobileMenu.module.scss @@ -0,0 +1,107 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.menu { + position: fixed; + top: 48px; + left: 0; + width: 100%; + height: calc(100vh - 48px); + background-color: $c-bg-dark; + z-index: 99; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s ease-in-out; + + &--open { + transform: translateX(0); + } + + @include tablet { + display: none; + } + + &__nav { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding-top: 24px; + gap: 32px; + } + + &__link { + font-family: inherit; + font-weight: 800; + font-size: 14px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: $c-text-secondary; + text-decoration: none; + padding-bottom: 4px; + border-bottom: 3px solid transparent; + transition: color 0.3s; + + &:hover { + color: $c-text-primary; + } + + &--active { + color: $c-text-primary; + border-bottom: 3px solid $c-text-primary; + } + } + + &__footer { + display: flex; + height: 64px; + border-top: 1px solid $c-border; + } + + &__icon_wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + border-right: 1px solid $c-border; + border-bottom: 3px solid transparent; + text-decoration: none; + transition: background-color 0.3s; + + &:last-child { + border-right: none; + } + + &:hover { + background-color: $c-selected; + } + + &--active { + border-bottom: 3px solid $c-text-primary; + } + + img { + width: 16px; + height: 16px; + } + } + + &__badge { + position: absolute; + top: 16px; + right: calc(50% - 20px); + background-color: $c-accent-red; + color: #fff; + font-size: 10px; + font-weight: bold; + width: 14px; + height: 14px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/src/components/MobileMenu/MobileMenu.tsx b/src/components/MobileMenu/MobileMenu.tsx new file mode 100644 index 00000000000..9aac492a57a --- /dev/null +++ b/src/components/MobileMenu/MobileMenu.tsx @@ -0,0 +1,80 @@ +import { NavLink } from 'react-router-dom'; +import cn from 'classnames'; +import { useAppSelector } from '../../store/hooks'; +import styles from './MobileMenu.module.scss'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const NAV_LINKS = [ + { to: '/', text: 'Home' }, + { to: '/phones', text: 'Phones' }, + { to: '/tablets', text: 'Tablets' }, + { to: '/accessories', text: 'Accessories' }, +]; + +export const MobileMenu = ({ isOpen, onClose }: Props) => { + const cartItems = useAppSelector(state => state.cart.items); + const favoritesItems = useAppSelector(state => state.favorites.items); + + const cartTotalQuantity = cartItems.reduce( + (total, item) => total + item.quantity, + 0, + ); + const favTotalQuantity = favoritesItems.length; + + return ( + + ); +}; diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss new file mode 100644 index 00000000000..c3a0ec4c545 --- /dev/null +++ b/src/components/Pagination/Pagination.module.scss @@ -0,0 +1,74 @@ +@import '../../styles/variables'; + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + // margin-top: 40px; + + margin-top: auto; + padding-top: 40px; + + &__list { + display: flex; + list-style: none; + gap: 8px; + } + + &__button { + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + background-color: $c-bg-elements; + color: $c-text-primary; + border: 1px solid $c-border; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + border-color: $c-text-secondary; + } + + &--active { + background-color: $c-text-primary; + color: $c-bg-dark; + border-color: $c-text-primary; + } + + + } + + &__arrow { + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + background-color: $c-bg-elements; + color: $c-text-primary; + border: 1px solid $c-border; + cursor: pointer; + transition: border-color 0.3s; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + border-color: $c-text-secondary; + } + + } +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 00000000000..414177acb71 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,77 @@ +import cn from 'classnames'; +import styles from './Pagination.module.scss'; + +interface Props { + totalItems: number; + itemsPerPage: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +export const Pagination = ({ + totalItems, + itemsPerPage, + currentPage, + onPageChange, +}: Props) => { + const totalPages = Math.ceil(totalItems / itemsPerPage); + + if (totalPages <= 1) { + return null; + } + + const MAX_VISIBLE_PAGES = 5; + + let startPage = Math.max(1, currentPage - Math.floor(MAX_VISIBLE_PAGES / 2)); + + let endPage = startPage + MAX_VISIBLE_PAGES - 1; + + if (endPage > totalPages) { + endPage = totalPages; + startPage = Math.max(1, endPage - MAX_VISIBLE_PAGES + 1); + } + + const pages = []; + + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + return ( +
+ + +
    + {pages.map(page => ( +
  • + +
  • + ))} +
+ + +
+ ); +}; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..41f69dbc68b --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,186 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.card { + width: 100%; + max-width: 400px; + height: 440px; + background-color: $c-bg-card; + padding: 24px; + box-sizing: border-box; + display: flex; + flex-direction: column; + transition: box-shadow 0.3s ease, transform 0.3s ease; + + @include tablet { + height: 506px; + padding: 32px; + } + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + } + + &__image_link { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 150px; + margin-bottom: 24px; + overflow: hidden; + + @include tablet { + height: 196px; + } + } + + &__image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + transition: transform 0.3s ease; + + .card__image_link:hover & { + transform: scale(1.1); + } + } + + &__title { + height: 42px; + color: $c-text-primary; + text-decoration: none; + font-size: 14px; + line-height: 21px; + font-weight: 600; + margin-bottom: 8px; + + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + &:hover { + text-decoration: underline; + } + } + + &__prices { + display: flex; + align-items: center; + gap: 8px; + height: 31px; + margin-bottom: 8px; + } + + &__price_current { + font-size: 22px; + font-weight: 800; + color: $c-text-primary; + } + + &__price_full { + font-size: 14px; + font-weight: 500; + color: $c-text-secondary; + text-decoration: line-through; + } + + &__divider { + height: 1px; + background-color: $c-border; + border: none; + margin-bottom: 8px; + } + + &__specs { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + height: 77px; + } + + &__spec { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + width: 100%; + + &_title { + color: $c-text-secondary; + flex-shrink: 0; + margin-right: 8px; + } + + &_value { + color: $c-text-primary; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; + } + } + + &__actions { + display: flex; + gap: 8px; + width: 100%; + height: 40px; + margin-top: auto; + } + + &__button { + height: 40px; + border: none; + font-family: inherit; + font-weight: 600; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + } + + &__button_add { + flex: 1; + background-color: $c-primary; + color: #fff; + + &:hover { + background-color: $c-primary-hover; + } + } + + &__button_added { + flex: 1; + background-color: transparent; + color: #fff; + border: 1px solid $c-border; + + &:hover { + border-color: $c-text-primary; + } + } + + &__button_fav { + width: 40px; + flex-shrink: 0; + background-color: transparent; + border: 1px solid $c-border; + color: $c-text-primary; + + &:hover { + border-color: $c-text-primary; + } + + &--active { + color: $c-accent-red; + border-color: $c-border; + } + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..e99587072c3 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,103 @@ +import { Link } from 'react-router-dom'; +import cn from 'classnames'; +import { Product } from '../../types/Product'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { addToCart } from '../../store/cartSlice'; +import { toggleFavorite } from '../../store/favoritesSlice'; +import styles from './ProductCard.module.scss'; + +interface Props { + product: Product; + hideOldPrice?: boolean; +} + +export const ProductCard = ({ product, hideOldPrice }: Props) => { + const dispatch = useAppDispatch(); + const cartItems = useAppSelector(state => state.cart.items); + const favoritesItems = useAppSelector(state => state.favorites.items); + + const isAddedToCart = cartItems.some(item => item.id === product.id); + const isFavorite = favoritesItems.some(item => item.id === product.id); + + const productLink = `/${product.category}/${product.itemId || product.id}`; + + const handleAddToCart = () => { + if (!isAddedToCart) { + dispatch(addToCart(product)); + } + }; + + const currentImage = + product.image || + product.imageUrl || + (product.images ? product.images[0] : ''); + + const imageSrc = `./${currentImage.startsWith('/') ? currentImage.slice(1) : currentImage}`; + + return ( +
+ + {product.name} + + + + {product.name} + + +
+ + ${product.price || product.priceDiscount} + + {!hideOldPrice && (product.fullPrice || product.priceRegular) && ( + + ${product.fullPrice || product.priceRegular} + + )} +
+ +
+ +
+
+ Screen + {product.screen} +
+
+ Capacity + {product.capacity} +
+
+ RAM + {product.ram} +
+
+ +
+ + + +
+
+ ); +}; diff --git a/src/components/ProductsList/ProductsList.module.scss b/src/components/ProductsList/ProductsList.module.scss new file mode 100644 index 00000000000..b097c8c485b --- /dev/null +++ b/src/components/ProductsList/ProductsList.module.scss @@ -0,0 +1,6 @@ +.list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(272px, 1fr)); + gap: 16px; + justify-items: center; +} diff --git a/src/components/ProductsList/ProductsList.tsx b/src/components/ProductsList/ProductsList.tsx new file mode 100644 index 00000000000..e5ce24a4b1d --- /dev/null +++ b/src/components/ProductsList/ProductsList.tsx @@ -0,0 +1,17 @@ +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; +import styles from './ProductsList.module.scss'; + +interface Props { + products: Product[]; +} + +export const ProductsList = ({ products }: Props) => { + return ( +
+ {products.map(product => ( + + ))} +
+ ); +}; diff --git a/src/components/ProductsSlider/ProductsSlider.module.scss b/src/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 00000000000..be71e828517 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,81 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.slider { + margin-bottom: 80px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + } + + &__buttons { + display: flex; + gap: 16px; + } + + &__btn { + width: 32px; + height: 32px; + background-color: #323542; + border: 1px solid $c-border; + border-radius: 0; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + transition: all 0.3s; + + img { + width: 16px; + height: 16px; + transition: opacity 0.3s; + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + border-color: $c-border; + background-color: transparent; + } + + &:hover:not(:disabled) { + background-color: #4A4D58; + border-color: $c-border; + } + + } + + &__track_container { + overflow: hidden; + } + + &__track { + display: flex; + gap: 16px; + overflow-x: auto; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + padding-bottom: 16px; + + &::-webkit-scrollbar { display: none; } + + -ms-overflow-style: none; + scrollbar-width: none; + } + + &__item { + scroll-snap-align: start; + flex-shrink: 0; + width: 272px; + max-width: 100%; + } +} diff --git a/src/components/ProductsSlider/ProductsSlider.tsx b/src/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..8ae9a94db9f --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,83 @@ +import { useRef, useState, useEffect } from 'react'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; +import styles from './ProductsSlider.module.scss'; + +interface Props { + title: string; + products: Product[]; + hideOldPrice?: boolean; +} + +export const ProductsSlider = ({ title, products, hideOldPrice }: Props) => { + const trackRef = useRef(null); + + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + const checkScroll = () => { + if (trackRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = trackRef.current; + + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(Math.ceil(scrollLeft + clientWidth) < scrollWidth); + } + }; + + useEffect(() => { + checkScroll(); + window.addEventListener('resize', checkScroll); + + return () => window.removeEventListener('resize', checkScroll); + }, [products]); + + const scrollLeft = () => { + if (trackRef.current) { + trackRef.current.scrollBy({ left: -288 * 2, behavior: 'smooth' }); + } + }; + + const scrollRight = () => { + if (trackRef.current) { + trackRef.current.scrollBy({ left: 288 * 2, behavior: 'smooth' }); + } + }; + + return ( +
+
+

{title}

+
+ + +
+
+ +
+
+ {products.map(product => ( +
+ +
+ ))} +
+
+
+ ); +}; diff --git a/src/components/ShopByCategory/ShopByCategory.module.scss b/src/components/ShopByCategory/ShopByCategory.module.scss new file mode 100644 index 00000000000..ccb3053824d --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.module.scss @@ -0,0 +1,68 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.category { + margin-bottom: 80px; + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 24px; + } + + &__grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + + @include tablet { + grid-template-columns: repeat(3, 1fr); + } + } + + &__card { + text-decoration: none; + display: flex; + flex-direction: column; + gap: 24px; + transition: transform 0.3s; + + &:hover { + transform: scale(1.02); + } + } + + &__photo_wrap { + background-color: $c-bg-elements; + aspect-ratio: 1 / 1; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + } + + &__photo { + width: 100%; + height: 100%; + object-fit: cover; + } + + &__info { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__name { + font-size: 20px; + font-weight: 700; + color: $c-text-primary; + } + + &__count { + font-size: 14px; + font-weight: 600; + color: $c-text-secondary; + } +} diff --git a/src/components/ShopByCategory/ShopByCategory.tsx b/src/components/ShopByCategory/ShopByCategory.tsx new file mode 100644 index 00000000000..fec742af9e9 --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.tsx @@ -0,0 +1,54 @@ +import { Link } from 'react-router-dom'; +import styles from './ShopByCategory.module.scss'; + +export const ShopByCategory = () => { + return ( +
+

Shop by category

+ +
+ +
+ Mobile phones +
+
+

Mobile phones

+ 95 models +
+ + + +
+ Tablets +
+
+

Tablets

+ 24 models +
+ + + +
+ Accessories +
+
+

Accessories

+ 100 models +
+ +
+
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..68d4dda6346 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,17 @@ +import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { HashRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from './store'; import { App } from './App'; +import './App.scss'; -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..010f392f55e --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,236 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px 64px; + + &__back { + background: transparent; + border: none; + color: #F1F2F9; + font-weight: 600; + cursor: pointer; + margin-bottom: 24px; + font-family: inherit; + + &:hover { color: #905BFF; } + } + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 32px; + } + + &__content { + display: flex; + flex-direction: column; + gap: 32px; + + @include desktop { + flex-direction: row; + align-items: flex-start; + } + } + + &__list { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; + } + + &__empty { + font-size: 20px; + color: $c-text-secondary; + } +} + +.summary { + width: 100%; + border: 1px solid $c-border; + padding: 24px; + + @include desktop { + width: 368px; + } + + &__total { + text-align: center; + margin-bottom: 24px; + border-bottom: 1px solid $c-border; + padding-bottom: 24px; + } + + &__price { + font-size: 32px; + font-weight: 800; + margin-bottom: 8px; + display: block; + color: $c-text-primary; + } + + &__text { + color: $c-text-secondary; + font-weight: 600; + } + + &__button { + width: 100%; + height: 48px; + background-color: #905BFF; + color: #fff; + border: none; + font-weight: 700; + font-family: inherit; + cursor: pointer; + + &:hover { + background-color: #A378FF; + } + } +} + +.cart_item { + display: flex; + flex-direction: column; + gap: 16px; + background-color: #161827; + padding: 16px; + + @include tablet { + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 24px; + gap: 24px; + } + + &__info { + display: flex; + align-items: center; + gap: 16px; + + @include tablet { + flex: 1; + } + } + + &__remove { + width: 16px; + height: 16px; + background: transparent; + border: none; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + img { + width: 16px; + height: 16px; + opacity: 0.5; + transition: opacity 0.3s; + } + } + + &__image_link { + width: 80px; + height: 80px; + flex-shrink: 0; + } + + &__image { + width: 100%; + height: 100%; + object-fit: contain; + } + + &__title { + color: $c-text-primary; + text-decoration: none; + font-weight: 600; + font-size: 14px; + line-height: 21px; + } + + &__action { + display: flex; + align-items: center; + justify-content: space-between; + + @include tablet { + gap: 32px; + justify-content: flex-end; + } + } + + &__controls { + display: flex; + align-items: center; + gap: 16px; + } + + &__quantity { + color: $c-text-primary; + font-weight: 600; + font-size: 14px; + width: 16px; + text-align: center; + } + + &__btn { + width: 32px; + height: 32px; + background: #323542; + border: 1px solid $c-border; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + transition: border-color 0.3s; + + img { + width: 16px; + height: 16px; + opacity: 0.8; + transition: opacity 0.3s; + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + } + + &__price { + font-size: 22px; + font-weight: 800; + color: $c-text-primary; + + @include tablet { + width: 100px; + text-align: right; + } + } + + &__remove:hover img { + opacity: 1; + } + + &__title:hover { + text-decoration: underline; + } + + &__btn:hover:not(:disabled) { + border-color: $c-text-secondary; + + img { + opacity: 1; + } + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..1eeed684e03 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,146 @@ +import { useNavigate, Link } from 'react-router-dom'; +import { useAppSelector, useAppDispatch } from '../../store/hooks'; +import { + removeFromCart, + updateQuantity, + clearCart, +} from '../../store/cartSlice'; +import styles from './CartPage.module.scss'; + +export const CartPage = () => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const cartItems = useAppSelector(state => state.cart.items); + + const totalAmount = cartItems.reduce((sum, item) => { + const itemPrice = item.product.priceDiscount || item.product.price || 0; + + return sum + itemPrice * item.quantity; + }, 0); + + const totalItemsCount = cartItems.reduce( + (count, item) => count + item.quantity, + 0, + ); + + const handleCheckout = () => { + const isConfirmed = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (isConfirmed) { + dispatch(clearCart()); + } + }; + + return ( +
+ + +

Cart

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

Your cart is empty

+ ) : ( +
+
+ {cartItems.map(item => { + const imageSrc = + item.product.image || + item.product.imageUrl || + item.product.images?.[0] || + ''; + const finalImage = `./${imageSrc.startsWith('/') ? imageSrc.slice(1) : imageSrc}`; + const itemPrice = + item.product.priceDiscount || item.product.price; + + return ( +
+
+ + + + {item.product.name} + + + + {item.product.name} + +
+ +
+
+ + + {item.quantity} + + +
+ + + ${itemPrice} + +
+
+ ); + })} +
+ +
+
+ ${totalAmount} + + Total for {totalItemsCount} items + +
+ +
+
+ )} +
+ ); +}; 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/CatalogPage/CatalogPage.module.scss b/src/modules/CatalogPage/CatalogPage.module.scss new file mode 100644 index 00000000000..88ccf4f84fb --- /dev/null +++ b/src/modules/CatalogPage/CatalogPage.module.scss @@ -0,0 +1,135 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px 64px; + + display: flex; + flex-direction: column; + min-height: 80vh; + + &__breadcrumbs { + margin-bottom: 24px; + } + + &__list_container { + flex: 1; + display: flex; + flex-direction: column; + } + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 8px; + text-transform: capitalize; + } + + &__count { + display: block; + font-size: 14px; + color: $c-text-secondary; + font-weight: 600; + margin-bottom: 32px; + } + + &__filters { + display: flex; + gap: 16px; + margin-bottom: 24px; + + & > div { + flex: 1; + + @include tablet { + flex: none; + width: 176px; + } + } + } + + &__filter { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + } + + &__label { + font-size: 12px; + font-weight: 600; + color: $c-text-secondary; + } + + &__select { + height: 40px; + width: 100%; + padding: 0 16px; + background-color: #323542; + border: 1px solid $c-border; + color: $c-text-primary; + font-family: inherit; + font-weight: 600; + border-radius: 0; + cursor: pointer; + + option { + background-color: $c-bg-elements; + color: $c-text-primary; + } + + &:hover { + border-color: #905BFF; + } + } + + &__message { + text-align: center; + padding: 40px; + color: $c-text-secondary; + font-size: 18px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + } + + &__reload_btn { + padding: 8px 24px; + background-color: transparent; + border: 1px solid $c-border; + color: $c-text-primary; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + border-color: $c-text-primary; + background-color: rgba(255, 255, 255, 0.05); + } + } +} + +.grid { + display: grid; + grid-template-columns: 1fr; + gap: 40px 16px; + margin-bottom: 40px; + + @include tablet { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: 768px) { + grid-template-columns: repeat(3, 1fr); + } + + @include desktop { + grid-template-columns: repeat(4, 1fr); + } +} diff --git a/src/modules/CatalogPage/CatalogPage.tsx b/src/modules/CatalogPage/CatalogPage.tsx new file mode 100644 index 00000000000..5e39ab904ab --- /dev/null +++ b/src/modules/CatalogPage/CatalogPage.tsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Product } from '../../types/Product'; +import { getProductsByCategory } from '../../api/products'; +import { ProductsList } from '../../components/ProductsList/ProductsList'; +import styles from './CatalogPage.module.scss'; +import { Pagination } from '../../components/Pagination/Pagination'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; + +interface Props { + category: 'phones' | 'tablets' | 'accessories'; +} + +export const CatalogPage = ({ category }: Props) => { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const [searchParams, setSearchParams] = useSearchParams(); + + const sortBy = searchParams.get('sort') || 'age'; + const perPage = searchParams.get('perPage') || 'all'; + const currentPage = +(searchParams.get('page') || 1); + + useEffect(() => { + setIsLoading(true); + setIsError(false); + + getProductsByCategory(category) + .then(data => setProducts(data)) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, [category]); + + const handleSortChange = (e: React.ChangeEvent) => { + const newSort = e.target.value; + const params = new URLSearchParams(searchParams); + + if (newSort === 'age') { + params.delete('sort'); + } else { + params.set('sort', newSort); + } + + setSearchParams(params); + }; + + const handlePerPageChange = (e: React.ChangeEvent) => { + const newPerPage = e.target.value; + const params = new URLSearchParams(searchParams); + + if (newPerPage === 'all') { + params.delete('perPage'); + } else { + params.set('perPage', newPerPage); + } + + params.delete('page'); + setSearchParams(params); + }; + + const sortedProducts = [...products]; + + sortedProducts.sort((a, b) => { + switch (sortBy) { + case 'age': + return (b.year || 0) - (a.year || 0); + case 'title': + return a.name.localeCompare(b.name); + case 'price': + const priceA = a.price || a.priceDiscount || 0; + const priceB = b.price || b.priceDiscount || 0; + + return priceA - priceB; + default: + return 0; + } + }); + + let visibleProducts = sortedProducts; + let itemsPerPageNum = sortedProducts.length; + + if (perPage !== 'all') { + itemsPerPageNum = Number(perPage); + const startIndex = (currentPage - 1) * itemsPerPageNum; + const endIndex = startIndex + itemsPerPageNum; + + visibleProducts = sortedProducts.slice(startIndex, endIndex); + } + + const handlePageChange = (newPage: number) => { + const params = new URLSearchParams(searchParams); + + params.set('page', newPage.toString()); + setSearchParams(params); + // window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+ +

{category}

+ {products.length} models + + {!isLoading && !isError && products.length > 0 && ( +
+
+ + +
+ +
+ + +
+
+ )} + + {isLoading &&
Loading...
} + {isError && ( +
+ Something went wrong. + +
+ )} + {!isLoading && !isError && products.length === 0 && ( +
There are no {category} yet.
+ )} + + {!isLoading && !isError && visibleProducts.length > 0 && ( +
+ + + {perPage !== 'all' && ( + + )} +
+ )} +
+ ); +}; diff --git a/src/modules/CatalogPage/index.ts b/src/modules/CatalogPage/index.ts new file mode 100644 index 00000000000..1cad0ffbfe4 --- /dev/null +++ b/src/modules/CatalogPage/index.ts @@ -0,0 +1 @@ +export * from './CatalogPage'; diff --git a/src/modules/ContactsPage/ContactsPage.module.scss b/src/modules/ContactsPage/ContactsPage.module.scss new file mode 100644 index 00000000000..30fe610f81c --- /dev/null +++ b/src/modules/ContactsPage/ContactsPage.module.scss @@ -0,0 +1,23 @@ +@import '../../styles/variables'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 48px 16px; + text-align: center; + flex: 1; + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 24px; + } + + &__text { + font-size: 16px; + line-height: 1.5; + color: $c-text-secondary; + font-weight: 600; + } +} diff --git a/src/modules/ContactsPage/ContactsPage.tsx b/src/modules/ContactsPage/ContactsPage.tsx new file mode 100644 index 00000000000..704f797e3f6 --- /dev/null +++ b/src/modules/ContactsPage/ContactsPage.tsx @@ -0,0 +1,14 @@ +import styles from './ContactsPage.module.scss'; + +export const ContactsPage = () => { + return ( +
+

Contacts

+

+ This is a placeholder for the contacts page. +
+ Feel free to reach out to our support team! +

+
+ ); +}; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..619caa073f9 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,32 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px 64px; + + &__breadcrumbs { + margin-bottom: 24px; + } + + &__title { + font-size: 32px; + font-weight: 800; + margin-bottom: 8px; + color: $c-text-primary; + } + + &__count { + color: $c-text-secondary; + font-size: 14px; + margin-bottom: 32px; + display: block; + } + + &__empty { + font-size: 20px; + color: $c-text-secondary; + padding: 40px 0; + } +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..890107b108a --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,27 @@ +import { useAppSelector } from '../../store/hooks'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; +import { ProductsList } from '../../components/ProductsList/ProductsList'; +import styles from './FavoritesPage.module.scss'; + +export const FavoritesPage = () => { + const favorites = useAppSelector(state => state.favorites.items); + + return ( +
+
+ +
+ +

Favorites

+ {favorites.length} items + + {favorites.length === 0 ? ( +

+ You have not added any products to favorites 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.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..367d6500bf9 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; +import { Product } from '../../types/Product'; +import { getHotProducts, getBrandNewProducts } from '../../api/products'; +import { ProductsSlider } from '../../components/ProductsSlider/ProductsSlider'; +import { BannerSlider } from '../../components/BannerSlider/BannerSlider'; +import { ShopByCategory } from '../../components/ShopByCategory/ShopByCategory'; // ДОДАНО ІМПОРТ + +export const HomePage = () => { + const [hotProducts, setHotProducts] = useState([]); + const [newProducts, setNewProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + Promise.all([getHotProducts(), getBrandNewProducts()]) + .then(([hot, brandNew]) => { + setHotProducts(hot); + setNewProducts(brandNew); + }) + // eslint-disable-next-line no-console + .catch(err => console.error('Error loading homepage data', err)) + .finally(() => setIsLoading(false)); + }, []); + + return ( +
+

Product Catalog

+ + + + {isLoading ? ( +
Loading...
+ ) : ( + <> + + + + + + + )} +
+ ); +}; 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.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..6eced6a4ee3 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,15 @@ +import { Link } from 'react-router-dom'; + +export const NotFoundPage = () => { + return ( +
+

Page not found

+ + Go to Home page + +
+ ); +}; diff --git a/src/modules/NotFoundPage/indext.ts b/src/modules/NotFoundPage/indext.ts new file mode 100644 index 00000000000..6197aa75aa8 --- /dev/null +++ b/src/modules/NotFoundPage/indext.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..eda77e30858 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,371 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.loader, .not_found { + text-align: center; + padding: 100px; + font-size: 24px; + color: $c-text-primary; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px 64px; +} + +.back_button { + background: transparent; + border: none; + color: #F1F2F9; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + margin: 24px 0; + padding: 0; + transition: color 0.3s; + + &:hover { + color: #905BFF; + } +} + +.title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 40px; +} + +.main_grid { + display: flex; + flex-direction: column; + gap: 40px; + margin-bottom: 80px; + + @include tablet { + flex-direction: row; + justify-content: space-between; + gap: 32px; + } + + @include desktop { + gap: 64px; + } +} + +.gallery { + display: flex; + flex-direction: column-reverse; + gap: 16px; + width: 100%; + + @include tablet { + flex-direction: row; + width: auto; + flex: 1; + } +} + +.thumbnails { + display: flex; + gap: 16px; + overflow-x: auto; + + &::-webkit-scrollbar { display: none; } + + -ms-overflow-style: none; + scrollbar-width: none; + + @include tablet { + flex-direction: column; + } +} + +.thumb { + width: 50px; + height: 50px; + flex-shrink: 0; + border: 1px solid $c-border; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.3s; + + @include tablet { + width: 64px; + height: 64px; + } + + @include desktop { + width: 80px; + height: 80px; + } + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } +} + +.thumb_active { + border-color: $c-text-primary; +} + +.main_image { + width: 100%; + aspect-ratio: 1 / 1; + max-width: 464px; + display: flex; + align-items: center; + justify-content: center; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } +} + +.controls { + width: 100%; + + @include tablet { + max-width: 320px; + flex-shrink: 0; + } +} + +.section { + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid $c-border; +} + +.section_header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.section_label { + display: block; + font-size: 12px; + color: $c-text-secondary; + font-weight: 600; +} + +.item_id { + font-size: 12px; + font-weight: 600; + color: $c-text-secondary; +} + +.colors { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.color_circle { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + box-shadow: 0 0 0 1px $c-border inset; + + &_active { + border-color: $c-text-primary; + box-shadow: 0 0 0 2px $c-bg-dark inset; + } +} + +.capacities { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.cap_box { + border: 1px solid $c-border; + padding: 8px 16px; + font-size: 14px; + font-weight: 600; + color: $c-text-primary; + cursor: pointer; + transition: all 0.3s; + + &_active { + background-color: $c-text-primary; + color: $c-bg-dark; + border-color: $c-text-primary; + } +} + +.price_block { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.price_discount { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; +} + +.price_regular { + font-size: 22px; + color: $c-text-secondary; + text-decoration: line-through; +} + +.actions { + display: flex; + gap: 8px; + margin-bottom: 32px; + height: 48px; +} + +.add_to_cart { + flex: 1; + background-color: #905BFF; + color: #fff; + border: none; + font-weight: 700; + font-size: 14px; + font-family: inherit; + cursor: pointer; + transition: background-color 0.3s; + + &:not(.added_to_cart):hover { + background-color: #A378FF; + } +} + +.added_to_cart { + background-color: transparent; + border: 1px solid $c-border; + color: #fff; +} + +.fav_button { + width: 48px; + flex-shrink: 0; + background-color: transparent; + border: 1px solid $c-border; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.3s; + + &:hover { + border-color: $c-text-secondary; + } + + &_active { + border-color: $c-border; + } +} + +.fav_icon { + width: 16px; + height: 16px; +} + +.quick_specs { + display: flex; + flex-direction: column; + gap: 8px; +} + +.spec_item { + display: flex; + justify-content: space-between; + font-size: 12px; + + span:first-child { + color: $c-text-secondary; + } + + span:last-child { + color: $c-text-primary; + font-weight: 600; + } +} + +.details_grid { + display: grid; + grid-template-columns: 1fr; + gap: 64px; + margin-bottom: 80px; + + @include desktop { + grid-template-columns: 1fr 1fr; + } +} + +.section_title { + font-size: 24px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 32px; + padding-bottom: 16px; + border-bottom: 1px solid $c-border; +} + +.desc_block { + margin-bottom: 32px; + + h3 { + font-size: 20px; + font-weight: 700; + color: $c-text-primary; + margin-bottom: 16px; + } + + p { + font-size: 14px; + line-height: 1.5; + color: $c-text-secondary; + margin-bottom: 16px; + } +} + +.specs_table { + display: flex; + flex-direction: column; + gap: 8px; +} + +.spec_row { + display: flex; + justify-content: space-between; + font-size: 14px; + + span:first-child { + color: $c-text-secondary; + } + + span:last-child { + color: $c-text-primary; + font-weight: 600; + text-align: right; + max-width: 60%; + } +} + +.suggested { + margin-top: 80px; +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..1de43bb52e4 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,285 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ProductDetails, Product } from '../../types/Product'; +import { getProductDetails, getSuggestedProducts } from '../../api/products'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; +import { ProductsSlider } from '../../components/ProductsSlider/ProductsSlider'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { addToCart } from '../../store/cartSlice'; +import { toggleFavorite } from '../../store/favoritesSlice'; +import cn from 'classnames'; +import styles from './ProductDetailsPage.module.scss'; +import { getImageUrl } from '../../api/getImageUrl'; + +const COLOR_HEX: Record = { + black: '#1F2020', + spacegray: '#4C4C4C', + 'space gray': '#4C4C4C', + silver: '#F0F0F0', + white: '#FBFBFB', + gold: '#F3D2B3', + yellow: '#FFE681', + green: '#AEE1CD', + purple: '#D1CDDA', + red: '#C92127', + midnightgreen: '#4E5851', +}; + +export const ProductDetailsPage = () => { + const { productId } = useParams(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + const [product, setProduct] = useState(null); + const [suggested, setSuggested] = useState([]); + const [mainImage, setMainImage] = useState(''); + const [isLoading, setIsLoading] = useState(true); + + const cartItems = useAppSelector(state => state.cart.items); + const favoritesItems = useAppSelector(state => state.favorites.items); + + const isAddedToCart = product + ? cartItems.some(item => item.id === product.id) + : false; + const isFavorite = product + ? favoritesItems.some(item => item.id === product.id) + : false; + + useEffect(() => { + if (!productId) { + return; + } + + setIsLoading(true); + window.scrollTo({ top: 0, behavior: 'smooth' }); + + Promise.all([getProductDetails(productId), getSuggestedProducts()]) + .then(([details, suggestedData]) => { + setProduct(details); + setSuggested(suggestedData); + setMainImage(details.images?.[0] || details.image || ''); + }) + .catch(() => setProduct(null)) + .finally(() => setIsLoading(false)); + }, [productId]); + + const handleColorChange = (newColor: string) => { + if (product && product.color !== newColor) { + const newId = product.id.replace( + product.color.replace(' ', '-'), + newColor.replace(' ', '-'), + ); + + navigate(`/${product.category}/${newId}`); + } + }; + + const handleCapacityChange = (newCapacity: string) => { + if (product && product.capacity !== newCapacity) { + const newId = product.id.replace( + product.capacity.toLowerCase(), + newCapacity.toLowerCase(), + ); + + navigate(`/${product.category}/${newId}`); + } + }; + + const handleAddToCart = () => { + if (product && !isAddedToCart) { + dispatch(addToCart(product as unknown as Product)); + } + }; + + if (isLoading) { + return
Loading...
; + } + + if (!product) { + return
Product not found
; + } + + return ( +
+ + + + +

{product.name}

+ +
+
+
+ {product.images?.map(img => ( +
setMainImage(img)} + > + {/* ВИКОРИСТОВУЄМО getImageUrl */} + thumb +
+ ))} +
+
+ {/* ВИКОРИСТОВУЄМО getImageUrl */} + {product.name} +
+
+ +
+
+
+ Available colors + + ID:{' '} + {product.namespaceId + ? Math.floor(Math.random() * 900000) + 100000 + : '802390'} + +
+ +
+ {product.colorsAvailable.map(color => ( +
handleColorChange(color)} + /> + ))} +
+
+ +
+ Select capacity +
+ {product.capacityAvailable.map(cap => ( +
handleCapacityChange(cap)} + > + {cap} +
+ ))} +
+
+ +
+ + ${product.priceDiscount || product.price} + + + ${product.priceRegular || product.fullPrice} + +
+ +
+ + +
+ +
+
+ Screen {product.screen} +
+
+ Resolution {product.resolution} +
+
+ Processor {product.processor} +
+
+ RAM {product.ram} +
+
+
+
+ +
+
+

About

+ {product.description?.map(desc => ( +
+

{desc.title}

+ {desc.text.map((p, index) => ( +

{p}

+ ))} +
+ ))} +
+ +
+

Tech specs

+
+
+ Screen {product.screen} +
+
+ Resolution {product.resolution} +
+
+ Processor {product.processor} +
+
+ RAM {product.ram} +
+
+ Capacity {product.capacity} +
+
+ Camera {product.camera} +
+
+ Zoom {product.zoom} +
+
+ Cell {product.cell?.join(', ') || 'N/A'} +
+
+
+
+ +
+ +
+
+ ); +}; 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/RightsPage/RightsPage.module.scss b/src/modules/RightsPage/RightsPage.module.scss new file mode 100644 index 00000000000..30fe610f81c --- /dev/null +++ b/src/modules/RightsPage/RightsPage.module.scss @@ -0,0 +1,23 @@ +@import '../../styles/variables'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 48px 16px; + text-align: center; + flex: 1; + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 24px; + } + + &__text { + font-size: 16px; + line-height: 1.5; + color: $c-text-secondary; + font-weight: 600; + } +} diff --git a/src/modules/RightsPage/RightsPage.tsx b/src/modules/RightsPage/RightsPage.tsx new file mode 100644 index 00000000000..e4148444a74 --- /dev/null +++ b/src/modules/RightsPage/RightsPage.tsx @@ -0,0 +1,14 @@ +import styles from './RightsPage.module.scss'; + +export const RightsPage = () => { + return ( +
+

Rights

+

+ All rights reserved © Nice Gadgets 2026. +
+ Terms and conditions apply. +

+
+ ); +}; diff --git a/src/store/cartSlice.ts b/src/store/cartSlice.ts new file mode 100644 index 00000000000..48a4c23a9af --- /dev/null +++ b/src/store/cartSlice.ts @@ -0,0 +1,62 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { CartItem } from '../types/CartItem'; +import { Product } from '../types/Product'; + +export interface CartState { + items: CartItem[]; +} + +const initialState: CartState = { + items: [], +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState, + reducers: { + addToCart: (state, action: PayloadAction) => { + const product = action.payload; + const existingItem = state.items.find( + cartItem => cartItem.id === product.id, + ); + + if (existingItem) { + return; + } + + state.items.push({ + id: product.id, + quantity: 1, + product, + }); + }, + + removeFromCart: (state, action: PayloadAction) => { + state.items = state.items.filter( + cartItem => cartItem.id !== action.payload, + ); + }, + + updateQuantity: ( + state, + action: PayloadAction<{ id: string; quantity: number }>, + ) => { + const { id, quantity } = action.payload; + const itemToUpdate = state.items.find(cartItem => cartItem.id === id); + + if (itemToUpdate && quantity > 0) { + itemToUpdate.quantity = quantity; + } + }, + + clearCart: state => { + state.items = []; + }, + }, +}); + +export const { addToCart, removeFromCart, updateQuantity, clearCart } = + cartSlice.actions; + +export default cartSlice.reducer; diff --git a/src/store/favoritesSlice.ts b/src/store/favoritesSlice.ts new file mode 100644 index 00000000000..cd975207e49 --- /dev/null +++ b/src/store/favoritesSlice.ts @@ -0,0 +1,43 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Product } from '../types/Product'; + +export interface FavoritesState { + items: Product[]; +} + +const loadFavoritesFromStorage = (): Product[] => { + try { + const saved = localStorage.getItem('favorites'); + + return saved ? JSON.parse(saved) : []; + } catch (e) { + return []; + } +}; + +const initialState: FavoritesState = { + items: loadFavoritesFromStorage(), +}; + +const favoritesSlice = createSlice({ + name: 'favorites', + initialState, + reducers: { + toggleFavorite: (state, action: PayloadAction) => { + const product = action.payload; + const existingIndex = state.items.findIndex( + item => item.id === product.id, + ); + + if (existingIndex >= 0) { + state.items.splice(existingIndex, 1); + } else { + state.items.push(product); + } + }, + }, +}); + +export const { toggleFavorite } = favoritesSlice.actions; +export default favoritesSlice.reducer; diff --git a/src/store/hooks.ts b/src/store/hooks.ts new file mode 100644 index 00000000000..f92270e7bd9 --- /dev/null +++ b/src/store/hooks.ts @@ -0,0 +1,5 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './index'; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 00000000000..4660861fed4 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,20 @@ +import { configureStore } from '@reduxjs/toolkit'; +import cartReducer from './cartSlice'; +import favoritesReducer from './favoritesSlice'; + +export const store = configureStore({ + reducer: { + cart: cartReducer, + favorites: favoritesReducer, + }, +}); + +store.subscribe(() => { + const state = store.getState(); + + localStorage.setItem('cart', JSON.stringify(state.cart.items)); + localStorage.setItem('favorites', JSON.stringify(state.favorites.items)); +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss new file mode 100644 index 00000000000..b07ea2873db --- /dev/null +++ b/src/styles/_mixins.scss @@ -0,0 +1,13 @@ +@import './variables'; + +@mixin tablet { + @media (min-width: $bp-tablet) { + @content; + } +} + +@mixin desktop { + @media (min-width: $bp-desktop) { + @content; + } +} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss new file mode 100644 index 00000000000..13e1d7f5d13 --- /dev/null +++ b/src/styles/_variables.scss @@ -0,0 +1,16 @@ +$c-bg-dark: #0F1121; +$c-bg-card: #161827; +$c-bg-elements: #161618; +$c-text-primary: #fff; +$c-text-secondary: #75767f; +$c-accent-purple: #9056ec; +$c-accent-red: #eb5757; +$c-border: #323542; +$c-primary: #905BFF; +$c-primary-hover: #A378FF; +$c-selected: #323542; +$c-border: #323542; +$font-family: 'Mont', sans-serif; +$bp-mobile: 320px; +$bp-tablet: 640px; +$bp-desktop: 1200px; diff --git a/src/types/CartItem.ts b/src/types/CartItem.ts new file mode 100644 index 00000000000..e22f60a5541 --- /dev/null +++ b/src/types/CartItem.ts @@ -0,0 +1,7 @@ +import { Product } from './Product'; + +export interface CartItem { + id: string; + quantity: number; + product: Product; +} diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 00000000000..90032fd2c8b --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,37 @@ +export interface Product { + id: string; + category: string; + phoneId?: string; + itemId?: string; + name: string; + fullPrice: number; + price: number; + priceRegular?: number; + priceDiscount?: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; + imageUrl?: string; + images?: string[]; +} + +export interface ProductDescription { + title: string; + text: string[]; +} + +export interface ProductDetails extends Product { + namespaceId: string; + capacityAvailable: string[]; + colorsAvailable: string[]; + description: ProductDescription[]; + resolution: string; + processor: string; + zoom?: string; + cell: string[]; + camera?: string; + images: string[]; +} diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..4c1c856a931 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,10 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], -}) +export default defineConfig(({ command }) => { + return { + plugins: [react()], + // Якщо ти назвав репозиторій на гітхабі інакше — зміни назву між слешами + base: command === 'build' ? '/react_phone-catalog/' : '/', + }; +});