diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..8aabf83 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,13 @@ +/**/node_modules/* +node_modules/ + +dist/ +build/ +out//**/node_modules/* +node_modules/ + +dist/ +build/ +out/ +**/*.cjs +!**/*.ts \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..df14a06 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,113 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/strict-type-checked', + 'plugin:react-hooks/recommended', + 'plugin:@typescript-eslint/stylistic-type-checked', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:prettier/recommended', + 'airbnb', + 'plugin:react/jsx-runtime', + ], + ignorePatterns: ['dist', 'node_modules', 'build', 'public', 'assets'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + ecmaFeatures: { + jsx: true, + }, + }, + plugins: [ + 'react-refresh', + '@typescript-eslint', + 'unused-imports', + 'simple-import-sort', + 'prettier', + 'unicorn', + '@stylistic/eslint-plugin-js', + 'react', + 'react-hooks', + ], + rules: { + '@typescript-eslint/no-floating-promises': [ + 'error', + { + ignoreIIFE: true, + }, + ], + eqeqeq: 'error', + 'operator-linebreak': ['error', 'after', { overrides: { '?': 'before', ':': 'before' } }], + 'no-console': 'warn', + 'no-undef': 'off', + 'no-unused-vars': 'off', + 'no-shadow': 'off', + 'implicit-arrow-linebreak': 'off', + 'arrow-body-style': 'off', + 'object-curly-newline': [ + 'error', + { + ObjectExpression: { consistent: true, multiline: true }, + ObjectPattern: { consistent: true, multiline: true }, + ImportDeclaration: { consistent: true, multiline: true }, + ExportDeclaration: { consistent: true, multiline: true }, + }, + ], + 'import/prefer-default-export': 'off', + 'import/no-unresolved': 'error', + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: true, + }, + ], + 'prettier/prettier': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-use-before-define': 'error', + '@typescript-eslint/no-shadow': ['error'], + + 'react/jsx-filename-extension': [1, { extensions: ['.js', '.ts', '.tsx', '.jsx'] }], + 'react/jsx-one-expression-per-line': [1, { allow: 'single-child' }], + 'react/display-name': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/require-default-props': [1, { ignoreFunctionalComponents: true }], + 'react/function-component-definition': [ + 2, + { + namedComponents: 'arrow-function', + unnamedComponents: 'arrow-function', + }, + ], + 'react-refresh/only-export-components': [ + 'warn', + { + allowConstantExport: true, + }, + ], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'jsx-a11y/label-has-associated-control': [ + 2, + { + labelComponents: ['CustomInputLabel'], + labelAttributes: ['label'], + controlComponents: ['CustomInput'], + depth: 3, + }, + ], + }, + settings: { + 'import/resolver': { + typescript: {}, + }, + }, + noInlineConfig: true, +}; diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e8e9944 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +1. Acceptance criteria: + +- +- + +2. Screenshot (not technical tasks): + +3. Comments diff --git a/.gitignore b/.gitignore index 95045d7..f7d3a28 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,10 @@ dist # TernJS port file .tern-port .DS_Store + +# do not track package-lock.json file +package-lock.json + +# Vite + +.vite diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2d0a86a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +npm run format +npm run ci:format + diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..a84cc5a --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,2 @@ +npm run lint +npm test diff --git a/.lintstagedrs b/.lintstagedrs new file mode 100644 index 0000000..45f8716 --- /dev/null +++ b/.lintstagedrs @@ -0,0 +1,4 @@ +{ + "*.{json,tsx,ts,html}": ["prettier --write --ignore-unknown"], + "*.{tsx,ts}": ["eslint --quiet --fix"] +} \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ba327f8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# Add files here to ignore them from prettier formatting +/dist +node_modules +**/*.mp3 +**/*.jpg \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ebe7974 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +trailingComma: 'all' +tabWidth: 2 +semi: true +singleQuote: true +printWidth: 100 diff --git a/README.md b/README.md index d416b84..0e4635d 100644 --- a/README.md +++ b/README.md @@ -1 +1,178 @@ -# eCommerce-Application \ No newline at end of file +# eCommerce-Application + +It's a comprehensive online shopping portal that provides an interactive and seamless experience to users. From product discovery to checkout, the application ensures a smooth journey for the user, enhancing their engagement and boosting their purchasing confidence + +## Goals + +1. Sell products via site + +2. RSS students demonstrate acquired knowledge and ability to work in a team. + +## Pages + +1. Login and Registration pages 🖥️ +2. Main page 🏠 +3. Catalog Product page 📋 +4. Detailed Product page 🔎 +5. User Profile page 👤 +6. Basket page 🛒 +7. About Us page 🙋‍♂️🙋‍♀️ + +## Technology Stack + +0. TypeScript +1. React +2. Vite +3. Loadash +4. AntDesign +5. SCSS +6. HTML +7. CSS +8. CommerceTools - a leading provider of commerce solutions for B2C and B2B enterprises. CommerceTools offers a cloud-native, microservices-based commerce platform that enables brands to create unique and engaging digital commerce experiences. +9. Linters: ESLint, Prettier, airbnb rules +10. Husky +11. git +12. Jest + +## Organization + +0. Agile / Scrum +1. Jira (Board, Dashboard, Releases, Sprint, Poker Planning, Automation) +2. Confluence (Knowledge Base, MoMs, Agreements, Roles and Responsibilities etc..) +3. GitHub (Pull Request, workflow, Review) + +## Design + +This is a [link](https://www.figma.com/design/vjzFNPME7k3at8mZA0J1AR/Cozy-House?node-id=0%3A1&t=pkImd2amNwJgTpT2-1) to our project design. + +## Setup project locally + +1. Install Node.js version >= 20.0.0 +2. Clone this [repository](https://github.com/comtvset/eCommerce-Application) +3. Go to the root of project +4. To install all dependencies run code + +``` +npm i +``` + +5. Run project in browser locally + +``` +npm run dev +``` + +# Scripts: + +The project has a few scripts for working with different tools. The common rules how to use them. + +1. Open package.json +2. Find section '_scripts_' +3. Select the tool you want to apply +4. Open terminal and run command like: npm run ... + +Example: + +``` +// run Eslint +npm run lint +``` + +## List of scripts + +### dev + +to run the whole project in browser using development mode + +Example: + +``` +npm run dev +``` + +### build + +to create deployment build for production + +Example: + +``` +npm run build +``` + +### preview + +previewing the build locally + +Example: + +``` +npm run preview +``` + +### prepare + +automatically added by _husky init_ command to prevent fail + +Example: + +``` +npm run prepare +``` + +### lint + +start check of code quality with additional params. At the end you can find list of errors/warning in the terminal: + +--cache - check only changed files + +--ext - check only file with specified extensions + +--report-unused-disable-directives +This option causes ESLint to report directive comments like // eslint-disable-line when no errors would have been reported on that line anyway. + +--max-warnings - Number of warnings to trigger nonzero exit code + +Example: + +``` +npm run lint +``` + +### format + +start check of code quality with additional params. At the end you can find list of errors/warning in the terminal: + +--cache - check only changed files + +--write - format a certain file + +Example: + +``` +npm run format +``` + +### ci:format + +start check of code quality with additional params. At the end you can find list of errors/warning in the terminal: + +--cache - check only changed files + +--write - to format a file in-place + +Example: + +``` +npm run ci:format +``` + +### test + +to run the test + +Example: + +``` +npm run test +``` diff --git a/index.html b/index.html new file mode 100644 index 0000000..6995b16 --- /dev/null +++ b/index.html @@ -0,0 +1,11 @@ + + + + + + + Cozy House + + + + diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..73f8ded --- /dev/null +++ b/netlify.toml @@ -0,0 +1,5 @@ +[[redirects]] +from = "/*" +to = "/index.html" +status = 200 + diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ed5d17 --- /dev/null +++ b/package.json @@ -0,0 +1,84 @@ +{ + "name": "ecommerce", + "private": true, + "version": "1.0.0", + "type": "module", + "keywords": [ + "RSS", + "ecommerce", + "Final task" + ], + "description": "RSS Final [task](https://github.com/rolling-scopes-school/tasks/tree/master/tasks/eCommerce-Application)", + "main": "./src/main.tsx", + "husky": { + "hooks": { + "pre-commit": "lint-staged", + "pre-push": "npx validate-branch-name" + } + }, + "lint-staged": { + "*.{tsx,ts}": "npm run lint:fix" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint --cache . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "format": "npx prettier . --cache --write src/**/*.*", + "ci:format": "prettier . --check", + "prepare": "husky || true", + "test": "vitest run --coverage" + }, + "license": "ISC", + "dependencies": { + "@commercetools/platform-sdk": "^7.8.0", + "@commercetools/sdk-client-v2": "^2.5.0", + "@commercetools/sdk-middleware-auth": "^7.0.1", + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/react": "^15.0.7", + "@types/prop-types": "^15.7.12", + "@types/uuid": "^9.0.8", + "dotenv": "^16.4.5", + "isomorphic-fetch": "^3.0.0", + "node-fetch": "^3.3.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.51.4", + "react-router-dom": "^6.23.1", + "react-slick": "^0.30.2", + "slick-carousel": "^1.8.1", + "vite-tsconfig-paths": "^4.3.2" + }, + "devDependencies": { + "@stylistic/eslint-plugin-js": "^1.7.2", + "@types/node": "^20.12.12", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@types/react-slick": "^0.23.13", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "@vitest/coverage-v8": "^1.6.0", + "eslint": "^8.57.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-react": "^7.34.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "eslint-plugin-simple-import-sort": "^10.0.0", + "eslint-plugin-unicorn": "^50.0.1", + "eslint-plugin-unused-imports": "^3.0.0", + "husky": "^9.0.11", + "lint-staged": "^15.2.0", + "prettier": "^3.2.4", + "pretty-quick": "^4.0.0", + "sass": "^1.75.0", + "typescript": "^5.2.2", + "vite": "^5.2.0", + "vitest": "^1.6.0" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..12ef905 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,30 @@ +import React, { createContext, useState, useMemo } from 'react'; +import { Router } from 'src/components/router/Router.tsx'; +import { CustomerDraft } from '@commercetools/platform-sdk'; + +interface CurrentUserContextType { + currentUser: CustomerDraft | null; + setCurrentUser: React.Dispatch>; +} + +export const CurrentUserContext = createContext(null); + +export const App = () => { + const [currentUser, setCurrentUser] = useState(null); + + const contextValue = useMemo( + () => ({ + currentUser, + setCurrentUser, + }), + [currentUser, setCurrentUser], + ); + + return ( + + + + ); +}; + +export default App; diff --git a/src/components/address/Address.module.scss b/src/components/address/Address.module.scss new file mode 100644 index 0000000..84b319e --- /dev/null +++ b/src/components/address/Address.module.scss @@ -0,0 +1,42 @@ +.checkboxes { + display: flex; + flex-direction: column; + justify-content: center; + margin: 15px; + gap: 15px; + align-items: flex-start; + width: 160px; +} + +.checkbox_billing { + margin: 15px; + gap: 15px; +} + +.formbody { + display: flex; + flex-direction: column; + flex-wrap: wrap; + font-size: 1.4rem; + text-transform: uppercase; + + > div { + display: flex; + flex-direction: column; + flex-wrap: wrap; + + > div { + font-size: 1.4rem; + color: red; + margin-top: 5px; + text-transform: none; + } + > label { + margin-top: 10px; + } + } +} + +.error_tooltip { + white-space: pre-wrap; +} diff --git a/src/components/address/Address.tsx b/src/components/address/Address.tsx new file mode 100644 index 0000000..e2ac5d8 --- /dev/null +++ b/src/components/address/Address.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import style from 'src/components/address/Address.module.scss'; +import Selector from 'src/components/selector/Selector.tsx'; +import { InputWithLabel } from 'src/components/input/InputWithLabel.tsx'; +import { Checkbox } from 'src/components/checkbox/Checkbox.tsx'; + +interface AddressProps { + formData?: { + isShippingDefaultAddress?: boolean; + isEqualAddress?: boolean; + streetName?: string | undefined; + city?: string | undefined; + country?: string | undefined; + postalCode?: string | undefined; + }; + handleChange: (event: React.ChangeEvent) => void; + handleBoolean?: (value: boolean) => void; + handleSameAddress?: (value: boolean) => void; + errors: { + streetName: string | undefined; + city: string | undefined; + country: string | undefined; + postalCode: string | undefined; + }; + title: string; + showIsTheSameAddress?: boolean; + disabledMode?: boolean; +} + +export const AddressForm: React.FC = ({ + formData, + handleChange, + handleBoolean, + handleSameAddress, + errors, + title, + showIsTheSameAddress = true, + disabledMode = false, +}) => { + const noop = () => { + // Intentionally do nothing + }; + const handleCheckboxChange = handleSameAddress ?? noop; + + return ( +
+

{title}

+
+ + {showIsTheSameAddress && ( + + )} +
+
+ + + + +
+
+ ); +}; diff --git a/src/components/address/BillingAddress.tsx b/src/components/address/BillingAddress.tsx new file mode 100644 index 0000000..b89fd3a --- /dev/null +++ b/src/components/address/BillingAddress.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import style from 'src/components/address/Address.module.scss'; +import Selector from 'src/components/selector/Selector.tsx'; +import { InputWithLabel } from 'src/components/input/InputWithLabel.tsx'; +import { Checkbox } from 'src/components/checkbox/Checkbox.tsx'; + +interface BillingAddressProps { + formData: { + isBillingDefaultAddress: boolean; + billingStreet: string | undefined; + billingCity: string | undefined; + billingCountry: string | undefined; + billingPostalCode: string | undefined; + }; + handleChange: (event: React.ChangeEvent) => void; + handleBoolean: (value: boolean) => void; + errors: { + billingStreet: string | undefined; + billingCity: string | undefined; + billingCountry: string | undefined; + billingPostalCode: string | undefined; + }; + title: string; + disabledMode?: boolean; +} + +export const BillingAddressForm: React.FC = ({ + formData, + handleChange, + handleBoolean, + errors, + title, + disabledMode = false, +}) => { + return ( +
+

{title}

+
+ +
+
+ + + + +
+
+ ); +}; diff --git a/src/components/card/Card.module.scss b/src/components/card/Card.module.scss new file mode 100644 index 0000000..9ff7a35 --- /dev/null +++ b/src/components/card/Card.module.scss @@ -0,0 +1,177 @@ +@import 'src/styles/variables'; +@import 'src/styles/mixins'; + +.container, +.main_image { + margin: 0 auto; +} + +.container { + width: 90%; + height: 100vh; +} + +.product { + display: flex; + width: 100%; + height: 100%; + padding: 10% 0 10% 0; + @include media-1000 { + flex-direction: column; + } +} + +.container_images, +.main_image, +.info { + width: 50%; +} + +.image { + display: block; + width: 100%; + height: 40vh; + object-fit: contain; + transition: transform 0.5s ease; + &:hover { + cursor: pointer; + transform: scale(1.1); + } +} + +.images_container { + display: flex; + width: 60%; + margin-top: 20px; + margin-left: auto; + margin-right: auto; +} + +.images { + display: block; + width: 10%; + background-color: #ffffff; + height: 25vh; + object-fit: cover; +} + +.name_thing, +.description { + color: $secondaryTextColor; +} + +.name_thing { + margin-top: 0px; + font-size: 2.4rem; + @include media-1000 { + text-align: center; + } +} + +.description { + font-size: 1.6rem; + line-height: 2.5rem; +} + +.container_images { + @include media-1200 { + width: 65%; + } + @include media-1000 { + width: 80%; + margin: 0 auto; + } + @include media-600 { + width: 90%; + } + + @include media-400 { + width: 100%; + } +} + +.info { + @include media-1200 { + width: 45%; + } + @include media-1000 { + width: 100%; + margin-top: 30px; + } +} + +.price { + font-size: 2rem; + color: $secondaryTextColor; + margin-bottom: 0px; +} +.prices { + display: flex; + gap: 10%; +} + +.price_start, +.price_finish { + font-size: 2rem; + font-weight: 700; + margin-top: 10px; +} +.price_start { + color: $secondaryTextColor; +} +.price_sale { + text-decoration: line-through; +} +.price_finish { + color: $priceSale; +} + +.background { + position: fixed; + left: 0; + top: 0; + bottom: 0; + right: 0; + background-color: rgba(64, 63, 61, 0.9); +} + +.modal_image { + display: block; + width: 80%; + height: 90vh; + margin: 0 auto; + object-fit: contain; + @include media-800 { + width: 90%; + } + @include media-600 { + width: 100%; + } +} + +.images_container2 { + margin-left: auto; + margin-right: auto; + width: 50%; + height: 60%; + @include media-1200 { + width: 60%; + } + @include media-800 { + width: 70%; + } + @include media-600 { + width: 100%; + } +} + +.images2 { + display: block; + width: 90%; + height: 90vh; + object-fit: cover; +} + +.images_container2 > ul > li > button::before { + color: #ffffff; +} diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx new file mode 100644 index 0000000..04b175f --- /dev/null +++ b/src/components/card/Card.tsx @@ -0,0 +1,231 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import style from 'src/components/card/Card.module.scss'; +import { Layout } from 'src/components/layout/Layout.tsx'; +import { apiRoot } from 'src/services/api/ctpClient.ts'; +import { Modal } from 'src/components/modalWindow/modalImage.tsx'; +import { Paragraph } from 'src/components/text/Text.tsx'; +import { getCurrencySymbol } from 'src/utils/CurrencyUtils.ts'; +import Slider from 'react-slick'; +import 'slick-carousel/slick/slick.css'; +import 'slick-carousel/slick/slick-theme.css'; +import { ProductCatalogData } from '@commercetools/platform-sdk'; + +interface Image { + url: string; +} + +interface IProductData { + id: string; + masterData: ProductCatalogData; +} + +export const CardOne: React.FC = () => { + const [product, setProduct] = useState(); + const [error, setError] = useState(null); + const [selectedImage, setSelectedImage] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); + const [modal, setModal] = useState(false); + const { id } = useParams<{ id: string }>(); + + const handleImageChange = (newImage: Image, index: number) => { + setSelectedImage(newImage); + setSelectedIndex(index); + }; + + const showModalWindow = () => { + setModal(true); + }; + + const closeModalWindow = () => { + setModal(false); + }; + + const isImages = product?.masterData.staged.masterVariant.images ?? []; + const isLength = isImages.length > 1; + const isFirstPrice = product?.masterData.current.masterVariant.prices; + const isDiscount = isFirstPrice + ? ((isFirstPrice[0].discounted?.value.centAmount ?? 0) / 100).toFixed(2) + : ''; + const isNoDiscount = isFirstPrice + ? ((isFirstPrice[0]?.value.centAmount ?? 0) / 100).toFixed(2).toString() + : ''; + const isCurrencyCode = isFirstPrice ? isFirstPrice[0].discounted?.value.currencyCode : ''; + const isCurrencyNoDiscount = isFirstPrice ? isFirstPrice[0].value.currencyCode : ''; + const currencyCode = getCurrencySymbol(isCurrencyCode) ?? ''; + const currencyNoDiscount = getCurrencySymbol(isCurrencyNoDiscount) ?? ''; + + useEffect(() => { + if (typeof id !== 'undefined') { + apiRoot + .products() + .withId({ ID: id }) + .get() + .execute() + .then((response) => { + setProduct(response.body); + setError(null); + }) + .catch(() => { + setError('An error occurred while including product data. Please generate later'); + }); + } + }, [id]); + + useEffect(() => { + if (product?.masterData.staged.masterVariant.images?.length) { + setSelectedImage(product.masterData.staged.masterVariant.images[0]); + } + }, [product]); + + useEffect(() => { + if (modal) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + }, [modal]); + + const settings = { + dots: true, + infinite: true, + speed: 500, + slidesToShow: isImages.length, + slidesToScroll: 1, + }; + const { dots, infinite, speed, slidesToShow, slidesToScroll } = settings; + + const settings2 = { + initialSlide: selectedIndex, + dot: true, + infinit: true, + spee: 500, + slidesToSho: 1, + slidesToScrol: 1, + }; + const { initialSlide, dot, infinit, spee, slidesToSho, slidesToScrol } = settings2; + + return ( + + {error &&
{error}
} + {product && ( +
+
+
+ {selectedImage && ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + showModalWindow(); + } + }} + > + {product.masterData.current.name['en-US']} +
+ )} +
+ {isLength && ( + { + handleImageChange(isImages[currentSlide], selectedIndex); + }} + > + {product.masterData.staged.masterVariant.images?.map((image) => ( + {product.masterData.current.name['en-US']} + ))} + + )} +
+
+ + + +
+ {isFirstPrice?.[0].discounted ? ( + <> + + + + ) : ( + + )} +
+
+
+ )} + {product && selectedImage && modal && ( + + {!isLength && ( + {product.masterData.current.name['en-US']} + )} + {isLength && ( + { + handleImageChange(isImages[currentSlide], currentSlide); + }} + > + {product.masterData.staged.masterVariant.images?.map((image) => ( + {product.masterData.current.name['en-US']} + ))} + + )} + + )} + {modal &&
} + + ); +}; diff --git a/src/components/cards/Cards.module.scss b/src/components/cards/Cards.module.scss new file mode 100644 index 0000000..ac49ef7 --- /dev/null +++ b/src/components/cards/Cards.module.scss @@ -0,0 +1,79 @@ +@import 'src/styles/variables'; + +.cards_container { + display: flex; + flex-wrap: wrap; + width: 80%; + padding: 20px; + justify-content: space-between; + border-radius: 5px; +} + +.card { + display: flex; + width: 400px; + margin-top: 20px; + box-shadow: 0px 0px 10px 0px #a5a5a596; + justify-content: space-evenly; + height: 500px; + background-color: #ffffff; + flex-direction: column; + align-items: center; + border-radius: 10px; + cursor: pointer; + transition: 0.3s ease; + user-select: none; +} + +.card:hover { + color: hsla(0, 63%, 3%, 0.564); + box-shadow: 0px 0px 10px 4px #a5a5a596; +} + +.card:active { + transform: scale(0.98); +} + +.image_container { + display: flex; + width: 90%; + justify-content: center; + transition: 0.3s ease; +} + +.image_container:hover { + transition: 0.3s ease; + transform: scale(1.2); +} + +.card_info { + display: flex; + flex-direction: column; + width: 90%; + gap: 10px; +} + +.image { + object-fit: contain; + width: 200px; + height: 200px; + border-radius: 10px; + box-shadow: 0px 0px 4px 1px #a5a5a596; + padding: 5px; +} + +.name_thing { + color: $secondaryTextColor; + margin-bottom: 0px; +} + +.description { + margin-bottom: 0px; + font-size: 1.6rem; +} + +@media (max-width: 510px) { + .image_container:hover { + transform: none; + } +} diff --git a/src/components/cards/Cards.tsx b/src/components/cards/Cards.tsx new file mode 100644 index 0000000..6b15b1c --- /dev/null +++ b/src/components/cards/Cards.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { ProductProjection } from '@commercetools/platform-sdk'; +import { Paragraph } from 'src/components/text/Text.tsx'; +import style from 'src/components/cards/Cards.module.scss'; +import style1 from 'src/components/card/Card.module.scss'; +import { Link } from 'src/components/link/Link.tsx'; +import { getCurrencySymbol } from 'src/utils/CurrencyUtils.ts'; + +interface CardProps { + products: ProductProjection[]; +} + +export const Card: React.FC = ({ products }) => { + return ( +
+ {products.map((product) => { + const priceObj = product.masterVariant.prices?.[0].value; + const priceDiscountObj = product.masterVariant.prices?.[0].discounted?.value; + + const centAmount = priceObj?.centAmount; + const centAmountDiscount = priceDiscountObj?.centAmount; + const currencyCode = priceObj?.currencyCode; + const currencySymbol = getCurrencySymbol(currencyCode) ?? ''; + const price = + centAmount !== undefined ? `${currencySymbol}${(centAmount / 100).toFixed(2)}` : ''; + + const priceDiscount = + centAmountDiscount !== undefined + ? `${currencySymbol}${(centAmountDiscount / 100).toFixed(2)}` + : ''; + + const priceStartClass = centAmountDiscount + ? `${style1.price_start} ${style1.price_sale}` + : style1.price_start; + + return ( + +
+ {product.name['en-US']} +
+
+ + +
+
+ + {centAmountDiscount !== undefined && ( + + )} +
+ + ); + })} +
+ ); +}; diff --git a/src/components/category/Category.module.scss b/src/components/category/Category.module.scss new file mode 100644 index 0000000..32e84a6 --- /dev/null +++ b/src/components/category/Category.module.scss @@ -0,0 +1,41 @@ +.category_container { + display: flex; + margin-top: 10px; + justify-content: center; + flex-direction: column; + font-size: 1.6rem; +} + +.category { + display: flex; + flex-direction: column; + cursor: pointer; + margin: 5px; +} + +.category_parent { + display: flex; + flex-direction: column; + cursor: pointer; +} + +.subcategory { + margin-left: 30px; + cursor: pointer; + padding: 5px; +} + +.category:hover, +.subcategory:hover { + color: orange; +} + +@media (max-width: 500px) { + .category_container { + font-size: 1.2rem; + } + + .subcategory { + margin-left: 15px; + } +} diff --git a/src/components/category/Category.tsx b/src/components/category/Category.tsx new file mode 100644 index 0000000..2aa85a7 --- /dev/null +++ b/src/components/category/Category.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import style from 'src/components/category/Category.module.scss'; + +interface CategoryComponentProps { + onCategoryClick: (category: string) => void; + selectedCategory: string; +} + +const categories = [ + { name: 'Decor', isSubcategory: false }, + { name: 'Wall Decor', isSubcategory: true }, + { name: 'X-mas', isSubcategory: true }, + { name: 'Toys', isSubcategory: false }, + { name: 'Food', isSubcategory: false }, + { name: 'Jar', isSubcategory: true }, + { name: 'All category', isSubcategory: false }, +]; + +export const CategoryComponent: React.FC = ({ + onCategoryClick, + selectedCategory, +}) => ( +
+

Category:

+
+ {categories.map((category) => ( +
{ + onCategoryClick(category.name); + }} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter') onCategoryClick(category.name); + }} + > + {selectedCategory === category.name && '● '} + {category.name} +
+ ))} +
+
+); diff --git a/src/components/checkbox/Checkbox.module.scss b/src/components/checkbox/Checkbox.module.scss new file mode 100644 index 0000000..1c594cd --- /dev/null +++ b/src/components/checkbox/Checkbox.module.scss @@ -0,0 +1,39 @@ +.custom_checkbox { + display: flex; + flex-direction: row; + align-items: center; + + input[type='checkbox'] { + appearance: none; + width: 20px; + height: 20px; + outline: none; + margin: 0 10px 0 0; + } + label { + height: 20px; + } +} + +.custom_checkbox input[type='checkbox']::before { + content: ''; + display: inline-block; + width: 20px; + height: 20px; + border: 1px solid var(--primary-text-color); + border-radius: 4px; + background-color: white; +} + +.custom_checkbox input[type='checkbox']:checked:disabled::before, +.custom_checkbox input[type='checkbox']:disabled::before { + background-color: var(--background-color); +} + +.custom_checkbox input[type='checkbox']:checked::before { + background-color: var(--background-color); + border-color: var(--primary-text-color); + outline: none; + content: '\2713'; + background-color: white; +} diff --git a/src/components/checkbox/Checkbox.tsx b/src/components/checkbox/Checkbox.tsx new file mode 100644 index 0000000..48c839b --- /dev/null +++ b/src/components/checkbox/Checkbox.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import style from 'src/components/checkbox/Checkbox.module.scss'; + +interface CheckboxProps { + label: string; + checked: boolean; + id: string; + onChange?: (checked: boolean) => void; + disabledMode?: boolean; +} + +export const Checkbox: React.FC = ({ + id, + label, + checked, + onChange, + disabledMode = false, +}) => { + const handleChange = (event: React.ChangeEvent) => { + onChange?.(event.target.checked); + }; + + return ( +
+ + +
+ ); +}; diff --git a/src/components/country/country.ts b/src/components/country/country.ts new file mode 100644 index 0000000..e29260c --- /dev/null +++ b/src/components/country/country.ts @@ -0,0 +1,14 @@ +export enum Country { + France = 'FR', + Germany = 'DE', + Italy = 'IT', + Netherlands = 'NL', + Underfined = '...', +} + +export const countryLookup: Record = { + FR: 'France', + DE: 'Germany', + IT: 'Italy', + NL: 'Netherlands', +}; diff --git a/src/components/filter/Filter.module.scss b/src/components/filter/Filter.module.scss new file mode 100644 index 0000000..e7b8302 --- /dev/null +++ b/src/components/filter/Filter.module.scss @@ -0,0 +1,56 @@ +.filters_container { + display: flex; + flex-direction: column; + margin-top: 40px; + border-radius: 5px; + flex-wrap: wrap; + justify-content: flex-start; + padding: 5px; + font-size: 1.7rem; + color: (--primary-text-color); +} + +.filter_container { + padding: 1rem; + border-bottom: 1px solid #ddd; + color: (--primary-text-color); +} + +.filter_group { + margin-top: 1rem; +} + +.option_group { + display: flex; + margin-bottom: 0.5rem; + align-items: center; + flex-direction: row-reverse; + justify-content: space-between; +} + +.color_option label { + margin-right: 0.5rem; +} + +.input_container { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; + font-size: 1.6rem; +} + +input { + height: initial !important; + outline: initial !important; +} + +@media (max-width: 500px) { + .filters_container { + font-size: 1.2rem; + } + + h3 { + font-size: 1.6rem; + } +} diff --git a/src/components/filter/Filter.tsx b/src/components/filter/Filter.tsx new file mode 100644 index 0000000..b41aac4 --- /dev/null +++ b/src/components/filter/Filter.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react'; +import { ProductProjection } from '@commercetools/platform-sdk'; +import style from 'src/components/filter/Filter.module.scss'; +import myStyles from 'src/components/form/registration/RegistrationForm.module.scss'; +import { fetchAllProducts } from 'src/services/api/filterRequests.ts'; +import { FilterComponent, FilterOption } from './FilterComponent.tsx'; + +interface FilterProps { + colors: FilterOption[]; + sizes: FilterOption[]; + prices: FilterOption[]; + handleChange(event: React.ChangeEvent): void; + onReset(products: ProductProjection[]): void; +} + +export const Filter: React.FC = ({ + colors = [], + sizes = [], + prices = [], + handleChange, + onReset, +}) => { + const [selectedOptions, setSelectedOptions] = useState([]); + const [productState, setProductState] = useState([]); + const errorsArray = []; + const trashArray = []; + + const handleFilterChange = (event: React.ChangeEvent) => { + const { value } = event.target; + + if (event.target.type === 'radio') { + setSelectedOptions([value]); + } else if (event.target.checked) { + setSelectedOptions([...selectedOptions, value]); + } else { + setSelectedOptions(selectedOptions.filter((option) => option !== value)); + } + + handleChange(event); + }; + + const isChecked = (value: string): boolean => { + return selectedOptions.includes(value); + }; + + useEffect(() => { + const errorArray = []; + const getAllproducts = async () => { + try { + const result = await fetchAllProducts(); + setProductState(result); + } catch (error) { + errorArray.push(error); + } + }; + (async () => { + await getAllproducts(); + })(); + }, []); + + const resetFilters = async () => { + setSelectedOptions([]); + try { + const result = await fetchAllProducts(); + setProductState(result); + onReset(result); + } catch (error) { + errorsArray.push(error); + } + }; + + const handleResetButtonClick = () => { + resetFilters().catch((error: unknown) => { + errorsArray.push(error); + trashArray.push(productState); + }); + }; + + return ( +
+

Product Filters

+ + + + +
+ ); +}; diff --git a/src/components/filter/FilterComponent.tsx b/src/components/filter/FilterComponent.tsx new file mode 100644 index 0000000..e96c761 --- /dev/null +++ b/src/components/filter/FilterComponent.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import style from './Filter.module.scss'; + +export interface FilterOption { + label: string; + value: string; +} + +interface FilterComponentProps { + options: FilterOption[]; + title: string; + type: 'checkbox' | 'radio'; + handleChange?(event: React.ChangeEvent): void; + checked?: (value: string) => boolean; +} + +export const FilterComponent: React.FC = ({ + options, + title, + type, + handleChange, + checked, +}) => { + return ( +
+

{title}

+
+ {options.map((option) => ( +
+ + +
+ ))} +
+
+ ); +}; diff --git a/src/components/form/form.tsx b/src/components/form/form.tsx new file mode 100644 index 0000000..3207d8d --- /dev/null +++ b/src/components/form/form.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import styles from 'src/logic/loginPage/loginPage.module.scss'; +import myStyles from 'src/components/form/registration/RegistrationForm.module.scss'; +import { validateEmail, validatePassword } from 'src/components/validation/Validation.ts'; + +import { myStatus } from 'src/components/tempFolderForDevelop/statusHandler.ts'; +import { IResponse, myRedirect } from 'src/components/tempFolderForDevelop/responseHandler.ts'; +import { ModalWindow } from 'src/components/modalWindow/modalWindow.tsx'; +import { useNavigate } from 'react-router-dom'; +import { Paragraph } from 'src/components/text/Text.tsx'; +import { Link } from 'src/components/link/Link.tsx'; +import { loginRequest } from 'src/services/api/loginRequest.ts'; +import { saveCredentials } from 'src/services/userData/saveEmailPassword.ts'; + +export const Form = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [errorEmail, setErrorEmail] = useState(''); + const [errorPassword, setErrorPassword] = useState(''); + const [showModal, setShowModal] = useState(false); + const [modalData, setModalData] = useState(null); + const [showPassword, setShowPassword] = useState(false); + + const navigation = useNavigate(); + + const handleValidation = useCallback(() => { + if (email) { + const emailError = validateEmail(email); + setErrorEmail(emailError); + } else { + setErrorEmail(''); + } + + if (password) { + const passwordError = validatePassword(password); + setErrorPassword(passwordError); + } else { + setErrorPassword(''); + } + }, [email, password]); + + const handleClick = async (event: React.MouseEvent) => { + event.preventDefault(); + + const emailError = validateEmail(email); + const passwordError = validatePassword(password); + + if (!email) { + setErrorEmail('⚠ This field is required!'); + } + if (!password) { + setErrorPassword('⚠ This field is required!'); + } + + if (emailError.length === 0 && passwordError.length === 0) { + try { + saveCredentials(email, password); + await loginRequest(email, password); + + const responseServer = myStatus(true, 'Congratulations, successful login!'); + myRedirect(responseServer); + setTimeout(() => { + setEmail(''); + setPassword(''); + navigation('/'); + }, 1000); + setModalData(responseServer); + setShowModal(true); + } catch (error) { + const responseServer = myStatus(false, 'Incorrect email or password!'); + myRedirect(responseServer); + setModalData(responseServer); + setShowModal(true); + } + } + }; + + useEffect(() => { + handleValidation(); + }, [email, password, handleValidation]); + + useEffect(() => { + if (showModal) { + const timer = setTimeout(() => { + setShowModal(false); + }, 1000); + + return () => { + clearTimeout(timer); + }; + } + return () => { + (''); + }; + }, [showModal]); + + return ( + <> +
+ EMAIL + { + setEmail(e.target.value); + }} + required + /> + {errorEmail} + PASSWORD + { + setPassword(e.target.value); + }} + required + /> + {errorPassword} + + +
+ + +
+
+ {showModal && modalData && } + + ); +}; diff --git a/src/components/form/profile/AddressProfileForm.tsx b/src/components/form/profile/AddressProfileForm.tsx new file mode 100644 index 0000000..17c6f84 --- /dev/null +++ b/src/components/form/profile/AddressProfileForm.tsx @@ -0,0 +1,405 @@ +import { Address, createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; +import React, { useEffect, useState } from 'react'; +import { getLoginClient } from 'src/services/api/BuildClient.ts'; +import { PROJECT_KEY } from 'src/services/api/BuildClientRegistration.ts'; +import styles from 'src/components/form/profile/UserProfileForm.module.scss'; +import { AddressForm } from 'src/components/address/Address.tsx'; +import { ModalWindow } from 'src/components/modalWindow/modalWindow.tsx'; +import { validateField } from 'src/components/validation/Validation.ts'; +import { validatePostalCode } from 'src/components/validation/PostalCodeValidation.ts'; +import { Country, countryLookup } from 'src/components/country/country.ts'; +import { ICustomerModel, customerModel } from 'src/model/Customer.ts'; +import useModalEffect from 'src/components/form/profile/UseModalEffect.ts'; +import { ServerError } from 'src/utils/error/RequestErrors.ts'; + +interface AddressProfileProps { + userProfileFormData: ICustomerModel; +} + +export const AddressProfileForm: React.FC = ({ userProfileFormData }) => { + const apiRoot = createApiBuilderFromCtpClient(getLoginClient().client).withProjectKey({ + projectKey: PROJECT_KEY, + }); + + const [countryNewAddress] = useState(Country.Underfined); + const [countryBilling] = useState(Country.Underfined); + const [id] = useState(localStorage.getItem('fullID') ?? ''); + const [formData, setFormData] = useState({ + ...customerModel, + ...userProfileFormData, + }); + const [, setIsFormValid] = useState(false); + + const popupMessage = { status: '', message: '' }; + const [modalData, setModalData] = useState(popupMessage); + useModalEffect(modalData, setModalData); + + const [errors, setErrors] = useState(customerModel); + const [addresses, setAddresses] = useState([]); + const [isEditAddress, setIsEditAddress] = useState(false); + const [editAddressID, setEditAddressID] = useState(''); + + interface IEmptyAddress { + streetName: string; + city: string; + postalCode: string; + country: Country; + } + const emptyAddress: IEmptyAddress = { + streetName: '', + city: '', + postalCode: '', + country: Country.Underfined, + }; + const [newAddress, setNewAddress] = useState
(emptyAddress); + + const proceedExceptions = (error: unknown, message: string) => { + if (error instanceof ServerError) { + setModalData({ status: 'Error', message: error.message }); + } else if (error instanceof Error) { + setModalData({ status: 'Error', message: error.message }); + } else { + setModalData({ status: 'Error', message }); + } + }; + + useEffect(() => { + const apiRoot2 = createApiBuilderFromCtpClient(getLoginClient().client).withProjectKey({ + projectKey: PROJECT_KEY, + }); + const fetchAddresses = async (): Promise => { + try { + const response = await apiRoot2.customers().withId({ ID: id }).get().execute(); + setAddresses(response.body.addresses); + setFormData((prevFormData) => ({ + ...prevFormData, + version: response.body.version, + })); + } catch (error) { + proceedExceptions(error, 'Could not retrieve customer addresses'); + } + }; + if (id) { + fetchAddresses().catch((error: unknown) => { + proceedExceptions(error, 'Could not retrieve customer addresses'); + }); + } + }, [id]); + + const handleDefaultAddress = (checked: boolean, type: 'shipping' | 'billing') => { + setFormData((prevFormData) => ({ + ...prevFormData, + [type === 'shipping' ? 'isShippingDefaultAddress' : 'isBillingDefaultAddress']: checked, + })); + }; + + const validateOneField = (name: string, value: string) => { + const error = validateField(name, value, countryNewAddress, countryBilling); + setErrors((prevErrors) => ({ + ...prevErrors, + [name]: error || '', + })); + }; + + const handleNewAddressChange = ( + event: React.ChangeEvent, + ) => { + const { name, value } = event.target; + + setNewAddress((prevNewAddress) => ({ + ...prevNewAddress, + [name]: value, + })); + validateOneField(name, value); + }; + + const fetchLatestVersion = async (): Promise => { + try { + const response = await apiRoot.customers().withId({ ID: id }).get().execute(); + return response.body.version; + } catch (error) { + proceedExceptions(error, 'Fetching latest version'); + return null; + } + }; + + const handleAddAddress = async () => { + const latestVersion = await fetchLatestVersion(); + if (latestVersion !== null) { + try { + const response = await apiRoot + .customers() + .withId({ ID: id }) + .post({ + body: { + version: latestVersion, + actions: [ + { + action: 'addAddress', + address: { + streetName: newAddress.streetName, + city: newAddress.city, + postalCode: newAddress.postalCode, + country: Country[newAddress.country as keyof typeof Country], + }, + }, + ], + }, + }) + .execute(); + + setAddresses(response.body.addresses); + setFormData((prevFormData) => ({ + ...prevFormData, + version: response.body.version, + })); + setNewAddress(emptyAddress); + setModalData({ status: 'Success', message: 'Address added successfully' }); + } catch (error) { + setModalData({ status: 'Error', message: 'Could not add a new address' }); + proceedExceptions(error, 'Adding new address'); + } + } + }; + + const handleEditAddress = async () => { + const latestVersion = await fetchLatestVersion(); + if (latestVersion !== null) { + try { + const response = await apiRoot + .customers() + .withId({ ID: id }) + .post({ + body: { + version: latestVersion, + actions: [ + { + action: 'changeAddress', + addressId: editAddressID, + address: { + streetName: newAddress.streetName, + city: newAddress.city, + postalCode: newAddress.postalCode, + country: Country[newAddress.country as keyof typeof Country], + }, + }, + ], + }, + }) + .execute(); + + setAddresses(response.body.addresses); + setIsEditAddress(false); + setFormData((prevFormData) => ({ + ...prevFormData, + version: response.body.version, + })); + setNewAddress(emptyAddress); + setModalData({ status: 'Success', message: 'Address edited successfully' }); + } catch (error) { + setModalData({ status: 'Error', message: 'Could not save edited address' }); + proceedExceptions(error, 'Editing address'); + } + } + }; + + const handleDeleteAddress = async (addressId: string) => { + const latestVersion = await fetchLatestVersion(); + if (latestVersion !== null) { + try { + const response = await apiRoot + .customers() + .withId({ ID: id }) + .post({ + body: { + version: latestVersion, + actions: [ + { + action: 'removeAddress', + addressId, + }, + ], + }, + }) + .execute(); + + setAddresses(response.body.addresses); + setFormData((prevFormData) => ({ + ...prevFormData, + version: response.body.version, + })); + setModalData({ status: 'Success', message: 'Address deleted successfully' }); + } catch (error) { + setModalData({ status: 'Error', message: 'Error deleting address' }); + proceedExceptions(error, 'Deleting address'); + } + } + }; + + useEffect(() => { + const allFieldsValid = Object.values(errors).every( + (error) => error === '' || typeof error === 'boolean', + ); + setIsFormValid(allFieldsValid); + }, [errors]); + + useEffect(() => { + const error = validatePostalCode(countryNewAddress, newAddress.postalCode); + setErrors((prevErrors) => ({ + ...prevErrors, + postalCode: error, + })); + }, [countryNewAddress, newAddress.postalCode]); + + useEffect(() => { + const error = validatePostalCode(countryBilling, formData.billingPostalCode); + setErrors((prevErrors) => ({ + ...prevErrors, + billingPostalCode: error, + })); + }, [countryBilling, formData.billingPostalCode]); + + return ( + <> +
+
+

Add New Address

+
+ +
+ {isEditAddress && ( + + )} + {isEditAddress && ( + + )} +
+
+ {!isEditAddress && ( + + )} +
+
+

Your Addresses

+ {addresses + .slice() + .reverse() + .map((address) => ( +
+
+
+
+ + +
+
+
+ {!isEditAddress && ( + + )} + + +
+
+
+
+ street name: +

{address.streetName}

+
+
+ city: +

{address.city}

+
+
+ postal code: +

{address.postalCode}

+
+
+ country: +

{countryLookup[address.country]}

+
+
+
+ ))} +
+
+ + {modalData.message && } + + ); +}; diff --git a/src/components/form/profile/BasicUserDataProfile.tsx b/src/components/form/profile/BasicUserDataProfile.tsx new file mode 100644 index 0000000..0b5e7f4 --- /dev/null +++ b/src/components/form/profile/BasicUserDataProfile.tsx @@ -0,0 +1,194 @@ +import React, { useEffect, useState } from 'react'; +import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; +import { Country } from 'src/components/country/country.ts'; +import { ModalWindow } from 'src/components/modalWindow/modalWindow.tsx'; +import { ICustomerModel, customerModel } from 'src/model/Customer.ts'; +import { getLoginClient } from 'src/services/api/BuildClient.ts'; +import { PROJECT_KEY } from 'src/services/api/BuildClientRegistration.ts'; +import { RegistrationMainFields } from 'src/components/form/registration/RegistrationMainFields.tsx'; +import styles from 'src/components/form/profile/UserProfileForm.module.scss'; +import { validateField } from 'src/components/validation/Validation.ts'; +import { updateCustomerField } from 'src/services/api/updateCustomer.ts'; +import { updateEmail } from 'src/services/userData/saveEmailPassword.ts'; +import { mapCustomerToModel } from 'src/services/DTO/Customer.ts'; +import { ServerError } from 'src/utils/error/RequestErrors.ts'; +import useModalEffect from './UseModalEffect.ts'; + +interface BasicUserDataProfileProps { + userProfileFormData: ICustomerModel; +} + +export const BasicUserDataProfile: React.FC = ({ + userProfileFormData, +}) => { + const apiRoot2 = createApiBuilderFromCtpClient(getLoginClient().client).withProjectKey({ + projectKey: PROJECT_KEY, + }); + const [api, setAPI] = useState(apiRoot2); + + const [id] = useState(localStorage.getItem('fullID') ?? ''); + const [isDisabledUserInfo, setEditUserInfo] = useState(true); + const [formData, setFormData] = useState(customerModel); + const [isFormValid, setIsFormValid] = useState(false); + const [isEmail, setEmail] = useState(false); + + const popupMessage = { status: '', message: '' }; + const [modalData, setModalData] = useState(popupMessage); + useModalEffect(modalData, setModalData); + + const [errors, setErrors] = useState(customerModel); + + const proceedExceptions = (error: unknown, message: string) => { + if (error instanceof ServerError) { + setModalData({ status: 'Error', message: error.message }); + } else if (error instanceof Error) { + setModalData({ status: 'Error', message: error.message }); + } else { + setModalData({ status: 'Error', message }); + } + }; + + const fetchLatestVersion = async (): Promise => { + try { + const response = await api.customers().withId({ ID: id }).get().execute(); + return response.body.version; + } catch (error) { + proceedExceptions(error, 'Fetching latest version'); + return null; + } + }; + + useEffect(() => { + setFormData({ + ...userProfileFormData, + }); + }, [id, userProfileFormData]); + + const validateOneField = (name: string, value: string) => { + const error = validateField(name, value, Country.Underfined, Country.Underfined); + const errorValidate = error === '' ? '' : error; + setErrors({ + ...errors, + [name]: errorValidate, + }); + }; + + const handleChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setFormData({ + ...formData, + [name]: value, + }); + + validateOneField(name, value); + }; + + useEffect(() => { + const allFieldsValid = Object.values(errors).every( + (error) => error === '' || typeof error === 'boolean', + ); + setIsFormValid(allFieldsValid); + }, [errors, formData.firstName, formData.lastName, formData.dateOfBirth, formData.email]); + + const handleUserInfoTab = async () => { + try { + const bodyRequest = { + firstName: formData.firstName, + lastName: formData.lastName, + dateOfBirth: formData.dateOfBirth, + email: formData.email, + }; + if (isDisabledUserInfo) { + setEmail(true); + setEditUserInfo(false); + } else { + setEditUserInfo(true); + setEmail(false); + + if (isFormValid) { + if (id) { + const latestVersion = await fetchLatestVersion(); + + updateCustomerField( + api, + id, + latestVersion ?? -1, + formData.firstName ?? '', + formData.lastName ?? '', + formData.dateOfBirth ?? '', + formData.email, + ) + .then((response) => { + const customerData = mapCustomerToModel(response.body); + + updateEmail(formData.email); + + setAPI( + createApiBuilderFromCtpClient(getLoginClient().client).withProjectKey({ + projectKey: PROJECT_KEY, + }), + ); + + setFormData(customerData); + setModalData({ + status: 'Success', + message: 'You have successfully updated your details', + }); + }) + .catch((error: unknown) => { + if (error instanceof ServerError) { + setModalData({ status: 'Error', message: error.message }); + } else if (error instanceof Error) { + setModalData({ status: 'Error', message: error.message }); + } else { + setModalData({ status: 'Error', message: 'Unexpected error occurred.' }); + } + setFormData({ + ...formData, + firstName: bodyRequest.firstName, + lastName: bodyRequest.firstName, + dateOfBirth: bodyRequest.dateOfBirth, + email: bodyRequest.email, + }); + }); + } else { + setModalData({ status: 'Error', message: 'Please, make relogin again.' }); + } + } + } + } catch (error) { + // TODO + } + }; + + return ( + <> +
+ +
+ + {modalData.message && } + + ); +}; diff --git a/src/components/form/profile/ChangePasswordForm.tsx b/src/components/form/profile/ChangePasswordForm.tsx new file mode 100644 index 0000000..f2e0f7c --- /dev/null +++ b/src/components/form/profile/ChangePasswordForm.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useState } from 'react'; +import styleAddr from 'src/components/address/Address.module.scss'; +import styles from 'src/components/form/profile/UserProfileForm.module.scss'; +import { InputWithLabel } from 'src/components/input/InputWithLabel.tsx'; +import { IPasswordForm, passwordForm } from 'src/components/form/profile/IPasswordForm.ts'; +import { validatePassword } from 'src/components/validation/Validation.ts'; +import { ModalWindow } from 'src/components/modalWindow/modalWindow.tsx'; +import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; +import { getLoginClient } from 'src/services/api/BuildClient.ts'; +import { PROJECT_KEY } from 'src/services/api/BuildClientRegistration.ts'; +import { ServerError } from 'src/utils/error/RequestErrors.ts'; +import { getPassword, setPassword } from 'src/services/userData/saveEmailPassword.ts'; +import { updatePassword } from 'src/services/api/ResetPassword.ts'; +import useModalEffect from 'src/components/form/profile/UseModalEffect.ts'; + +interface ChangePasswordFormProps { + version: number; +} + +export const ChangePasswordForm: React.FC = ({ version }) => { + const apiRoot2 = createApiBuilderFromCtpClient(getLoginClient().client).withProjectKey({ + projectKey: PROJECT_KEY, + }); + const [api, setAPI] = useState(apiRoot2); + + const [isDisabledPassword, setEditPassword] = useState(true); + const [errors, setErrors] = useState(passwordForm); + const [formData, setFormData] = useState(passwordForm); + const popupMessage = { status: '', message: '' }; + const [modalData, setModalData] = useState(popupMessage); + useModalEffect(modalData, setModalData); + + const [id] = useState(localStorage.getItem('fullID') ?? ''); + const [, setCustomerVersion] = useState(version); + const [isFormValid, setIsFormValid] = useState(false); + const [isOldPasswordCorrect, setIsOldPasswordCorrect] = useState(false); + const [isSamePasswords, setIsSamePasswords] = useState(false); + + const proceedExceptions = (error: unknown, message: string) => { + if (error instanceof ServerError) { + setModalData({ status: 'Error', message: error.message }); + } else if (error instanceof Error) { + setModalData({ status: 'Error', message: error.message }); + } else { + setModalData({ status: 'Error', message }); + } + }; + + const fetchLatestVersion = async (): Promise => { + let response; + let result = -1; + + try { + response = await api.customers().withId({ ID: id }).get().execute(); + } catch (error) { + proceedExceptions(error, 'Fetching latest version'); + } + if (response !== undefined) { + result = response.body.version; + } + return result; + }; + + const validatePasswordsForm = (name: string, value: string) => { + const error = validatePassword(value); + const errorValidate = error === '' ? '' : error; + setErrors({ + ...errors, + [name]: errorValidate, + }); + }; + + const handleChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setFormData({ + ...formData, + [name]: value, + }); + + validatePasswordsForm(name, value); + }; + + useEffect(() => { + const allFieldsValid = Object.values(errors).every( + (error) => error === '' || typeof error === 'boolean', + ); + setIsFormValid(allFieldsValid); + }, [errors, formData]); + + const handlePasswordChange = async () => { + if (id && isSamePasswords && formData.oldPassword) { + if (localStorage.getItem('changePassword')) { + setModalData({ + status: 'Invalid', + message: 'Please logout and login again after password was changed.', + }); + } else { + const latestVersion = await fetchLatestVersion(); + + updatePassword(api, formData, latestVersion) + .then((response) => { + setCustomerVersion(response.body.version); + setPassword(formData.newPassword); + setFormData(passwordForm); + setIsOldPasswordCorrect(false); + localStorage.setItem('changePassword', 'true'); + + setModalData({ status: 'Success', message: 'Password updated successfully.' }); + setEditPassword(true); + setAPI( + createApiBuilderFromCtpClient(getLoginClient().client).withProjectKey({ + projectKey: PROJECT_KEY, + }), + ); + }) + .catch((error: unknown) => { + if (error instanceof ServerError) { + setModalData({ status: 'Error', message: error.message }); + } else { + setModalData({ status: 'Error', message: 'Unexpected error occurred.' }); + } + }); + } + } else { + setModalData({ + status: 'Invalid', + message: 'The New Password and Confirm Password should be the same', + }); + } + }; + + const handleOldPassword = () => { + if (formData.oldPassword === getPassword()) { + setIsOldPasswordCorrect(true); + setEditPassword(false); + setIsSamePasswords(false); + setModalData({ status: 'Sucess!', message: 'Password sucessfuly verifyed' }); + } else { + setIsOldPasswordCorrect(false); + setEditPassword(true); + setModalData({ status: 'Error', message: 'Current Password is wrong. Try again.' }); + } + }; + + const isConfirmPassword = () => { + if ( + formData.newPassword === formData.confirmPassword && + isOldPasswordCorrect && + formData.newPassword && + formData.confirmPassword && + isFormValid + ) { + setIsSamePasswords(true); + } else setIsSamePasswords(false); + }; + + useEffect(() => { + if ( + formData.newPassword === formData.confirmPassword && + isOldPasswordCorrect && + formData.newPassword && + formData.confirmPassword && + isFormValid + ) { + setIsSamePasswords(true); + } else setIsSamePasswords(false); + }, [formData.confirmPassword, formData.newPassword, isFormValid, isOldPasswordCorrect]); + + return ( + <> +
+
+ { + handleChange(e); + }} + required + error={errors.oldPassword} + /> + + + { + handleChange(e); + isConfirmPassword(); + }} + required + error={errors.newPassword} + disabledMode={isDisabledPassword} + /> + + { + handleChange(e); + isConfirmPassword(); + }} + required + error={errors.confirmPassword} + disabledMode={isDisabledPassword} + /> +
+ {modalData.message && } +
+ {isOldPasswordCorrect && ( + + )} + + ); +}; diff --git a/src/components/form/profile/IPasswordForm.ts b/src/components/form/profile/IPasswordForm.ts new file mode 100644 index 0000000..82c6891 --- /dev/null +++ b/src/components/form/profile/IPasswordForm.ts @@ -0,0 +1,11 @@ +export interface IPasswordForm { + oldPassword: string; + newPassword: string; + confirmPassword: string; +} + +export const passwordForm: IPasswordForm = { + oldPassword: '', + newPassword: '', + confirmPassword: '', +}; diff --git a/src/components/form/profile/UseModalEffect.ts b/src/components/form/profile/UseModalEffect.ts new file mode 100644 index 0000000..3bb3637 --- /dev/null +++ b/src/components/form/profile/UseModalEffect.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; + +const useModalEffect = ( + modalData: { status: string; message: string }, + setModalData: React.Dispatch>, +) => { + useEffect(() => { + if (modalData.status) { + const timer = setTimeout(() => { + setModalData({ status: '', message: '' }); + }, 4000); + + return () => { + clearTimeout(timer); + }; + } + return () => { + (''); + }; + }, [modalData, setModalData]); +}; + +export default useModalEffect; diff --git a/src/components/form/profile/UserProfileForm.module.scss b/src/components/form/profile/UserProfileForm.module.scss new file mode 100644 index 0000000..3ef35a8 --- /dev/null +++ b/src/components/form/profile/UserProfileForm.module.scss @@ -0,0 +1,155 @@ +@import 'src/styles/mixins.scss'; + +.tab_container { + display: flex; + justify-content: center; + align-items: flex-start; + flex-direction: column; + margin: 50px 50px; + > div { + width: 100%; + } +} + +.tabs { + display: flex; + gap: 10px; + margin-bottom: 20px; + border-bottom: 2px solid #ddd; + flex-wrap: wrap; +} + +.tab { + padding: 10px 20px; + cursor: pointer; + background-color: #efe6d3; + border: 1px solid #ddd; + border-bottom: none; + border-radius: 5px 5px 0 0; + transition: + background-color 0.3s, + color 0.3s; +} + +.tab:hover { + background-color: #b4d23d; +} + +.activeTab { + background-color: #94b21b; + color: white; + :hover { + background-color: #b4d23d; + } +} + +.content { + @include customer-info-block; +} + +.content.hidden { + opacity: 0; + position: absolute; + pointer-events: none; +} + +.content.visible { + opacity: 1; + position: relative; +} + +.addresses { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + + > div { + @include customer-info-block; + width: 45%; + } +} + +.verifyButton { + align-self: self-start; + margin: 15px 0; + height: 40px; + border-radius: 10px; + padding: 10px; + background-color: var(--register-button); +} + +.updteButton { + align-self: self-start; + margin: 15px 0; + height: 40px; + border-radius: 10px; + padding: 10px; + background-color: var(--hover-text-color); +} + +.addressRow { + display: flex; + flex-direction: row; +} + +.newaddress_card { + display: flex; + flex-direction: column; +} + +.address_list { +} + +.address_row { + display: flex; + flex-direction: row; + gap: 20px; + font-size: 1rem; + text-transform: uppercase; + > div > span { + font-weight: 600; + } +} + +.editdelete_container { + display: flex; + justify-content: space-between; + align-items: stretch; + margin-bottom: 20px; + .address_checkboxes { + display: flex; + flex-direction: row; + gap: 30px; + } +} + +.buttons_editaddress { + display: flex; + flex-direction: row; + gap: 20px; +} + +.addresses_container { + display: flex; + flex-direction: column; + flex-wrap: nowrap; +} +.addresses { + flex-direction: column; + padding: 10px 0px; + margin-right: 20px; + > div { + width: 100%; + } +} + +@media (width < 650px) { + .tab_container { + margin: 10px 10px; + } + + .tabs { + border-bottom: none; + } +} diff --git a/src/components/form/profile/UserProfileForm.tsx b/src/components/form/profile/UserProfileForm.tsx new file mode 100644 index 0000000..02a1b7c --- /dev/null +++ b/src/components/form/profile/UserProfileForm.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from 'react'; +import { ICustomerModel, customerModel } from 'src/model/Customer.ts'; +import styles from 'src/components/form/profile/UserProfileForm.module.scss'; +import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; + +import { getLoginClient } from 'src/services/api/BuildClient.ts'; + +import { ModalWindow } from 'src/components/modalWindow/modalWindow.tsx'; + +import { ServerError } from 'src/utils/error/RequestErrors.ts'; +import { mapCustomerToModel } from 'src/services/DTO/Customer.ts'; + +import { ChangePasswordForm } from 'src/components/form/profile/ChangePasswordForm.tsx'; +import { AddressProfileForm } from 'src/components/form/profile/AddressProfileForm.tsx'; +import useModalEffect from 'src/components/form/profile/UseModalEffect.ts'; +import { PROJECT_KEY } from 'src/services/api/BuildClientRegistration.ts'; +import { BasicUserDataProfile } from './BasicUserDataProfile.tsx'; + +export const UserProfileForm: React.FC = () => { + const apiRoot2 = createApiBuilderFromCtpClient(getLoginClient().client).withProjectKey({ + projectKey: PROJECT_KEY, + }); + const [api] = useState(apiRoot2); + const [activeTab, setActiveTab] = useState('basicInfo'); + + const [id] = useState(localStorage.getItem('fullID') ?? ''); + const [customerVersion, setCustomerVersion] = useState(-1); + + const [formData, setFormData] = useState(customerModel); + + const popupMessage = { status: '', message: '' }; + const [modalData, setModalData] = useState(popupMessage); + useModalEffect(modalData, setModalData); + + useEffect(() => { + if (id) { + const initTabByCustomerData = async () => { + return api.customers().withId({ ID: id }).get().execute(); + }; + initTabByCustomerData() + .then((response) => { + const customerData = mapCustomerToModel(response.body); + setFormData(customerData); + setCustomerVersion(response.body.version); + }) + .catch((error: unknown) => { + if (error instanceof ServerError) { + setModalData({ status: 'Error', message: error.message }); + } else { + setModalData({ status: 'Error', message: 'Unexpected error occurred.' }); + } + }); + } else { + setModalData({ status: 'Error', message: 'Please, make relogin again.' }); + } + }, [id, api, activeTab]); + + const handleSwitchTab = () => { + switch (activeTab) { + case 'basicInfo': + return ; + case 'address': + return ; + case 'password': + return ; + default: + return null; + } + }; + return ( +
+
+ + + +
+
{handleSwitchTab()}
+ {modalData.message && } +
+ ); +}; diff --git a/src/components/form/registration/RegistrationForm.module.scss b/src/components/form/registration/RegistrationForm.module.scss new file mode 100644 index 0000000..96bd975 --- /dev/null +++ b/src/components/form/registration/RegistrationForm.module.scss @@ -0,0 +1,106 @@ +@import 'src/styles/mixins'; +@import 'src/styles/variables'; + +.registration { + display: flex; + flex-direction: column; + align-items: center; + height: 100vw; + background-color: var(--background-color); + font-size: 1.3rem; + flex-wrap: nowrap; + align-content: center; + input { + width: 400px; + } +} + +.submitButton { + background-color: var(--register-button); + border-radius: 0.5rem; + height: auto; + padding: 0.5rem 0; + letter-spacing: 0.05rem; + border: 1px solid var(--background-color); + color: var(--background-color); + font-weight: 600; + cursor: pointer; + margin: 20px auto; + padding: 10px 65px; +} + +.submitButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.register_text { + font-size: 1.2rem; + font-weight: 700; + color: $secondaryTextColor; +} + +.login_link { + display: inline-block; + padding: 10px 75px; + background-color: $primaryBackgroundButton; + color: $secondaryTextColor; + font-size: 1.6rem; + font-weight: 700; + justify-self: center; + align-self: center; + margin-bottom: 20px; + border-radius: 0.5rem; + + &:hover { + background-color: $primaryBackgroundButtonHover; + transition: background-color 0.3s ease; + color: $secondaryTextColor; + } + + &:active { + background-color: $primaryBackgroundButtonActive; + } +} + +.myLabel { + display: flex; + align-items: center; + justify-content: flex-end; + font-size: 1.3rem; + color: hsla(0, 63%, 3%, 0.564); +} + +.myInput { + outline: none; + width: 20px; +} + +.myButton { + outline: none; + background-color: #d6b258; + cursor: pointer; +} + +.myButton:hover { + background-color: #f0ac00; +} + +.myButton:active { + background-color: #f0ca00; +} + +.myInput_header { + position: relative; + top: 15px; + font-size: 1.4rem; + color: var(--primary-text-color); +} + +@media (width < 650px) { + .registration { + input { + width: 300px; + } + } +} diff --git a/src/components/form/registration/RegistrationForm.tsx b/src/components/form/registration/RegistrationForm.tsx new file mode 100644 index 0000000..ba6050a --- /dev/null +++ b/src/components/form/registration/RegistrationForm.tsx @@ -0,0 +1,326 @@ +import React, { useEffect, useState, useContext } from 'react'; +import style from 'src/components/form/registration/RegistrationForm.module.scss'; +import { AddressForm } from 'src/components/address/Address.tsx'; +import { validateField } from 'src/components/validation/Validation.ts'; +import { validatePostalCode } from 'src/components/validation/PostalCodeValidation.ts'; +import { Country } from 'src/components/country/country.ts'; +import { Paragraph } from 'src/components/text/Text.tsx'; +import { Link } from 'src/components/link/Link.tsx'; +import { BaseAddress, CustomerDraft } from '@commercetools/platform-sdk'; +import { apiRoot } from 'src/services/api/ctpClient.ts'; +import { useNavigate } from 'react-router-dom'; +import { v4 as uuidv4 } from 'uuid'; + +import { loginRequest } from 'src/services/api/loginRequest.ts'; +import { saveCredentials } from 'src/services/userData/saveEmailPassword.ts'; +import { createCustomer } from 'src/services/api/registrationCustomer.ts'; +import { ServerError } from 'src/utils/error/RequestErrors.ts'; +import { ModalWindow } from 'src/components/modalWindow/modalWindow.tsx'; +import { BillingAddressForm } from 'src/components/address/BillingAddress.tsx'; + +import { customerModel, ICustomerModel } from 'src/model/Customer.ts'; +import { CurrentUserContext } from 'src/App.tsx'; +import { RegistrationMainFields } from './RegistrationMainFields.tsx'; + +let countryShipping: Country; +let countryBilling: Country; + +const requiredFields: (keyof ICustomerModel)[] = [ + 'email', + 'password', + 'firstName', + 'lastName', + 'dateOfBirth', +]; + +export const RegistrationForm: React.FC = () => { + const context = useContext(CurrentUserContext); + + if (!context) { + throw new Error('RegistrationForm must be used within a CurrentUserContext.Provider'); + } + + const { setCurrentUser } = context; + const [formData, setFormData] = useState(customerModel); + + const [errors, setErrors] = useState(customerModel); + + const [isFormValid, setIsFormValid] = useState(false); + + const popupMessage = { status: '', message: '' }; + + const [modalData, setModalData] = useState(popupMessage); + + const navigation = useNavigate(); + + countryShipping = Country[formData.country as keyof typeof Country]; + countryBilling = Country[formData.billingCountry as keyof typeof Country]; + + const handleDefaultAddress = (checked: boolean) => { + setFormData({ + ...formData, + isShippingDefaultAddress: checked, + }); + }; + const handleBillingAddress = (checked: boolean) => { + setFormData({ + ...formData, + isBillingDefaultAddress: checked, + }); + }; + + const validateOneField = (name: string, value: string) => { + const error = validateField(name, value, countryShipping, countryBilling); + const errorValidate = error === '' ? '' : error; + setErrors({ + ...errors, + [name]: errorValidate, + }); + }; + + const handleSameAddress = (checked: boolean) => { + setFormData((prevFormData) => ({ + ...prevFormData, + isEqualAddress: checked, + ...(checked && { + billingStreet: prevFormData.streetName, + billingCity: prevFormData.city, + billingCountry: prevFormData.country, + billingPostalCode: prevFormData.postalCode, + }), + })); + }; + + useEffect(() => { + if (formData.isEqualAddress) { + const billingAddressFields: string[] = [ + 'billingStreet', + 'billingCity', + 'billingCountry', + 'billingPostalCode', + ]; + billingAddressFields.forEach((field) => { + if (typeof formData[field] === 'string') { + const value = formData[field] as string; + const error = validateField(field, value, countryShipping, countryBilling); + const errorValidate = error === '' ? '' : error; + setErrors((prevErrors) => ({ + ...prevErrors, + [field]: errorValidate, + })); + } + }); + } + }, [formData.isEqualAddress, formData]); + + const handleChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setFormData({ + ...formData, + [name]: value, + }); + + validateOneField(name, value); + }; + + useEffect(() => { + const allFieldsValid = Object.values(errors).every( + (error) => error === '' || typeof error === 'boolean', + ); + setIsFormValid(allFieldsValid); + }, [errors, formData]); + + useEffect(() => { + const error = validatePostalCode(countryShipping, formData.postalCode); + setErrors((prevErrors) => ({ + ...prevErrors, + postalCode: error, + })); + }, [formData.country, formData.postalCode]); + + useEffect(() => { + const error = validatePostalCode(countryBilling, formData.billingPostalCode); + setErrors((prevErrors) => ({ + ...prevErrors, + billingPostalCode: error, + })); + }, [formData.billingCountry, formData.billingPostalCode]); + + useEffect(() => { + if (modalData.status) { + const timer = setTimeout(() => { + setModalData({ status: '', message: '' }); + }, 4000); + + return () => { + clearTimeout(timer); + }; + } + return () => { + (''); + }; + }, [modalData]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const isAnyEmpty = requiredFields.some((field) => !formData[field]); + + if (!isAnyEmpty && isFormValid) { + let generatedCustomerID: string; + let generatedShippAddrID: string | undefined; + let generatedBillAddrID: string | undefined; + const generateUUID = (): string => { + return uuidv4(); + }; + + const addresses: BaseAddress[] = [ + { + id: generateUUID(), + streetName: formData.streetName, + city: formData.city, + country: Country[formData.country as keyof typeof Country], + postalCode: formData.postalCode, + }, + { + id: generateUUID(), + streetName: formData.billingStreet, + city: formData.billingCity, + country: Country[formData.billingCountry as keyof typeof Country], + postalCode: formData.billingPostalCode, + }, + ]; + + const newCustomer: CustomerDraft = { + key: generateUUID(), + email: formData.email, + password: formData.password, + firstName: formData.firstName, + lastName: formData.lastName, + dateOfBirth: formData.dateOfBirth, + addresses, + ...(formData.isShippingDefaultAddress && { defaultShippingAddress: 0 }), + ...(formData.isBillingDefaultAddress && { defaultBillingAddress: 1 }), + }; + + createCustomer(newCustomer) + .then(async ({ body }) => { + generatedCustomerID = body.customer.id; + generatedShippAddrID = body.customer.addresses[0].id; + generatedBillAddrID = body.customer.addresses[1].id; + if (body.customer.email) { + if (formData.password) { + saveCredentials(formData.email, formData.password); + setCurrentUser({ ...body.customer }); + await loginRequest(formData.email, formData.password); + } + + setTimeout(() => { + navigation('/'); + }, 1000); + setModalData({ status: 'Success', message: 'You have been registered.' }); + } + }) + .catch((error: unknown) => { + if (error instanceof ServerError) { + setModalData({ status: 'Error', message: error.message }); + } + }); + + if (formData.isShippingDefaultAddress) { + const setDefualtAdd = () => { + return apiRoot + .customers() + .withId({ ID: generatedCustomerID }) + .post({ + body: { + version: 1, + actions: [ + { + action: 'setDefaultShippingAddress', + addressId: generatedShippAddrID, + }, + ], + }, + }) + .execute(); + }; + setDefualtAdd() + .then(() => { + // TODO + }) + .catch((error: unknown) => { + if (error) { + // TODO + } + }); + } + if (formData.isBillingDefaultAddress) { + const setDefualtAdd = () => { + return apiRoot + .customers() + .withId({ ID: generatedCustomerID }) + .post({ + body: { + version: 1, + actions: [ + { + action: 'setDefaultBillingAddress', + addressId: generatedBillAddrID, + }, + ], + }, + }) + .execute(); + }; + setDefualtAdd() + .then(() => { + // TODO + }) + .catch((error: unknown) => { + if (error) { + // TODO + } + }); + } + } + }; + + return ( + <> +
+ + + + + +
+ + +
+ + {modalData.message && } + + ); +}; + +export default RegistrationForm; diff --git a/src/components/form/registration/RegistrationMainFields.tsx b/src/components/form/registration/RegistrationMainFields.tsx new file mode 100644 index 0000000..89c151d --- /dev/null +++ b/src/components/form/registration/RegistrationMainFields.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import styleAddr from 'src/components/address/Address.module.scss'; + +import { InputWithLabel } from 'src/components/input/InputWithLabel.tsx'; + +export interface RegistrationMainFieldsProps { + formData: { + email: string | undefined; + password: string | undefined; + firstName: string | undefined; + lastName: string | undefined; + dateOfBirth: string | undefined; + }; + handleChange: (event: React.ChangeEvent) => void; + errors: { + email: string | undefined; + password: string | undefined; + firstName: string | undefined; + lastName: string | undefined; + dateOfBirth: string | undefined; + }; + showFields?: { + email: boolean; + password: boolean; + firstName: boolean; + lastName: boolean; + dateOfBirth: boolean; + }; + disabledMode?: boolean; +} + +export const RegistrationMainFields: React.FC = ({ + formData, + handleChange, + errors, + showFields = { email: true, password: true, firstName: true, lastName: true, dateOfBirth: true }, + disabledMode = false, +}) => { + return ( +
+ {showFields.email && ( + + )} + {showFields.password && ( + + )} + + {showFields.firstName && ( + + )} + {showFields.lastName && ( + + )} + {showFields.dateOfBirth && ( + + )} +
+ ); +}; diff --git a/src/components/header/Header.module.scss b/src/components/header/Header.module.scss new file mode 100644 index 0000000..b1cff48 --- /dev/null +++ b/src/components/header/Header.module.scss @@ -0,0 +1,120 @@ +@import 'src/styles/variables'; +@import 'src/styles/mixins'; + +.header, +.navigation { + display: flex; + justify-content: space-between; +} + +.header { + background-color: rgba(255, 255, 255); + position: sticky; + top: 0; + width: 100%; + + &.hidden { + display: none; + } +} + +.container { + display: flex; + height: 100%; + width: 90%; + margin: 0 auto; + justify-content: space-between; + + @include media-600 { + flex-direction: column; + } +} + +.navigation { + width: 27%; + align-items: end; + + @include media-1200 { + width: 32%; + } + + @include media-1000 { + width: 40%; + } + + @include media-800 { + width: 55%; + } + + @include media-600 { + width: 63%; + } + + @include media-500 { + width: 80%; + } + + @include media-400 { + width: 90%; + } +} + +.login_container { + display: flex; + &.hidden { + display: none; + } +} + +.logout_container { + display: flex; +} + +.logo, +.logout, +.link { + color: $secondaryTextColor; + &:hover { + cursor: pointer; + transition-duration: 1s; + } +} + +.logo { + font-size: 3.2rem; + &.inactive:hover { + cursor: auto; + color: $secondaryTextColor; + } + @include media-600 { + margin-left: 5px; + } +} + +.logout { + &:hover { + color: var(--hover-text-color); + cursor: pointer; + } +} + +.link, +.logout { + display: flex; + height: 100%; + font-size: 1.6rem; + align-items: flex-end; + padding: 5px 10px 0 10px; +} + +.link.active, +.logout.active { + color: $secondaryActiveLink; + text-decoration: underline; + transition-duration: 1s; + + &:hover { + color: $secondaryActiveLink; + cursor: auto; + } +} diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx new file mode 100644 index 0000000..94b9a35 --- /dev/null +++ b/src/components/header/Header.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useState } from 'react'; +import styles from 'src/components/header/Header.module.scss'; +import { Link } from 'components/link/Link.tsx'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { Form } from 'src/components/form/form.tsx'; + +const links = [ + { + to: '/login', + title: 'LOG IN', + id: 1, + }, + { + to: '/register', + title: 'REGISTER', + id: 2, + }, +]; + +export const Header: React.FC = () => { + const location = useLocation().pathname; + const navigation = useNavigate(); + const [activeLink, setActiveLink] = useState(location); + const [isLoggedIn, setIsLoggedIn] = useState(false); + + useEffect(() => { + setActiveLink(location); + const user = localStorage.getItem('userTokens'); + setIsLoggedIn(!!user); + }, [location]); + + useEffect(() => { + const user = localStorage.getItem('userTokens'); + if (user && window.location.pathname === '/login') { + navigation('/'); + } + if (!user && window.location.pathname === '/profile') { + navigation('/login'); + } + }, [navigation]); + + const clearLocalStorage = () => { + localStorage.clear(); + }; + + const handelLogout = () => { + clearLocalStorage(); + setIsLoggedIn(false); + Form(); + }; + const isProductPage = location.startsWith('/product/'); + const is404Page = + !isProductPage && + location !== '/' && + location !== '/login' && + location !== '/register' && + location !== '/catalog' && + location !== '/profile'; + const isHeaderInactive = location === '/'; + const isToken = localStorage.getItem('userTokens'); + return ( +
+
+ + +
+
+
+ ); +}; diff --git a/src/components/input/InputWithLabel.tsx b/src/components/input/InputWithLabel.tsx new file mode 100644 index 0000000..a6e1553 --- /dev/null +++ b/src/components/input/InputWithLabel.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import style from 'src/components/address/Address.module.scss'; + +interface InputProps { + id: string; + type: string; + name: string; + label: string; + value: string | undefined; + onChange: (event: React.ChangeEvent) => void; + required?: boolean; + error?: string; + className?: string; + disabledMode?: boolean; +} + +export const InputWithLabel: React.FC = ({ + id, + type, + name, + label, + value, + onChange, + required = false, + error, + className, + disabledMode = false, +}) => { + return ( +
+ + + {error && ( +
+ {error} +
+ )} +
+ ); +}; diff --git a/src/components/layout/Layout.module.scss b/src/components/layout/Layout.module.scss new file mode 100644 index 0000000..ea0a514 --- /dev/null +++ b/src/components/layout/Layout.module.scss @@ -0,0 +1,11 @@ +@import 'src/styles/variables'; +.layout { + width: inherit; + background-image: url('../../public/main_image2.jpg'); + background-repeat: no-repeat; + background-size: cover; + display: flex; + background-position: center; + width: 100%; + height: 70vh; +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx new file mode 100644 index 0000000..6946363 --- /dev/null +++ b/src/components/layout/Layout.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +interface LayoutProps { + children: React.ReactNode; + className: string; +} + +export const Layout: React.FC = ({ children, className }) => { + return
{children}
; +}; diff --git a/src/components/link/Link.tsx b/src/components/link/Link.tsx new file mode 100644 index 0000000..a971d8b --- /dev/null +++ b/src/components/link/Link.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +interface LinkProps { + to: string; + title?: string; + className: string; + children?: React.ReactNode; + id?: string; + onClick?: () => void; +} + +export const Link: React.FC = ({ to, title, className, id, children, onClick }) => { + return ( + + {title} + {children} + + ); +}; diff --git a/src/components/modalWindow/modalImage.module.scss b/src/components/modalWindow/modalImage.module.scss new file mode 100644 index 0000000..990e4b6 --- /dev/null +++ b/src/components/modalWindow/modalImage.module.scss @@ -0,0 +1,35 @@ +@import 'src/styles/variables'; +@import 'src/styles/mixins'; + +.modal { + width: 80%; + height: 80vh; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -55%); + z-index: 10; +} + +.modal_content { + display: flex; + width: 100%; + margin: 0 auto; + @include media-600 { + width: 80%; + align-self: center; + } +} + +.close_container { + width: 36px; + height: 36px; + &:hover { + cursor: pointer; + } +} + +.modal_close { + display: block; + width: 36px; +} diff --git a/src/components/modalWindow/modalImage.tsx b/src/components/modalWindow/modalImage.tsx new file mode 100644 index 0000000..915735b --- /dev/null +++ b/src/components/modalWindow/modalImage.tsx @@ -0,0 +1,30 @@ +import React, { ReactNode } from 'react'; +import style from 'src/components/modalWindow/modalImage.module.scss'; +import closeIcon from 'src/public/icons8-close-64.png'; + +interface ModalProps { + children: ReactNode; + closeModalWindow: () => void; +} +export const Modal: React.FC = ({ children, closeModalWindow }) => { + return ( +
+
+ {children} +
{ + if (event.key === 'Enter' || event.key === ' ') { + closeModalWindow(); + } + }} + > + close_image +
+
+
+ ); +}; diff --git a/src/components/modalWindow/modalWindow.module.scss b/src/components/modalWindow/modalWindow.module.scss new file mode 100644 index 0000000..da62d57 --- /dev/null +++ b/src/components/modalWindow/modalWindow.module.scss @@ -0,0 +1,29 @@ +.parent { + width: 100%; + height: 95%; + position: fixed; + display: flex; + justify-content: flex-end; + align-items: flex-start; + top: 50px; + left: -10px; +} + +.children { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 10px; + font-size: 16px; + padding: 10px; + background-color: rgba(94, 94, 94, 0.192); +} + +.children.error { + background-color: red; +} + +.children.positive { + background-color: green; +} diff --git a/src/components/modalWindow/modalWindow.tsx b/src/components/modalWindow/modalWindow.tsx new file mode 100644 index 0000000..5e4e595 --- /dev/null +++ b/src/components/modalWindow/modalWindow.tsx @@ -0,0 +1,31 @@ +import styles from 'src/components/modalWindow/modalWindow.module.scss'; +import { IResponse } from 'src/components/tempFolderForDevelop/responseHandler.ts'; +import PropTypes from 'prop-types'; +import { ErrorType } from 'src/utils/error/RequestErrors.ts'; + +interface ModalWindowProps { + data: IResponse; +} + +const ModalWindow: React.FC = ({ data }) => { + const { status, message } = data; + return ( +
+
+ {status} + {message} +
+
+ ); +}; + +ModalWindow.propTypes = { + data: PropTypes.shape({ + status: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + }).isRequired, +}; + +export { ModalWindow }; diff --git a/src/components/router/Router.tsx b/src/components/router/Router.tsx new file mode 100644 index 0000000..2b39801 --- /dev/null +++ b/src/components/router/Router.tsx @@ -0,0 +1,26 @@ +import { RegistrationPage } from 'src/logic/registrationPage/registrationPage.tsx'; +import { Main } from 'src/logic/mainPage/MainPage.tsx'; +import { Login } from 'src/logic/loginPage/LoginPage.tsx'; +import { Error } from 'src/logic/errorPage/ErrorPage.tsx'; +import { Catalog } from 'src/logic/catalogPage/CatalogPage.tsx'; +import { UserProfilePage } from 'src/logic/userProfilePage/UserProfilePage.tsx'; +import { Product } from 'src/logic/productPage/Product.tsx'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { Header } from 'src/components/header/Header.tsx'; + +export const Router = () => { + return ( + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +}; diff --git a/src/components/search/Search.module.scss b/src/components/search/Search.module.scss new file mode 100644 index 0000000..c50b06a --- /dev/null +++ b/src/components/search/Search.module.scss @@ -0,0 +1,14 @@ +.search_container { + display: flex; + margin-top: 10px; + justify-content: center; +} + +form { + display: flex; +} + +.input_search, +.button_input { + border-radius: 0px; +} diff --git a/src/components/search/Search.tsx b/src/components/search/Search.tsx new file mode 100644 index 0000000..2ca42f7 --- /dev/null +++ b/src/components/search/Search.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import style from 'src/components/search/Search.module.scss'; + +interface SearchComponentProps { + onSearch: (query: string) => void; +} + +export const SearchComponent: React.FC = ({ onSearch }) => { + const [query, setQuery] = useState(''); + + const handleInputChange = (event: React.ChangeEvent) => { + setQuery(event.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSearch(query); + }; + + return ( +
+
+ + +
+
+ ); +}; diff --git a/src/components/selector/Selector.tsx b/src/components/selector/Selector.tsx new file mode 100644 index 0000000..38cc99b --- /dev/null +++ b/src/components/selector/Selector.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +interface Option { + value: string | undefined; + label: string; +} + +interface SelectorProps { + id: string; + name: string; + value: string | undefined; + label: string; + options: Option[]; + onChange: React.ChangeEventHandler; + onBlur: React.ChangeEventHandler; + error?: string; + disabledMode?: boolean; +} + +const Selector: React.FC<{ selectorProps: SelectorProps }> = ({ selectorProps }) => { + const { + id, + name, + value, + label, + options, + onChange, + onBlur, + error, + disabledMode = false, + } = selectorProps; + + return ( +
+ + + + {error &&
{error}
} +
+ ); +}; + +export default Selector; diff --git a/src/components/sort/Sort.module.scss b/src/components/sort/Sort.module.scss new file mode 100644 index 0000000..bbf1f1f --- /dev/null +++ b/src/components/sort/Sort.module.scss @@ -0,0 +1,18 @@ +.sort_container { + display: flex; + align-items: center; + margin-top: 20px; +} + +.form_container { + display: flex; + flex-direction: column; + font-size: 16px; + width: 100%; +} + +.select_sort { + margin-right: 10px; + border: 1px solid #ccc; + border-radius: 4px; +} diff --git a/src/components/sort/Sort.tsx b/src/components/sort/Sort.tsx new file mode 100644 index 0000000..54a974a --- /dev/null +++ b/src/components/sort/Sort.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import style from 'src/components/sort/Sort.module.scss'; + +interface SortComponentProps { + onSort: (criteria: string) => void; + defaultCriteria?: string; +} + +export const SortComponent: React.FC = ({ + onSort, + defaultCriteria = 'price', +}) => { + const [criteria, setCriteria] = useState(defaultCriteria); + + const handleCriteriaChange = (event: React.ChangeEvent) => { + const newCriteria = event.target.value; + setCriteria(newCriteria); + onSort(newCriteria); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSort(criteria); + }; + + return ( +
+
+

Sort by:

+ +
+
+ ); +}; diff --git a/src/components/tempFolderForDevelop/responseHandler.ts b/src/components/tempFolderForDevelop/responseHandler.ts new file mode 100644 index 0000000..167ac73 --- /dev/null +++ b/src/components/tempFolderForDevelop/responseHandler.ts @@ -0,0 +1,14 @@ +export interface IResponse { + status: string; + message: string; +} + +export const myRedirect = (res: IResponse) => { + if (res.status === 'Success') { + return 'Success'; + } + if (res.status === 'Invalid') { + return 'Invalid'; + } + return ''; +}; diff --git a/src/components/tempFolderForDevelop/statusHandler.ts b/src/components/tempFolderForDevelop/statusHandler.ts new file mode 100644 index 0000000..5cc744c --- /dev/null +++ b/src/components/tempFolderForDevelop/statusHandler.ts @@ -0,0 +1,17 @@ +export const myStatus = (status: boolean, message: string) => { + const obj = { + status: '', + message: '', + }; + + if (status) { + obj.status = 'Success'; + obj.message = message; + // obj.message = 'Login successful!'; + } else { + obj.status = 'Invalid'; + obj.message = message; + // obj.message = 'Incorrect email or password!'; + } + return obj; +}; diff --git a/src/components/text/Text.tsx b/src/components/text/Text.tsx new file mode 100644 index 0000000..b626581 --- /dev/null +++ b/src/components/text/Text.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +type Tags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span'; + +interface ParagraphProps { + tag: Tags; + title: string; + className: string; +} + +export const Paragraph: React.FC = ({ tag, title, className }) => { + return React.createElement( + tag, + { + className, + }, + title, + ); +}; diff --git a/src/components/validation/PostalCodeValidation.ts b/src/components/validation/PostalCodeValidation.ts new file mode 100644 index 0000000..095dd4d --- /dev/null +++ b/src/components/validation/PostalCodeValidation.ts @@ -0,0 +1,42 @@ +import { Country } from '../country/country.ts'; + +export const validatePostalCode = ( + country: Country, + postalCodeParam: string | undefined, +): string => { + let pattern: RegExp; + let error = ''; + let postalCode; + + if (!postalCodeParam) { + postalCode = ''; + } else { + postalCode = postalCodeParam; + } + + switch (country) { + case Country.France: + pattern = /^\d{2}[ ]?\d{3}$/; + if (!pattern.test(postalCode)) { + error = '2 Correct formats:<2numbers space 3numbers> <5 numbers> '; + } + break; + case Country.Germany: + case Country.Italy: + pattern = /^[\d]{5}$/; + if (!pattern.test(postalCode)) { + error = 'Correct format: 5 numbers'; + } + break; + case Country.Netherlands: + pattern = /^\d{4}\s?[A-Za-z]{2}$/; + if (!pattern.test(postalCode)) { + error = 'Correct format: <4 numbers><2 letters>'; + } + break; + default: + return ''; + } + + return pattern.test(postalCode) ? '' : error; +}; diff --git a/src/components/validation/Validation.ts b/src/components/validation/Validation.ts new file mode 100644 index 0000000..18611cb --- /dev/null +++ b/src/components/validation/Validation.ts @@ -0,0 +1,128 @@ +// validation rules are compatible with CommerceTools +// https://docs.commercetools.com/api/projects/customers + +import { Country } from '../country/country.ts'; +import { validatePostalCode } from './PostalCodeValidation.ts'; + +export const validateEmail = (value: string) => { + const errorMessages: string[] = []; + + if (value.startsWith('@')) { + errorMessages.push('Cannot start with @'); + } + if (!/^[^.]/.test(value)) { + errorMessages.push('Cannot start with a dot'); + } + if (!/^\S*$/.test(value)) { + errorMessages.push('Cannot contain spaces'); + } + if (!/^[a-zA-Z0-9@.]+$/.test(value)) { + errorMessages.push('Can only contain English letters or digits'); + } + if (!value.includes('@')) { + errorMessages.push('Must contain @'); + } + if (!/^\S+@\S+\.\S+$/.test(value)) { + errorMessages.push('Must contain a domain name like example.com'); + } + const dotMatches = value.match(/\./g); + if (!(dotMatches !== null && dotMatches.length === 1)) { + errorMessages.push('Must contain only one dot'); + } + if (!/^[^@\s]+@[^.@\s]+\.[^@\s]+$/.test(value)) { + errorMessages.push('Must be in the correct format like user@example.com'); + } + return errorMessages.join('\n'); +}; + +export const validatePassword = (password: string): string => { + const errorMessages: string[] = []; + + if (password.length < 8) { + errorMessages.push('Minimum 8 characters required'); + } + if (!/[a-z]/.test(password)) { + errorMessages.push('At least 1 lowercase letter required'); + } + if (!/[A-Z]/.test(password)) { + errorMessages.push('At least 1 uppercase letter required'); + } + if (!/\d/.test(password)) { + errorMessages.push('At least 1 digit required'); + } + if (/\s/.test(password)) { + errorMessages.push('Spaces are not allowed'); + } + + return errorMessages.join('\n'); +}; + +const validateName = (name: string): string => { + const emailRegex = /^[a-zA-Z]+$/; + return emailRegex.test(name) ? '' : 'Must contain at least 1 letter and use only Latin letters.'; +}; + +const validateBirthday = (value: string): string => { + const birthday = new Date(value); + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const minDate = new Date(currentYear - 110, currentDate.getMonth(), currentDate.getDate()); + const maxDate = new Date(currentYear - 15, currentDate.getMonth(), currentDate.getDate()); + + let result = ''; + if (birthday < minDate || birthday > maxDate) { + result = 'Age shold be more then 15 yo'; + } + + return result; +}; + +const validateNonEmpty = (value: string): string => { + const trimmedValue = value.trim(); + if (!trimmedValue) { + return 'Must contain at least one character'; + } + return ''; +}; + +const validateCountry = (value: string): string => { + if (!(value in Country)) { + return 'Please select a valid country.'; + } + return ''; +}; + +export const validateField = ( + name: string, + inputValue: string, + countryShipping: Country, + countryBilling: Country, +): string => { + const validateValue: Country | string = inputValue; + + switch (name) { + case 'email': + return validateEmail(validateValue); + case 'password': + return validatePassword(validateValue); + case 'firstName': + case 'lastName': + case 'city': + case 'billingCity': + return validateName(validateValue); + case 'dateOfBirth': + return validateBirthday(validateValue); + case 'streetName': + case 'billingStreet': + return validateNonEmpty(validateValue); + case 'postalCode': + return validatePostalCode(countryShipping, validateValue); + case 'billingPostalCode': + return validatePostalCode(countryBilling, validateValue); + case 'country': + case 'billingCountry': + return validateCountry(validateValue); + default: + return ''; + } +}; diff --git a/src/index.scss b/src/index.scss new file mode 100644 index 0000000..a02f3ae --- /dev/null +++ b/src/index.scss @@ -0,0 +1,54 @@ +@import 'src/styles/normalize'; +@import 'src/styles/variables'; + +@font-face { + font-family: 'Open Sans', sans-serif; + src: url(src/public/OpenSans-Regular.ttf) format('truetype'); +} + +* { + box-sizing: border-box; +} + +:root { + font-family: 'Open Sans', sans-serif; + font-weight: 400; + font-size: 10px; + background-color: var(--background-color); + color: var(--primary-text-color); + + --background-color: #efe6d3; + --primary-text-color: hsla(0, 63%, 3%, 0.564); + --error-text: rgba(233, 49, 49, 0.769); + --register-button: #2db942; + --hover-text-color: #f5861f; +} + +#root { + display: flex; + flex-direction: column; + position: relative; + height: 100vh; +} + +a { + color: var(--primary-text-color); + text-decoration: none; +} + +a:hover { + color: var(--hover-text-color); +} + +input, +select, +button { + max-width: 400px; + border-radius: 5px; + border: none; + font-size: 2rem; +} + +h2 { + font-size: 1.9em; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..9a950c1 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,14 @@ +import { createRoot } from 'react-dom/client'; +import React from 'react'; +import { App } from 'src/App.tsx'; +import 'src/index.scss'; + +const rootElement = document.createElement('div'); +rootElement.id = 'root'; +document.body.appendChild(rootElement); + +createRoot(rootElement).render( + + + , +); diff --git a/src/logic/catalogPage/CatalogPage.module.scss b/src/logic/catalogPage/CatalogPage.module.scss new file mode 100644 index 0000000..a9e5ea5 --- /dev/null +++ b/src/logic/catalogPage/CatalogPage.module.scss @@ -0,0 +1,18 @@ +.layout { + width: 90%; + display: flex; + flex-direction: row; + margin: 0 auto; + align-items: flex-start; + justify-content: center; +} + +.test { + display: flex; + flex-direction: column; + width: 20%; +} + +.dots { + height: 5px; +} diff --git a/src/logic/catalogPage/CatalogPage.tsx b/src/logic/catalogPage/CatalogPage.tsx new file mode 100644 index 0000000..ee98c01 --- /dev/null +++ b/src/logic/catalogPage/CatalogPage.tsx @@ -0,0 +1,292 @@ +import React, { useEffect, useState } from 'react'; +import style from 'src/logic/catalogPage/CatalogPage.module.scss'; +import { Card } from 'src/components/cards/Cards.tsx'; +import { Filter } from 'src/components/filter/Filter.tsx'; +import { filterTools } from 'src/services/tools/filterTools.ts'; +import { + fetchAllProducts, + fetchCategory, + fetchColorProducts, + fetchPriceProducts, + fetchSearchProducts, + fetchSizeProducts, + fetchSortNameProducts, + fetchSortPriceProducts, +} from 'src/services/api/filterRequests.ts'; +import { ProductProjection } from '@commercetools/platform-sdk'; +import { SearchComponent } from 'src/components/search/Search.tsx'; +import { SortComponent } from 'src/components/sort/Sort.tsx'; +import { CategoryComponent } from 'src/components/category/Category.tsx'; + +const initializeFilters = async () => { + const [colors, sizes, prices] = await filterTools(); + return { colors, sizes, prices }; +}; + +interface FilterOption { + label: string; + value: string; +} + +interface Filters { + colors: FilterOption[]; + sizes: FilterOption[]; + prices: FilterOption[]; +} + +export const Catalog: React.FC = () => { + const [defaultColor, setDefaultColor] = useState([]); + const [productState, setProductState] = useState([]); + const [colorsArray, setColorsArray] = useState([]); + const [sizesArray, setSizesArray] = useState([]); + const [pricesArray, setPricesArray] = useState([]); + const [errorState, setErrorState] = useState(null); + const [selectedCategory, setSelectedCategory] = useState('All category'); + + const [filters, setFilters] = useState({ + colors: [], + sizes: [], + prices: [], + }); + + useEffect(() => { + const getProducts = async () => { + try { + const result = await fetchAllProducts(); + setDefaultColor(result); + setProductState(result); + } catch (error: unknown) { + setErrorState('Error fetching products'); + } + }; + getProducts().catch(() => { + setErrorState('Error fetching products'); + }); + + const initialize = async () => { + try { + const { colors, sizes, prices } = await initializeFilters(); + setFilters({ colors, sizes, prices }); + } catch (error: unknown) { + setErrorState('Error initializing filters'); + } + }; + initialize().catch(() => { + setErrorState('Error initializing filters'); + }); + }, []); + + const handleChange = (event: React.ChangeEvent): void => { + setSelectedCategory('All category'); + const { name: category, value } = event.target; + + const handleAsyncChange = async () => { + if (event.target.checked) { + if (category === 'color') { + const updatedColorsArray = [...colorsArray, value]; + setColorsArray(updatedColorsArray); + try { + const result = await fetchColorProducts(updatedColorsArray, sizesArray, pricesArray); + setProductState(result); + } catch (error: unknown) { + setErrorState('Error fetching color products'); + } + } + if (category === 'size') { + const updatedSizesArray = [...sizesArray, value]; + setSizesArray(updatedSizesArray); + try { + const result = await fetchSizeProducts(updatedSizesArray, colorsArray, pricesArray); + setProductState(result); + } catch (error: unknown) { + setErrorState('Error fetching size products'); + } + } + if (category === 'price') { + const getPrice = async (priceRange: string) => { + const updatedPricesArray = [value]; + setPricesArray(updatedPricesArray); + try { + const result = await fetchPriceProducts(priceRange, colorsArray, sizesArray); + setProductState(result); + } catch (error: unknown) { + setErrorState('Error fetching price products'); + } + }; + + if (value === 'below 75') { + await getPrice('0 to 7500'); + } else if (value === '75 to 125') { + await getPrice('7500 to 12500'); + } else if (value === '125 to 200') { + await getPrice('12500 to 20000'); + } else if (value === '200 to 250') { + await getPrice('20000 to 25000'); + } else if (value === 'above 250') { + await getPrice('25000 to 100000000000000'); + } + } + } else { + if (category === 'color') { + const updatedColorsArray = colorsArray.filter((color) => color !== value); + setColorsArray(updatedColorsArray); + try { + const result = + updatedColorsArray.length >= 0 + ? await fetchColorProducts(updatedColorsArray, sizesArray, pricesArray) + : defaultColor; + setProductState(result); + } catch (error: unknown) { + setErrorState('Error fetching color products'); + } + } + if (category === 'size') { + const updatedSizesArray = sizesArray.filter((size) => size !== value); + setSizesArray(updatedSizesArray); + try { + const result = + updatedSizesArray.length >= 0 + ? await fetchSizeProducts(updatedSizesArray, colorsArray, pricesArray) + : defaultColor; + setProductState(result); + } catch (error: unknown) { + setErrorState('Error fetching size products'); + } + } + if (category === 'price') { + const updatedPricesArray = pricesArray.filter((price) => price !== value); + setPricesArray(updatedPricesArray); + setProductState(defaultColor); + } + } + }; + + handleAsyncChange().catch(() => { + setErrorState('Error handling change'); + }); + }; + + const handleReset = (products: ProductProjection[]) => { + setProductState(products); + setColorsArray([]); + setSizesArray([]); + setPricesArray([]); + }; + + const handleSearch = (query: string) => { + setSelectedCategory('All category'); + const fetchAndSetProducts = async () => { + try { + const searchProducts = await fetchSearchProducts(query); + setProductState(searchProducts); + } catch (error: unknown) { + setErrorState('Error searching products'); + } + }; + + fetchAndSetProducts().catch(() => { + setErrorState('Error handling change'); + }); + }; + + const handleSort = (criteria: string) => { + setSelectedCategory('All category'); + if (criteria === 'price high') { + const fetchAndSetProducts = async () => { + try { + const sortPriceProducts = await fetchSortPriceProducts('desc'); + setProductState(sortPriceProducts); + } catch (error: unknown) { + setErrorState('Error searching products'); + } + }; + + fetchAndSetProducts().catch(() => { + setErrorState('Error handling change'); + }); + } + if (criteria === 'price low') { + const fetchAndSetProducts = async () => { + try { + const sortPriceProducts = await fetchSortPriceProducts('asc'); + setProductState(sortPriceProducts); + } catch (error: unknown) { + setErrorState('Error searching products'); + } + }; + + fetchAndSetProducts().catch(() => { + setErrorState('Error handling change'); + }); + } + if (criteria === 'name a-z') { + const fetchAndSetProducts = async () => { + try { + const sortNameProducts = await fetchSortNameProducts('asc'); + setProductState(sortNameProducts); + } catch (error: unknown) { + setErrorState('Error searching products'); + } + }; + + fetchAndSetProducts().catch(() => { + setErrorState('Error handling change'); + }); + } + if (criteria === 'name z-a') { + const fetchAndSetProducts = async () => { + try { + const sortNameProducts = await fetchSortNameProducts('desc'); + setProductState(sortNameProducts); + } catch (error: unknown) { + setErrorState('Error searching products'); + } + }; + + fetchAndSetProducts().catch(() => { + setErrorState('Error handling change'); + }); + } + }; + + const handleCategoryClick = (category: string) => { + setSelectedCategory(category); + const fetchAndSetCategory = async () => { + try { + const currentCategory = await fetchCategory(category); + setProductState(currentCategory); + } catch (error: unknown) { + setErrorState('Error searching products'); + } + }; + + fetchAndSetCategory().catch(() => { + setErrorState('Error handling change'); + }); + }; + + return ( + <> + {errorState &&
{errorState}
} +
+ +
+
+ + + +
+ +
+ + ); +}; diff --git a/src/logic/errorPage/ErrorPage.module.scss b/src/logic/errorPage/ErrorPage.module.scss new file mode 100644 index 0000000..f1773b9 --- /dev/null +++ b/src/logic/errorPage/ErrorPage.module.scss @@ -0,0 +1,56 @@ +@import 'src/styles/variables'; +@import 'src/styles/mixins'; + +.error_container { + background-image: url('../../public/error.jpg'); + display: flex; + flex-direction: column; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + width: 100%; + height: 100vh; + justify-content: center; + align-items: center; + gap: 20px; +} + +.error_link, +.error_text, +.error_title { + color: $secondaryTextColor; +} + +.error_title, +.error_text { + text-align: center; +} + +.error_title { + font-size: 6.4rem; + margin: 0; +} + +.error_text { + font-size: 2.4rem; +} + +.error_link { + display: inline-block; + padding: 10px 20px; + background-color: $primaryBackgroundButton; + border-radius: 10px; + font-size: 1.6rem; + justify-self: center; + align-self: center; + + &:hover { + background-color: $primaryBackgroundButtonHover; + transition: background-color 0.3s ease; + color: $secondaryTextColor; + } + + &:active { + background-color: $primaryBackgroundButtonActive; + } +} diff --git a/src/logic/errorPage/ErrorPage.tsx b/src/logic/errorPage/ErrorPage.tsx new file mode 100644 index 0000000..374900c --- /dev/null +++ b/src/logic/errorPage/ErrorPage.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Link } from 'src/components/link/Link.tsx'; +import { Layout } from 'src/components/layout/Layout.tsx'; +import { Paragraph } from 'src/components/text/Text.tsx'; +import style from 'src/logic/errorPage/ErrorPage.module.scss'; + +export const Error: React.FC = () => { + return ( + + + + + + ); +}; diff --git a/src/logic/loginPage/LoginPage.tsx b/src/logic/loginPage/LoginPage.tsx new file mode 100644 index 0000000..15ef5de --- /dev/null +++ b/src/logic/loginPage/LoginPage.tsx @@ -0,0 +1,14 @@ +import { Form } from 'src/components/form/form.tsx'; +import styles from 'src/logic/loginPage/loginPage.module.scss'; + +const Login = () => { + return ( +
+
+
+
+
+ ); +}; + +export { Login }; diff --git a/src/logic/loginPage/loginPage.module.scss b/src/logic/loginPage/loginPage.module.scss new file mode 100644 index 0000000..12e2609 --- /dev/null +++ b/src/logic/loginPage/loginPage.module.scss @@ -0,0 +1,74 @@ +@import '../../styles/mixins'; +@import 'src/styles/variables'; + +.container_login { + display: flex; + background-image: url('../../public/login-bg.jpeg'); + background-size: cover; + background-repeat: no-repeat; + background-position: center; + width: 100%; + height: 100vh; + justify-content: center; + align-items: center; + color: black; +} + +.login { + @include form(0.6); +} + +.form { + display: flex; + flex-direction: column; + gap: 15px; + margin: 50px auto; + width: 200px; + > span { + width: 300px; + } +} + +.error { + color: red; + font-size: 1.4rem; + white-space: pre-wrap; +} + +.login_text { + font-size: 1.6rem; + font-weight: 700; +} + +.login_link { + display: inline-block; + padding: 10px 58px; + background-color: $primaryBackgroundButton; + border-radius: 0.5rem; + color: $secondaryTextColor; + font-size: 1.6rem; + font-weight: 700; + justify-self: center; + align-self: center; + + &:hover { + background-color: $primaryBackgroundButtonHover; + transition: background-color 0.3s ease; + color: $secondaryTextColor; + } + + &:active { + background-color: $primaryBackgroundButtonActive; + } +} + +@media (width < 768px) { + .login { + width: 300px; + } + .form { + > span { + width: 200px; + } + } +} diff --git a/src/logic/mainPage/MainPAge.module.scss b/src/logic/mainPage/MainPAge.module.scss new file mode 100644 index 0000000..42989fd --- /dev/null +++ b/src/logic/mainPage/MainPAge.module.scss @@ -0,0 +1,4 @@ +.main { + width: inherit; + height: inherit; +} diff --git a/src/logic/mainPage/MainPage.tsx b/src/logic/mainPage/MainPage.tsx new file mode 100644 index 0000000..dec0cc8 --- /dev/null +++ b/src/logic/mainPage/MainPage.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Layout } from 'components/layout/Layout.tsx'; +import styles from 'src/logic/mainPage/MainPAge.module.scss'; +import style from 'components/layout/Layout.module.scss'; + +export const Main: React.FC = () => { + return ( + +
+ + ); +}; diff --git a/src/logic/productPage/Product.tsx b/src/logic/productPage/Product.tsx new file mode 100644 index 0000000..1463223 --- /dev/null +++ b/src/logic/productPage/Product.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { CardOne } from 'src/components/card/Card.tsx'; + +export const Product: React.FC = () => { + return ; +}; diff --git a/src/logic/registrationPage/registration.module.scss b/src/logic/registrationPage/registration.module.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/logic/registrationPage/registrationPage.tsx b/src/logic/registrationPage/registrationPage.tsx new file mode 100644 index 0000000..e0d7d4e --- /dev/null +++ b/src/logic/registrationPage/registrationPage.tsx @@ -0,0 +1,5 @@ +import { RegistrationForm } from 'src/components/form/registration/RegistrationForm.tsx'; + +export const RegistrationPage = () => { + return ; +}; diff --git a/src/logic/userProfilePage/UserProfilePage.module.scss b/src/logic/userProfilePage/UserProfilePage.module.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/logic/userProfilePage/UserProfilePage.tsx b/src/logic/userProfilePage/UserProfilePage.tsx new file mode 100644 index 0000000..73e3a33 --- /dev/null +++ b/src/logic/userProfilePage/UserProfilePage.tsx @@ -0,0 +1,5 @@ +import { UserProfileForm } from 'src/components/form/profile/UserProfileForm.tsx'; + +export const UserProfilePage = () => { + return ; +}; diff --git a/src/model/Customer.ts b/src/model/Customer.ts new file mode 100644 index 0000000..d6de5b4 --- /dev/null +++ b/src/model/Customer.ts @@ -0,0 +1,39 @@ +export interface ICustomerModel { + email: string; + password: string | undefined; + firstName: string | undefined; + lastName: string | undefined; + dateOfBirth: string | undefined; + isShippingDefaultAddress: boolean; + isEqualAddress: boolean; + streetName: string | undefined; + city: string | undefined; + postalCode: string | undefined; + country: string | undefined; + isBillingDefaultAddress: boolean; + billingStreet: string | undefined; + billingCity: string | undefined; + billingCountry: string | undefined; + billingPostalCode: string | undefined; + version?: number; + [key: string]: string | boolean | number | undefined; +} + +export const customerModel: ICustomerModel = { + email: '', + password: '', + firstName: '', + lastName: '', + dateOfBirth: '', + isShippingDefaultAddress: false, + isEqualAddress: false, + streetName: '', + city: '', + postalCode: '', + country: '', + isBillingDefaultAddress: false, + billingStreet: '', + billingCity: '', + billingCountry: '', + billingPostalCode: '', +}; diff --git a/src/public/OpenSans-Regular.ttf b/src/public/OpenSans-Regular.ttf new file mode 100644 index 0000000..67803bb Binary files /dev/null and b/src/public/OpenSans-Regular.ttf differ diff --git a/src/public/error.jpg b/src/public/error.jpg new file mode 100644 index 0000000..2bc40ea Binary files /dev/null and b/src/public/error.jpg differ diff --git a/src/public/favicon.png b/src/public/favicon.png new file mode 100644 index 0000000..e348575 Binary files /dev/null and b/src/public/favicon.png differ diff --git a/src/public/hide.png b/src/public/hide.png new file mode 100644 index 0000000..62dc80a Binary files /dev/null and b/src/public/hide.png differ diff --git a/src/public/icons8-close-64.png b/src/public/icons8-close-64.png new file mode 100644 index 0000000..bb26556 Binary files /dev/null and b/src/public/icons8-close-64.png differ diff --git a/src/public/login-bg.jpeg b/src/public/login-bg.jpeg new file mode 100644 index 0000000..d6b2f18 Binary files /dev/null and b/src/public/login-bg.jpeg differ diff --git a/src/public/main_image2.jpg b/src/public/main_image2.jpg new file mode 100644 index 0000000..dd9df15 Binary files /dev/null and b/src/public/main_image2.jpg differ diff --git a/src/public/show.png b/src/public/show.png new file mode 100644 index 0000000..a659b82 Binary files /dev/null and b/src/public/show.png differ diff --git a/src/services/DTO/Customer.ts b/src/services/DTO/Customer.ts new file mode 100644 index 0000000..70c0488 --- /dev/null +++ b/src/services/DTO/Customer.ts @@ -0,0 +1,28 @@ +import { Customer } from '@commercetools/platform-sdk'; +import { countryLookup } from 'src/components/country/country.ts'; +import { ICustomerModel } from 'src/model/Customer.ts'; + +export const mapCustomerToModel = (customer: Customer): ICustomerModel => { + const isShippingDefaultAddress = !!customer.defaultShippingAddressId?.toString(); + const isBillingDefaultAddress = !!customer.defaultBillingAddressId?.toString(); + const country = countryLookup[customer.addresses[0].country]; + const billingCountry = countryLookup[customer.addresses[1].country]; + return { + email: customer.email, + password: customer.password, + firstName: customer.firstName, + lastName: customer.lastName, + dateOfBirth: customer.dateOfBirth, + isShippingDefaultAddress, + isEqualAddress: false, + streetName: customer.addresses[0].streetName, + city: customer.addresses[0]?.city, + postalCode: customer.addresses[0]?.postalCode, + country, + isBillingDefaultAddress, + billingStreet: customer.addresses[1]?.streetName, + billingCity: customer.addresses[1]?.city, + billingCountry, + billingPostalCode: customer.addresses[1]?.postalCode, + }; +}; diff --git a/src/services/api/BuildClient.ts b/src/services/api/BuildClient.ts new file mode 100644 index 0000000..bdf003c --- /dev/null +++ b/src/services/api/BuildClient.ts @@ -0,0 +1,73 @@ +import fetch from 'node-fetch'; +import { ClientBuilder } from '@commercetools/sdk-client-v2'; + +// prettier-ignore +import type { AuthMiddlewareOptions, HttpMiddlewareOptions } from '@commercetools/sdk-client-v2'; +import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; +import { MyTokenCache } from './MyTokenCache.ts'; +import { getCredentials } from '../userData/saveEmailPassword.ts'; + +const PROJECT_KEY: string = import.meta.env.VITE_CTP_PROJECT_KEY as string; +const CLIENT_ID: string = import.meta.env.VITE_CTP_CLIENT_ID as string; +const CLIENT_SECRET: string = import.meta.env.VITE_CTP_CLIENT_SECRET as string; +const AUTH_URL: string = import.meta.env.VITE_CTP_AUTH_URL as string; +const API_URL: string = import.meta.env.VITE_CTP_API_URL as string; +const SCOPESString: string = (import.meta.env.VITE_CTP_SCOPES as string) || ''; +const SCOPES: string[] = SCOPESString.split(' '); + +const scopes = SCOPES; + +const authMiddlewareOptions: AuthMiddlewareOptions = { + host: AUTH_URL, + projectKey: PROJECT_KEY, + credentials: { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + }, + scopes, + fetch, +}; + +const httpMiddlewareOptions: HttpMiddlewareOptions = { + host: API_URL, + fetch, +}; + +export const getLoginClient = () => { + const newTokenCache: MyTokenCache = new MyTokenCache(); + const PasswordAuthMiddlewareOptions = { + host: AUTH_URL, + projectKey: PROJECT_KEY, + credentials: { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + user: { + username: getCredentials().email, + password: getCredentials().password, + }, + }, + scopes, + tokenCache: newTokenCache, + fetch, + }; + + const client = new ClientBuilder() + .withProjectKey(PROJECT_KEY) + .withPasswordFlow(PasswordAuthMiddlewareOptions) + .withHttpMiddleware(httpMiddlewareOptions) + // .withLoggerMiddleware() // OFF LOGGER + .build(); + + return { client, tokenCache: newTokenCache }; +}; + +export const apiRoot2 = createApiBuilderFromCtpClient(getLoginClient().client).withProjectKey({ + projectKey: PROJECT_KEY, +}); + +export const ctpClient = new ClientBuilder() + .withProjectKey(PROJECT_KEY) + .withAnonymousSessionFlow(authMiddlewareOptions) + .withHttpMiddleware(httpMiddlewareOptions) + // .withLoggerMiddleware() // OFF LOGGER + .build(); diff --git a/src/services/api/BuildClientRegistration.ts b/src/services/api/BuildClientRegistration.ts new file mode 100644 index 0000000..0e227e3 --- /dev/null +++ b/src/services/api/BuildClientRegistration.ts @@ -0,0 +1,113 @@ +import { + type Client, + ClientBuilder, + HttpMiddlewareOptions, + AuthMiddlewareOptions, +} from '@commercetools/sdk-client-v2'; +import fetch from 'node-fetch'; + +// const VITE_REFRESH_TOKEN: string = import.meta.env.VITE_REFRESH_TOKEN as string; +export const PROJECT_KEY: string = import.meta.env.VITE_CTP_PROJECT_KEY as string; +const CLIENT_ID: string = import.meta.env.VITE_CTP_CLIENT_ID as string; +const CLIENT_SECRET: string = import.meta.env.VITE_CTP_CLIENT_SECRET as string; +const AUTH_URL: string = import.meta.env.VITE_CTP_AUTH_URL as string; +const API_URL: string = import.meta.env.VITE_CTP_API_URL as string; +// const SCOPES: string[] = [import.meta.env.VITE_CTP_SCOPES as string]; +const SCOPESString: string = (import.meta.env.VITE_CTP_SCOPES as string) || ''; + +const SCOPES: string[] = SCOPESString.split(' '); + +const scopes = SCOPES; + +// interface TokenCache { +// myCache: TokenStore; +// get: () => TokenStore; +// set: (newCache: TokenStore) => void; +// } + +// interface RefreshAuthMiddlewareOptions { +// host: string; +// projectKey: string; +// credentials: { +// clientId: string; +// clientSecret: string; +// }; +// refreshToken: string; +// tokenCache?: TokenCache; +// oauthUri?: string; +// scopes?: string[]; +// fetch?: unknown; +// } + +// const MytokenCache: TokenCache = { +// myCache: { +// token: '', +// expirationTime: -1, +// }, +// get() { +// return this.myCache; +// }, +// set(newCache) { +// this.myCache = newCache; +// }, +// }; + +const authMiddlewareOptions: AuthMiddlewareOptions = { + host: AUTH_URL, + projectKey: PROJECT_KEY, + credentials: { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + }, + scopes, + fetch, +}; + +// const refreshOptions: RefreshAuthMiddlewareOptions = { +// host: AUTH_URL, +// projectKey: PROJECT_KEY, +// credentials: { +// clientId: CLIENT_ID, +// clientSecret: CLIENT_SECRET, +// }, +// refreshToken: VITE_REFRESH_TOKEN, +// tokenCache: MytokenCache, +// scopes: [`manage_project:${PROJECT_KEY}`], +// fetch, +// }; + +// const Httpptions: HttpMiddlewareOptions = { +// host: API_URL, +// includeResponseHeaders: true, +// maskSensitiveHeaderData: true, +// includeOriginalRequest: false, +// includeRequestInErrorResponse: false, +// enableRetry: true, +// retryConfig: { +// maxRetries: 3, +// retryDelay: 200, +// backoff: false, +// retryCodes: [503], +// }, +// fetch, +// }; + +const Httpptions: HttpMiddlewareOptions = { + host: API_URL, + fetch, +}; + +// interface QueueMiddlewareOptions { +// concurrency: number; +// } + +// const queueOptions: QueueMiddlewareOptions = { +// concurrency: 5, +// }; + +export const ctpClientRegistration: Client = new ClientBuilder() + .withProjectKey(PROJECT_KEY) + .withClientCredentialsFlow(authMiddlewareOptions) + .withHttpMiddleware(Httpptions) + .withLoggerMiddleware() + .build(); diff --git a/src/services/api/MyTokenCache.ts b/src/services/api/MyTokenCache.ts new file mode 100644 index 0000000..165bc25 --- /dev/null +++ b/src/services/api/MyTokenCache.ts @@ -0,0 +1,17 @@ +import { TokenCache, TokenStore } from '@commercetools/sdk-client-v2'; + +export class MyTokenCache implements TokenCache { + myCache: TokenStore = { + token: '', + expirationTime: 0, + refreshToken: '', + }; + + set(newCache: TokenStore) { + this.myCache = newCache; + } + + get() { + return this.myCache; + } +} diff --git a/src/services/api/ResetPassword.ts b/src/services/api/ResetPassword.ts new file mode 100644 index 0000000..2b6bb51 --- /dev/null +++ b/src/services/api/ResetPassword.ts @@ -0,0 +1,25 @@ +import { ByProjectKeyRequestBuilder } from '@commercetools/platform-sdk'; +import { IPasswordForm } from 'src/components/form/profile/IPasswordForm.ts'; + +export const updatePassword = async ( + api: ByProjectKeyRequestBuilder, + formData: IPasswordForm, + version: number, +) => { + const accessToken: string = localStorage.getItem('token') ?? ''; + + return api + .me() + .password() + .post({ + body: { + version, + currentPassword: formData.oldPassword, + newPassword: formData.newPassword, + }, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .execute(); +}; diff --git a/src/services/api/ctpClient.ts b/src/services/api/ctpClient.ts new file mode 100644 index 0000000..140d929 --- /dev/null +++ b/src/services/api/ctpClient.ts @@ -0,0 +1,19 @@ +import { ctpClient } from 'src/services/api/BuildClient.ts'; +import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; + +const PROJECT_KEY: string = import.meta.env.VITE_CTP_PROJECT_KEY as string; +export const apiRoot = createApiBuilderFromCtpClient(ctpClient).withProjectKey({ + projectKey: PROJECT_KEY, +}); + +// const getProject = async () => { +// return apiRoot.get().execute(); +// }; + +// (async () => { +// await getProject(); +// })(); + +// export const sdkTest = () => { +// return true; +// }; diff --git a/src/services/api/ctpClientRegistration.ts b/src/services/api/ctpClientRegistration.ts new file mode 100644 index 0000000..6d91cb6 --- /dev/null +++ b/src/services/api/ctpClientRegistration.ts @@ -0,0 +1,9 @@ +import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; +import { PROJECT_KEY, ctpClientRegistration } from './BuildClientRegistration.ts'; + +// Create apiRoot from the imported ClientBuilder and include your Project key +export const apiRootRegistration = createApiBuilderFromCtpClient( + ctpClientRegistration, +).withProjectKey({ + projectKey: PROJECT_KEY, +}); diff --git a/src/services/api/filterRequests.ts b/src/services/api/filterRequests.ts new file mode 100644 index 0000000..22368ff --- /dev/null +++ b/src/services/api/filterRequests.ts @@ -0,0 +1,196 @@ +import { apiRoot } from './ctpClient.ts'; + +export const fetchAllProducts = async () => { + const response = await apiRoot.productProjections().search().get().execute(); + return response.body.results; +}; + +export const fetchCategory = async (category: string) => { + let filter = ''; + + if (category === 'Decor') { + filter = 'categories.id: subtree("59dbad07-067b-46ef-b62e-c6d5eddc2128")'; + } + if (category === 'Wall Decor') { + filter = 'categories.id: subtree("043c187b-e2e2-42f4-b7e9-e11e441cbee8")'; + } + if (category === 'X-mas') { + filter = 'categories.id: subtree("c7f2242d-4ade-4049-b23c-3aeb238580cc")'; + } + if (category === 'Toys') { + filter = 'categories.id: subtree("28b63001-0763-48c2-96e5-fbda8effc91b")'; + } + if (category === 'Food') { + filter = 'categories.id: subtree("ed2eba2a-1ebf-4295-ac7f-1080f469fa7d")'; + } + if (category === 'Jar') { + filter = 'categories.id: subtree("d32d17fe-3bda-460e-8494-9e50c9ca5a10")'; + } + + const response = await apiRoot + .productProjections() + .search() + .get({ + queryArgs: { + 'filter.query': [filter], + }, + }) + .execute(); + return response.body.results; +}; + +export const fetchSearchProducts = async (query: string) => { + const response = await apiRoot + .productProjections() + .search() + .get({ + queryArgs: { + 'text.en-US': query, + }, + }) + .execute(); + return response.body.results; +}; + +export const fetchSortPriceProducts = async (value: string) => { + const response = await apiRoot + .productProjections() + .search() + .get({ + queryArgs: { + sort: `price ${value}`, + }, + }) + .execute(); + return response.body.results; +}; + +export const fetchSortNameProducts = async (value: string) => { + const response = await apiRoot + .productProjections() + .search() + .get({ + queryArgs: { + sort: `name.en-US ${value}`, + }, + }) + .execute(); + return response.body.results; +}; + +export const fetchColorProducts = async ( + colors: string[], + sizesArray: string[], + prices: string[], +) => { + const filters = []; + const setColor = colors.map((value: string) => `"${value}"`).join(','); + + if (colors.length) { + filters.push(`variants.attributes.color-item: ${setColor}`); + } + + if (sizesArray.length > 0) { + const setSize = sizesArray.map((value: string) => `"${value}"`).join(','); + filters.push(`variants.attributes.size-item: ${setSize}`); + } + + if (prices.length > 0) { + if (prices[0] === 'below 75') { + filters.push('variants.price.centAmount: range(0 to 7500)'); + } else if (prices[0] === '75 to 125') { + filters.push('variants.price.centAmount: range(7500 to 12500)'); + } else if (prices[0] === '125 to 200') { + filters.push('variants.price.centAmount: range(12500 to 20000)'); + } else if (prices[0] === '200 to 250') { + filters.push('variants.price.centAmount: range(20000 to 25000)'); + } else if (prices[0] === 'above 250') { + filters.push('variants.price.centAmount: range(25000 to 100000000000000)'); + } + } + + const response = await apiRoot + .productProjections() + .search() + .get({ + queryArgs: { + limit: 30, + filter: filters, + }, + }) + .execute(); + return response.body.results; +}; + +export const fetchSizeProducts = async ( + sizes: string[], + colorsArray: string[], + prices: string[], +) => { + const filters = []; + const setSize = sizes.map((value: string) => `"${value}"`).join(','); + + if (sizes.length) { + filters.push(`variants.attributes.size-item: ${setSize}`); + } + + if (colorsArray.length > 0) { + const setColor = colorsArray.map((value: string) => `"${value}"`).join(','); + filters.push(`variants.attributes.color-item: ${setColor}`); + } + + if (prices.length > 0) { + if (prices[0] === 'below 75') { + filters.push('variants.price.centAmount: range(0 to 7500)'); + } else if (prices[0] === '75 to 125') { + filters.push('variants.price.centAmount: range(7500 to 12500)'); + } else if (prices[0] === '125 to 200') { + filters.push('variants.price.centAmount: range(12500 to 20000)'); + } else if (prices[0] === '200 to 250') { + filters.push('variants.price.centAmount: range(20000 to 25000)'); + } else if (prices[0] === 'above 250') { + filters.push('variants.price.centAmount: range(25000 to 100000000000000)'); + } + } + + const response = await apiRoot + .productProjections() + .search() + .get({ + queryArgs: { + limit: 30, + filter: filters, + }, + }) + .execute(); + return response.body.results; +}; + +export const fetchPriceProducts = async ( + prices: string, + colorsArray: string[], + sizesArray: string[], +) => { + const filters = []; + + filters.push(`variants.price.centAmount: range(${prices})`); + + if (colorsArray.length > 0) { + const setColor = colorsArray.map((value: string) => `"${value}"`).join(','); + filters.push(`variants.attributes.color-item: ${setColor}`); + } + + if (sizesArray.length > 0) { + const setSize = sizesArray.map((value: string) => `"${value}"`).join(','); + filters.push(`variants.attributes.size-item: ${setSize}`); + } + + const response = await apiRoot + .productProjections() + .search() + .get({ + queryArgs: { limit: 30, filter: filters }, + }) + .execute(); + return response.body.results; +}; diff --git a/src/services/api/loginRequest.ts b/src/services/api/loginRequest.ts new file mode 100644 index 0000000..1d56397 --- /dev/null +++ b/src/services/api/loginRequest.ts @@ -0,0 +1,45 @@ +import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; +import { ctpClient, getLoginClient } from './BuildClient.ts'; +import { saveToken } from './saveToken.ts'; + +export const loginRequest = async (myEmail: string, myPassword: string) => { + const PROJECT_KEY: string = import.meta.env.VITE_CTP_PROJECT_KEY as string; + const apiRoot = createApiBuilderFromCtpClient(ctpClient).withProjectKey({ + projectKey: PROJECT_KEY, + }); + + const loginUser = () => { + return apiRoot + .me() + .login() + .post({ + body: { + email: myEmail, + password: myPassword, + }, + }) + .execute(); + }; + + const { client, tokenCache } = getLoginClient(); + + const apiRoot2 = createApiBuilderFromCtpClient(client).withProjectKey({ + projectKey: PROJECT_KEY, + }); + + const result = loginUser() + .then((response) => { + const idUser = response.body.customer.id; + const newIdUser = idUser.split('-')[0]; + localStorage.setItem('id', newIdUser); + localStorage.setItem('fullID', idUser); + }) + .then(() => { + return apiRoot2.carts().get().execute(); + }) + .then(() => { + saveToken(tokenCache); + }); + + return result; +}; diff --git a/src/services/api/registrationCustomer.ts b/src/services/api/registrationCustomer.ts new file mode 100644 index 0000000..47b30ca --- /dev/null +++ b/src/services/api/registrationCustomer.ts @@ -0,0 +1,19 @@ +import { CustomerDraft, createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; +import { ServerError } from 'src/utils/error/RequestErrors.ts'; +import { ctpClient } from './BuildClient.ts'; + +export const createCustomer = (newCustomer: CustomerDraft) => { + const PROJECT_KEY: string = import.meta.env.VITE_CTP_PROJECT_KEY as string; + const apiRoot = createApiBuilderFromCtpClient(ctpClient).withProjectKey({ + projectKey: PROJECT_KEY, + }); + return apiRoot + .customers() + .post({ + body: newCustomer, + }) + .execute() + .catch((error: unknown) => { + throw new ServerError('Error during customer registration.', error); + }); +}; diff --git a/src/services/api/saveToken.ts b/src/services/api/saveToken.ts new file mode 100644 index 0000000..cba7dd4 --- /dev/null +++ b/src/services/api/saveToken.ts @@ -0,0 +1,13 @@ +import { MyTokenCache } from './MyTokenCache.ts'; + +export const saveToken = (tokenCache: MyTokenCache) => { + const { token, refreshToken } = tokenCache.myCache; + + const tokens = { + curToken: token, + refToken: refreshToken, + }; + + localStorage.setItem('userTokens', JSON.stringify(tokens)); + localStorage.setItem('token', tokens.curToken); +}; diff --git a/src/services/api/updateCustomer.ts b/src/services/api/updateCustomer.ts new file mode 100644 index 0000000..b3d7cfa --- /dev/null +++ b/src/services/api/updateCustomer.ts @@ -0,0 +1,41 @@ +import { ByProjectKeyRequestBuilder } from '@commercetools/platform-sdk'; + +export const updateCustomerField = async ( + apiRoot2: ByProjectKeyRequestBuilder, + id: string, + customerVersion: number, + firstName: string, + lastName: string, + dateOfBirth: string, + email: string, +) => { + const response = await apiRoot2 + .customers() + .withId({ ID: id }) + .post({ + body: { + version: customerVersion, + actions: [ + { + action: 'setFirstName', + firstName, + }, + { + action: 'setLastName', + lastName, + }, + { + action: 'setDateOfBirth', + dateOfBirth, + }, + { + action: 'changeEmail', + email, + }, + ], + }, + }) + .execute(); + + return response; +}; diff --git a/src/services/tools/filterTools.ts b/src/services/tools/filterTools.ts new file mode 100644 index 0000000..a3b8236 --- /dev/null +++ b/src/services/tools/filterTools.ts @@ -0,0 +1,45 @@ +import { fetchAllProducts } from 'src/services/api/filterRequests.ts'; + +interface Attribute { + name: string; + value: string[]; +} + +interface Product { + masterVariant?: { + attributes?: Attribute[]; + }; +} + +export const filterTools = async () => { + let allColors: string[] = []; + let allSizes: string[] = []; + + const productsAll: Product[] = await fetchAllProducts(); + + productsAll.forEach((product) => { + product.masterVariant?.attributes?.forEach((attribute) => { + if (attribute.name === 'color-item') { + allColors = allColors.concat(attribute.value); + } else if (attribute.name === 'size-item') { + allSizes = allSizes.concat(attribute.value); + } + }); + }); + + const uniqueColors = [...new Set(allColors)]; + const colors = uniqueColors.map((color) => ({ label: color, value: color })); + + const uniqueSizes = [...new Set(allSizes)]; + const sizes = uniqueSizes.map((size) => ({ label: size, value: size })); + + const prices = [ + { label: 'below 75', value: 'below 75' }, + { label: '75 to 125', value: '75 to 125' }, + { label: '125 to 200', value: '125 to 200' }, + { label: '200 to 250', value: '200 to 250' }, + { label: 'above 250', value: 'above 250' }, + ]; + + return [colors, sizes, prices]; +}; diff --git a/src/services/userData/saveEmailPassword.ts b/src/services/userData/saveEmailPassword.ts new file mode 100644 index 0000000..75753ce --- /dev/null +++ b/src/services/userData/saveEmailPassword.ts @@ -0,0 +1,26 @@ +let savedEmail = ''; +let savedPassword = 'null'; + +export const saveCredentials = (email: string, password: string) => { + savedEmail = email; + savedPassword = password; +}; + +export const updateEmail = (email: string) => { + savedEmail = email; +}; + +export const setPassword = (password: string) => { + savedPassword = password; +}; + +export const getPassword = (): string => { + return savedPassword; +}; + +export const getCredentials = () => { + return { + email: savedEmail, + password: savedPassword, + }; +}; diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss new file mode 100644 index 0000000..50dfd43 --- /dev/null +++ b/src/styles/_mixins.scss @@ -0,0 +1,56 @@ +@mixin form($opacity) { + display: flex; + flex-direction: column; + width: 474px; + background-color: rgba(255, 255, 255, $opacity); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); + border-radius: 10px; + justify-content: center; + align-items: center; +} + +@mixin customer-info-block { + padding: 20px; + border: 1px solid #ddd; + border-radius: 0 5px 5px 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + transition: opacity 0.3s ease-in-out; + margin-bottom: 20px; + margin-right: 20px; +} + +@mixin media-1200 { + @media (max-width: $screen-width-1200) { + @content; + } +} + +@mixin media-1000 { + @media (max-width: $screen-width-1000) { + @content; + } +} + +@mixin media-800 { + @media (max-width: $screen-width-800) { + @content; + } +} + +@mixin media-600 { + @media (max-width: $screen-width-600) { + @content; + } +} + +@mixin media-500 { + @media (max-width: $screen-width-500) { + @content; + } +} + +@mixin media-400 { + @media (max-width: $screen-width-400) { + @content; + } +} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss new file mode 100644 index 0000000..787051d --- /dev/null +++ b/src/styles/_variables.scss @@ -0,0 +1,18 @@ +//color +$secondaryTextColor: #000000; +$primaryBackgroundButton: #94b21b; +$primaryBackgroundButtonHover: #b4d23d; +$primaryBackgroundButtonActive: #80ee59; +$secondaryActiveLink: #f8119f; +$priceSale: #be0a1f; + +// container width +$maxWidthDesktop: 1170px; + +//screen width +$screen-width-1200: 1200px; +$screen-width-1000: 1000px; +$screen-width-800: 800px; +$screen-width-600: 600px; +$screen-width-500: 500px; +$screen-width-400: 400px; diff --git a/src/styles/normalize.css b/src/styles/normalize.css new file mode 100644 index 0000000..88ab743 --- /dev/null +++ b/src/styles/normalize.css @@ -0,0 +1,334 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { + /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { + /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type='button']::-moz-focus-inner, +[type='reset']::-moz-focus-inner, +[type='submit']::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type='button']:-moz-focusring, +[type='reset']:-moz-focusring, +[type='submit']:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type='checkbox'], +[type='radio'] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type='number']::-webkit-inner-spin-button, +[type='number']::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type='search'] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type='search']::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/src/styles/style.css b/src/styles/style.css new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/example.test.ts b/src/tests/example.test.ts new file mode 100644 index 0000000..7610cb1 --- /dev/null +++ b/src/tests/example.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest'; + +test('Example test', () => { + const result = 1 + 1; + expect(result).toEqual(2); +}); diff --git a/src/utils/CurrencyUtils.ts b/src/utils/CurrencyUtils.ts new file mode 100644 index 0000000..ec0ae39 --- /dev/null +++ b/src/utils/CurrencyUtils.ts @@ -0,0 +1,9 @@ +enum CurrencySymbol { + USD = '$', + EUR = '€', + GBP = '£', +} + +export const getCurrencySymbol = (currencyCode: string | undefined): string | undefined => { + return currencyCode ? CurrencySymbol[currencyCode as keyof typeof CurrencySymbol] : undefined; +}; diff --git a/src/utils/error/RequestErrors.ts b/src/utils/error/RequestErrors.ts new file mode 100644 index 0000000..1311d09 --- /dev/null +++ b/src/utils/error/RequestErrors.ts @@ -0,0 +1,49 @@ +interface CommercetoolsErrorDetail { + code: string; + message: string; +} + +interface CommercetoolsErrorResponse { + statusCode: number; + message: string; + errors: CommercetoolsErrorDetail[]; +} + +export const ErrorType = ['Invalid', 'Error']; + +const isCommercetoolsError = (error: unknown): error is { body: CommercetoolsErrorResponse } => { + return ( + typeof error === 'object' && + error !== null && + 'body' in error && + typeof (error as { body: unknown }).body === 'object' && + (error as { body: unknown }).body !== null && + 'errors' in (error as { body: CommercetoolsErrorResponse }).body && + Array.isArray((error as { body: CommercetoolsErrorResponse }).body.errors) + ); +}; + +export const detailedError = (error: unknown) => { + const errorsList: string[] = []; + + if (isCommercetoolsError(error)) { + error.body.errors.forEach((err: CommercetoolsErrorDetail) => { + errorsList.push(`${err.code}: ${err.message}`); + }); + } + return errorsList.join('\n'); +}; + +export class ServerError extends Error { + constructor( + public message: string, + public originalError?: unknown, + ) { + super(message); + this.name = 'ServerError'; + const detailedErr = detailedError(originalError).split(':').at(1)?.toString(); + if (detailedErr) { + this.message = detailedErr; + } + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ccbebc7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "paths": { + "src/*": ["./src/*"], + "components/*": ["./src/components/*"], + "tests/*": ["./src/tests/*"] + }, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..05cd038 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + base: './', + plugins: [tsconfigPaths(), react()], + css: { + modules: { + localsConvention: 'camelCase', + }, + }, + resolve: { + alias: { + 'node-fetch': 'isomorphic-fetch', + }, + }, +});