diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b51149cf57..4f84730cb1 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,7 +5,7 @@ module.exports = { }, extends: [ 'plugin:react/recommended', - "plugin:react-hooks/recommended", + 'plugin:react-hooks/recommended', 'airbnb-typescript', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended', @@ -14,11 +14,11 @@ module.exports = { ], overrides: [ { - 'files': ['**/*.spec.jsx'], - 'rules': { + files: ['**/*.spec.jsx'], + rules: { 'react/jsx-filename-extension': ['off'], - } - } + }, + }, ], parser: '@typescript-eslint/parser', parserOptions: { @@ -34,18 +34,21 @@ module.exports = { 'import', 'react-hooks', '@typescript-eslint', - 'prettier' + 'prettier', ], rules: { // JS - 'semi': 'off', + semi: 'off', '@typescript-eslint/semi': ['error', 'always'], 'prefer-const': 2, curly: [2, 'all'], - 'max-len': ['error', { - ignoreTemplateLiterals: true, - ignoreComments: true, - }], + 'max-len': [ + 'error', + { + ignoreTemplateLiterals: true, + ignoreComments: true, + }, + ], 'no-redeclare': [2, { builtinGlobals: true }], 'no-console': 2, 'operator-linebreak': 0, @@ -57,7 +60,11 @@ module.exports = { 2, { blankLine: 'always', prev: '*', next: 'return' }, { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' }, - { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] }, + { + blankLine: 'any', + prev: ['const', 'let', 'var'], + next: ['const', 'let', 'var'], + }, { blankLine: 'always', prev: 'directive', next: '*' }, { blankLine: 'always', prev: 'block-like', next: '*' }, ], @@ -73,16 +80,22 @@ module.exports = { 'react/jsx-props-no-spreading': 0, 'react/state-in-constructor': [2, 'never'], 'react-hooks/rules-of-hooks': 2, - 'jsx-a11y/label-has-associated-control': ["error", { - assert: "either", - }], - 'jsx-a11y/label-has-for': [2, { - components: ['Label'], - required: { - some: ['id', 'nesting'], + 'jsx-a11y/label-has-associated-control': [ + 'error', + { + assert: 'either', }, - allowChildren: true, - }], + ], + 'jsx-a11y/label-has-for': [ + 2, + { + components: ['Label'], + required: { + some: ['id', 'nesting'], + }, + allowChildren: true, + }, + ], 'react/jsx-uses-react': 'off', 'react/react-in-jsx-scope': 'off', @@ -91,7 +104,9 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-unused-vars': ['error'], '@typescript-eslint/indent': ['error', 2], - '@typescript-eslint/ban-types': ['error', { + '@typescript-eslint/ban-types': [ + 'error', + { extendDefaults: true, types: { '{}': false, @@ -99,7 +114,13 @@ module.exports = { }, ], }, - ignorePatterns: ['dist', '.eslintrc.cjs', 'vite.config.ts', 'src/vite-env.d.ts', 'cypress'], + ignorePatterns: [ + 'dist', + '.eslintrc.cjs', + 'vite.config.ts', + 'src/vite-env.d.ts', + 'cypress', + ], settings: { react: { version: 'detect', diff --git a/package-lock.json b/package-lock.json index 836b9e63b4..c04af0e507 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,16 +11,20 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "bootstrap": "^5.3.3", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", + "react-bootstrap": "^2.10.5", "react-dom": "^18.3.1", + "react-modal": "^3.16.1", "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "swiper": "^11.1.14" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1184,10 +1188,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": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -1875,6 +1880,31 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.6.tgz", + "integrity": "sha512-iLo82l82ilMiVGy342SELjshuWottlb5+VefO3jOQqQRNYnJBFpUSadswDPbRimSgJUZuFwIEYs6AabkP038fA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@remix-run/router": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz", @@ -1883,6 +1913,48 @@ "node": ">=14.0.0" } }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.8.0.tgz", + "integrity": "sha512-xJEOXUOTmT4FngTmhdjKFRrVVF0hwCLNPdatLCHkyS4dkiSK12cEu1Y0fjxktjJrdst9jJIc5J6ihMJCoWEN/g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "@popperjs/core": "^2.11.6", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.4.9", + "@types/warning": "^3.0.0", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.1.tgz", @@ -2126,6 +2198,15 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@swc/helpers": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", + "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2215,14 +2296,12 @@ "node_modules/@types/prop-types": { "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 + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "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, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2241,7 +2320,6 @@ "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", - "dev": true, "dependencies": { "@types/react": "*" } @@ -2258,6 +2336,12 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2982,6 +3066,25 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true }, + "node_modules/bootstrap": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", + "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -3904,6 +4007,15 @@ "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -4986,6 +5098,12 @@ "node": ">=4" } }, + "node_modules/exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==", + "license": "BSD-3-Clause" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -6026,6 +6144,15 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -8611,6 +8738,19 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, "node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -8717,6 +8857,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-bootstrap": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.5.tgz", + "integrity": "sha512-XueAOEn64RRkZ0s6yzUTdpFtdUXs5L5491QU//8ZcODKJNDLt/r01tNyriZccjgRImH1REynUc9pqjiRMpDLWQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.6.9", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -8734,6 +8904,31 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-modal": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", + "integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==", + "license": "MIT", + "dependencies": { + "exenv": "^1.2.0", + "prop-types": "^15.7.2", + "react-lifecycles-compat": "^3.0.0", + "warning": "^4.0.3" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18", + "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -9930,6 +10125,25 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, + "node_modules/swiper": { + "version": "11.1.14", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-11.1.14.tgz", + "integrity": "sha512-VbQLQXC04io6AoAjIUWuZwW4MSYozkcP9KjLdrsG/00Q/yiwvhz9RQyt0nHXV10hi9NVnDNy1/wv7Dzq1lkOCQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/swiperjs" + }, + { + "type": "open_collective", + "url": "http://opencollective.com/swiper" + } + ], + "license": "MIT", + "engines": { + "node": ">= 4.7.0" + } + }, "node_modules/synckit": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", @@ -10203,8 +10417,7 @@ "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -10358,6 +10571,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -10542,6 +10770,15 @@ } } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index ae251685c8..eb26248b7e 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,20 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "bootstrap": "^5.3.3", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", + "react-bootstrap": "^2.10.5", "react-dom": "^18.3.1", + "react-modal": "^3.16.1", "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "swiper": "^11.1.14" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -57,7 +61,8 @@ "update": "mate-scripts update", "postinstall": "npm run update && cypress verify", "predeploy": "npm run build", - "deploy": "mate-scripts deploy" + "deploy": "mate-scripts deploy", + "dev": "vite" }, "browserslist": { "production": [ diff --git a/public/img/banner-phones.png b/public/img/banners/image1.png similarity index 100% rename from public/img/banner-phones.png rename to public/img/banners/image1.png diff --git a/public/img/banner-tablets.png b/public/img/banners/image2.png similarity index 100% rename from public/img/banner-tablets.png rename to public/img/banners/image2.png diff --git a/public/img/banner-accessories.png b/public/img/banners/image3.png similarity index 100% rename from public/img/banner-accessories.png rename to public/img/banners/image3.png diff --git a/public/img/category-accessories.png b/public/img/category-accessories.png deleted file mode 100644 index 67c5bfdb35..0000000000 Binary files a/public/img/category-accessories.png and /dev/null differ diff --git a/public/img/category-phones.webp b/public/img/category-phones.webp deleted file mode 100644 index 5ba1b294b1..0000000000 Binary files a/public/img/category-phones.webp and /dev/null differ diff --git a/public/img/category-tablets.png b/public/img/category-tablets.png deleted file mode 100644 index 57e33c5807..0000000000 Binary files a/public/img/category-tablets.png and /dev/null differ diff --git a/public/img/shopByCategory/accessories.png b/public/img/shopByCategory/accessories.png new file mode 100644 index 0000000000..1847eb7a89 Binary files /dev/null and b/public/img/shopByCategory/accessories.png differ diff --git a/public/img/shopByCategory/phones.png b/public/img/shopByCategory/phones.png new file mode 100644 index 0000000000..01cb631053 Binary files /dev/null and b/public/img/shopByCategory/phones.png differ diff --git a/public/img/shopByCategory/tablets.png b/public/img/shopByCategory/tablets.png new file mode 100644 index 0000000000..68084fd25a Binary files /dev/null and b/public/img/shopByCategory/tablets.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000000..cffa35ba1d --- /dev/null +++ b/public/index.html @@ -0,0 +1,11 @@ + + + + + + Phone catalog + + +
+ + diff --git a/src/App.module.scss b/src/App.module.scss new file mode 100644 index 0000000000..ca3f0f607d --- /dev/null +++ b/src/App.module.scss @@ -0,0 +1,94 @@ +@import './normilize.css'; +@import './utils/mixins'; +@import './utils/variables'; + +@font-face { + font-family: Mont; + src: url(../public/fonts/Mont-Regular.otf); + font-weight: 400; +} + +@font-face { + font-family: Mont; + src: url(../public/fonts/Mont-Bold.otf); + font-weight: 700; +} + +@font-face { + font-family: Mont; + src: url(../public/fonts/Mont-SemiBold.otf); + font-weight: 600; +} + +html { + scroll-behavior: smooth; + &:has(.app__menu:target) { + overflow: hidden; + } +} + +body { + font-family: Mont, sans-serif; + font-size: 12px; +} + +.app { + min-height: 100vh; + background-color: var(--color-page-background); + + &__header { + position: sticky; + z-index: 2; + top: 0; + left: 0; + right: 0; + } + + &__title { + display: none; + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: -1px !important; + border: 0 !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + } + + &__container { + max-width: 1136px; + box-sizing: content-box; + min-height: calc(100vh - $header-height-mobile - $footer-height-mobile); + + margin: 0 auto; + padding-inline: 16px; + + @include on-tablet { + min-height: calc(100vh - $header-height-tablet - $footer-height-tablet); + padding-inline: 24px; + } + + @include on-desktop { + min-height: calc(100vh - $header-height-desktop - $footer-height-desktop); + padding-inline: 32px; + } + } + + &__menu { + position: fixed; + top: 0; + left: 0; + right: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + z-index: 2; + + &--open { + opacity: 1; + pointer-events: all; + } + } +} diff --git a/src/App.scss b/src/App.scss deleted file mode 100644 index 71bc413aad..0000000000 --- a/src/App.scss +++ /dev/null @@ -1 +0,0 @@ -// not empty diff --git a/src/App.tsx b/src/App.tsx index 372e4b4206..72a29becfb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,78 @@ -import './App.scss'; +import { Outlet } from 'react-router-dom'; -export const App = () => ( -
-

Product Catalog

-
-); +import styles from './App.module.scss'; + +import { Menu } from './components/Menu'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { CartProvider } from './context/CartContext'; +import React, { useEffect, useState } from 'react'; +import { FavoritesProvider } from './context/FavoritesContext'; +import { ThemeProvider, useTheme } from './context/ThemeContext'; +type Props = { + isMenuOpen: boolean; + openMenu: () => void; + closeMenu: () => void; +}; + +const AppContent: React.FC = ({ isMenuOpen, openMenu, closeMenu }) => { + const { isDarkTheme } = useTheme(); + + useEffect(() => { + if (isDarkTheme) { + document.body.classList.add('dark-theme'); + } else { + document.body.classList.remove('dark-theme'); + } + }, [isDarkTheme]); + + return ( +
+

Product Catalog

+
+
+
+
+ +
+
+ +
+
+
+
+
+ ); +}; + +export const App = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const openMenu = () => { + setIsMenuOpen(true); + document.body.style.overflow = 'hidden'; + }; + + const closeMenu = () => { + setIsMenuOpen(false); + document.body.style.overflow = ''; + }; + + return ( + + + + + + + + ); +}; diff --git a/src/Root.tsx b/src/Root.tsx new file mode 100644 index 0000000000..9f7b979f98 --- /dev/null +++ b/src/Root.tsx @@ -0,0 +1,29 @@ +import { Routes, Route, BrowserRouter } from 'react-router-dom'; +import { App } from './App'; +import { HomePage } from './pages/HomePage'; +import { Catalog } from './pages/Catalog'; +import { NotFoundPage } from './pages/NotFoundPage'; +import { ProductDetailsPage } from './pages/ProductDetailsPage'; +import { CartPage } from './pages/CartPage'; +import { Favorites } from './pages/Favorites'; +import { BASE_URL } from './utils/constants'; + +export const Root = () => ( + + + }> + } /> + + } /> + } /> + + + } /> + } /> + + + } /> + + + +); diff --git a/src/components/AboutProduct/AboutProduct.module.scss b/src/components/AboutProduct/AboutProduct.module.scss new file mode 100644 index 0000000000..282300dc0a --- /dev/null +++ b/src/components/AboutProduct/AboutProduct.module.scss @@ -0,0 +1,25 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.aboutProduct { + display: flex; + flex-direction: column; + gap: 32px; + + &__title { + padding-bottom: 16px; + border-bottom: 1px solid var(--color-grey-second); + } + + &__description { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__text { + @include body-text; + + color: var(--color-grey); + } +} diff --git a/src/components/AboutProduct/AboutProduct.tsx b/src/components/AboutProduct/AboutProduct.tsx new file mode 100644 index 0000000000..9157df2e42 --- /dev/null +++ b/src/components/AboutProduct/AboutProduct.tsx @@ -0,0 +1,28 @@ +import styles from './AboutProduct.module.scss'; +import { Title } from '../Title'; +import React from 'react'; +import { Product, ProductDescription } from '../../utils/types'; + +type Props = { + product: Product; +}; + +export const AboutProduct: React.FC = ({ product }) => { + return ( +
+
+ About +
+ {product.description.map((desc: ProductDescription, index) => { + return ( +
+
+ {desc.title} +
+
{desc.text}
+
+ ); + })} +
+ ); +}; diff --git a/src/components/AboutProduct/index.js b/src/components/AboutProduct/index.js new file mode 100644 index 0000000000..046edc0496 --- /dev/null +++ b/src/components/AboutProduct/index.js @@ -0,0 +1 @@ +export * from './AboutProduct'; diff --git a/src/components/AddToFavourites/AddToFavourites.module.scss b/src/components/AddToFavourites/AddToFavourites.module.scss new file mode 100644 index 0000000000..aa92152060 --- /dev/null +++ b/src/components/AddToFavourites/AddToFavourites.module.scss @@ -0,0 +1,26 @@ +@import '../../utils/variables'; + +.addToFavourites { + background-image: url(../../img/icons/heart.png); + background-size: 16px 16px; + background-repeat: no-repeat; + background-position: center; + background-color: var(--color-button); + border: 1px solid var(--color-button-border); + border-radius: 48px; + + &--dark { + background-image: url(../../img/icons/night_theme_heart.png); + } + + &--added { + background-image: url(../../img/icons/heart_yellow.png); + background-color: var(--color-button-disabled); + border: 1px solid var(--color-button-border-disabled); + } + + &:hover { + background-color: var(--color-button-hover); + border: 1px solid var(--color-button-border-hover); + } +} diff --git a/src/components/AddToFavourites/AddToFavourites.tsx b/src/components/AddToFavourites/AddToFavourites.tsx new file mode 100644 index 0000000000..b87f975495 --- /dev/null +++ b/src/components/AddToFavourites/AddToFavourites.tsx @@ -0,0 +1,47 @@ +import React, { useContext } from 'react'; +import styles from './AddToFavourites.module.scss'; +import classNames from 'classnames'; +import { Products } from '../../utils/types'; +import { FavoritesContext } from '../../context/FavoritesContext'; +import { useTheme } from '../../context/ThemeContext'; + +type Props = { + size: 's' | 'm'; + product: Products; +}; +export const AddToFavourites: React.FC = ({ size, product }) => { + const { isDarkTheme } = useTheme(); + const { favorites, setFavorites } = useContext(FavoritesContext); + + const productExistinFavorites = favorites.find( + favItem => favItem.itemId === product.itemId, + ); + + const handleFavoritesAdd = () => { + setFavorites(prevFavorites => [...prevFavorites, product]); + }; + + const handleFavoritesRemove = () => { + setFavorites(previousFavorites => + previousFavorites.filter(prevItem => prevItem.itemId !== product.itemId), + ); + }; + + const elementSize = + size === 's' + ? { width: '40px', height: '40px' } + : { width: '48px', height: '48px' }; + + return ( + + ); +}; diff --git a/src/components/AddToFavourites/index.js b/src/components/AddToFavourites/index.js new file mode 100644 index 0000000000..96d230a952 --- /dev/null +++ b/src/components/AddToFavourites/index.js @@ -0,0 +1 @@ +export * from './AddToFavourites'; diff --git a/src/components/ArrowButton/ArrowButton.module.scss b/src/components/ArrowButton/ArrowButton.module.scss new file mode 100644 index 0000000000..9def218f4d --- /dev/null +++ b/src/components/ArrowButton/ArrowButton.module.scss @@ -0,0 +1,83 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.arrowButton { + display: block; + width: 32px; + height: 32px; + + border: 1px solid var(--color-button-border); + background-color: var(--color-button); + border-radius: 48px; + + background-size: 16px 16px; + background-repeat: no-repeat; + background-position: center; + + &:hover { + border-color: var(--color-button-border-hover); + background-color: var(--color-button-hover); + } + + &--right { + background-image: url(../../img/icons/arrow_right_black.png); + } + + &--right-dark { + background-image: url(../../img/icons/arrow_right_white.png); + } + + &--left { + background-image: url(../../img/icons/arrow_left_black.png); + } + + &--left-dark { + background-image: url(../../img/icons/arrow_left_white.png); + } + + &--up { + background-image: url(../../img/icons/arrow_up_black.png); + } + + &--up-dark { + background-image: url(../../img/icons/arrow_up_white.png); + } + + &--down { + background-image: url(../../img/icons/arrow_down_grey.png); + } + + &--wide { + height: 189px; + + @include on-desktop { + height: 400px; + } + } + + &--right.arrowButton--disabled, + &--right-dark.arrowButton--disabled { + background-color: var(--color-button-disabled); + border: 1px solid var(--color-button-border-disabled); + background-image: url(../../img/icons/arrow_right_grey.png); + } + + &--left.arrowButton--disabled, + &--left-dark.arrowButton--disabled { + background-color: var(--color-button-disabled); + border: 1px solid var(--color-button-border-disabled); + background-image: url(../../img/icons/arrow_left_grey.png); + } + + &--up.arrowButton--disabled { + background-color: var(--color-button-disabled); + border: 1px solid var(--color-button-border-disabled); + background-image: url(../../img/icons/arrow_up_grey.png); + } + + &--down.arrowButton--disabled { + background-color: var(--color-button-disabled); + border: 1px solid var(--color-button-border-disabled); + background-image: url(../../img/icons/arrow_down_grey.png); + } +} diff --git a/src/components/ArrowButton/ArrowButton.tsx b/src/components/ArrowButton/ArrowButton.tsx new file mode 100644 index 0000000000..a13edd229b --- /dev/null +++ b/src/components/ArrowButton/ArrowButton.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import styles from './ArrowButton.module.scss'; +import classNames from 'classnames'; +import { ArrowType } from '../../utils/types'; +import { useTheme } from '../../context/ThemeContext'; + +type Props = { + type: ArrowType; + size?: 'default' | 'wide'; + disabled?: boolean; + onClick?: () => void; +}; + +export const ArrowButton: React.FC = ({ + type, + size = 'default', + disabled = false, + onClick = () => {}, +}) => { + const { isDarkTheme } = useTheme(); + + const buttonClass = classNames(styles.arrowButton, { + [styles[`arrowButton--${type}`]]: !isDarkTheme, + [styles[`arrowButton--${type}-dark`]]: isDarkTheme, + [styles['arrowButton--wide']]: size === 'wide', + [styles['arrowButton--disabled']]: disabled, + }); + + return ( + + ); +}; diff --git a/src/components/ArrowButton/index.js b/src/components/ArrowButton/index.js new file mode 100644 index 0000000000..ce91b1996e --- /dev/null +++ b/src/components/ArrowButton/index.js @@ -0,0 +1 @@ +export * from './ArrowButton'; diff --git a/src/components/ArrowGrey/ArrowGrey.module.scss b/src/components/ArrowGrey/ArrowGrey.module.scss new file mode 100644 index 0000000000..fc2434ef8b --- /dev/null +++ b/src/components/ArrowGrey/ArrowGrey.module.scss @@ -0,0 +1,7 @@ +.arrow { + width: 16px; + height: 16px; + background-image: url(../../img/icons/arrow_right_grey.png); + background-repeat: no-repeat; + background-position: center; +} diff --git a/src/components/ArrowGrey/ArrowGrey.tsx b/src/components/ArrowGrey/ArrowGrey.tsx new file mode 100644 index 0000000000..9654e32655 --- /dev/null +++ b/src/components/ArrowGrey/ArrowGrey.tsx @@ -0,0 +1,4 @@ +import styles from './ArrowGrey.module.scss'; +export const ArrowGrey = () => { + return
; +}; diff --git a/src/components/ArrowGrey/index.js b/src/components/ArrowGrey/index.js new file mode 100644 index 0000000000..274ab4fa31 --- /dev/null +++ b/src/components/ArrowGrey/index.js @@ -0,0 +1 @@ +export * from './ArrowGrey'; diff --git a/src/components/Breadcrumbs/Breadcrumbs.module.scss b/src/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 0000000000..9562f9e23f --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,33 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.breadcrumbs { + padding-top: 24px; + padding-bottom: 24px; + display: flex; + gap: 8px; + + @include on-tablet { + padding-bottom: 40px; + } + + &__prev-page { + display: flex; + justify-content: center; + align-items: center; + text-decoration: none; + font-weight: 600; + color: var(--color-black); + line-height: 15px; + text-transform: capitalize; + } + + &__current-page { + line-height: 15px; + font-weight: 600; + color: var(--color-grey); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000000..c9fafefde0 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,34 @@ +import { NavLink, useParams } from 'react-router-dom'; +import styles from './Breadcrumbs.module.scss'; +import React from 'react'; +import { useTheme } from '../../context/ThemeContext'; +import homeIcon from '../../img/icons/home.png'; +import homeIconDark from '../../img/icons/night_theme_home.png'; +import { ArrowGrey } from '../ArrowGrey'; +import { BASE_URL } from '../../utils/constants'; + +type Props = { + name: string | undefined; +}; +export const Breadcrumbs: React.FC = ({ name }) => { + const { category } = useParams(); + const { isDarkTheme } = useTheme(); + + return ( +
+ + home icon + + + + {category} + + +

{name}

+
+ ); +}; diff --git a/src/components/Breadcrumbs/index.js b/src/components/Breadcrumbs/index.js new file mode 100644 index 0000000000..ce977548b1 --- /dev/null +++ b/src/components/Breadcrumbs/index.js @@ -0,0 +1 @@ +export * from './Breadcrumbs'; diff --git a/src/components/CartIsEmpty/CartIsEmpty.module.scss b/src/components/CartIsEmpty/CartIsEmpty.module.scss new file mode 100644 index 0000000000..29eee9db52 --- /dev/null +++ b/src/components/CartIsEmpty/CartIsEmpty.module.scss @@ -0,0 +1,20 @@ +@import '../../utils/mixins'; + +.cartIsEmpty { + &__container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 20px; + } + &__image { + width: 100px; + height: 100px; + + @include on-tablet { + width: 200px; + height: 200px; + } + } +} diff --git a/src/components/CartIsEmpty/CartIsEmpty.tsx b/src/components/CartIsEmpty/CartIsEmpty.tsx new file mode 100644 index 0000000000..e60028af11 --- /dev/null +++ b/src/components/CartIsEmpty/CartIsEmpty.tsx @@ -0,0 +1,18 @@ +import { BASE_URL } from '../../utils/constants'; +import { Title } from '../Title'; +import styles from './CartIsEmpty.module.scss'; + +export const CartIsEmpty = () => { + return ( +
+
+ Your cart is empty + not found image +
+
+ ); +}; diff --git a/src/components/CartIsEmpty/index.js b/src/components/CartIsEmpty/index.js new file mode 100644 index 0000000000..4955d89fda --- /dev/null +++ b/src/components/CartIsEmpty/index.js @@ -0,0 +1 @@ +export * from './CartIsEmpty'; diff --git a/src/components/CartProduct/CartProduct.module.scss b/src/components/CartProduct/CartProduct.module.scss new file mode 100644 index 0000000000..ecc3b2d9b2 --- /dev/null +++ b/src/components/CartProduct/CartProduct.module.scss @@ -0,0 +1,92 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.cartProduct { + width: 100%; + height: 160px; + padding: 16px; + border: 1px solid var(--color-grey-third); + background-color: var(--color-card-background); + border-radius: 16px; + + display: flex; + flex-direction: column; + gap: 16px; + + &:hover { + box-shadow: 0 2px 16px 0 #0000001a; + } + + @include on-tablet { + padding: 24px; + flex-direction: row; + justify-content: space-between; + gap: 24px; + } + + &__top { + display: flex; + justify-content: space-between; + align-items: center; + + @include on-tablet { + gap: 24px; + } + } + + &__bottom { + display: flex; + justify-content: space-between; + align-items: center; + + @include on-tablet { + gap: 50px; + } + } + + &__buttons { + display: flex; + gap: 14px; + align-items: center; + } + + &__buttonClose { + background-color: var(--color-card-background); + border: none; + } + + &__deleteIcon { + width: 16px; + height: 16px; + background-image: url(../../img/icons/close-grey.png); + background-repeat: no-repeat; + background-position: center; + } + + &__image { + width: 80px; + height: 80px; + object-fit: contain; + } + + &__name { + color: var(--color-black); + width: 128px; + + @include body-text; + + @include on-tablet { + width: 136px; + } + + @include on-desktop { + width: 100%; + } + } + + &__quantity { + color: var(--color-black); + + @include body-text; + } +} diff --git a/src/components/CartProduct/CartProduct.tsx b/src/components/CartProduct/CartProduct.tsx new file mode 100644 index 0000000000..cec379863e --- /dev/null +++ b/src/components/CartProduct/CartProduct.tsx @@ -0,0 +1,90 @@ +import React, { useContext } from 'react'; +import styles from './CartProduct.module.scss'; +import { CartItem } from '../../utils/types'; +import { QuantityButton } from '../QuantityButton'; +import { Title } from '../Title'; +import { CartContext } from '../../context/CartContext'; +import { BASE_URL } from '../../utils/constants'; + +type Props = { + cartProduct: CartItem; +}; +export const CartProduct: React.FC = ({ cartProduct }) => { + const product = cartProduct.product; + const { setCart } = useContext(CartContext); + + // #region functions + + function handleProductDelete(id: number) { + setCart(prevCart => prevCart.filter(item => item.id !== id)); + } + + function handleQantityDecrease(id: number) { + setCart(prevCart => + prevCart.map((cartItem: CartItem) => { + if (cartItem.id === id) { + return { ...cartItem, quantity: cartItem.quantity - 1 }; + } else { + return cartItem; + } + }), + ); + } + + function handleQantityIncrease(id: number) { + setCart(prevCart => + prevCart.map((cartItem: CartItem) => { + if (cartItem.id === id) { + return { ...cartItem, quantity: cartItem.quantity + 1 }; + } else { + return cartItem; + } + }), + ); + } + + // #endregion + + return ( +
+
+ + + product image +

{product.name}

+
+
+
+ { + handleQantityDecrease(cartProduct.id); + }} + /> +

{cartProduct.quantity}

+ { + handleQantityIncrease(cartProduct.id); + }} + /> +
+
+ {`$${product.price}`} +
+
+
+ ); +}; diff --git a/src/components/CartProduct/index.js b/src/components/CartProduct/index.js new file mode 100644 index 0000000000..2e149fe78e --- /dev/null +++ b/src/components/CartProduct/index.js @@ -0,0 +1 @@ +export * from './CartProduct'; diff --git a/src/components/CheckoutButton/CheckoutButton.module.scss b/src/components/CheckoutButton/CheckoutButton.module.scss new file mode 100644 index 0000000000..5a688a6083 --- /dev/null +++ b/src/components/CheckoutButton/CheckoutButton.module.scss @@ -0,0 +1,18 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.checkoutButton { + width: 100%; + height: 48px; + background-color: var(--color-accent); + border: 1px solid var(--color-accent-button-border); + border: none; + border-radius: 48px; + + @include buttons-text; + + &:hover { + background-color: var(--color-accent-button-hover); + box-shadow: 0 3px 13px 0 #17203166; + } +} diff --git a/src/components/CheckoutButton/CheckoutButton.tsx b/src/components/CheckoutButton/CheckoutButton.tsx new file mode 100644 index 0000000000..ad6fe2cd62 --- /dev/null +++ b/src/components/CheckoutButton/CheckoutButton.tsx @@ -0,0 +1,49 @@ +import { useContext } from 'react'; +import styles from './CheckoutButton.module.scss'; +import { useState } from 'react'; +import Button from 'react-bootstrap/Button'; +import Modal from 'react-bootstrap/Modal'; +import { CartContext } from '../../context/CartContext'; +import './modal.scss'; + +export const CheckoutButton = () => { + const { setCart } = useContext(CartContext); + const [show, setShow] = useState(false); + + const handleClose = () => setShow(false); + const handleShow = () => setShow(true); + const handleClear = () => { + setCart([]); + handleClose(); + }; + + return ( + <> + + +
+ + + Checkout is not implemented yet. Do you want to clear the Cart? + + + + + + +
+ + ); +}; diff --git a/src/components/CheckoutButton/index.js b/src/components/CheckoutButton/index.js new file mode 100644 index 0000000000..1e42f0b9ae --- /dev/null +++ b/src/components/CheckoutButton/index.js @@ -0,0 +1 @@ +export * from './CheckoutButton'; diff --git a/src/components/CheckoutButton/modal.scss b/src/components/CheckoutButton/modal.scss new file mode 100644 index 0000000000..940e2b6aef --- /dev/null +++ b/src/components/CheckoutButton/modal.scss @@ -0,0 +1,28 @@ +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; +@import 'bootstrap/scss/mixins'; +@import 'bootstrap/scss/transitions'; +@import 'bootstrap/scss/bootstrap-utilities'; +@import 'bootstrap/scss/modal'; +@import 'bootstrap/scss/buttons'; +@import '../../utils/variables'; + +.custom-modal { + .modal-content { + font-size: 18px; + } + .modal-footer { + .btn { + border-radius: 48px; + } + .btn-secondary { + background-color: var(--color-grey); + } + .btn-primary { + width: 84px; + height: 38px; + border: none; + background-color: var(--color-accent); + } + } +} diff --git a/src/components/DarkThemeButton/DarkThemeButton.module.scss b/src/components/DarkThemeButton/DarkThemeButton.module.scss new file mode 100644 index 0000000000..2f08ced706 --- /dev/null +++ b/src/components/DarkThemeButton/DarkThemeButton.module.scss @@ -0,0 +1,31 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.darkThemeButton { + width: 48px; + height: 48px; + box-shadow: -1px 0 0 0 var(--color-grey-third); + border-bottom: 1px solid var(--color-grey-third); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + @include on-desktop { + width: 64px; + height: 64px; + } + + &__btn { + width: 20px; + height: 20px; + outline: none; + border: none; + + background-color: transparent; + background-image: url(../../img/icons/night_theme_btn.png); + background-size: cover; + background-repeat: no-repeat; + background-position: center; + } +} diff --git a/src/components/DarkThemeButton/DarkThemeButton.tsx b/src/components/DarkThemeButton/DarkThemeButton.tsx new file mode 100644 index 0000000000..a401bb641c --- /dev/null +++ b/src/components/DarkThemeButton/DarkThemeButton.tsx @@ -0,0 +1,15 @@ +import { useTheme } from '../../context/ThemeContext'; +import styles from './DarkThemeButton.module.scss'; + +export const DarkThemeButton = () => { + const { isDarkTheme, toggleTheme } = useTheme(); + + return ( +
+

+ {isDarkTheme ? 'OFF' : 'ON'} +

+
+ ); +}; diff --git a/src/components/DarkThemeButton/index.js b/src/components/DarkThemeButton/index.js new file mode 100644 index 0000000000..80839bb086 --- /dev/null +++ b/src/components/DarkThemeButton/index.js @@ -0,0 +1 @@ +export * from './DarkThemeButton'; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 0000000000..15321b34f3 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,67 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.footer { + height: $footer-height-mobile; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 32px 16px; + border-top: 1px solid var(--color-grey-third); + background-color: var(--color-header-footer); + + @include on-tablet { + height: $footer-height-tablet; + padding: 32px; + flex-direction: row; + align-items: center; + } + + &__logo { + display: block; + width: 89px; + height: 32px; + } + + &__contacts { + display: flex; + flex-direction: column; + gap: 16px; + + @include on-tablet { + flex-direction: row; + gap: 14px; + } + + @include on-desktop { + gap: 106px; + } + } + + &__contact { + display: block; + color: var(--color-footer-links); + text-decoration: none; + text-transform: uppercase; + font-weight: 800; + line-height: 11px; + letter-spacing: 4%; + + &:hover { + color: var(--color-grey); + } + } + + &__back { + align-self: center; + display: flex; + align-items: center; + gap: 16px; + } + + &__back-title { + color: var(--color-grey); + + @include small-text; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..e755386d1d --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,52 @@ +import { Link } from 'react-router-dom'; +import logo from '../../img/logo.png'; +import logoDarkTheme from '../../img/night_theme_logo.png'; +import styles from './Footer.module.scss'; +import { ArrowButton } from '../ArrowButton'; +import { ArrowType } from '../../utils/types'; +import { useTheme } from '../../context/ThemeContext'; + +export const Footer = () => { + const { isDarkTheme } = useTheme(); + + return ( +
+ + {isDarkTheme ? ( + page logo + ) : ( + page logo + )} + +
+ + github + + + contacts + + + rights + +
+
+

Back to top

+ + + +
+
+ ); +}; diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js new file mode 100644 index 0000000000..ddcc5a9cd1 --- /dev/null +++ b/src/components/Footer/index.js @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/GoBack/GoBack.module.scss b/src/components/GoBack/GoBack.module.scss new file mode 100644 index 0000000000..d0ab7c8778 --- /dev/null +++ b/src/components/GoBack/GoBack.module.scss @@ -0,0 +1,15 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.goBack { + @include small-text; + + display: flex; + justify-content: center; + align-items: center; + color: var(--color-grey); + + &:hover { + color: var(--color-black); + } +} diff --git a/src/components/GoBack/GoBack.tsx b/src/components/GoBack/GoBack.tsx new file mode 100644 index 0000000000..0af209567b --- /dev/null +++ b/src/components/GoBack/GoBack.tsx @@ -0,0 +1,25 @@ +import { useNavigate } from 'react-router-dom'; +import styles from './GoBack.module.scss'; +import React from 'react'; +import { useTheme } from '../../context/ThemeContext'; + +type Props = { + children: React.ReactNode; +}; +export const GoBack: React.FC = ({ children }) => { + const navigate = useNavigate(); + const { isDarkTheme } = useTheme(); + + return ( +
navigate(-1)} + style={{ + cursor: 'pointer', + color: isDarkTheme ? '#F1F2F9' : '#89939A', + }} + > + {children} +
+ ); +}; diff --git a/src/components/GoBack/index.js b/src/components/GoBack/index.js new file mode 100644 index 0000000000..7a534e1e6b --- /dev/null +++ b/src/components/GoBack/index.js @@ -0,0 +1 @@ +export * from './GoBack'; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 0000000000..4cac5124ef --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,109 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.header { + height: $header-height-mobile; + display: none; + background-color: var(--color-header-footer); + border-bottom: 1px solid var(--color-grey-third); + + @include on-tablet { + display: flex; + justify-content: space-between; + } + + @include on-desktop { + height: $header-height-desktop; + } + + &--mobile { + display: flex; + justify-content: space-between; + + @include on-tablet { + display: none; + } + } + + &__menuButton { + display: block; + width: 48px; + height: 48px; + background-size: 16px 16px; + background-repeat: no-repeat; + background-position: center; + box-shadow: -1px 0 0 0 var(--color-grey-third); + border: none; + border-bottom: 1px solid var(--color-grey-third); + background-color: #fff; + background-image: url(../../img/icons/menu.png); + + &--dark { + background-color: #0f1121; + background-image: url(../../img/icons/menu-white.png); + } + + @include on-desktop { + width: 64px; + height: 64px; + } + } + + &__left { + display: flex; + gap: 16px; + + @include on-tablet { + gap: 24px; + } + } + + &__right { + display: flex; + } + + &__logo { + display: flex; + margin: 13px 16px; + width: 64px; + height: 22px; + + @include on-desktop { + margin: 18px 24px; + width: 80px; + height: 28px; + } + } + + &__nav { + height: 100%; + display: flex; + align-items: center; + + &_list { + height: 100%; + display: flex; + align-items: center; + list-style: none; + gap: 32px; + } + + &_link { + @include nav-link-styles; + + display: flex; + align-items: center; + height: 100%; + + &:hover { + color: var(--color-black); + } + } + + &_item { + height: 100%; + display: flex; + align-items: center; + } + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..eefbbde5ae --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,130 @@ +import { NavLink, Link } from 'react-router-dom'; +import { IconType } from '../../utils/types'; +import { HeaderIcon } from '../HeaderIcon'; + +import logo from '../../img/logo.png'; +import logoDarkTheme from '../../img/night_theme_logo.png'; +import styles from './Header.module.scss'; +import React, { useContext } from 'react'; +import { CartContext } from '../../context/CartContext'; +import { FavoritesContext } from '../../context/FavoritesContext'; +import { useTheme } from '../../context/ThemeContext'; +import { DarkThemeButton } from '../DarkThemeButton'; +import classNames from 'classnames'; +import { BASE_URL } from '../../utils/constants'; + +type Props = { + onMenuClick: () => void; + closeMenu: () => void; +}; + +export const Header: React.FC = ({ onMenuClick, closeMenu }) => { + const { cart } = useContext(CartContext); + const { favorites } = useContext(FavoritesContext); + const { isDarkTheme } = useTheme(); + const cartQuantity = cart ? cart.length : null; + const favoritesQuantity = favorites ? favorites.length : null; + const getLinkStyle = ({ isActive }: { isActive: boolean }) => { + let color; + let borderBottom; + + if (isActive && isDarkTheme) { + color = '#F1F2F9'; + borderBottom = '3px solid #F1F2F9'; + } else if (isActive && !isDarkTheme) { + color = '#0F0F11'; + borderBottom = '3px solid #0F0F11'; + } else { + color = ''; + borderBottom = ''; + } + + return { + color, + borderBottom, + }; + }; + + return ( + <> +
+
+ + page logo + + +
+
+ + + + +
+
+
+ + page logo + +
+ + +
+
+ + ); +}; diff --git a/src/components/Header/index.js b/src/components/Header/index.js new file mode 100644 index 0000000000..266dec8a1b --- /dev/null +++ b/src/components/Header/index.js @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/HeaderIcon/HeaderIcon.module.scss b/src/components/HeaderIcon/HeaderIcon.module.scss new file mode 100644 index 0000000000..68570b1c7c --- /dev/null +++ b/src/components/HeaderIcon/HeaderIcon.module.scss @@ -0,0 +1,54 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.icon { + position: relative; + width: 48px; + height: 48px; + + @include on-desktop { + width: 64px; + height: 64px; + } + + &--wide { + width: 50%; + height: 64px; + + &:hover { + border-bottom: 2px solid var(--color-black); + } + } + + &__image { + display: block; + width: 100%; + height: 100%; + background-size: 16px 16px; + background-repeat: no-repeat; + background-position: center; + box-shadow: -1px 0 0 0 var(--color-grey-third); + } + + &__image--favourites { + background-image: url(../../img/icons/favourites.png); + } + + &__image--favourites-dark { + background-image: url(../../img/icons/night_theme_heart.png); + } + + &__image--cart { + background-image: url(../../img/icons/cart.png); + } + + &__image--cart-dark { + background-image: url(../../img/icons/night_theme_cart.png); + } + + &__quantity { + position: absolute; + left: 50%; + bottom: 50%; + } +} diff --git a/src/components/HeaderIcon/HeaderIcon.tsx b/src/components/HeaderIcon/HeaderIcon.tsx new file mode 100644 index 0000000000..dee172338d --- /dev/null +++ b/src/components/HeaderIcon/HeaderIcon.tsx @@ -0,0 +1,44 @@ +import { NavLink } from 'react-router-dom'; +import styles from './HeaderIcon.module.scss'; +import { QuantityIndicator } from '../QuantityIndicator'; +import { useTheme } from '../../context/ThemeContext'; +import { BASE_URL } from '../../utils/constants'; + +type Props = { + type: string; + href: string; + size?: 'default' | 'wide'; + onClick: () => void; + number: number | null; +}; +export const HeaderIcon: React.FC = ({ + type, + size = 'default', + href, + number, + onClick, +}) => { + const { isDarkTheme } = useTheme(); + const sizeClass = size === 'wide' ? styles['icon--wide'] : ''; + const getLinkStyle = ({ isActive }: { isActive: boolean }) => { + return { + borderBottom: isActive ? '3px solid var(--color-black)' : '', + }; + }; + + return ( +
+ + {number !== null && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/components/HeaderIcon/index.js b/src/components/HeaderIcon/index.js new file mode 100644 index 0000000000..3aac0d9d1f --- /dev/null +++ b/src/components/HeaderIcon/index.js @@ -0,0 +1 @@ +export * from './HeaderIcon'; diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 0000000000..36624981d2 --- /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 0000000000..887221a135 --- /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.js b/src/components/Loader/index.js new file mode 100644 index 0000000000..d5ce981151 --- /dev/null +++ b/src/components/Loader/index.js @@ -0,0 +1 @@ +export * from './Loader'; diff --git a/src/components/Menu/Menu.module.scss b/src/components/Menu/Menu.module.scss new file mode 100644 index 0000000000..1507f96ded --- /dev/null +++ b/src/components/Menu/Menu.module.scss @@ -0,0 +1,84 @@ +@import '../../utils/variables'; +@import '../../utils/mixins'; + +.menu { + height: 100vh; + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: var(--color-page-background); + + @include on-tablet { + display: none; + } + + &__closeButton { + display: block; + width: 48px; + height: 48px; + background-size: 16px 16px; + background-repeat: no-repeat; + background-position: center; + box-shadow: -1px 0 0 0 var(--color-grey-third); + border: none; + background-color: var(--color-page-background); + background-image: url(../../img/icons/close.png); + + &--dark { + background-image: url(../../img/icons/close-white.png); + } + + @include on-desktop { + width: 64px; + height: 64px; + } + } + + &__top-bar { + display: flex; + margin-bottom: 24px; + justify-content: space-between; + box-shadow: 0 1px 0 0 var(--color-grey-third); + background-color: var(--color-page-background); + } + + &__bottom { + display: flex; + border: 1px solid var(--color-grey-second); + } + + &__logo { + display: flex; + margin: 13px 16px; + width: 64px; + height: 22px; + } + + &__nav_list { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + list-style: none; + } + + &__nav_link { + @include nav-link-styles; + + height: 100%; + display: flex; + align-items: center; + + &:hover { + color: var(--color-black); + } + } + + &__nav_item { + height: 27px; + + &:hover { + border-bottom: 2px solid var(--color-black); + } + } +} diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx new file mode 100644 index 0000000000..e74bea0cf4 --- /dev/null +++ b/src/components/Menu/Menu.tsx @@ -0,0 +1,95 @@ +import React, { useContext } from 'react'; +import { IconType } from '../../utils/types'; +import { HeaderIcon } from '../HeaderIcon'; +import styles from './Menu.module.scss'; +import { Link } from 'react-router-dom'; +import { CartContext } from '../../context/CartContext'; +import { FavoritesContext } from '../../context/FavoritesContext'; +import { useTheme } from '../../context/ThemeContext'; +import logo from '../../img/logo.png'; +import logoDarkTheme from '../../img/night_theme_logo.png'; +import { BASE_URL } from '../../utils/constants'; + +type Props = { + closeMenu: () => void; +}; + +export const Menu: React.FC = ({ closeMenu }) => { + const { cart } = useContext(CartContext); + const { favorites } = useContext(FavoritesContext); + const { isDarkTheme } = useTheme(); + const cartQuantity = cart ? cart.length : null; + const favoritesQuantity = favorites ? favorites.length : null; + + return ( + + ); +}; diff --git a/src/components/Menu/index.js b/src/components/Menu/index.js new file mode 100644 index 0000000000..629d3d0aa1 --- /dev/null +++ b/src/components/Menu/index.js @@ -0,0 +1 @@ +export * from './Menu'; diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss new file mode 100644 index 0000000000..8cc345dfc7 --- /dev/null +++ b/src/components/Pagination/Pagination.module.scss @@ -0,0 +1,52 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.pagination { + display: flex; + justify-content: center; + padding-bottom: 64px; + + @include on-desktop { + padding-bottom: 80px; + } + + &__buttons { + // max-width: 248px; + display: flex; + gap: 16px; + } + + &__numbers { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + &__button { + display: block; + width: 32px; + height: 32px; + border: 1px solid var(--color-pagination-border); + background-color: var(--color-pagination-background); + color: var(--color-black); + border-radius: 48px; + font-family: inherit; + font-size: 14px; + line-height: 21px; + + &.pagination__button--active { + background-color: var(--color-pagination-background-selected); + border-color: var(--color-pagination-border-selected); + color: #fff; + + &:hover { + background-color: var(--color-pagination-background-selected); + } + } + + &:hover { + background-color: var(--color-pagination-background-hover); + border-color: var(--color-pagination-border-hover); + } + } +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000000..71bf0953d4 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import styles from './Pagination.module.scss'; +import { ArrowButton } from '../ArrowButton'; +import { ArrowType } from '../../utils/types'; +import classNames from 'classnames'; + +type Props = { + pagesAmount: number; + searchParams: URLSearchParams; + setSearchParams: (value: URLSearchParams) => void; + activePage: string; +}; + +export const Pagination: React.FC = ({ + pagesAmount, + searchParams, + setSearchParams, + activePage, +}) => { + const pagesAmountArray = Array.from( + { length: pagesAmount }, + (_, index) => index + 1, + ); + // #region functions + const handlePageSelect = (number: number) => { + const params = new URLSearchParams(searchParams); + + params.set('page', String(number)); + setSearchParams(params); + }; + + const handleNextClick = () => { + const params = new URLSearchParams(searchParams); + + params.set('page', String(+activePage + 1)); + setSearchParams(params); + }; + + const handlePrevClick = () => { + const params = new URLSearchParams(searchParams); + + params.set('page', String(+activePage - 1)); + setSearchParams(params); + }; + + const pagesAmountSlicedArray = ( + pageIsActive: number, + pagesToSlice: number, + ) => { + const activeIndex = pagesAmountArray.indexOf(pageIsActive); + const sliced = pagesAmountArray.slice( + activeIndex, + activeIndex + pagesToSlice + 1, + ); + + return sliced; + }; + // #endregion + + // #region vars + + const slicedArray = pagesAmountSlicedArray(+activePage, 2); + + // #endregion + + return ( +
+
+
+ +
+
+ { + pagesAmount <= 5 ? ( + pagesAmountArray.map((pageNumber: number) => ( + + )) + ) : ( + /* eslint-disable @typescript-eslint/indent */ + <> + {slicedArray[slicedArray.length - 1] === pagesAmount || + slicedArray[slicedArray.length - 1] + 1 === pagesAmount ? ( + pagesAmountArray.slice(-5).map((pageNumber: number) => ( + + )) + ) : ( + <> + {slicedArray.map((pageNumber: number) => ( + + ))} + + + + + + )} + + ) + /* eslint-enable @typescript-eslint/indent */ + } +
+ +
+ +
+
+
+ ); +}; diff --git a/src/components/Pagination/index.js b/src/components/Pagination/index.js new file mode 100644 index 0000000000..e016c96b72 --- /dev/null +++ b/src/components/Pagination/index.js @@ -0,0 +1 @@ +export * from './Pagination'; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 0000000000..d284030b46 --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,113 @@ +@import '../../utils/variables'; +@import '../../utils/mixins'; + +.productCard { + height: 440px; + padding: 32px; + display: flex; + gap: 8px; + flex-direction: column; + background-color: var(--color-card-background); + border: 1px solid var(--color-card-border); + border-radius: 8px; + + &:hover { + cursor: pointer; + box-shadow: 0 2px 16px 0 #0000001a; + } + + @include on-tablet { + height: 506px; + } + + &__imagewrapper { + display: flex; + width: 100%; + min-height: 109px; + + @include on-tablet { + min-height: 181px; + } + + @include on-desktop { + height: 196px; + } + } + + &__image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease-in-out; + + &:hover { + transform: scale(1.1); + } + } + + &__title { + @include body-text; + + padding-top: 16px; + text-decoration: none; + color: var(--color-black); + } + + &__prices { + display: flex; + gap: 8px; + } + + &__fullprice { + font-size: 22px; + font-weight: 800; + line-height: 30px; + color: var(--color-black); + } + + &__price { + font-size: 22px; + font-weight: 600; + line-height: 29px; + color: var(--color-grey); + text-decoration: line-through; + text-decoration-color: var(--color-grey); + position: relative; + } + + &__line { + height: 1px; + width: 100%; + border-bottom: 1px solid var(--color-grey-second); + } + + &__features-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + padding-block: 8px; + } + + &__features { + display: flex; + justify-content: space-between; + } + + &__feature { + @include small-text; + + color: var(--color-grey); + } + + &__feature-value { + font-weight: 700; + line-height: 15.34px; + color: var(--color-black); + } + + &__buttons { + display: flex; + gap: 8px; + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000000..dc591e4105 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,60 @@ +import styles from './ProductCard.module.scss'; +import React from 'react'; +import { Products } from '../../utils/types'; +import { ToBuyButton } from '../ToBuyButton'; +import { AddToFavourites } from '../AddToFavourites'; +import { Link } from 'react-router-dom'; +import { BASE_URL } from '../../utils/constants'; + +type Props = { + product: Products; + width?: number; +}; + +export const ProductCard: React.FC = ({ product, width }) => { + const { image, name, fullPrice, price, screen, capacity, ram } = product; + + return ( +
+ + {name} + + + {name} + +
+

{`$${fullPrice}`}

+

{price}

+
+
+
+
+

Screen

+

{screen}

+
+
+

Capacity

+

{capacity}

+
+
+

RAM

+

{ram}

+
+
+
+ + +
+
+ ); +}; diff --git a/src/components/ProductCard/index.js b/src/components/ProductCard/index.js new file mode 100644 index 0000000000..7ce031c382 --- /dev/null +++ b/src/components/ProductCard/index.js @@ -0,0 +1 @@ +export * from './ProductCard'; diff --git a/src/components/ProductSlider/ProductSlider.module.scss b/src/components/ProductSlider/ProductSlider.module.scss new file mode 100644 index 0000000000..e234ba4bd4 --- /dev/null +++ b/src/components/ProductSlider/ProductSlider.module.scss @@ -0,0 +1,29 @@ +@import '../../utils/mixins'; + +.productSlider { + &__top { + display: flex; + justify-content: space-between; + gap: 70px; + padding-bottom: 24px; + + @include on-tablet { + gap: 0; + } + } + + &__buttons { + display: flex; + gap: 16px; + } + + &__outerwrapper { + overflow: hidden; + } + + &__innerwrapper { + display: flex; + gap: 16px; + transition: transform 0.5s; + } +} diff --git a/src/components/ProductSlider/ProductSlider.tsx b/src/components/ProductSlider/ProductSlider.tsx new file mode 100644 index 0000000000..f414d4cd86 --- /dev/null +++ b/src/components/ProductSlider/ProductSlider.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; +import { ArrowType, Products, SortType } from '../../utils/types'; +import { fetchProducts } from '../../utils/fetch'; +import { ProductCard } from '../ProductCard'; +import styles from './ProductSlider.module.scss'; +import { Title } from '../Title'; +import { ArrowButton } from '../ArrowButton'; + +type Props = { + title: string; + category: string; + sortBy: SortType; +}; + +export const ProductSlider: React.FC = ({ title, category, sortBy }) => { + const [cardWidth, setCardWidth] = useState(0); + const [displayedProducts, setDisplayedProducts] = useState([]); + const [leftImageIndex, setLeftImageIndex] = useState(0); + + const SLIDER_GAP = 16; + const widthToSlide = + leftImageIndex === 0 ? 0 : leftImageIndex * (cardWidth + SLIDER_GAP); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 320 && window.innerWidth < 640) { + setCardWidth(212); + } else if (window.innerWidth >= 640 && window.innerWidth < 1200) { + setCardWidth(237); + } else if (window.innerWidth >= 1200) { + setCardWidth(272); + } + }; + + handleResize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + useEffect(() => { + fetchProducts(category, sortBy).then(result => { + setDisplayedProducts(result); + }); + }, [category, sortBy]); + + const handleNextClick = () => { + setLeftImageIndex(prevIndex => prevIndex + 1); + }; + + const handlePrevClick = () => { + setLeftImageIndex(prevIndex => prevIndex - 1); + }; + + return ( +
+
+ {title} +
+ + = displayedProducts.length - 1} + /> +
+
+
+
+ {displayedProducts?.map(displayedProduct => ( + + ))} +
+
+
+ ); +}; diff --git a/src/components/ProductSlider/index.js b/src/components/ProductSlider/index.js new file mode 100644 index 0000000000..5349238270 --- /dev/null +++ b/src/components/ProductSlider/index.js @@ -0,0 +1 @@ +export * from './ProductSlider'; diff --git a/src/components/ProductsList/ProductsList.module.scss b/src/components/ProductsList/ProductsList.module.scss new file mode 100644 index 0000000000..1b6af699f3 --- /dev/null +++ b/src/components/ProductsList/ProductsList.module.scss @@ -0,0 +1,33 @@ +@import '../../utils/mixins'; + +.products { + &__list { + display: flex; + flex-direction: column; + gap: 40px; + + @include on-tablet { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 40px 16px; + } + + @include on-desktop { + grid-template-columns: repeat(24, 1fr); + } + } + + &__item { + @include on-tablet { + grid-column: span 6; + } + + @include on-media-screen { + grid-column: span 4; + } + + @include on-desktop { + grid-column: span 6; + } + } +} diff --git a/src/components/ProductsList/ProductsList.tsx b/src/components/ProductsList/ProductsList.tsx new file mode 100644 index 0000000000..3e07f9183a --- /dev/null +++ b/src/components/ProductsList/ProductsList.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { ProductCard } from '../ProductCard'; +import { Products } from '../../utils/types'; +import styles from './ProductsList.module.scss'; + +type Props = { + products: Products[]; +}; +export const ProductsList: React.FC = ({ products }) => { + return ( +
+ {products.map((product: Products) => { + return ( +
+ +
+ ); + })} +
+ ); +}; diff --git a/src/components/ProductsList/index.js b/src/components/ProductsList/index.js new file mode 100644 index 0000000000..09f9887f27 --- /dev/null +++ b/src/components/ProductsList/index.js @@ -0,0 +1 @@ +export * from './ProductsList'; diff --git a/src/components/QuantityButton/QuantityButton.module.scss b/src/components/QuantityButton/QuantityButton.module.scss new file mode 100644 index 0000000000..85136946c1 --- /dev/null +++ b/src/components/QuantityButton/QuantityButton.module.scss @@ -0,0 +1,47 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.quantityButton { + display: block; + width: 32px; + height: 32px; + + border: 1px solid var(--color-button-border); + background-color: var(--color-button); + border-radius: 48px; + + background-size: 16px 16px; + background-repeat: no-repeat; + background-position: center; + + &:hover { + border: 1px solid var(--color-button-border-hover); + background-color: var(--color-button-hover); + } + + &--plus { + background-image: url(../../img/icons/plus.png); + } + + &--plus-dark { + background-image: url(../../img/icons/plus-white.png); + } + + &--minus { + background-image: url(../../img/icons/minus.png); + } + + &--minus-dark { + background-image: url(../../img/icons/minus-white.png); + } + + &--disabled { + background-image: url(../../img/icons/minus-grey.png); + background-color: var(--color-button-disabled); + border-color: var(--color-button-border-disabled); + + &:hover { + background-color: var(--color-page-background); + } + } +} diff --git a/src/components/QuantityButton/QuantityButton.tsx b/src/components/QuantityButton/QuantityButton.tsx new file mode 100644 index 0000000000..4f59763a18 --- /dev/null +++ b/src/components/QuantityButton/QuantityButton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styles from './QuantityButton.module.scss'; +import classNames from 'classnames'; +import { useTheme } from '../../context/ThemeContext'; + +type Props = { + type: 'plus' | 'minus'; + disabled?: boolean; + onClick?: () => void; +}; + +export const QuantityButton: React.FC = ({ + type, + disabled, + onClick = () => {}, +}) => { + const { isDarkTheme } = useTheme(); + const buttonClass = classNames(styles.quantityButton, { + [styles[`quantityButton--${type}`]]: !isDarkTheme, + [styles[`quantityButton--${type}-dark`]]: isDarkTheme, + [styles['quantityButton--disabled']]: disabled, + }); + + return ( + + ); +}; diff --git a/src/components/QuantityButton/index.js b/src/components/QuantityButton/index.js new file mode 100644 index 0000000000..5c0c09404b --- /dev/null +++ b/src/components/QuantityButton/index.js @@ -0,0 +1 @@ +export * from './QuantityButton'; diff --git a/src/components/QuantityIndicator/QuantityIndicator.module.scss b/src/components/QuantityIndicator/QuantityIndicator.module.scss new file mode 100644 index 0000000000..acec5d2e85 --- /dev/null +++ b/src/components/QuantityIndicator/QuantityIndicator.module.scss @@ -0,0 +1,15 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.quantityIndicator { + display: flex; + width: 14px; + height: 14px; + background-color: var(--color-accent); + border: 1px solid #fff; + border-radius: 50%; + font-size: 9px; + color: #fff; + justify-content: center; + align-items: center; +} diff --git a/src/components/QuantityIndicator/QuantityIndicator.tsx b/src/components/QuantityIndicator/QuantityIndicator.tsx new file mode 100644 index 0000000000..9ae8a57af8 --- /dev/null +++ b/src/components/QuantityIndicator/QuantityIndicator.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './QuantityIndicator.module.scss'; + +type Props = { + number: number | null; +}; + +export const QuantityIndicator: React.FC = ({ number }) => { + return
{number}
; +}; diff --git a/src/components/QuantityIndicator/index.js b/src/components/QuantityIndicator/index.js new file mode 100644 index 0000000000..e6dae3e3b9 --- /dev/null +++ b/src/components/QuantityIndicator/index.js @@ -0,0 +1 @@ +export * from './QuantityIndicator'; diff --git a/src/components/ShopByCategory/ShopByCategory.module.scss b/src/components/ShopByCategory/ShopByCategory.module.scss new file mode 100644 index 0000000000..2e602041b1 --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.module.scss @@ -0,0 +1,62 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.shopByCategory { + &__title { + margin-bottom: 24px; + } + + &__categories { + display: flex; + flex-direction: column; + gap: 32px; + + @include on-tablet { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 16px; + } + + @include on-desktop { + grid-template-columns: repeat(24, 32px); + } + } + + &__category { + @include on-tablet { + grid-column: span 4; + } + + @include on-desktop { + grid-column: span 8; + } + } + + &__link { + display: block; + width: 100%; + aspect-ratio: 1 / 1; + } + + &__image { + width: 100%; + height: 100%; + object-fit: cover; + margin-bottom: 24px; + transition: transform 0.3s ease-in-out; + + &:hover { + transform: scale(1.05); + } + } + + &__category-title { + margin-bottom: 4px; + } + + &__quantity { + color: var(--color-grey); + + @include body-text; + } +} diff --git a/src/components/ShopByCategory/ShopByCategory.tsx b/src/components/ShopByCategory/ShopByCategory.tsx new file mode 100644 index 0000000000..be61dec85b --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.tsx @@ -0,0 +1,83 @@ +import styles from './ShopByCategory.module.scss'; +import { Title } from '../Title'; +import { fetchProducts } from '../../utils/fetch'; +import { useEffect, useState } from 'react'; +import { Products } from '../../utils/types'; +import { Link } from 'react-router-dom'; +import { BASE_URL } from '../../utils/constants'; + +export const ShopByCategory = () => { + const [phones, setPhones] = useState([]); + const [tablets, setTablets] = useState([]); + const [accessories, setAccessories] = useState([]); + + useEffect(() => { + fetchProducts('phones').then(res => setPhones(res)); + fetchProducts('tablets').then(res => setTablets(res)); + fetchProducts('accessories').then(res => setAccessories(res)); + }, []); + + return ( +
+
+ Shop by category +
+
+
+ + phone category image + +
+ Mobile phones +
+

{`${phones.length} models`}

+
+
+ + phone category image + +
+ Tablets +
+

{`${tablets.length} models`}

+
+
+ + phone category image + +
+ Accessories +
+

{`${accessories.length} models`}

+
+
+
+ ); +}; diff --git a/src/components/ShopByCategory/index.js b/src/components/ShopByCategory/index.js new file mode 100644 index 0000000000..8081526324 --- /dev/null +++ b/src/components/ShopByCategory/index.js @@ -0,0 +1 @@ +export * from './ShopByCategory'; diff --git a/src/components/Slider/Slider.module.scss b/src/components/Slider/Slider.module.scss new file mode 100644 index 0000000000..f364e515f5 --- /dev/null +++ b/src/components/Slider/Slider.module.scss @@ -0,0 +1,53 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.slider { + display: none; + + @include on-tablet { + display: block; + } + + &__images { + display: flex; + gap: 19px; + } + + &__image { + display: block; + object-fit: cover; + + width: 100%; + aspect-ratio: 1 / 1; + border: 1px solid var(--color-grey-second); + + @include on-tablet { + border-radius: 8px; + width: 100%; + height: 189px; + } + + @include on-desktop { + height: 400px; + } + } + + &__buttons { + display: flex; + justify-content: center; + gap: 4px; + } + + &__button { + display: block; + margin: 10px 5px; + width: 14px; + height: 4px; + border: none; + background-color: var(--color-grey-second); + + &--active { + background-color: var(--color-black); + } + } +} diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx new file mode 100644 index 0000000000..8d8338957a --- /dev/null +++ b/src/components/Slider/Slider.tsx @@ -0,0 +1,74 @@ +import styles from './Slider.module.scss'; + +import { useEffect, useState } from 'react'; +import image1 from '/img/banners/image1.png'; +import image2 from '/img/banners/image2.png'; +import image3 from '/img/banners/image3.png'; +import { ArrowButton } from '../ArrowButton'; +import { ArrowType } from '../../utils/types'; +import classNames from 'classnames'; + +const IMAGES = [image1, image2, image3]; + +export const Slider = () => { + const [imageIndex, setImageIndex] = useState(0); + + // #region functions + + const goToPreviousImage = () => { + setImageIndex(prevIndex => + prevIndex > 0 ? prevIndex - 1 : IMAGES.length - 1, + ); + }; + + const goToNextImage = () => { + setImageIndex(prevIndex => + prevIndex < IMAGES.length - 1 ? prevIndex + 1 : 0, + ); + }; + + useEffect(() => { + const intervalId = setInterval(goToNextImage, 5000); + + return () => clearInterval(intervalId); + }, [imageIndex]); + + // #endregion + + return ( + <> +
+
+ + slider image + +
+
+ {IMAGES.map((_, index) => { + return ( + + ); + })} +
+
+ + ); +}; diff --git a/src/components/Slider/index.js b/src/components/Slider/index.js new file mode 100644 index 0000000000..f48a854158 --- /dev/null +++ b/src/components/Slider/index.js @@ -0,0 +1 @@ +export * from './Slider'; diff --git a/src/components/SortProducts/SortProducts.module.scss b/src/components/SortProducts/SortProducts.module.scss new file mode 100644 index 0000000000..20c581f246 --- /dev/null +++ b/src/components/SortProducts/SortProducts.module.scss @@ -0,0 +1,15 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.selects { + margin-bottom: 24px; + display: flex; + gap: 16px; + + &__label { + @include small-text; + + color: var(--color-grey); + margin-bottom: 4px; + } +} diff --git a/src/components/SortProducts/SortProducts.tsx b/src/components/SortProducts/SortProducts.tsx new file mode 100644 index 0000000000..9562c77c85 --- /dev/null +++ b/src/components/SortProducts/SortProducts.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { SortType } from '../../utils/types'; +import Dropdown from 'react-bootstrap/Dropdown'; +import styles from './SortProducts.module.scss'; +import './dropdown.scss'; + +type Props = { + selectedSortType: string; + searchParams: URLSearchParams; + setSearchParams: (value: URLSearchParams) => void; + itemsOnPage: string; +}; +export const SortProducts: React.FC = ({ + selectedSortType, + searchParams, + setSearchParams, + itemsOnPage, +}) => { + const handleSortTypeChange = (sortType: string | null) => { + if (sortType !== null) { + const params = new URLSearchParams(searchParams); + + params.set('sort', sortType); + setSearchParams(params); + } + }; + + const handlePagesChange = (selectedItemsOnPage: string | null) => { + const params = new URLSearchParams(searchParams); + + if (selectedItemsOnPage) { + params.set('perPage', selectedItemsOnPage); + setSearchParams(params); + } + }; + + return ( +
+
+ + + + + {selectedSortType === SortType.newest + ? 'Newest' + : selectedSortType === SortType.alpha + ? 'Alphabetically' + : 'Cheapest'} + + + + Newest + + Alphabetically + + Cheapest + + +
+
+ + + + + {itemsOnPage === 'all' ? 'All' : itemsOnPage} + + + + all + 4 + 8 + 16 + + +
+
+ ); +}; diff --git a/src/components/SortProducts/dropdown.scss b/src/components/SortProducts/dropdown.scss new file mode 100644 index 0000000000..ae1d29f0ed --- /dev/null +++ b/src/components/SortProducts/dropdown.scss @@ -0,0 +1,77 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; +@import 'bootstrap/scss/mixins'; +@import 'bootstrap/scss/dropdown'; + +.dropdown-toggle { + display: flex !important; + align-items: center; + width: 136px; + height: 40px; + padding: 10px 12px !important; + font-family: inherit; + font-size: 14px !important; + line-height: 21px !important; + border: 1px solid var(--color-button-border) !important; + border-radius: 8px !important; + background-color: var(--color-dropdownfield-background) !important; + color: var(--color-black) !important; + position: relative; + + &::after { + display: none; + } + + &::before { + content: ''; + background-image: url(../../img/icons/arrow_down_grey.png); + background-size: contain; + background-repeat: no-repeat; + width: 16px; + height: 16px; + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + } + + &:hover { + border-color: var(--color-grey) !important; + } + + &:focus { + border-color: var(--color-black) !important; + } + + @include on-tablet { + width: 187px; + } + + @include on-desktop { + width: 176px; + } +} + +.dropdown-menu { + width: 100%; + background-color: var(--color-dropdown-background); + border: 1px solid var(--color-grey-third) !important; + box-shadow: 0 2px 15px 0 #0000000d !important; + border-radius: 8px; + padding-block: 8px; +} + +.dropdown-item { + @include body-text; + + color: var(--color-grey); + padding: 6px 12px; + cursor: pointer; + + &:hover { + color: var(--color-dropdown-hover-text); + background-color: var(--color-dropdown-hover); + } +} diff --git a/src/components/SortProducts/index.js b/src/components/SortProducts/index.js new file mode 100644 index 0000000000..ac7f01588a --- /dev/null +++ b/src/components/SortProducts/index.js @@ -0,0 +1 @@ +export * from './SortProducts'; diff --git a/src/components/Swiper/Swiper.scss b/src/components/Swiper/Swiper.scss new file mode 100644 index 0000000000..8abe0fd669 --- /dev/null +++ b/src/components/Swiper/Swiper.scss @@ -0,0 +1,56 @@ +@import '../../utils/mixins'; + +@media (min-width: 640px) { + .swiper { + display: none; + } +} + +.swiper-container { + width: 100%; + height: 100%; + position: relative !important; + z-index: 0 !important; + padding-bottom: 20px; + overflow: hidden; +} + +.swiper-slide { + display: flex; + align-items: flex-end; + justify-content: center; + background-color: transparent; +} + +.swiper-slide img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + padding-bottom: 40px; + display: block; +} + +.swiper-pagination { + bottom: 5px !important; + text-align: center; + position: absolute; + z-index: 1; + height: 25px; + background-color: transparent; + padding: 0; +} + +.swiper-pagination-bullet { + width: 14px; + height: 4px; + background-color: #b4bdc3; + border-radius: 0; + display: inline-block; + margin: 0 15px 0 0 !important; + cursor: pointer; + opacity: 1; +} + +.swiper-pagination-bullet-active { + background-color: #313237; +} diff --git a/src/components/Swiper/Swiper.tsx b/src/components/Swiper/Swiper.tsx new file mode 100644 index 0000000000..7ea45f1027 --- /dev/null +++ b/src/components/Swiper/Swiper.tsx @@ -0,0 +1,31 @@ +// import React from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Pagination } from 'swiper/modules'; +import 'swiper/css'; +import 'swiper/css/pagination'; +import './Swiper.scss'; + +import image1 from '/img/banners/image1.png'; +import image2 from '/img/banners/image2.png'; +import image3 from '/img/banners/image3.png'; + +export const MobileSwiper = () => { + return ( + + + Banner 1 + + + Banner 2 + + + Banner 3 + + + ); +}; diff --git a/src/components/Swiper/index.js b/src/components/Swiper/index.js new file mode 100644 index 0000000000..91e05d2527 --- /dev/null +++ b/src/components/Swiper/index.js @@ -0,0 +1 @@ +export * from './Swiper'; diff --git a/src/components/TechSpecs/TechSpecs.module.scss b/src/components/TechSpecs/TechSpecs.module.scss new file mode 100644 index 0000000000..ebc7633bcf --- /dev/null +++ b/src/components/TechSpecs/TechSpecs.module.scss @@ -0,0 +1,42 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.techSpecs { + display: flex; + flex-direction: column; + gap: 30px; + + @include on-tablet { + gap: 25px; + } + + &__title { + padding-bottom: 16px; + border-bottom: 1px solid var(--color-grey-second); + } + + &__characteristics { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__characteristic { + display: flex; + justify-content: space-between; + } + + &__characteristicName { + font-weight: 500; + color: var(--color-grey); + + @include small-text; + } + + &__characteristicValue { + font-weight: 500; + color: var(--color-black); + + @include small-text; + } +} diff --git a/src/components/TechSpecs/TechSpecs.tsx b/src/components/TechSpecs/TechSpecs.tsx new file mode 100644 index 0000000000..7ad27219d7 --- /dev/null +++ b/src/components/TechSpecs/TechSpecs.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import styles from './TechSpecs.module.scss'; +import { Product } from '../../utils/types'; +import { Title } from '../Title'; + +type Props = { + product: Product; +}; + +export const TechSpecs: React.FC = ({ product }) => { + return ( +
+
+ Tech specs +
+
+
+

Screen

+

+ {product.screen} +

+
+
+

Resolution

+

+ {product.resolution} +

+
+
+

Processor

+

+ {product.processor} +

+
+
+

RAM

+

{product.ram}

+
+
+

+ Built in memory +

+

+ {product.capacity} +

+
+
+

Camera

+

+ {product.camera} +

+
+
+

Zoom

+

+ {product.zoom} +

+
+
+

Cell

+

+ {product.cell} +

+
+
+
+ ); +}; diff --git a/src/components/TechSpecs/index.js b/src/components/TechSpecs/index.js new file mode 100644 index 0000000000..eada3132a0 --- /dev/null +++ b/src/components/TechSpecs/index.js @@ -0,0 +1 @@ +export * from './TechSpecs'; diff --git a/src/components/Title/Title.module.scss b/src/components/Title/Title.module.scss new file mode 100644 index 0000000000..8db05d2d81 --- /dev/null +++ b/src/components/Title/Title.module.scss @@ -0,0 +1,58 @@ +@import '../../utils/mixins'; + +.h1 { + font-size: 32px; + font-weight: 700; + line-height: 41px; + letter-spacing: -0.01em; + color: var(--color-titles); + + @include on-tablet { + font-size: 48px; + line-height: 56px; + } +} + +.h2 { + font-size: 22px; + font-weight: 700; + line-height: 31px; + letter-spacing: 0; + color: var(--color-titles); + + @include on-tablet { + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + } +} + +.h3 { + font-size: 20px; + font-weight: 600; + line-height: 26px; + letter-spacing: 0; + color: var(--color-titles); + + @include on-tablet { + font-size: 22px; + font-weight: 700; + line-height: 31px; + letter-spacing: 0; + } +} + +.h4 { + font-size: 16px; + font-weight: 600; + line-height: 20px; + letter-spacing: 0; + color: var(--color-titles); + + @include on-tablet { + font-size: 20px; + font-weight: 600; + line-height: 26px; + letter-spacing: 0; + } +} diff --git a/src/components/Title/Title.tsx b/src/components/Title/Title.tsx new file mode 100644 index 0000000000..9ac578c08d --- /dev/null +++ b/src/components/Title/Title.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import styles from './Title.module.scss'; + +type Props = { + level: 1 | 2 | 3 | 4; + children: React.ReactNode; +}; +export const Title: React.FC = ({ level, children }) => { + const Tag = `h${level}` as keyof JSX.IntrinsicElements; + + return {children}; +}; diff --git a/src/components/Title/index.js b/src/components/Title/index.js new file mode 100644 index 0000000000..304b1af24b --- /dev/null +++ b/src/components/Title/index.js @@ -0,0 +1 @@ +export * from './Title'; diff --git a/src/components/ToBuyButton/ToBuyButton.module.scss b/src/components/ToBuyButton/ToBuyButton.module.scss new file mode 100644 index 0000000000..0d832621af --- /dev/null +++ b/src/components/ToBuyButton/ToBuyButton.module.scss @@ -0,0 +1,34 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.toBuyButton { + max-width: 263px; + flex-grow: 1; + background-color: var(--color-accent); + border: 1px solid var(--color-accent-button-border); + border-radius: 48px; + + @include buttons-text; + + &:hover { + background-color: var(--color-accent-button-hover); + box-shadow: 0 3px 13px 0 #17203166; + } + + &--dark { + &:hover { + box-shadow: none; + } + } + + &--exist { + border: 1px solid var(--color-accent-button-border-selected); + background-color: var(--color-accent-button-selected); + color: var(--color-button-selected-text); + + &:hover { + box-shadow: none; + background-color: var(--color-accent-button-selected); + } + } +} diff --git a/src/components/ToBuyButton/ToBuyButton.tsx b/src/components/ToBuyButton/ToBuyButton.tsx new file mode 100644 index 0000000000..25fe1eb1af --- /dev/null +++ b/src/components/ToBuyButton/ToBuyButton.tsx @@ -0,0 +1,42 @@ +import React, { useContext } from 'react'; +import styles from './ToBuyButton.module.scss'; +import { CartContext } from '../../context/CartContext'; +import { Products } from '../../utils/types'; +import classNames from 'classnames'; +import { findMaxId } from '../../utils/functions'; +import { useTheme } from '../../context/ThemeContext'; + +type Props = { + height: string; + product: Products; +}; + +export const ToBuyButton: React.FC = ({ height, product }) => { + const { cart, setCart } = useContext(CartContext); + const { isDarkTheme } = useTheme(); + + const productExistinCart = cart.find( + cartItem => cartItem.product.itemId === product.itemId, + ); + + const handleProductBuy = () => { + setCart(prevCart => [ + ...prevCart, + { id: findMaxId(cart), quantity: 1, product: product }, + ]); + }; + + return ( + + ); +}; diff --git a/src/components/ToBuyButton/index.js b/src/components/ToBuyButton/index.js new file mode 100644 index 0000000000..7ef9a3741e --- /dev/null +++ b/src/components/ToBuyButton/index.js @@ -0,0 +1 @@ +export * from './ToBuyButton'; diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx new file mode 100644 index 0000000000..7c634039d0 --- /dev/null +++ b/src/context/CartContext.tsx @@ -0,0 +1,32 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { CartItem } from '../utils/types'; + +type CartContextType = { + cart: CartItem[]; + setCart: React.Dispatch>; +}; + +export const CartContext = React.createContext({ + cart: [], + setCart: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const CartProvider: React.FC = ({ children }) => { + const [cart, setCart] = useState(() => { + const storedCart = localStorage.getItem('cart'); + + return storedCart ? JSON.parse(storedCart) : []; + }); + + useEffect(() => { + localStorage.setItem('cart', JSON.stringify(cart)); + }, [cart]); + + const value = useMemo(() => ({ cart, setCart }), [cart]); + + return {children}; +}; diff --git a/src/context/FavoritesContext.tsx b/src/context/FavoritesContext.tsx new file mode 100644 index 0000000000..c9c1b9a858 --- /dev/null +++ b/src/context/FavoritesContext.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Products } from '../utils/types'; + +type FavoritesContextType = { + favorites: Products[]; + setFavorites: React.Dispatch>; +}; + +export const FavoritesContext = React.createContext({ + favorites: [], + setFavorites: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const FavoritesProvider: React.FC = ({ children }) => { + const [favorites, setFavorites] = useState(() => { + const storedFavorites = localStorage.getItem('favorites'); + + return storedFavorites ? JSON.parse(storedFavorites) : []; + }); + + useEffect(() => { + localStorage.setItem('favorites', JSON.stringify(favorites)); + }, [favorites]); + + const value = useMemo(() => ({ favorites, setFavorites }), [favorites]); + + return ( + + {children} + + ); +}; diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx new file mode 100644 index 0000000000..2f4e21d573 --- /dev/null +++ b/src/context/ThemeContext.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useState, useEffect, useContext } from 'react'; + +type ThemeContextType = { + isDarkTheme: boolean; + toggleTheme: () => void; +}; + +const ThemeContext = createContext(undefined); + +export const useTheme = () => { + const context = useContext(ThemeContext); + + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + + return context; +}; + +type Props = { + children: React.ReactNode; +}; + +export const ThemeProvider: React.FC = ({ children }) => { + const [isDarkTheme, setIsDarkTheme] = useState(false); + + useEffect(() => { + const savedTheme = localStorage.getItem('theme'); + + if (savedTheme) { + setIsDarkTheme(savedTheme === 'dark'); + } + }, []); + + useEffect(() => { + localStorage.setItem('theme', isDarkTheme ? 'dark' : 'light'); + }, [isDarkTheme]); + + const toggleTheme = () => { + setIsDarkTheme(prevTheme => !prevTheme); + }; + + return ( + + {children} + + ); +}; diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 0000000000..af3d030048 --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,9 @@ +declare module '*.scss' { + const content: { [className: string]: string }; + export default content; +} + +declare module '*.css' { + const content: { [className: string]: string }; + export default content; +} diff --git a/src/images.d.ts b/src/images.d.ts new file mode 100644 index 0000000000..1c5923252c --- /dev/null +++ b/src/images.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: string; + export default value; +} diff --git a/src/img/icons/arrow_down_grey.png b/src/img/icons/arrow_down_grey.png new file mode 100644 index 0000000000..9fc496046d Binary files /dev/null and b/src/img/icons/arrow_down_grey.png differ diff --git a/src/img/icons/arrow_left_black.png b/src/img/icons/arrow_left_black.png new file mode 100644 index 0000000000..227203ad3c Binary files /dev/null and b/src/img/icons/arrow_left_black.png differ diff --git a/src/img/icons/arrow_left_grey.png b/src/img/icons/arrow_left_grey.png new file mode 100644 index 0000000000..da9aa75f6a Binary files /dev/null and b/src/img/icons/arrow_left_grey.png differ diff --git a/src/img/icons/arrow_left_white.png b/src/img/icons/arrow_left_white.png new file mode 100644 index 0000000000..35e14fb3c6 Binary files /dev/null and b/src/img/icons/arrow_left_white.png differ diff --git a/src/img/icons/arrow_right_black.png b/src/img/icons/arrow_right_black.png new file mode 100644 index 0000000000..2fd8e05ba3 Binary files /dev/null and b/src/img/icons/arrow_right_black.png differ diff --git a/src/img/icons/arrow_right_grey.png b/src/img/icons/arrow_right_grey.png new file mode 100644 index 0000000000..713b26d7e8 Binary files /dev/null and b/src/img/icons/arrow_right_grey.png differ diff --git a/src/img/icons/arrow_right_white.png b/src/img/icons/arrow_right_white.png new file mode 100644 index 0000000000..b8deafceb4 Binary files /dev/null and b/src/img/icons/arrow_right_white.png differ diff --git a/src/img/icons/arrow_up_black.png b/src/img/icons/arrow_up_black.png new file mode 100644 index 0000000000..b5a0c468a4 Binary files /dev/null and b/src/img/icons/arrow_up_black.png differ diff --git a/src/img/icons/arrow_up_grey.png b/src/img/icons/arrow_up_grey.png new file mode 100644 index 0000000000..626b450ef3 Binary files /dev/null and b/src/img/icons/arrow_up_grey.png differ diff --git a/src/img/icons/arrow_up_white.png b/src/img/icons/arrow_up_white.png new file mode 100644 index 0000000000..266e68ba08 Binary files /dev/null and b/src/img/icons/arrow_up_white.png differ diff --git a/src/img/icons/cart.png b/src/img/icons/cart.png new file mode 100644 index 0000000000..8a84a35b86 Binary files /dev/null and b/src/img/icons/cart.png differ diff --git a/src/img/icons/close-grey.png b/src/img/icons/close-grey.png new file mode 100644 index 0000000000..4d8beda285 Binary files /dev/null and b/src/img/icons/close-grey.png differ diff --git a/src/img/icons/close-white.png b/src/img/icons/close-white.png new file mode 100644 index 0000000000..c919f7f327 Binary files /dev/null and b/src/img/icons/close-white.png differ diff --git a/src/img/icons/close.png b/src/img/icons/close.png new file mode 100644 index 0000000000..198a69590a Binary files /dev/null and b/src/img/icons/close.png differ diff --git a/src/img/icons/favourites.png b/src/img/icons/favourites.png new file mode 100644 index 0000000000..605aac6123 Binary files /dev/null and b/src/img/icons/favourites.png differ diff --git a/src/img/icons/heart.png b/src/img/icons/heart.png new file mode 100644 index 0000000000..605aac6123 Binary files /dev/null and b/src/img/icons/heart.png differ diff --git a/src/img/icons/heart_yellow.png b/src/img/icons/heart_yellow.png new file mode 100644 index 0000000000..63fbc5c7a2 Binary files /dev/null and b/src/img/icons/heart_yellow.png differ diff --git a/src/img/icons/home.png b/src/img/icons/home.png new file mode 100644 index 0000000000..ce4e1425f7 Binary files /dev/null and b/src/img/icons/home.png differ diff --git a/src/img/icons/menu-white.png b/src/img/icons/menu-white.png new file mode 100644 index 0000000000..a356e522f7 Binary files /dev/null and b/src/img/icons/menu-white.png differ diff --git a/src/img/icons/menu.png b/src/img/icons/menu.png new file mode 100644 index 0000000000..543d3dca50 Binary files /dev/null and b/src/img/icons/menu.png differ diff --git a/src/img/icons/minus-grey.png b/src/img/icons/minus-grey.png new file mode 100644 index 0000000000..0b813afd49 Binary files /dev/null and b/src/img/icons/minus-grey.png differ diff --git a/src/img/icons/minus-white.png b/src/img/icons/minus-white.png new file mode 100644 index 0000000000..2c5475efde Binary files /dev/null and b/src/img/icons/minus-white.png differ diff --git a/src/img/icons/minus.png b/src/img/icons/minus.png new file mode 100644 index 0000000000..58d40d7f19 Binary files /dev/null and b/src/img/icons/minus.png differ diff --git a/src/img/icons/night_theme_btn.png b/src/img/icons/night_theme_btn.png new file mode 100644 index 0000000000..dd5c9207c1 Binary files /dev/null and b/src/img/icons/night_theme_btn.png differ diff --git a/src/img/icons/night_theme_cart.png b/src/img/icons/night_theme_cart.png new file mode 100644 index 0000000000..40496d13d3 Binary files /dev/null and b/src/img/icons/night_theme_cart.png differ diff --git a/src/img/icons/night_theme_heart.png b/src/img/icons/night_theme_heart.png new file mode 100644 index 0000000000..6393e242ba Binary files /dev/null and b/src/img/icons/night_theme_heart.png differ diff --git a/src/img/icons/night_theme_home.png b/src/img/icons/night_theme_home.png new file mode 100644 index 0000000000..bb4f84759f Binary files /dev/null and b/src/img/icons/night_theme_home.png differ diff --git a/src/img/icons/plus-white.png b/src/img/icons/plus-white.png new file mode 100644 index 0000000000..49bbf3175d Binary files /dev/null and b/src/img/icons/plus-white.png differ diff --git a/src/img/icons/plus.png b/src/img/icons/plus.png new file mode 100644 index 0000000000..2371233173 Binary files /dev/null and b/src/img/icons/plus.png differ diff --git a/src/img/logo.png b/src/img/logo.png new file mode 100644 index 0000000000..f80332f91e Binary files /dev/null and b/src/img/logo.png differ diff --git a/src/img/night_theme_logo.png b/src/img/night_theme_logo.png new file mode 100644 index 0000000000..3b8923807d Binary files /dev/null and b/src/img/night_theme_logo.png differ diff --git a/public/img/page-not-found.png b/src/img/notFound/page-not-found.png similarity index 100% rename from public/img/page-not-found.png rename to src/img/notFound/page-not-found.png diff --git a/public/img/product-not-found.png b/src/img/notFound/product-not-found.png similarity index 100% rename from public/img/product-not-found.png rename to src/img/notFound/product-not-found.png diff --git a/src/index.tsx b/src/index.tsx index 50470f1508..b0620ab4c6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,4 @@ import { createRoot } from 'react-dom/client'; -import { App } from './App'; +import { Root } from './Root'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render(); diff --git a/src/normilize.css b/src/normilize.css new file mode 100644 index 0000000000..0968aba799 --- /dev/null +++ b/src/normilize.css @@ -0,0 +1,28 @@ +* { + box-sizing: border-box; +} + +a, +p, +body, +div, +header, +footer, +html, +img, +li, +nav, +ul, +h1, +h2, +h3, +h4, +button, +article { + margin: 0; + padding: 0; +} + +button { + cursor: pointer; +} diff --git a/src/pages/CartPage/CartPage.module.scss b/src/pages/CartPage/CartPage.module.scss new file mode 100644 index 0000000000..731ada2fea --- /dev/null +++ b/src/pages/CartPage/CartPage.module.scss @@ -0,0 +1,76 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.cartPage { + &__goback { + display: flex; + gap: 4px; + padding-block: 24px; + + @include on-tablet { + padding-top: 40px; + padding-bottom: 16px; + } + } + + &__title { + padding-bottom: 32px; + } + + &__container { + display: flex; + flex-direction: column; + gap: 32px; + padding-bottom: 56px; + + @include on-tablet { + padding-bottom: 64px; + } + + @include on-desktop { + display: grid; + grid-template-columns: repeat(24, 32px); + column-gap: 16px; + padding-bottom: 80px; + } + } + + &__items { + display: flex; + flex-direction: column; + gap: 16px; + + @include on-desktop { + grid-column: 1 / 17; + } + } + + &__total { + width: 100%; + height: 190px; + padding: 24px; + border: 1px solid var(--color-grey-third); + border-radius: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + + @include on-desktop { + height: 206px; + grid-column: 17 / -1; + } + } + + &__totalItems { + color: var(--color-grey); + + @include body-text; + } + + &__line { + height: 1px; + width: 100%; + border-bottom: 1px solid var(--color-grey-third); + } +} diff --git a/src/pages/CartPage/CartPage.tsx b/src/pages/CartPage/CartPage.tsx new file mode 100644 index 0000000000..12e123acf3 --- /dev/null +++ b/src/pages/CartPage/CartPage.tsx @@ -0,0 +1,55 @@ +import { useContext } from 'react'; +import { CartContext } from '../../context/CartContext'; +import { GoBack } from '../../components/GoBack'; +import styles from './CartPage.module.scss'; +import { Title } from '../../components/Title'; +import { CartIsEmpty } from '../../components/CartIsEmpty'; +import { CartProduct } from '../../components/CartProduct'; +import { + calculateTotalAmount, + calculateTotalPrice, +} from '../../utils/functions'; +import { CheckoutButton } from '../../components/CheckoutButton'; +import { CartItem } from '../../utils/types'; +import blackArrow from '../../img/icons/arrow_left_black.png'; +import whiteArrow from '../../img/icons/arrow_left_white.png'; +import { useTheme } from '../../context/ThemeContext'; + +export const CartPage = () => { + const { cart } = useContext(CartContext); + const { isDarkTheme } = useTheme(); + + return ( +
+
+ arrow icon + Back +
+
+ Cart +
+ + {cart.length === 0 && } + + {cart.length > 0 && ( +
+
+ {cart.map((cartItem: CartItem) => ( + + ))} +
+
+
+ {'$' + calculateTotalPrice(cart)} +

{`Total for ${calculateTotalAmount(cart)} items`}

+
+
+ +
+
+ )} +
+ ); +}; diff --git a/src/pages/CartPage/index.js b/src/pages/CartPage/index.js new file mode 100644 index 0000000000..90c010237a --- /dev/null +++ b/src/pages/CartPage/index.js @@ -0,0 +1 @@ +export * from './CartPage'; diff --git a/src/pages/Catalog/Catalog.module.scss b/src/pages/Catalog/Catalog.module.scss new file mode 100644 index 0000000000..e5ef38da22 --- /dev/null +++ b/src/pages/Catalog/Catalog.module.scss @@ -0,0 +1,69 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.catalog { + &__navigation { + padding-top: 24px; + padding-bottom: 24px; + display: flex; + gap: 8px; + + @include on-tablet { + padding-bottom: 40px; + } + } + + &__current-page { + display: flex; + justify-content: center; + align-items: center; + font-weight: 600; + color: var(--color-grey); + line-height: 15px; + text-transform: capitalize; + } + + &__title { + padding-bottom: 8px; + } + + &__quantity { + color: var(--color-grey); + padding-bottom: 32px; + + @include body-text; + + @include on-tablet { + padding-bottom: 40px; + } + } + &__list { + padding-bottom: 24px; + + @include on-tablet { + padding-bottom: 40px; + } + } + + &__error { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + } + + &__reload { + background-color: var(--color-accent); + border: none; + border-radius: 48px; + padding: 12px 24px; + color: white; + cursor: pointer; + + @include buttons-text; + + &:hover { + box-shadow: 0 3px 13px 0 #17203166; + } + } +} diff --git a/src/pages/Catalog/Catalog.tsx b/src/pages/Catalog/Catalog.tsx new file mode 100644 index 0000000000..9638937808 --- /dev/null +++ b/src/pages/Catalog/Catalog.tsx @@ -0,0 +1,177 @@ +import styles from './Catalog.module.scss'; + +import { NavLink, useParams } from 'react-router-dom'; +import { Title } from '../../components/Title'; +import { useEffect, useState } from 'react'; +import { fetchProducts } from '../../utils/fetch'; +import { Products, SortType } from '../../utils/types'; +import { Loader } from '../../components/Loader'; +import { ProductsList } from '../../components/ProductsList'; +import { SortProducts } from '../../components/SortProducts'; +import { useSearchParams } from 'react-router-dom'; +import { Pagination } from '../../components/Pagination'; +import { NotFoundPage } from '../NotFoundPage'; +import homeIcon from '../../img/icons/home.png'; +import homeIconDark from '../../img/icons/night_theme_home.png'; +import { useTheme } from '../../context/ThemeContext'; +import { ArrowGrey } from '../../components/ArrowGrey'; +import { BASE_URL } from '../../utils/constants'; + +export const Catalog = () => { + // #region state + const [products, setProducts] = useState([]); + const [displayedProducts, setDisplayedProducts] = useState( + [], + ); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [updatedAt, setUpdatedAt] = useState(new Date()); + const [searchParams, setSearchParams] = useSearchParams(); + const { isDarkTheme } = useTheme(); + // #endregion + // #region functions + const getTitleName = (currentCategory: string) => { + switch (currentCategory) { + case 'phones': + return 'Phones page'; + case 'tablets': + return 'Tablets page'; + case 'accessories': + return 'Accessories page'; + default: + return ''; + } + }; + + const reloadPage = () => { + setUpdatedAt(new Date()); + setErrorMessage(''); + }; + + const displayPageItems = ( + allItems: Products[], + currentPage: number, + itemsOnPage: number, + ) => { + const indexOfFirst = (currentPage - 1) * itemsOnPage; + const indexOfLast = indexOfFirst + itemsOnPage; + + return allItems.slice(indexOfFirst, indexOfLast); + }; + + // #endregion + // #region variables + const { category } = useParams(); + const knownCategories = ['phones', 'tablets', 'accessories']; + const pageTitle = category ? getTitleName(category) : ''; + const selectedSortType = + (searchParams.get('sort') as SortType) || SortType.newest; + const itemsOnPage = searchParams.get('perPage') || 'all'; + const activePage = searchParams.get('page') || '1'; + const pagesAmount = + itemsOnPage === 'all' ? 1 : Math.ceil(products.length / +itemsOnPage); + // #endregion + + useEffect(() => { + if (!category || !knownCategories.includes(category)) { + return; + } + + if (category) { + setIsLoading(true); + setProducts([]); + setDisplayedProducts([]); + fetchProducts(category, selectedSortType) + .then(res => { + setProducts(res); + }) + .catch(() => { + setErrorMessage('Something went wrong'); + }) + .finally(() => setIsLoading(false)); + } + }, [category, updatedAt, selectedSortType]); + + // useEffect(() => { + // if (category && !searchParams.get('sort')) { + // searchParams.set('sort', SortType.newest); + // setSearchParams(searchParams); + // } + // }, [category, searchParams, setSearchParams]); + + useEffect(() => { + if (itemsOnPage === 'all') { + setDisplayedProducts(products); + } else { + setDisplayedProducts( + displayPageItems(products, +activePage, +itemsOnPage), + ); + } + }, [products, searchParams, category, activePage, itemsOnPage]); + + if (!category || !knownCategories.includes(category)) { + return ; + } + + return ( +
+
+ + home icon + + +

{category}

+
+ + {isLoading && } + + {errorMessage && ( +
+

{errorMessage}

+ +
+ )} + + {!isLoading && !errorMessage && products.length === 0 && ( +

{`There are no ${category} yet`}

+ )} + + {!isLoading && !errorMessage && displayedProducts.length > 0 && ( + <> +
+ {pageTitle} +
+

+ {`${products.length} models`} +

+ +
+ +
+ + )} + + {pagesAmount > 1 && ( + + )} +
+ ); +}; diff --git a/src/pages/Catalog/index.js b/src/pages/Catalog/index.js new file mode 100644 index 0000000000..50d66eb125 --- /dev/null +++ b/src/pages/Catalog/index.js @@ -0,0 +1 @@ +export * from './Catalog'; diff --git a/src/pages/Favorites/Favorites.module.scss b/src/pages/Favorites/Favorites.module.scss new file mode 100644 index 0000000000..3fc5a03b69 --- /dev/null +++ b/src/pages/Favorites/Favorites.module.scss @@ -0,0 +1,52 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.favorites { + &__navigation { + padding-top: 24px; + padding-bottom: 24px; + display: flex; + gap: 8px; + + @include on-tablet { + padding-bottom: 40px; + } + } + + &__current-page { + display: flex; + justify-content: center; + align-items: center; + font-weight: 600; + color: var(--color-grey); + line-height: 15px; + text-transform: capitalize; + } + + &__title { + padding-bottom: 8px; + } + + &__quantity { + color: var(--color-grey); + padding-bottom: 32px; + + @include body-text; + + @include on-tablet { + padding-bottom: 40px; + } + } + + &__list { + padding-bottom: 56px; + + @include on-tablet { + padding-bottom: 64px; + } + + @include on-desktop { + padding-bottom: 80px; + } + } +} diff --git a/src/pages/Favorites/Favorites.tsx b/src/pages/Favorites/Favorites.tsx new file mode 100644 index 0000000000..7377122c65 --- /dev/null +++ b/src/pages/Favorites/Favorites.tsx @@ -0,0 +1,41 @@ +import { NavLink } from 'react-router-dom'; +import styles from './Favorites.module.scss'; +import { Title } from '../../components/Title'; +import { useContext } from 'react'; +import { FavoritesContext } from '../../context/FavoritesContext'; +import { ProductsList } from '../../components/ProductsList'; +import homeIcon from '../../img/icons/home.png'; +import homeIconDark from '../../img/icons/night_theme_home.png'; +import { useTheme } from '../../context/ThemeContext'; +import { ArrowGrey } from '../../components/ArrowGrey'; +import { BASE_URL } from '../../utils/constants'; + +export const Favorites = () => { + const { favorites } = useContext(FavoritesContext); + const { isDarkTheme } = useTheme(); + + return ( +
+
+ + home icon + + +

Favorites

+
+
+ Favourites +
+

{`${favorites.length} items`}

+
+ +
+
+ ); +}; diff --git a/src/pages/Favorites/index.js b/src/pages/Favorites/index.js new file mode 100644 index 0000000000..c3ffcf8b38 --- /dev/null +++ b/src/pages/Favorites/index.js @@ -0,0 +1 @@ +export * from './Favorites'; diff --git a/src/pages/HomePage/HomePage.module.scss b/src/pages/HomePage/HomePage.module.scss new file mode 100644 index 0000000000..76e67c7b95 --- /dev/null +++ b/src/pages/HomePage/HomePage.module.scss @@ -0,0 +1,27 @@ +@import '../../utils/mixins'; + +.homePage { + &__title { + padding-block: 24px; + + @include on-tablet { + padding-block: 32px; + } + + @include on-desktop { + padding-block: 56px; + } + } + + &__section { + margin-bottom: 56px; + + @include on-tablet { + margin-bottom: 64px; + } + + @include on-desktop { + margin-bottom: 80px; + } + } +} diff --git a/src/pages/HomePage/HomePage.tsx b/src/pages/HomePage/HomePage.tsx new file mode 100644 index 0000000000..903d669e0c --- /dev/null +++ b/src/pages/HomePage/HomePage.tsx @@ -0,0 +1,38 @@ +import { ProductSlider } from '../../components/ProductSlider'; +import { MobileSwiper } from '../../components/Swiper'; +import { Title } from '../../components/Title'; +import { ProductCategory, SortType } from '../../utils/types'; +import { Slider } from '../../components/Slider'; +import styles from './HomePage.module.scss'; +import { ShopByCategory } from '../../components/ShopByCategory'; + +export const HomePage = () => { + return ( +
+
+ Welcome to Nice Gadgets store! +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ ); +}; diff --git a/src/pages/HomePage/index.js b/src/pages/HomePage/index.js new file mode 100644 index 0000000000..11e53da674 --- /dev/null +++ b/src/pages/HomePage/index.js @@ -0,0 +1 @@ +export * from './HomePage'; diff --git a/src/pages/NotFoundPage/NotFoundPage.module.scss b/src/pages/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 0000000000..53dc267d83 --- /dev/null +++ b/src/pages/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,22 @@ +@import '../../utils/mixins'; + +.notFoundPage { + &__container { + padding-top: 100px; + align-items: center; + text-align: center; + display: flex; + flex-direction: column; + gap: 50px; + } + + &__image { + width: 200px; + height: 200px; + + @include on-tablet { + width: 300px; + height: 300px; + } + } +} diff --git a/src/pages/NotFoundPage/NotFoundPage.tsx b/src/pages/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 0000000000..64fb69a13a --- /dev/null +++ b/src/pages/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,18 @@ +import { Title } from '../../components/Title'; +import styles from './NotFoundPage.module.scss'; +import image from '../../img/notFound/page-not-found.png'; + +export const NotFoundPage = () => { + return ( +
+
+ not found image + Oooops... page not found +
+
+ ); +}; diff --git a/src/pages/NotFoundPage/index.js b/src/pages/NotFoundPage/index.js new file mode 100644 index 0000000000..6197aa75aa --- /dev/null +++ b/src/pages/NotFoundPage/index.js @@ -0,0 +1 @@ +export * from './NotFoundPage'; diff --git a/src/pages/ProductDetailsPage/ProductDetailsPage.module.scss b/src/pages/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 0000000000..01240ab4f0 --- /dev/null +++ b/src/pages/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,287 @@ +@import '../../utils/mixins'; +@import '../../utils/variables'; + +.productDetails { + &__goback { + display: flex; + gap: 4px; + padding-bottom: 16px; + } + + &__error { + text-align: center; + padding-top: 30px; + } + + &__errorMessage { + padding-bottom: 20px; + } + + &__errorImage { + width: 150px; + height: 150px; + + @include on-tablet { + width: 200px; + height: 200px; + } + + @include on-desktop { + width: 250px; + height: 250px; + } + } + + &__title { + padding-bottom: 32px; + + @include on-tablet { + padding-bottom: 40px; + } + } + + &__top { + padding-bottom: 56px; + + @include on-tablet { + display: grid; + grid-template-columns: repeat(12, 1fr); + column-gap: 16px; + padding-bottom: 64px; + } + + @include on-desktop { + grid-template-columns: repeat(24, 32px); + padding-bottom: 80px; + } + } + + &__imageContainer { + width: 100%; + aspect-ratio: 1 / 1; + padding-bottom: 16px; + + @include on-tablet { + padding-bottom: 0; + grid-column: 2 / 8; + } + + @include on-desktop { + grid-column: 3 / 13; + } + } + + &__image { + width: 100%; + height: 100%; + object-position: center; + object-fit: contain; + } + + &__images { + display: flex; + gap: 8px; + padding-bottom: 40px; + + @include on-tablet { + flex-direction: column; + grid-column: 1 / 2; + grid-row: 1; + padding-bottom: 0; + } + + @include on-desktop { + grid-column: 1 / 3; + grid-row: 1; + } + } + + &__details { + width: 100%; + + @include on-tablet { + grid-column: 8 / -1; + } + + @include on-desktop { + grid-column: 14 / 21; + } + } + + &__images-item { + height: 49px; + padding: 5px; + border-radius: 4px; + border: 1px solid var(--color-grey-second); + object-fit: contain; + + @include on-tablet { + width: 100%; + height: auto; + aspect-ratio: 1 / 1; + padding: 3px; + } + + &--active { + border: 1px solid var(--color-black); + } + } + + &__label { + @include small-text; + + color: var(--color-grey); + padding-bottom: 8px; + } + + &__selectors { + display: flex; + flex-direction: column; + gap: 24px; + padding-bottom: 32px; + } + + &__selectorButtons { + display: flex; + gap: 8px; + padding-bottom: 24px; + flex-wrap: wrap; + } + + &__colorButtonContainer { + box-sizing: border-box; + width: 32px; + height: 32px; + border-radius: 36px; + border: 1px solid var(--color-border-icons); + + &:hover { + border-color: var(--color-grey); + } + + &--active { + border: 1px solid var(--color-black); + } + } + + &__colorButton { + margin: 0; + appearance: none; + width: 100%; + height: 100%; + border: 2px solid var(--color-page-background); + border-radius: 36px; + } + + &__line { + width: 100%; + border-bottom: 1px solid var(--color-grey-third); + } + + &__capacity { + @include body-text; + + height: 32px; + padding-inline: 8px; + font-family: inherit; + background-color: var(--color-page-background); + color: var(--color-black); + border-radius: 4px; + border: 1px solid var(--color-grey-second); + + &--active { + color: var(--color-white); + background-color: var(--color-black); + border: none; + } + } + + &__prices { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 16px; + } + + &__discountPrice { + font-size: 22px; + font-weight: 500; + line-height: 28px; + text-decoration: line-through; + color: var(--color-grey); + } + + &__buttons { + display: flex; + gap: 8px; + padding-bottom: 32px; + align-items: center; + } + + &__characteristics { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__characteristic { + display: flex; + justify-content: space-between; + } + + &__characteristicName { + font-weight: 500; + color: var(--color-grey); + + @include small-text; + } + &__characteristicValue { + font-weight: 500; + color: var(--color-black); + + @include small-text; + } + + &__description { + display: flex; + flex-direction: column; + gap: 56px; + padding-bottom: 56px; + + @include on-tablet { + gap: 64px; + padding-bottom: 64px; + } + + @include on-desktop { + display: grid; + grid-template-columns: repeat(24, 32px); + column-gap: 16px; + padding-bottom: 80px; + } + } + + &__about { + @include on-desktop { + grid-column: 1 / 13; + } + } + + &__specs { + @include on-desktop { + grid-column: 14 / 24; + } + } + + &__slider { + padding-bottom: 56px; + + @include on-tablet { + padding-bottom: 64px; + } + + @include on-desktop { + padding-bottom: 80px; + } + } +} diff --git a/src/pages/ProductDetailsPage/ProductDetailsPage.tsx b/src/pages/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 0000000000..cc4050e19c --- /dev/null +++ b/src/pages/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,295 @@ +import { useNavigate, useParams } from 'react-router-dom'; +import { fetchProduct, fetchProductsItem } from '../../utils/fetch'; +import { useEffect, useState } from 'react'; +import { Product, Products, SortType } from '../../utils/types'; +import styles from './ProductDetailsPage.module.scss'; +import { Loader } from '../../components/Loader'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { GoBack } from '../../components/GoBack'; +import { Title } from '../../components/Title'; +import classNames from 'classnames'; +import { colors } from '../../utils/colors'; +import { ToBuyButton } from '../../components/ToBuyButton'; +import { AddToFavourites } from '../../components/AddToFavourites'; +import { AboutProduct } from '../../components/AboutProduct'; +import { TechSpecs } from '../../components/TechSpecs'; +import { ProductSlider } from '../../components/ProductSlider'; +import { useTheme } from '../../context/ThemeContext'; +import blackArrow from '../../img/icons/arrow_left_black.png'; +import whiteArrow from '../../img/icons/arrow_left_white.png'; +import { BASE_URL } from '../../utils/constants'; + +export const ProductDetailsPage = () => { + // #region state + const [isLoading, setIsLoading] = useState(false); + const [displayedProduct, setDisplayedProduct] = useState( + null, + ); + const [displayedProductInCategory, setDisplayedProductInCategory] = + useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [displayedImageIndex, setDisplayedImageIndex] = useState(0); + const { category, productId } = useParams(); + const navigate = useNavigate(); + const { isDarkTheme } = useTheme(); + // #endregion + // #region functions + const handleColorChange = (color: string) => { + if (category === 'accessories') { + const normalizedColor = color.split(' ').join('-'); + + navigate( + `${BASE_URL}/${category}/${displayedProduct?.namespaceId + '-' + displayedProduct?.capacity + '-' + normalizedColor}`, + ); + } + + if (category === 'phones' || category === 'tablets') { + navigate( + `${BASE_URL}/${category}/${displayedProduct?.namespaceId + '-' + displayedProduct?.capacity.toLowerCase() + '-' + color}`, + ); + } + }; + + const handleCapacityChange = (capacity: string) => { + if (category === 'accessories') { + const normalizedColor = displayedProduct?.color.split(' ').join('-'); + + navigate( + `${BASE_URL}/${category}/${displayedProduct?.namespaceId + '-' + capacity.toLowerCase() + '-' + normalizedColor}`, + ); + } + + if (category === 'phones' || category === 'tablets') { + navigate( + `${BASE_URL}/${category}/${displayedProduct?.namespaceId + '-' + capacity.toLowerCase() + '-' + displayedProduct?.color}`, + ); + } + }; + + // #endregion + useEffect(() => { + if (category && productId) { + setIsLoading(true); + + Promise.all([ + fetchProduct(category, productId), + fetchProductsItem(productId), + ]) + .then(([productResult, productItemResult]) => { + if (productResult) { + setDisplayedProduct(productResult); + } else { + setErrorMessage('Product was not found'); + } + + if (productItemResult) { + setDisplayedProductInCategory(productItemResult); + } else { + setErrorMessage('Product was not found'); + } + }) + .catch(() => { + setErrorMessage('Something went wrong'); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [productId, category]); + + return ( +
+ {isLoading && } + + {errorMessage && ( +
+

+ {errorMessage} +

+ product not found image +
+ )} + {!isLoading && !errorMessage && displayedProduct && category && ( + <> + +
+ arrow icon + + Back +
+
+ {displayedProduct.name} +
+
+
+ product image +
+ +
+ {displayedProduct.images.map((image, index) => { + return ( + {`${displayedProduct?.name} { + setDisplayedImageIndex(index); + }} + /> + ); + })} +
+ +
+
+
+

+ Available colors +

+
+ {displayedProduct?.colorsAvailable.map(color => { + return ( +
+ handleColorChange(color)} + /> +
+ ); + })} +
+
+
+
+

+ Select capacity +

+
+ {displayedProduct?.capacityAvailable.map( + (capacity: string) => { + return ( + + ); + }, + )} +
+
+
+
+
+ {`$${displayedProduct.priceRegular}`} +

{`$${displayedProduct.priceDiscount}`}

+
+
+ {displayedProductInCategory && ( + <> + + + + )} +
+
+
+

+ Screen +

+

+ {displayedProduct.screen} +

+
+
+

+ Resolution +

+

+ {displayedProduct.resolution} +

+
+
+

+ Processor +

+

+ {displayedProduct.processor} +

+
+
+

+ RAM +

+

+ {displayedProduct.ram} +

+
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+ + )} +
+ ); +}; diff --git a/src/pages/ProductDetailsPage/index.js b/src/pages/ProductDetailsPage/index.js new file mode 100644 index 0000000000..6615089e5e --- /dev/null +++ b/src/pages/ProductDetailsPage/index.js @@ -0,0 +1 @@ +export * from './ProductDetailsPage'; diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000000..30da896298 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +// / diff --git a/src/utils/colors.tsx b/src/utils/colors.tsx new file mode 100644 index 0000000000..db99a1afea --- /dev/null +++ b/src/utils/colors.tsx @@ -0,0 +1,27 @@ +export type Colors = { + [key: string]: string; +}; +export const colors: Colors = { + black: '#4C4C4C', + spaceblack: '#565C6A', + green: '#5b7E65', + yellow: '#EED96C', + white: '#F0F0F0', + purple: '#CACDF5', + red: '#B1081B', + gold: '#F4E4D1', + sierrablue: '#AAC6E3', + graphite: '#96959D', + silver: '#AAAAAF', + spacegray: '#B6ADA7', + 'space gray': '#B6ADA7', + blue: '#6B89B3', + 'sky blue': '#7CACD0', + 'rose gold': '#FDD8D3', + rosegold: '#FDD8D3', + starlight: '#E8E0D5', + pink: '#FD889A', + midnightgreen: '#626B64', + midnight: '#31373F', + coral: '#F19584', +}; diff --git a/src/utils/constants.tsx b/src/utils/constants.tsx new file mode 100644 index 0000000000..eb25701213 --- /dev/null +++ b/src/utils/constants.tsx @@ -0,0 +1,2 @@ +export const BASE_URL = + window.location.hostname === 'localhost' ? '' : '/react_phone-catalog'; diff --git a/src/utils/fetch.tsx b/src/utils/fetch.tsx new file mode 100644 index 0000000000..848360c5e4 --- /dev/null +++ b/src/utils/fetch.tsx @@ -0,0 +1,74 @@ +import { BASE_URL } from './constants'; +import { Product, Products, SortType } from './types'; + +export const fetchProducts = ( + category: string, + sortBy?: SortType, +): Promise => { + return fetch(`${BASE_URL}/api/products.json`) + .then(response => response.json()) + .then(data => { + let filteredProducts: Products[] = data.filter( + (product: Products) => product.category === category, + ); + + switch (sortBy) { + case SortType.newest: + filteredProducts.sort((a, b) => b.year - a.year); + break; + case SortType.hotPrice: + filteredProducts.sort( + (a, b) => b.fullPrice - b.price - (a.fullPrice - a.price), + ); + break; + case SortType.alpha: + filteredProducts.sort((a, b) => a.name.localeCompare(b.name)); + break; + case SortType.cheapest: + filteredProducts.sort((a, b) => a.price - b.price); + break; + case SortType.random: + const randomItems = new Set(); + + while (randomItems.size < 10) { + const randomIndex = Math.floor( + Math.random() * filteredProducts.length, + ); + + randomItems.add(filteredProducts[randomIndex]); + } + + filteredProducts = Array.from(randomItems) as Products[]; + break; + default: + break; + } + + return filteredProducts; + }); +}; + +export const fetchProductsItem = ( + itemId: string, +): Promise => { + return fetch(`${BASE_URL}/api/products.json`) + .then(response => response.json()) + .then((data: Products[]) => { + return data.find(item => item.itemId === itemId); + }); +}; + +export const fetchProduct = ( + category: string, + productId: string, +): Promise => { + return fetch(`${BASE_URL}/api/${category}.json`) + .then(response => response.json()) + .then(data => { + const product = data.find((item: Product) => { + return item.id === productId; + }); + + return product; + }); +}; diff --git a/src/utils/functions.tsx b/src/utils/functions.tsx new file mode 100644 index 0000000000..dbf77f8d0e --- /dev/null +++ b/src/utils/functions.tsx @@ -0,0 +1,29 @@ +import { CartItem } from './types'; + +export function findMaxId(cart: CartItem[] | []) { + if (cart.length === 0) { + return 1; + } + + return Math.max(...cart.map((item: CartItem) => item.id)) + 1; +} + +export function calculateTotalPrice(cart: CartItem[]) { + let total = 0; + + cart.forEach(item => { + total += item.quantity * item.product.price; + }); + + return total; +} + +export function calculateTotalAmount(cart: CartItem[]) { + let total = 0; + + cart.forEach(item => { + total += item.quantity; + }); + + return total; +} diff --git a/src/utils/mixins.scss b/src/utils/mixins.scss new file mode 100644 index 0000000000..d1e5d53250 --- /dev/null +++ b/src/utils/mixins.scss @@ -0,0 +1,51 @@ +@mixin on-tablet { + @media (min-width: 640px) { + & { + @content; + } + } +} + +@mixin on-media-screen { + @media (min-width: 768px) { + & { + @content; + } + } +} + +@mixin on-desktop { + @media (min-width: 1200px) { + & { + @content; + } + } +} + +@mixin nav-link-styles { + text-decoration: none; + text-transform: uppercase; + color: var(--color-grey); + font-weight: 800; + line-height: 11px; +} + +@mixin buttons-text { + color: #fff; + font-weight: 600; + font-size: 14px; + line-height: 21px; + font-family: inherit; +} + +@mixin body-text { + font-weight: 400; + font-size: 14px; + line-height: 21px; +} + +@mixin small-text { + font-weight: 600; + font-size: 12px; + line-height: 15px; +} diff --git a/src/utils/types.tsx b/src/utils/types.tsx new file mode 100644 index 0000000000..52a7418b50 --- /dev/null +++ b/src/utils/types.tsx @@ -0,0 +1,77 @@ +export enum IconType { + menu = 'menu', + favourites = 'favourites', + cart = 'cart', + close = 'close', +} + +export enum ArrowType { + up = 'up', + down = 'down', + right = 'right', + left = 'left', +} + +export enum SortType { + withoutSort = 'withoutSort', + newest = 'year', + hotPrice = 'hotPrice', + alpha = 'title', + cheapest = 'price', + random = 'random', +} + +export enum ProductCategory { + phones = 'phones', + tablets = 'tablets', + accessories = 'accessories', + products = 'products', +} + +export type Products = { + id: number; + category: string; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +}; + +export type ProductDescription = { + title: string; + text: string[]; +}; + +export type Product = { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: ProductDescription[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera: string; + zoom: string; + cell: string[]; +}; + +export type CartItem = { + id: number; + quantity: number; + product: Products; +}; diff --git a/src/utils/variables.scss b/src/utils/variables.scss new file mode 100644 index 0000000000..86734c1ee8 --- /dev/null +++ b/src/utils/variables.scss @@ -0,0 +1,81 @@ +:root { + --color-white: #fff; + --color-black: #0f0f11; + --color-titles: #0f0f11; + --color-grey: #89939a; + --color-grey-second: #b4bdc3; + --color-grey-third: #e2e6e9; + --color-page-background: #fafbfc; + --color-accent: #4219d0; + --color-accent-button-border: #4219d0; + --color-accent-button-hover: #4219d0; + --color-accent-button-selected: #fafbfc; + --color-accent-button-border-selected: #e2e6e9; + --color-button-selected-text: #4219d0; + --color-card-background: #fff; + --color-card-border: #e2e6e9; + --color-header-footer: #fff; + --color-button: #fafbfc; + --color-button-border: #b4bdc3; + --color-button-hover: #fafbfc; + --color-button-border-hover: #0f0f11; + --color-button-disabled: #fafbfc; + --color-button-border-disabled: #e2e6e9; + --color-footer-links: #89939a; + --color-dropdownfield-background: #fafbfc; + --color-dropdown-background: #fff; + --color-dropdown-border: #e2e6e9; + --color-dropdown-hover: #fafbfc; + --color-dropdown-hover-text: #0f0f11; + --color-pagination-background: #fff; + --color-pagination-border: #e2e6e9; + --color-pagination-background-hover: #fff; + --color-pagination-border-hover: #0f0f11; + --color-pagination-background-selected: #0f0f11; + --color-pagination-border-selected: #e2e6e9; +} + +body.dark-theme { + --color-white: #0f1121; + --color-black: #f1f2f9; + --color-page-background: #0f1121; + --color-titles: #f1f2f9; + --color-grey: #75767f; + --color-grey-second: #4a4d58; + --color-grey-third: #3b3e4a; + --color-accent: #905bff; + --color-accent-button-border: #905bff; + --color-accent-button-hover: #a378ff; + --color-accent-button-selected: #323542; + --color-accent-button-border-selected: #323542; + --color-button-selected-text: #f1f2f9; + --color-button: #323542; + --color-button-border: #323542; + --color-button-hover: #4a4d58; + --color-button-border-hover: #4a4d58; + --color-button-disabled: #0f1121; + --color-button-border-disabled: #3b3e4a; + --color-card-background: #161827; + --color-card-border: #161827; + --color-header-footer: #0f1121; + --color-footer-links: #f1f2f9; + --color-dropdownfield-background: #323542; + --color-dropdown-background: #0f1121; + --color-dropdown-border: #3b3e4a; + --color-dropdown-hover: #323542; + --color-dropdown-hover-text: #f1f2f9; + --color-pagination-background: #161827; + --color-pagination-border: #161827; + --color-pagination-background-hover: #3b3e4a; + --color-pagination-border-hover: #3b3e4a; + --color-pagination-background-selected: #905bff; + --color-pagination-border-selected: #905bff; +} + +// elements height +$header-height-mobile: 48px; +$header-height-tablet: 48px; +$header-height-desktop: 64px; +$footer-height-mobile: 257px; +$footer-height-tablet: 96px; +$footer-height-desktop: 96px; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -///