diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..ebf4d01
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1 @@
+tailwind.config.js
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..d51ddf7
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,32 @@
+# Description
+
+Fixes # ()
+
+
+
+## Motivation
+
+
+
+## Type of change
+
+
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] This change requires a documentation update
+
+# How Has This Been Tested?
+
+
+
+- [ ]
+- [ ]
+
+# Checklist:
+
+- [ ] My code follows the style guidelines of this project
+- [ ] I have performed a self-review of my code
+- [ ] I have commented my code in hard-to-understand areas
+- [ ] My changes generate no new warnings
diff --git a/.gitignore b/.gitignore
index c6bba59..c21b2b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,28 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+
# Logs
logs
*.log
@@ -128,3 +153,4 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+yarn.lock
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 0000000..915c6b2
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,6 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+yarn format
+yarn test
+yarn lint
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..6fc2a81
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,13 @@
+{
+ "singleQuote": true,
+ "printWidth": 110,
+ "tabWidth": 2,
+ "semi": true,
+ "arrowParens": "always",
+ "trailingComma": "none",
+ "bracketSpacing": true,
+ "singleAttributePerLine": true,
+ "plugins": [
+ "prettier-plugin-tailwindcss"
+ ]
+}
diff --git a/README.md b/README.md
index d416b84..7f30e53 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,85 @@
-# eCommerce-Application
\ No newline at end of file
+# eCommerce Application ๐๏ธ๐
+
+Welcome to our eCommerce application! This platform replicates real-world shopping experiences in a digital environment ๐ช. 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 ๐.
+
+Users can browse through a vast range of products ๐๐๐, view detailed descriptions, add their favorite items to the basket ๐, and proceed to checkout ๐ณ. It includes features such as user registration and login ๐๐, product search ๐, product categorization, and sorting to make the shopping experience more streamlined and convenient.
+
+An important aspect of our application is that it's responsive ๐ฒ, ensuring it looks great on various devices with a minimum resolution of 390px. This feature makes the shopping experience enjoyable, irrespective of the device users prefer.
+
+Key pages in the application include:
+
+- Login and Registration pages ๐ฅ๏ธ
+- Main page ๐
+- Catalog Product page ๐
+- Detailed Product page ๐
+- User Profile page ๐ค
+- Basket page ๐
+- About Us page ๐โโ๏ธ๐โโ๏ธ
+
+The application is powered by [CommerceTools](https://docs.commercetools.com/docs) ๐, 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.
+
+The application developed by teams of three ๐จโ๐ป๐จโ๐ป๐จโ๐ป: ๐[glitch-surfer](https://github.com/glitch-surfer), ๐คนโโ๏ธ[filmaxim](https://github.com/filmaxim) & ๐โโ๏ธ[pavelvl21](https://github.com/pavelvl21).
+
+## Technology Stack ๐ป๐
+
+This app is SPA and develop with:
+- React โ๏ธ (It allows the application to have high performance and responsiveness, start with create-react-app and then customize some dependancies)
+- TypeScript (It ensure type safety and improve the maintainability and scalability of the application ๐)
+- Webpack (supporting both development and production build modes)
+- ESlint (react+ts recommended rules to enforce consistent coding styles and identify potential issues in the codebase)
+- Prettier (to automatically format code, ensuring a consistent and readable code style)
+- Husky (to manage Git hooks, automating tasks such as code formatting and linting checks during the commit process)
+- Jest (as the testing framework for the project, enabling comprehensive testing of code functionality)
+
+### Key Pages
+
+1. **Login Page ๐ฅ๏ธ๐**
+ The Login page is the user's entry point to the application. It have fields for entering the username and password. There validation checks in place to ensure that all fields are filled before allowing the user to submit the form. On successful login, the user directed to the main page of the application.
+
+2. **Registration Page ๐ฅ๏ธ๐**
+ The Registration page allow new users to sign up for the application. It have fields for entering user information including a username, email, and password. Once the user submits the form after entering all the necessary details, they will be registered and directed to the login page.
+
+3. **Main Page ๐ **
+ The Main Page is the core of the application. This is where the users can browse the catalog of products. The page provide a user-friendly interface that allows users to easily navigate through different categories and products. Centralized Navigation is a key feature on this page, and include links to all functional pages of the application. Each link should redirect the user correctly to the corresponding page without any errors. The main page also displays active promo codes.
+
+4. **Catalog Product Page ๐**
+ The Catalog Product page display a list of products in a specific category. The products displayed as interactive cards with a picture and essential details about the product. Users can click on these cards to get more detailed information about the product.
+
+5. **Detailed Product Page ๐**
+ The Detailed Product page is where the user can view detailed information about a specific product. It display an image of the product, a detailed description, the price, and the available options for the product. Users able to add the product to their basket from this page.
+
+6. **User Profile Page ๐ค**
+ The User Profile page display the personal information of the user. This includes their name, date of birth, and a list of their addresses. This page also provide an interface for users to edit their personal details and addresses.
+
+7. **Basket Page ๐**
+ The Basket page is where users can view the products they have added to their basket. They can increase or decrease the quantity of each item or remove items from the basket. The page also display the subtotal and total prices and provide an option for the users to proceed to the checkout page.
+
+8. **About Us Page ๐โโ๏ธ๐โโ๏ธ**
+ The About Us page give a brief introduction to the development team. It highlight each member's contributions to the project and provide personal details such as names, roles, short bios, relevant photos, and GitHub profile links.
+
+# Locally setup instruction & available scripts
+
+- clone this repo
+
+- `yarn install`
+If you don't have yarn, `npm i --global yarn` before
+
+- `yarn start`
+Runs dev-server on http://localhost:3000/
+
+- `yarn build`
+Build production bundle
+
+## Other scripts
+
+- `yarn test`
+Runs jest tests
+
+- `yarn lint`
+Runs js, jsx, ts, tsx files linting
+
+- `yarn format`
+Runs Prettier formatting across the codebase
+
+- `yarn eject`
+Give access to dependancies config files
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f392dfe
--- /dev/null
+++ b/package.json
@@ -0,0 +1,119 @@
+{
+ "name": "ecommerce-application1",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@babel/runtime": "^7.22.11",
+ "@commercetools/api-request-builder": "^6.0.0",
+ "@commercetools/platform-sdk": "^5.0.0",
+ "@commercetools/sdk-client-v2": "^2.2.0",
+ "@emotion/react": "^11.11.1",
+ "@emotion/styled": "^11.11.0",
+ "@headlessui/react": "^1.7.16",
+ "@heroicons/react": "^2.0.18",
+ "@material-ui/core": "^4.12.4",
+ "@mui/icons-material": "^5.14.3",
+ "@mui/lab": "^5.0.0-alpha.143",
+ "@mui/material": "^5.14.7",
+ "@mui/styles": "^5.14.7",
+ "@reduxjs/toolkit": "^1.9.5",
+ "@testing-library/jest-dom": "^5.14.1",
+ "@testing-library/react": "^14.0.0",
+ "@testing-library/user-event": "^13.2.1",
+ "@types/jest": "^27.0.1",
+ "@types/lodash": "^4.14.197",
+ "@types/node": "^16.7.13",
+ "@types/react": "^18.0.0",
+ "@types/react-dom": "^18.0.0",
+ "@types/react-router-dom": "^5.3.3",
+ "@types/toastify-js": "^1.12.0",
+ "babel-loader": "^9.1.3",
+ "dayjs": "^1.11.9",
+ "formik": "^2.4.3",
+ "formik-material-ui": "^4.0.0-alpha.2",
+ "i18next": "^23.4.6",
+ "postcode-validator": "^3.8.14",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-i18next": "^13.2.1",
+ "react-redux": "^8.1.2",
+ "react-responsive-carousel": "^3.2.23",
+ "react-router-dom": "^6.14.2",
+ "react-scripts": "5.0.1",
+ "sass": "^1.64.2",
+ "toastify-js": "^1.12.0",
+ "yup": "^1.2.0"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test --watchAll=false",
+ "eject": "react-scripts eject",
+ "lint": "eslint --ext .js,.jsx,.ts,.tsx src",
+ "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
+ "prepare": "husky install"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest",
+ "plugin:react/recommended",
+ "standard-with-typescript",
+ "plugin:jsx-a11y/recommended"
+ ],
+ "overrides": [],
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module",
+ "project": "./tsconfig.json"
+ },
+ "plugins": [
+ "react",
+ "jsx-a11y"
+ ],
+ "rules": {
+ "@typescript-eslint/semi": [
+ "error",
+ "always"
+ ],
+ "@typescript-eslint/no-explicit-any": "error",
+ "@typescript-eslint/explicit-function-return-type": "off",
+ "@typescript-eslint/member-delimiter-style": "off",
+ "react/react-in-jsx-scope": "off",
+ "react/jsx-uses-react": "off",
+ "no-console": "warn"
+ }
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "@babel/plugin-transform-runtime": "^7.22.10",
+ "@tailwindcss/forms": "^0.5.4",
+ "@types/react-slick": "^0.23.10",
+ "@types/react-test-renderer": "^18.0.0",
+ "@typescript-eslint/eslint-plugin": "^5.52.0",
+ "eslint": "^8.0.1",
+ "eslint-config-standard-with-typescript": "^37.0.0",
+ "eslint-plugin-import": "^2.25.2",
+ "eslint-plugin-jsx-a11y": "^6.7.1",
+ "eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
+ "eslint-plugin-promise": "^6.0.0",
+ "eslint-plugin-react": "^7.33.1",
+ "husky": "^8.0.3",
+ "prettier": "^3.0.1",
+ "prettier-plugin-tailwindcss": "^0.4.1",
+ "react-test-renderer": "^18.2.0",
+ "tailwindcss": "^3.3.3",
+ "typescript": "^5.1.6"
+ }
+}
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..9e8eb29
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..62ae11b
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+ Mr. Robot
+
+
+
+ You need to enable JavaScript to run this app.
+
+
+
diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts
new file mode 100644
index 0000000..dd4fa00
--- /dev/null
+++ b/src/@types/i18next.d.ts
@@ -0,0 +1,8 @@
+import 'i18next';
+import type { resources } from './resources';
+
+declare module 'i18next' {
+ interface CustomTypeOptions {
+ resources: typeof resources;
+ }
+}
diff --git a/src/@types/resources.ts b/src/@types/resources.ts
new file mode 100644
index 0000000..91baf19
--- /dev/null
+++ b/src/@types/resources.ts
@@ -0,0 +1,5 @@
+import { en } from '../locales/en';
+
+export const resources = {
+ en
+};
diff --git a/src/Init.tsx b/src/Init.tsx
new file mode 100644
index 0000000..6512e16
--- /dev/null
+++ b/src/Init.tsx
@@ -0,0 +1,13 @@
+import { Provider } from 'react-redux';
+import { App } from './components/App';
+import { store } from './slices';
+
+export const Init = () => {
+ return (
+
+
+
+ );
+};
diff --git a/src/__tests__/__snapshots__/registrationFormRender.test.tsx.snap b/src/__tests__/__snapshots__/registrationFormRender.test.tsx.snap
new file mode 100644
index 0000000..a1566f0
--- /dev/null
+++ b/src/__tests__/__snapshots__/registrationFormRender.test.tsx.snap
@@ -0,0 +1,1450 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RegistrationForm renders correctly renders correctly 1`] = `
+
+`;
diff --git a/src/__tests__/getAddressInitialValues.test.ts b/src/__tests__/getAddressInitialValues.test.ts
new file mode 100644
index 0000000..7dfbac3
--- /dev/null
+++ b/src/__tests__/getAddressInitialValues.test.ts
@@ -0,0 +1,266 @@
+import type { Customer } from '@commercetools/platform-sdk';
+import { getAddressesInitialValues } from '../components/forms/customerProfile/util/getInitialValues';
+
+describe('getAddressInitialValues', () => {
+ const fakeCustomerData: Customer = {
+ id: '',
+ version: 1,
+ createdAt: '',
+ lastModifiedAt: '',
+ email: '',
+ isEmailVerified: false,
+ authenticationMode: '',
+ addresses: []
+ };
+
+ const fakeCustomerWithOnlyOneAddress: Customer = {
+ ...fakeCustomerData,
+ addresses: [
+ {
+ city: 'fd',
+ country: 'AX',
+ id: 'FZxT-eI3',
+ postalCode: 'df',
+ streetName: 'sd'
+ }
+ ]
+ };
+
+ const fakeCustomerWithOneAddressFullData: Customer = {
+ ...fakeCustomerData,
+ addresses: [
+ {
+ city: 'fd',
+ country: 'AX',
+ id: 'FZxT-eI3',
+ postalCode: 'df',
+ streetName: 'sd'
+ }
+ ],
+ defaultShippingAddressId: 'FZxT-eI3',
+ defaultBillingAddressId: 'FZxT-eI3',
+ shippingAddressIds: ['FZxT-eI3'],
+ billingAddressIds: ['FZxT-eI3']
+ };
+
+ const fakeCustomerWithTwoAddresses: Customer = {
+ ...fakeCustomerData,
+ addresses: [
+ {
+ city: 'fd',
+ country: 'AX',
+ id: 'FZxT-eI3',
+ postalCode: '12345',
+ streetName: 'sd'
+ },
+ {
+ city: 'test',
+ country: 'AX',
+ id: 'TeST-eI3',
+ postalCode: 'df',
+ streetName: 'sdsfd'
+ }
+ ]
+ };
+
+ const fakeCustomerWithShippingAddressIds: Customer = {
+ ...fakeCustomerWithOnlyOneAddress,
+ shippingAddressIds: ['FZxT-eI3']
+ };
+
+ const fakeCustomerWithBillingAddressIds: Customer = {
+ ...fakeCustomerWithTwoAddresses,
+ billingAddressIds: ['TeST-eI3']
+ };
+
+ const fakeCustomerWithDefaultShippingAddress: Customer = {
+ ...fakeCustomerWithShippingAddressIds,
+ defaultShippingAddressId: 'FZxT-eI3'
+ };
+
+ const fakeCustomerWithDefaultBillingAddress: Customer = {
+ ...fakeCustomerWithBillingAddressIds,
+ defaultBillingAddressId: 'TeST-eI3'
+ };
+
+ const fakeCustomerwithFullData: Customer = {
+ ...fakeCustomerWithShippingAddressIds,
+ ...fakeCustomerWithBillingAddressIds,
+ ...fakeCustomerWithDefaultShippingAddress,
+ ...fakeCustomerWithDefaultBillingAddress
+ };
+
+ it('should return initial values for customer with no addresses', () => {
+ expect(getAddressesInitialValues(fakeCustomerData)).toEqual([]);
+ });
+
+ it('should return initial values for customer with only 1 address', () => {
+ expect(getAddressesInitialValues(fakeCustomerWithOnlyOneAddress)).toEqual([
+ {
+ id: 'FZxT-eI3',
+ city: 'fd',
+ country: 'Aland Islands',
+ postalCode: 'df',
+ streetName: 'sd',
+ shippingStateChecked: false,
+ billingStateChecked: false,
+ defaultShippingAddress: false,
+ defaultBillingAddress: false
+ }
+ ]);
+ });
+ it('should return initial values for customer with 2 addresses', () => {
+ expect(getAddressesInitialValues(fakeCustomerWithTwoAddresses)).toEqual([
+ {
+ id: 'FZxT-eI3',
+ city: 'fd',
+ country: 'Aland Islands',
+ postalCode: '12345',
+ streetName: 'sd',
+ shippingStateChecked: false,
+ billingStateChecked: false,
+ defaultShippingAddress: false,
+ defaultBillingAddress: false
+ },
+ {
+ id: 'TeST-eI3',
+ city: 'test',
+ country: 'Aland Islands',
+ postalCode: 'df',
+ streetName: 'sdsfd',
+ shippingStateChecked: false,
+ billingStateChecked: false,
+ defaultShippingAddress: false,
+ defaultBillingAddress: false
+ }
+ ]);
+ });
+ it('should return initial values for customer with shippingAddressIds', () => {
+ expect(getAddressesInitialValues(fakeCustomerWithShippingAddressIds)).toEqual([
+ {
+ id: 'FZxT-eI3',
+ city: 'fd',
+ country: 'Aland Islands',
+ postalCode: 'df',
+ streetName: 'sd',
+ shippingStateChecked: true,
+ billingStateChecked: false,
+ defaultShippingAddress: false,
+ defaultBillingAddress: false
+ }
+ ]);
+ });
+
+ it('should return initial values for customer with billingAddressIds', () => {
+ expect(getAddressesInitialValues(fakeCustomerWithBillingAddressIds)).toEqual([
+ {
+ id: 'FZxT-eI3',
+ city: 'fd',
+ country: 'Aland Islands',
+ postalCode: '12345',
+ streetName: 'sd',
+ shippingStateChecked: false,
+ billingStateChecked: false,
+ defaultShippingAddress: false,
+ defaultBillingAddress: false
+ },
+ {
+ id: 'TeST-eI3',
+ city: 'test',
+ country: 'Aland Islands',
+ postalCode: 'df',
+ streetName: 'sdsfd',
+ shippingStateChecked: false,
+ billingStateChecked: true,
+ defaultShippingAddress: false,
+ defaultBillingAddress: false
+ }
+ ]);
+ });
+
+ it('should return initial values for customer with defaultShippingAddressId', () => {
+ expect(getAddressesInitialValues(fakeCustomerWithDefaultShippingAddress)).toEqual([
+ {
+ id: 'FZxT-eI3',
+ city: 'fd',
+ country: 'Aland Islands',
+ postalCode: 'df',
+ streetName: 'sd',
+ shippingStateChecked: true,
+ billingStateChecked: false,
+ defaultShippingAddress: true,
+ defaultBillingAddress: false
+ }
+ ]);
+ });
+
+ it('should return initial values for customer with defaultBillingAddressId', () => {
+ expect(getAddressesInitialValues(fakeCustomerWithDefaultBillingAddress)).toEqual([
+ {
+ id: 'FZxT-eI3',
+ city: 'fd',
+ country: 'Aland Islands',
+ postalCode: '12345',
+ streetName: 'sd',
+ shippingStateChecked: false,
+ billingStateChecked: false,
+ defaultBillingAddress: false,
+ defaultShippingAddress: false
+ },
+ {
+ id: 'TeST-eI3',
+ city: 'test',
+ country: 'Aland Islands',
+ postalCode: 'df',
+ streetName: 'sdsfd',
+ shippingStateChecked: false,
+ billingStateChecked: true,
+ defaultBillingAddress: true,
+ defaultShippingAddress: false
+ }
+ ]);
+ });
+
+ it('should return initial values for customer with full data & 2 address', () => {
+ expect(getAddressesInitialValues(fakeCustomerwithFullData)).toEqual([
+ {
+ id: 'FZxT-eI3',
+ city: 'fd',
+ country: 'Aland Islands',
+ postalCode: '12345',
+ streetName: 'sd',
+ shippingStateChecked: true,
+ billingStateChecked: false,
+ defaultShippingAddress: true,
+ defaultBillingAddress: false
+ },
+ {
+ id: 'TeST-eI3',
+ city: 'test',
+ country: 'Aland Islands',
+ postalCode: 'df',
+ streetName: 'sdsfd',
+ shippingStateChecked: false,
+ billingStateChecked: true,
+ defaultBillingAddress: true,
+ defaultShippingAddress: false
+ }
+ ]);
+ });
+
+ it('should return initial values for customer with full data & 1 address', () => {
+ expect(getAddressesInitialValues(fakeCustomerWithOneAddressFullData)).toEqual([
+ {
+ id: 'FZxT-eI3',
+ city: 'fd',
+ country: 'Aland Islands',
+ postalCode: 'df',
+ streetName: 'sd',
+ shippingStateChecked: true,
+ billingStateChecked: true,
+ defaultShippingAddress: true,
+ defaultBillingAddress: true
+ }
+ ]);
+ });
+});
diff --git a/src/__tests__/navBar.tsx b/src/__tests__/navBar.tsx
new file mode 100644
index 0000000..10f91dd
--- /dev/null
+++ b/src/__tests__/navBar.tsx
@@ -0,0 +1,58 @@
+import { screen, render } from '@testing-library/react';
+import { PrivateNavGroup } from '../components/header/NavBar/PrivateNavGroup';
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
+
+export const renderPrivateNavGroup = (isLogged: boolean) =>
+ render(
+
+
+ {}}
+ isLogged={isLogged}
+ logout={() => {}}
+ />
+ }
+ />
+
+
+ );
+
+describe('PrivateNavGroup', () => {
+ it('displays login & registration link while logout state', () => {
+ renderPrivateNavGroup(false);
+
+ const LogInLink = screen.queryByTestId(/LoginOutlinedIcon/i);
+ expect(LogInLink).toBeInTheDocument();
+
+ const RegistrationLink = screen.queryByTestId(/HowToRegOutlinedIcon/i);
+ expect(RegistrationLink).toBeInTheDocument();
+ });
+
+ it('not display logout link while logout state', () => {
+ renderPrivateNavGroup(false);
+
+ const logout = screen.queryByTestId(/LogoutOutlinedIcon/i);
+ expect(logout).toBeFalsy();
+ });
+
+ it('does not display login & registration link while logIn state', () => {
+ renderPrivateNavGroup(true);
+
+ const LogInLink = screen.queryByTestId(/LoginOutlinedIcon/i);
+ expect(LogInLink).toBeFalsy();
+
+ const RegistrationLink = screen.queryByTestId(/HowToRegOutlinedIcon/i);
+ expect(RegistrationLink).toBeFalsy();
+ });
+
+ it('display logout link while logIn state', () => {
+ renderPrivateNavGroup(true);
+
+ const logout = screen.queryByTestId(/LogoutOutlinedIcon/i);
+ expect(logout).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/registrationFormRender.test.tsx b/src/__tests__/registrationFormRender.test.tsx
new file mode 100644
index 0000000..5fbcf47
--- /dev/null
+++ b/src/__tests__/registrationFormRender.test.tsx
@@ -0,0 +1,45 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import renderer from 'react-test-renderer';
+import { initialValuesRegistration } from '../components/forms/inputs/inputsData';
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { RegistrationForm } from '../components/forms/RegistrationForm/RegistrationForm';
+import { getValidationSchema } from '../components/forms/util/validationSchema';
+
+export const TestRegistrationForm = () => {
+ return (
+
+
+
+ }
+ />
+
+
+ );
+};
+
+describe('RegistrationForm renders correctly', () => {
+ // TODO: fix snapshot
+ it.skip('renders correctly', () => {
+ const component = renderer.create(TestRegistrationForm());
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('expands when billing address checkbox is unchecked', () => {
+ render(TestRegistrationForm());
+
+ expect(screen.queryByText(/Billing address:/i)).toBeFalsy();
+
+ fireEvent.click(screen.getByLabelText(/same as billing address/i));
+
+ expect(screen.getByText(/Billing address:/i)).toBeTruthy();
+ });
+});
diff --git a/src/__tests__/registrationFormSubmit.test.tsx b/src/__tests__/registrationFormSubmit.test.tsx
new file mode 100644
index 0000000..f47362e
--- /dev/null
+++ b/src/__tests__/registrationFormSubmit.test.tsx
@@ -0,0 +1,86 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { submitDataWithBilling } from '../components/forms/util/submitFakeData';
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { RegistrationForm } from '../components/forms/RegistrationForm/RegistrationForm';
+import { getValidationSchema } from '../components/forms/util/validationSchema';
+import type { FakeOnSubmit, InitialValuesRegistration } from '../utils/types';
+
+export const TestRegistrationForm = (
+ fakeOnSubmit: FakeOnSubmit,
+ initialValues: InitialValuesRegistration
+) => {
+ return (
+
+
+
+ }
+ />
+
+
+ );
+};
+
+describe('registrationForm submit correctly', () => {
+ it('submits with all correct fields', async () => {
+ const fakeOnSubmit = jest.fn();
+ render(TestRegistrationForm(fakeOnSubmit, submitDataWithBilling));
+
+ await waitFor(() => {
+ // eslint-disable-next-line testing-library/no-wait-for-side-effects
+ fireEvent.click(screen.getByText(/Create Account/i));
+ });
+
+ expect(fakeOnSubmit.mock.calls).toHaveLength(1);
+ expect(fakeOnSubmit.mock.calls[0][0]).not.toBe({
+ ...submitDataWithBilling,
+ firstName: 'John',
+ lastName: 'Doe'
+ });
+ expect(fakeOnSubmit.mock.calls[0][0]).toBe(submitDataWithBilling);
+ });
+
+ it('not submits with empty fields', async () => {
+ const fakeOnSubmit = jest.fn();
+ render(
+ TestRegistrationForm(fakeOnSubmit, {
+ ...submitDataWithBilling,
+ firstName: '',
+ lastName: ''
+ })
+ );
+
+ await waitFor(() => {
+ // eslint-disable-next-line testing-library/no-wait-for-side-effects
+ fireEvent.click(screen.getByText(/Create Account/i));
+ });
+
+ expect(fakeOnSubmit.mock.calls).toHaveLength(0);
+ });
+
+ it('no default address fields if checkbox unchecked', async () => {
+ const fakeOnSubmit = jest.fn();
+ render(TestRegistrationForm(fakeOnSubmit, submitDataWithBilling));
+ // defaultShippingAddress;
+ await waitFor(() => {
+ // eslint-disable-next-line testing-library/no-wait-for-side-effects
+ fireEvent.click(screen.getByText(/as default/i));
+ });
+
+ await waitFor(() => {
+ // eslint-disable-next-line testing-library/no-wait-for-side-effects
+ fireEvent.click(screen.getByText(/Create Account/i));
+ });
+
+ expect(fakeOnSubmit.mock.calls[0][0]).toStrictEqual({
+ ...submitDataWithBilling,
+ shippingStateChecked: false
+ });
+ });
+});
diff --git a/src/__tests__/registrationSubmitAdapter.test.ts b/src/__tests__/registrationSubmitAdapter.test.ts
new file mode 100644
index 0000000..b6e6f50
--- /dev/null
+++ b/src/__tests__/registrationSubmitAdapter.test.ts
@@ -0,0 +1,55 @@
+import { addressAdapter } from '../components/forms/util/addressDataAdapter';
+import type { HandleSubmitWithBoth } from '../utils/types';
+import { submitDataWithBilling } from '../components/forms/util/submitFakeData';
+
+describe('correct adapt submit data into request data', () => {
+ const adaptedSubmitData = addressAdapter(submitDataWithBilling);
+ const submitDataWithoutBilling: HandleSubmitWithBoth = {
+ ...submitDataWithBilling,
+ billingStreetName: ''
+ };
+ const adaptedSubmitDataWithoutBilling = addressAdapter(submitDataWithoutBilling);
+ const billingAddressIndex = adaptedSubmitData.billingAddresses[0];
+ const shippingAddressIndex = adaptedSubmitData.shippingAddresses[0];
+ const adaptedSubmitDatawithoutCheckedDefault = addressAdapter({
+ ...submitDataWithBilling,
+ shippingStateChecked: false,
+ billingStateChecked: false
+ });
+
+ it('add shipping address into addresses array', () => {
+ expect(adaptedSubmitData.shippingAddresses).toHaveLength(1);
+ expect(shippingAddressIndex).toBe(0);
+ expect(adaptedSubmitData.addresses[shippingAddressIndex].city).toBe('shippingCity');
+ });
+
+ it('ignore billing address, if empty billing fields', () => {
+ expect(adaptedSubmitDataWithoutBilling.addresses).toHaveLength(1);
+ expect(adaptedSubmitDataWithoutBilling.shippingAddresses).toHaveLength(1);
+ expect(adaptedSubmitDataWithoutBilling.addresses[0].city).toBe('shippingCity');
+ expect(adaptedSubmitDataWithoutBilling.billingAddresses[0] === shippingAddressIndex).toBe(true);
+ });
+
+ it('add billing address into addresses array', () => {
+ expect(adaptedSubmitData.addresses).toHaveLength(2);
+ expect(adaptedSubmitData.billingAddresses).toHaveLength(1);
+ expect(billingAddressIndex).toBe(1);
+ expect(adaptedSubmitData.addresses[billingAddressIndex].city).toBe('billingCity');
+ expect(adaptedSubmitData.addresses[billingAddressIndex].country).toBe('GR');
+ });
+
+ it('add default billing & shipping address, if checked', () => {
+ expect(adaptedSubmitData.defaultBillingAddress).toBe(billingAddressIndex);
+ expect(adaptedSubmitData.defaultShippingAddress).toBe(shippingAddressIndex);
+ });
+
+ it('no default shipping/billing addresses, if not checked', () => {
+ expect(adaptedSubmitDatawithoutCheckedDefault.defaultBillingAddress).toBeUndefined();
+ expect(adaptedSubmitDatawithoutCheckedDefault.defaultShippingAddress).toBeUndefined();
+ });
+
+ it('return correct other fields', () => {
+ expect(adaptedSubmitData.dateOfBirth).toBe('2002-01-01');
+ expect(adaptedSubmitData.firstName).toBe('firstName');
+ });
+});
diff --git a/src/__tests__/validationSchema.test.ts b/src/__tests__/validationSchema.test.ts
new file mode 100644
index 0000000..f770065
--- /dev/null
+++ b/src/__tests__/validationSchema.test.ts
@@ -0,0 +1,220 @@
+import { getValidationSchema } from '../components/forms/util/validationSchema';
+import { submitDataWithBilling, submitDataWithShipping } from '../components/forms/util/submitFakeData';
+
+describe('registrationSubmitValidation', () => {
+ const schemaWithShipping = getValidationSchema(true);
+ const schemaWithBoth = getValidationSchema(false);
+
+ it('correct yup schema', () => {
+ expect(schemaWithShipping.__isYupSchema__).toBe(true);
+ expect(schemaWithBoth.__isYupSchema__).toBe(true);
+ });
+
+ it('validate with shipping)', () => {
+ expect(schemaWithShipping.isValidSync(submitDataWithShipping)).toBe(true);
+ });
+
+ it('validate with both)', () => {
+ expect(schemaWithBoth.isValidSync(submitDataWithBilling)).toBe(true);
+ expect(schemaWithBoth.isValidSync(submitDataWithShipping)).toBe(false);
+ });
+
+ it('validate with shipping & billing', () => {
+ expect(schemaWithBoth.isValidSync(submitDataWithBilling)).toBe(true);
+ });
+
+ it('not validate incorrect firstName', () => {
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ firstName: ''
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ firstName: '1'
+ })
+ ).toBe(false);
+ });
+
+ it('not validate incorrect lastName', () => {
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ lastName: ''
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ lastName: '1'
+ })
+ ).toBe(false);
+ });
+
+ it('not validate incorrect password', () => {
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ password: ''
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ password: 'fd1@fS'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ password: 'fd@fSsgsg'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ password: 'fd@fs12gsg'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ password: 'fdDfs12gsg'
+ })
+ ).toBe(false);
+ });
+
+ it('not validate incorrect email', () => {
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ email: ''
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ email: 'fd1dsfS'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ email: 'fd1@fS'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ email: 'fd1@fS.'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ email: 'fd1@fS.d'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ email: 'fd1@fS.dfdff'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ email: '@fS.dds'
+ })
+ ).toBe(false);
+ });
+
+ it('not validate incorrect date', () => {
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ date: ''
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ date: '2012-01-01'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithBilling,
+ date: '21-11-2000'
+ })
+ ).toBe(false);
+ });
+
+ it('not validate incorrect country', () => {
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithShipping,
+ shippingCountry: ''
+ })
+ ).toBe(false);
+ expect(
+ schemaWithBoth.isValidSync({
+ ...submitDataWithBilling,
+ billingCountry: ''
+ })
+ ).toBe(false);
+ });
+
+ it('not validate incorrect city', () => {
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithShipping,
+ shippingCity: ''
+ })
+ ).toBe(false);
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithShipping,
+ shippingCity: '1'
+ })
+ ).toBe(false);
+ expect(
+ schemaWithBoth.isValidSync({
+ ...submitDataWithBilling,
+ billingCity: ''
+ })
+ ).toBe(false);
+ });
+
+ it('not validate incorrect postalCode', () => {
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithShipping,
+ shippingPostalCode: ''
+ })
+ ).toBe(false);
+ expect(
+ schemaWithBoth.isValidSync({
+ ...submitDataWithBilling,
+ billingPostalCode: ''
+ })
+ ).toBe(false);
+ });
+
+ it('not validate incorrect streetName', () => {
+ expect(
+ schemaWithShipping.isValidSync({
+ ...submitDataWithShipping,
+ shippingStreetName: ''
+ })
+ ).toBe(false);
+ expect(
+ schemaWithBoth.isValidSync({
+ ...submitDataWithBilling,
+ billingStreetName: ''
+ })
+ ).toBe(false);
+ });
+});
diff --git a/src/assets/404.webp b/src/assets/404.webp
new file mode 100644
index 0000000..04dc299
Binary files /dev/null and b/src/assets/404.webp differ
diff --git a/src/assets/burger-bender.webp b/src/assets/burger-bender.webp
new file mode 100644
index 0000000..08e4c87
Binary files /dev/null and b/src/assets/burger-bender.webp differ
diff --git a/src/assets/cart-bender.webp b/src/assets/cart-bender.webp
new file mode 100644
index 0000000..1ef703c
Binary files /dev/null and b/src/assets/cart-bender.webp differ
diff --git a/src/assets/footer-bender.webp b/src/assets/footer-bender.webp
new file mode 100644
index 0000000..46f5e8d
Binary files /dev/null and b/src/assets/footer-bender.webp differ
diff --git a/src/assets/index.d.ts b/src/assets/index.d.ts
new file mode 100644
index 0000000..efa5e0d
--- /dev/null
+++ b/src/assets/index.d.ts
@@ -0,0 +1,2 @@
+declare module '*.png';
+declare module '*.webp';
diff --git a/src/assets/logo.webp b/src/assets/logo.webp
new file mode 100644
index 0000000..155d5d3
Binary files /dev/null and b/src/assets/logo.webp differ
diff --git a/src/assets/main.webp b/src/assets/main.webp
new file mode 100644
index 0000000..eb31f37
Binary files /dev/null and b/src/assets/main.webp differ
diff --git a/src/assets/no-view.svg b/src/assets/no-view.svg
new file mode 100644
index 0000000..6e78dce
--- /dev/null
+++ b/src/assets/no-view.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/view.svg b/src/assets/view.svg
new file mode 100644
index 0000000..f8e4e19
--- /dev/null
+++ b/src/assets/view.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/AnonymOutlet.tsx b/src/components/AnonymOutlet.tsx
new file mode 100644
index 0000000..14c8b04
--- /dev/null
+++ b/src/components/AnonymOutlet.tsx
@@ -0,0 +1,10 @@
+import { NavRoutes } from '../utils/routes';
+import { Navigate } from 'react-router-dom';
+import type { OutletProps, RootState } from '../utils/types';
+import { useSelector } from 'react-redux';
+
+export const AnonymOutlet = ({ children }: OutletProps) => {
+ const isLogged = useSelector((state: { authData: RootState }) => state.authData.isLogged);
+
+ return isLogged ? children : ;
+};
diff --git a/src/components/App.tsx b/src/components/App.tsx
new file mode 100644
index 0000000..919ce46
--- /dev/null
+++ b/src/components/App.tsx
@@ -0,0 +1,38 @@
+import { Routes, Route, BrowserRouter as Router } from 'react-router-dom';
+import { routesConfig } from '../utils/routesConfig';
+import { Header } from './header/header';
+import { createTheme, ThemeProvider } from '@mui/material/styles';
+import { Footer } from './footer/Footer';
+
+const theme = createTheme({
+ palette: {
+ secondary: {
+ main: '#DB4444'
+ },
+ primary: {
+ main: '#000000'
+ }
+ }
+});
+
+export const App = () => {
+ return (
+
+
+
+
+
+ {routesConfig.map((route, index) => (
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/src/components/CategoriesProvider.tsx b/src/components/CategoriesProvider.tsx
new file mode 100644
index 0000000..323816c
--- /dev/null
+++ b/src/components/CategoriesProvider.tsx
@@ -0,0 +1,22 @@
+import { CategoriesContext } from '../contexts';
+import { useState, useMemo, type ReactNode } from 'react';
+import type { CategoriesContextInterface } from '../utils/types';
+
+export const CategoriesProveder = ({ children }: { children: ReactNode }) => {
+ const [categoryId, setCategoryId] = useState('');
+ const [categoryName, setCategoryName] = useState('');
+ const [currentFilter, setCurrentFilter] = useState([]);
+ const category: CategoriesContextInterface = useMemo(
+ () => ({
+ categoryId,
+ categoryName,
+ setCategoryId,
+ setCategoryName,
+ currentFilter,
+ setCurrentFilter
+ }),
+ [categoryId, currentFilter, categoryName, setCategoryId, setCategoryName, setCurrentFilter]
+ );
+
+ return {children} ;
+};
diff --git a/src/components/PrivateOutlet.tsx b/src/components/PrivateOutlet.tsx
new file mode 100644
index 0000000..91429b4
--- /dev/null
+++ b/src/components/PrivateOutlet.tsx
@@ -0,0 +1,10 @@
+import { NavRoutes } from '../utils/routes';
+import { Navigate } from 'react-router-dom';
+import type { OutletProps, RootState } from '../utils/types';
+import { useSelector } from 'react-redux';
+
+export const PrivateOutlet = ({ children }: OutletProps) => {
+ const isLogged = useSelector((state: { authData: RootState }) => state.authData.isLogged);
+
+ return isLogged ? : children;
+};
diff --git a/src/components/ProgressBar/ProgressBar.tsx b/src/components/ProgressBar/ProgressBar.tsx
new file mode 100644
index 0000000..6bf8c24
--- /dev/null
+++ b/src/components/ProgressBar/ProgressBar.tsx
@@ -0,0 +1,12 @@
+import CircularProgress from '@mui/material/CircularProgress';
+
+export const ProgressBar = () => {
+ return (
+
+
+
+ );
+};
diff --git a/src/components/ProgressBar/SkeletonCard.tsx b/src/components/ProgressBar/SkeletonCard.tsx
new file mode 100644
index 0000000..c3c497a
--- /dev/null
+++ b/src/components/ProgressBar/SkeletonCard.tsx
@@ -0,0 +1,43 @@
+import { Skeleton, Stack } from '@mui/material';
+
+export const SkeletonCard = () => (
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/src/components/breadcrumbsNav/BreadcrumbsNav.tsx b/src/components/breadcrumbsNav/BreadcrumbsNav.tsx
new file mode 100644
index 0000000..ee828c9
--- /dev/null
+++ b/src/components/breadcrumbsNav/BreadcrumbsNav.tsx
@@ -0,0 +1,29 @@
+import { Breadcrumbs } from '@mui/material';
+import { Link } from 'react-router-dom';
+import { useCategoryContext } from '../../hooks/useCategoryContext';
+import { NavRoutes } from '../../utils/routes';
+
+export const BreadcrumbsNav = ({ children }: { children: string | undefined }) => {
+ const { categoryName, setCategoryName, setCategoryId, setCurrentFilter } = useCategoryContext();
+ const isCategorySelected = Boolean(categoryName);
+
+ return (
+
+ {
+ setCategoryName('');
+ setCategoryId('');
+ setCurrentFilter([]);
+ }}
+ >
+ {children}
+
+ {isCategorySelected ? categoryName : ''}
+
+ );
+};
diff --git a/src/components/buttons/ArrowButtonsGroup.tsx b/src/components/buttons/ArrowButtonsGroup.tsx
new file mode 100644
index 0000000..fa9b52a
--- /dev/null
+++ b/src/components/buttons/ArrowButtonsGroup.tsx
@@ -0,0 +1,25 @@
+import { IconButton, Stack } from '@mui/material';
+import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
+import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
+
+export const ArrowButtonGroup = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/buttons/SubmitBtn.tsx b/src/components/buttons/SubmitBtn.tsx
new file mode 100644
index 0000000..a54b235
--- /dev/null
+++ b/src/components/buttons/SubmitBtn.tsx
@@ -0,0 +1,16 @@
+export interface SubmitBtnProps {
+ title: string;
+}
+
+export const SubmitBtn = ({ title }: SubmitBtnProps) => {
+ return (
+ <>
+
+ {title}
+
+ >
+ );
+};
diff --git a/src/components/cards/categoryCard/CategoryCard.tsx b/src/components/cards/categoryCard/CategoryCard.tsx
new file mode 100644
index 0000000..07584a5
--- /dev/null
+++ b/src/components/cards/categoryCard/CategoryCard.tsx
@@ -0,0 +1,31 @@
+import delivery from './assets/delivery.png';
+import droid from './assets/droid.png';
+import farming from './assets/farming.png';
+import kitchen from './assets/kitchen.png';
+import pet from './assets/pet.png';
+import vacuum from './assets/vacuum.png';
+import type { Mapping } from '../../../utils/types';
+
+export const CategoryCard = ({ category, callback }: { category: string; callback: () => void }) => {
+ const mapping: Mapping = {
+ Companions: droid,
+ Cleaners: vacuum,
+ Pets: pet,
+ Kitchens: kitchen,
+ Gardens: farming,
+ Deliveries: delivery
+ };
+ return (
+
+
+ {category}
+
+ );
+};
diff --git a/src/components/cards/categoryCard/assets/delivery.png b/src/components/cards/categoryCard/assets/delivery.png
new file mode 100644
index 0000000..5a334dc
Binary files /dev/null and b/src/components/cards/categoryCard/assets/delivery.png differ
diff --git a/src/components/cards/categoryCard/assets/droid.png b/src/components/cards/categoryCard/assets/droid.png
new file mode 100644
index 0000000..951f309
Binary files /dev/null and b/src/components/cards/categoryCard/assets/droid.png differ
diff --git a/src/components/cards/categoryCard/assets/farming.png b/src/components/cards/categoryCard/assets/farming.png
new file mode 100644
index 0000000..6ed0a12
Binary files /dev/null and b/src/components/cards/categoryCard/assets/farming.png differ
diff --git a/src/components/cards/categoryCard/assets/kitchen.png b/src/components/cards/categoryCard/assets/kitchen.png
new file mode 100644
index 0000000..b6dd159
Binary files /dev/null and b/src/components/cards/categoryCard/assets/kitchen.png differ
diff --git a/src/components/cards/categoryCard/assets/pet.png b/src/components/cards/categoryCard/assets/pet.png
new file mode 100644
index 0000000..24cee40
Binary files /dev/null and b/src/components/cards/categoryCard/assets/pet.png differ
diff --git a/src/components/cards/categoryCard/assets/vacuum.png b/src/components/cards/categoryCard/assets/vacuum.png
new file mode 100644
index 0000000..79362bc
Binary files /dev/null and b/src/components/cards/categoryCard/assets/vacuum.png differ
diff --git a/src/components/cards/productCard/PriceTag.tsx b/src/components/cards/productCard/PriceTag.tsx
new file mode 100644
index 0000000..aaf3318
--- /dev/null
+++ b/src/components/cards/productCard/PriceTag.tsx
@@ -0,0 +1,53 @@
+import { Typography } from '@mui/material';
+import type { PriceTagInterface } from '../../../utils/types';
+import DiscountOutlinedIcon from '@mui/icons-material/DiscountOutlined';
+
+export const PriceTag = ({ price, discount }: PriceTagInterface) => {
+ const hasDiscount = discount > 0;
+ const discountColor = '#DB4444';
+ const priceColor = '#000';
+ const width = hasDiscount ? '20px' : 0;
+
+ return (
+ // prettier-ignore
+ // prettier-config have a conflicts with eslint-config
+ <>
+
+ {hasDiscount
+ ? (
+
+
+ โฌ{discount}
+
+
+
+
+ โฌ{price}
+
+
+
)
+ : (
+
+ โฌ{price}
+ )}
+ >
+ );
+};
diff --git a/src/components/cards/productCard/ProductCard.tsx b/src/components/cards/productCard/ProductCard.tsx
new file mode 100644
index 0000000..a99831b
--- /dev/null
+++ b/src/components/cards/productCard/ProductCard.tsx
@@ -0,0 +1,200 @@
+import { Typography, CircularProgress, CardMedia, CardContent, Card, Rating } from '@mui/material';
+import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined';
+import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
+import type { ProductCardInterface } from '../../../utils/types';
+import { PriceTag } from './PriceTag';
+import { useNavigate } from 'react-router-dom';
+import { useEffect, useState } from 'react';
+import { LoadingButton } from '@mui/lab';
+import { useCart } from '../../../hooks/useCart';
+
+export const ProductCard = ({
+ imageUrl,
+ title,
+ titleName,
+ description,
+ price,
+ discount,
+ id,
+ rating
+}: ProductCardInterface) => {
+ const navigate = useNavigate();
+ const [isHovered, setIsHovered] = useState(false);
+ const [isSubmitting, setSubmitting] = useState(false);
+ const [isFulfilled, setFulfiled] = useState(false);
+
+ const { addToCart, cart } = useCart();
+
+ const handleCardClick = (id: string): void => {
+ navigate({ pathname: `/product/${id}` });
+ };
+
+ useEffect(() => {
+ if (cart !== null) {
+ const { lineItems } = cart;
+ const isAdded = Boolean(lineItems.find((item) => item.productId === id));
+ setFulfiled(isAdded);
+ }
+ }, [cart, id]);
+
+ const CartIcon = ({ predicate }: { predicate: boolean }) => {
+ // prettier-ignore
+ return predicate
+ ? (
+ <>
+
+ To Cart
+
+
+ >)
+ : (
+ );
+ };
+
+ const handleMouseEnter = () => {
+ setIsHovered(true);
+ };
+
+ const handleMouseLeave = () => {
+ setIsHovered(false);
+ };
+
+ const handleAddClick = async () => {
+ setSubmitting(true);
+ isFulfilled && navigate({ pathname: '/cart' });
+ const productId = id;
+
+ if (cart !== null && !isFulfilled) {
+ const { id, version } = cart;
+ try {
+ await addToCart({ cartId: id, cartVersion: version, productId });
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ setSubmitting(false);
+ setFulfiled(true);
+ };
+
+ return (
+
+ {
+ handleCardClick(id);
+ }}
+ />
+
+ {
+ handleCardClick(id);
+ }}
+ >
+ {titleName}
+
+
+ {description}
+ {
+ handleCardClick(id);
+ }}
+ >
+ ...more
+
+
+
+
+
+
+
+ {
+ handleAddClick().catch((error) => {
+ console.error(error);
+ });
+ }}
+ >
+ {
+ // prettier-ignore
+ isSubmitting
+ ? (
+ )
+ : (
+ )
+ }
+
+
+ );
+};
diff --git a/src/components/cart/emptyCart.tsx b/src/components/cart/emptyCart.tsx
new file mode 100644
index 0000000..5535dbf
--- /dev/null
+++ b/src/components/cart/emptyCart.tsx
@@ -0,0 +1,29 @@
+import { Button } from '@mui/material';
+import { Link } from 'react-router-dom';
+import { NavRoutes } from '../../utils/routes';
+import bender from '../../assets/cart-bender.webp';
+
+export const EmptyCart = () => {
+ return (
+
+
+
Cart is empty...
+
+
+ Catalog
+
+
+
+ );
+};
diff --git a/src/components/categories/Categories.tsx b/src/components/categories/Categories.tsx
new file mode 100644
index 0000000..036fc6f
--- /dev/null
+++ b/src/components/categories/Categories.tsx
@@ -0,0 +1,52 @@
+import { useDispatch, useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import type { Mapping, RootState } from '../../utils/types';
+import { useEffect } from 'react';
+import { fetchCategories } from '../../pages/catalogPage/utils/fetchCategories';
+import { setCategoriesData } from '../../slices/categoriesSlice';
+import { NavRoutes } from '../../utils/routes';
+import { CategoryCard } from '../cards/categoryCard/CategoryCard';
+
+export const Categories = () => {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ const categories = useSelector((state: { categoriesData: RootState }) => state.categoriesData.categories);
+
+ useEffect(() => {
+ const updateData = async (): Promise => {
+ const catecoriesData = await fetchCategories();
+ dispatch(setCategoriesData(catecoriesData));
+ };
+
+ updateData().catch((error) => {
+ throw error;
+ });
+ }, [dispatch]);
+
+ const routes: Mapping = {
+ Companions: NavRoutes.companionsPagePath,
+ Cleaners: NavRoutes.cleanersPagePath,
+ Pets: NavRoutes.petsPagePath,
+ Kitchens: NavRoutes.kitchensPagePath,
+ Gardens: NavRoutes.gardensPagePath,
+ Deliveries: NavRoutes.deliveriesPagePath
+ };
+
+ return (
+
+ {categories.map((category) => {
+ const { name, id } = category;
+ return (
+ {
+ navigate({ pathname: routes[name] });
+ }}
+ />
+ );
+ })}
+
+ );
+};
diff --git a/src/components/container/Container.tsx b/src/components/container/Container.tsx
new file mode 100644
index 0000000..90b776b
--- /dev/null
+++ b/src/components/container/Container.tsx
@@ -0,0 +1,17 @@
+import type { ContainerProps } from '../../utils/types';
+import { SearchInput } from '../searchInput/SearchInput';
+import { BreadcrumbsNav } from '../breadcrumbsNav/BreadcrumbsNav';
+
+export const Container = ({ titleName }: ContainerProps) => {
+ return (
+
+ );
+};
diff --git a/src/components/drawer/Drawer.tsx b/src/components/drawer/Drawer.tsx
new file mode 100644
index 0000000..471e6a7
--- /dev/null
+++ b/src/components/drawer/Drawer.tsx
@@ -0,0 +1,59 @@
+import * as React from 'react';
+import Box from '@mui/material/Box';
+import Drawer from '@mui/material/Drawer';
+import Button from '@mui/material/Button';
+
+type Anchor = 'top' | 'left' | 'bottom' | 'right';
+
+export const TemporaryDrawer = ({ children }: { children: React.ReactNode }) => {
+ const [state, setState] = React.useState({
+ top: false,
+ left: false,
+ bottom: false,
+ right: false
+ });
+
+ const toggleDrawer = (anchor: Anchor, open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
+ if (
+ event.type === 'keydown' &&
+ ((event as React.KeyboardEvent).key === 'Tab' || (event as React.KeyboardEvent).key === 'Shift')
+ ) {
+ return;
+ }
+
+ setState({ ...state, [anchor]: open });
+ };
+
+ const list = (anchor: Anchor, children: React.ReactNode | React.ReactNode[]) => (
+ {
+ if (event.currentTarget === event.target) {
+ toggleDrawer(anchor, false);
+ }
+ }}
+ onKeyDown={toggleDrawer(anchor, false)}
+ >
+ {children}
+
+ );
+
+ return (
+ <>
+
+ Filter
+
+
+ {list('left', children)}
+
+ >
+ );
+};
diff --git a/src/components/filter/CustomTextfield.tsx b/src/components/filter/CustomTextfield.tsx
new file mode 100644
index 0000000..a9727cc
--- /dev/null
+++ b/src/components/filter/CustomTextfield.tsx
@@ -0,0 +1,46 @@
+import { TextField } from '@mui/material';
+import { type ChangeEvent } from 'react';
+
+export const CustomTextField = ({
+ value,
+ currentValue,
+ initValue,
+ label,
+ setSliderValue
+}: {
+ value: string;
+ currentValue: number;
+ initValue: string;
+ label: string;
+ setSliderValue: (arr: number[]) => void;
+}) => {
+ return (
+ {
+ const exceptions = ['e', 'E', '+', '-'];
+ exceptions.includes(e.key) && e.preventDefault();
+ }}
+ onChange={(e: ChangeEvent): void => {
+ const { value } = e.target;
+ switch (initValue) {
+ case 'startValue':
+ setSliderValue([Number(value), currentValue]);
+ break;
+ case 'endValue':
+ setSliderValue([currentValue, Number(value)]);
+ break;
+ default:
+ break;
+ }
+ }}
+ />
+ );
+};
diff --git a/src/components/filter/FilterBar.tsx b/src/components/filter/FilterBar.tsx
new file mode 100644
index 0000000..559bde9
--- /dev/null
+++ b/src/components/filter/FilterBar.tsx
@@ -0,0 +1,272 @@
+import '../../i18n';
+import { useTranslation } from 'react-i18next';
+
+import { CheckboxWithLabel } from 'formik-material-ui';
+import { Typography, Button, Stack, TextField } from '@mui/material';
+
+import type { AttributesList, RootState, SelectedAttribute } from '../../utils/types';
+
+import { Formik, Field } from 'formik';
+
+import { useDispatch, useSelector } from 'react-redux';
+import { useEffect, useState, useMemo, type ChangeEvent } from 'react';
+
+import { useCategoryContext } from '../../hooks/useCategoryContext';
+import { updateProductsData } from '../../pages/catalogPage/utils/updateData';
+import { normalizeData } from '../../pages/catalogPage/utils/normalizeData';
+import { PrettoSlider } from './utils/PrettoSlider';
+import { getAttributesList } from './utils/getAttributesList';
+import { handleCheckboxChange } from './utils/handleCheckboxChange';
+import { handleSliderChange } from './utils/handleSliderChange';
+import { buildQueryString } from './utils/buildQueryString';
+import { handleKeyDawn } from './utils/handleKeydown';
+import { fetchFilteredProducts } from '../../helpers/api/apiRoot';
+import { getExtremums } from '../../pages/catalogPage/utils/getExtremums';
+
+export const FilterBar = () => {
+ const { t } = useTranslation();
+
+ const dispatch = useDispatch();
+
+ const { categoryId, setCurrentFilter } = useCategoryContext();
+ const { cardsData } = useSelector((state: { productsData: RootState }) => state.productsData);
+
+ const [prouctsCount, setCount] = useState('');
+ const [sliderValues, setSliderValues] = useState([0, 0]);
+ const [rawValues, setRawValues] = useState([0, 0]);
+ const [extremums, setExtremums] = useState([0, 0]);
+ const [attributesList, setAttributesLis] = useState([]);
+ const [isUpdatedValues, setUpdated] = useState(false);
+ const [inputStart, setInputStart] = useState('');
+ const [inputEnd, setInputEnd] = useState('');
+ const [selectedAttributes, setSelectedAttributes] = useState([
+ { name: '', value: '' }
+ ]);
+
+ const [startValue, endValue] = extremums;
+ const [minValue, maxValue] = sliderValues;
+ const [currentMinValue, currentMaxValue] = rawValues;
+
+ const filter = useMemo(() => {
+ const priceFilter = `variants.price.centAmount:range(${minValue * 100} to ${maxValue * 100})`;
+ const attributesFilter = buildQueryString(selectedAttributes);
+ const catecoryFilter = categoryId.length > 0 ? `categories.id:"${categoryId}"` : '';
+ return [catecoryFilter, priceFilter, ...attributesFilter];
+ }, [minValue, maxValue, categoryId, selectedAttributes]);
+
+ useEffect(() => {
+ const updateExtremums = async () => {
+ const catecoryFilter = categoryId.length > 0 ? `categories.id:"${categoryId}"` : undefined;
+ const data = await fetchFilteredProducts({ filter: catecoryFilter });
+ const extremums = getExtremums(data);
+ const normalizedData = normalizeData(data);
+
+ setAttributesLis(getAttributesList(normalizedData));
+ setExtremums(extremums);
+
+ if (!isUpdatedValues) {
+ setCount('');
+ setRawValues([startValue, endValue]);
+ }
+ };
+
+ updateExtremums().catch((error) => {
+ throw error;
+ });
+ }, [categoryId, startValue, endValue, isUpdatedValues]);
+
+ useEffect(() => {
+ const updateTotal = async () => {
+ const productsData = await fetchFilteredProducts({ filter });
+ if (isUpdatedValues) {
+ setCount(String(productsData.total));
+ }
+ };
+
+ if (cardsData.length > 0) {
+ updateTotal().catch((error) => {
+ throw error;
+ });
+ }
+ }, [isUpdatedValues, selectedAttributes, filter, cardsData.length, sliderValues]);
+
+ useEffect(() => {
+ setUpdated(false);
+ }, [categoryId]);
+
+ useEffect(() => {
+ setSelectedAttributes([]);
+ }, [categoryId]);
+
+ return (
+ => {
+ setSubmitting(false);
+ setCurrentFilter(filter);
+
+ const data = await fetchFilteredProducts({ filter, limit: 8 });
+ updateProductsData(dispatch, data);
+ setCount(String(data.total));
+
+ const extremumsData = await fetchFilteredProducts({ filter });
+ const extremums = getExtremums(extremumsData);
+ const [start, end] = extremums;
+
+ setRawValues([start, end]);
+ }}
+ >
+ {({ submitForm, handleChange, resetForm, values }) => (
+
+
+
+ Filter
+
+
+ ): void => {
+ const { value } = e.target;
+ setSliderValues([Number(value), endValue]);
+ setInputStart(value);
+ }}
+ />
+ ): void => {
+ e.preventDefault();
+ const { value } = e.target;
+ setUpdated(true);
+ setInputEnd(value);
+ setRawValues([startValue, Number(value)]);
+ setSliderValues([startValue, Number(value)]);
+ }}
+ />
+ {
+ handleSliderChange(event, newValue, activeThumb, rawValues, setRawValues);
+ setUpdated(true);
+ }}
+ onChangeCommitted={() => {
+ setSliderValues(rawValues);
+ }}
+ valueLabelDisplay="auto"
+ aria-labelledby="discrete-slider-restrict"
+ min={startValue}
+ max={endValue}
+ disableSwap
+ />
+ {
+ setInputStart('');
+ setInputEnd('');
+ resetForm();
+ setSelectedAttributes([]);
+ setRawValues(extremums);
+ setSliderValues([startValue, endValue]);
+ setUpdated(false);
+ submitForm().catch((error) => {
+ throw error;
+ });
+ }}
+ >
+ {t('button.reset')}
+
+
+ {attributesList.map(({ name, attributes }, i) => (
+
+
+ {t(`attributes.${name}.title`)}
+
+
+ {attributes.map((attr, index) => {
+ return (
+ {
+ return item.name === name && item.value === String(attr);
+ })}
+ component={CheckboxWithLabel}
+ name="attributes"
+ color="secondary"
+ key={`${String(attr)}-${index}`}
+ value={String(attr)}
+ Label={{ label: t(`attributes.${name}.${String(attr)}`) }}
+ onChange={(e: ChangeEvent) => {
+ handleChange(e);
+ setUpdated(true);
+ setSliderValues([startValue, endValue]);
+ handleCheckboxChange(
+ { name: `${name}`, value: `${String(attr)}` },
+ selectedAttributes,
+ setSelectedAttributes
+ );
+ }}
+ />
+ );
+ })}
+
+
+ ))}
+ {
+ submitForm().catch((error) => {
+ throw error;
+ });
+ }}
+ >
+ {isUpdatedValues && prouctsCount.length > 0 ? `show (${prouctsCount})` : 'set filter'}
+
+
+ )}
+
+ );
+};
diff --git a/src/components/filter/utils/PrettoSlider.ts b/src/components/filter/utils/PrettoSlider.ts
new file mode 100644
index 0000000..a56b917
--- /dev/null
+++ b/src/components/filter/utils/PrettoSlider.ts
@@ -0,0 +1,41 @@
+import styled from '@emotion/styled';
+import { Slider } from '@mui/material';
+
+export const PrettoSlider = styled(Slider)({
+ color: '#DB4444',
+ gridColumn: '1 / span 2',
+ height: 8,
+ '& .MuiSlider-track': {
+ border: 'none'
+ },
+ '& .MuiSlider-thumb': {
+ height: 24,
+ width: 24,
+ backgroundColor: '#000',
+ '&:focus, &:hover, &.Mui-active, &.Mui-focusVisible': {
+ boxShadow: 'inherit'
+ },
+ '&:before': {
+ display: 'none'
+ }
+ },
+ '& .MuiSlider-valueLabel': {
+ lineHeight: 1.2,
+ fontSize: 12,
+ background: 'unset',
+ padding: 0,
+ width: 32,
+ height: 32,
+ borderRadius: '50% 50% 50% 0',
+ backgroundColor: '#DB4444',
+ transformOrigin: 'bottom left',
+ transform: 'translate(50%, -100%) rotate(-45deg) scale(0)',
+ '&:before': { display: 'none' },
+ '&.MuiSlider-valueLabelOpen': {
+ transform: 'translate(50%, -100%) rotate(-45deg) scale(1)'
+ },
+ '& > *': {
+ transform: 'rotate(45deg)'
+ }
+ }
+});
diff --git a/src/components/filter/utils/buildQueryString.ts b/src/components/filter/utils/buildQueryString.ts
new file mode 100644
index 0000000..9513ec6
--- /dev/null
+++ b/src/components/filter/utils/buildQueryString.ts
@@ -0,0 +1,18 @@
+import _ from 'lodash';
+import type { SelectedAttribute } from '../../../utils/types';
+
+export const buildQueryString = (selectedAttributes: SelectedAttribute[]): string[] => {
+ const obj = _.groupBy(selectedAttributes, 'name');
+ const names = _.keys(obj);
+ const attrNames = names.map((item) => {
+ const values = obj[item].flatMap(({ name, value }) => {
+ return name === 'rating' ? `(${Number(value) - 1} to *)` : `"${value}"`;
+ });
+ const prefix = item === 'rating' ? 'range' : '';
+ const result = item.length > 0 ? `variants.attributes.${item}:${prefix}${values.join(',')}` : '';
+
+ return result;
+ });
+
+ return _.compact(attrNames);
+};
diff --git a/src/components/filter/utils/getAttributesList.ts b/src/components/filter/utils/getAttributesList.ts
new file mode 100644
index 0000000..d2efcba
--- /dev/null
+++ b/src/components/filter/utils/getAttributesList.ts
@@ -0,0 +1,25 @@
+import type { AttributesList, ProductsDataInterface } from '../../../utils/types';
+import _ from 'lodash';
+
+export const getAttributesList = (productsData: ProductsDataInterface[]): AttributesList[] => {
+ const attributesCollection = _.flatMap(productsData, 'attributes');
+
+ const uniqueKeys = _.chain(attributesCollection).map('name').uniq().sort().value();
+
+ const attributesList = _.map(uniqueKeys, (key) => {
+ const values = _.chain(attributesCollection).filter({ name: key }).flatMap('value').uniq().sort().value();
+ // prettier-ignore
+ const formattedValues =
+ key === 'rating'
+ ? _.chain(values)
+ .map(Math.ceil)
+ .uniq()
+ .sort((a, b) => Number(b) - Number(a))
+ .value()
+ : values;
+
+ return { name: key, attributes: formattedValues };
+ });
+
+ return attributesList;
+};
diff --git a/src/components/filter/utils/handleCheckboxChange.ts b/src/components/filter/utils/handleCheckboxChange.ts
new file mode 100644
index 0000000..ce5104b
--- /dev/null
+++ b/src/components/filter/utils/handleCheckboxChange.ts
@@ -0,0 +1,18 @@
+import type { Attribute } from '@commercetools/platform-sdk';
+import type { SelectedAttribute } from '../../../utils/types';
+
+export const handleCheckboxChange = (
+ attribute: Attribute,
+ selectedAttributes: SelectedAttribute[],
+ setSelectedAttributes: (attr: SelectedAttribute[]) => void
+) => {
+ const isSelected = selectedAttributes.some(
+ (attr) => attr.name === attribute.name && attr.value === attribute.value
+ );
+
+ const updatedAttributes = isSelected
+ ? selectedAttributes.filter((attr) => !(attr.name === attribute.name && attr.value === attribute.value))
+ : [...selectedAttributes, attribute];
+
+ setSelectedAttributes(updatedAttributes);
+};
diff --git a/src/components/filter/utils/handleKeydown.ts b/src/components/filter/utils/handleKeydown.ts
new file mode 100644
index 0000000..333c89f
--- /dev/null
+++ b/src/components/filter/utils/handleKeydown.ts
@@ -0,0 +1,6 @@
+import type { KeyboardEvent } from 'react';
+
+export const handleKeyDawn = (event: KeyboardEvent): void => {
+ const exceptions = ['e', 'E', '+', '-'];
+ exceptions.includes(event.key) && event.preventDefault();
+};
diff --git a/src/components/filter/utils/handleSliderChange.ts b/src/components/filter/utils/handleSliderChange.ts
new file mode 100644
index 0000000..b16b3ef
--- /dev/null
+++ b/src/components/filter/utils/handleSliderChange.ts
@@ -0,0 +1,15 @@
+export const handleSliderChange = (
+ event: Event,
+ newValue: number | number[],
+ activeThumb: number,
+ rawValues: number[],
+ setRawValuew: (values: number[]) => void
+) => {
+ event.preventDefault();
+ const [leftValue, rightValue] = Array.isArray(newValue) ? newValue : [newValue, newValue];
+
+ setRawValuew([
+ activeThumb === 0 ? Math.min(leftValue, rawValues[1]) : rawValues[0],
+ activeThumb === 1 ? Math.max(rightValue, rawValues[0]) : rawValues[1]
+ ]);
+};
diff --git a/src/components/footer/Footer.tsx b/src/components/footer/Footer.tsx
new file mode 100644
index 0000000..5d6bbee
--- /dev/null
+++ b/src/components/footer/Footer.tsx
@@ -0,0 +1,66 @@
+import { Link } from 'react-router-dom';
+import { NavGroup } from '../header/NavBar/NavGroup';
+import { Logo } from '../logo/logo';
+import { useSelector } from 'react-redux';
+import { type RootState } from '../../utils/types';
+import { links } from '../../utils/links';
+import { team } from '../../pages/AboutPage';
+
+export const Footer = () => {
+ const isLogged = useSelector((state: { authData: RootState }) => state.authData.isLogged);
+ const linksData = isLogged ? [links.cart, links.profile] : [links.cart, links.login, links.registration];
+
+ return (
+
+ );
+};
diff --git a/src/components/forms/LoginForm/LoginForm.tsx b/src/components/forms/LoginForm/LoginForm.tsx
new file mode 100644
index 0000000..a31ea09
--- /dev/null
+++ b/src/components/forms/LoginForm/LoginForm.tsx
@@ -0,0 +1,57 @@
+import { Form, Formik } from 'formik';
+import { PasswordInput } from '../inputs/PasswordInput';
+import { SubmitBtn } from '../../buttons/SubmitBtn';
+import { validationsSchemaLogin } from '../util/validationSchema';
+import * as yup from 'yup';
+import { links } from '../../../utils/links';
+import { Link } from 'react-router-dom';
+import { useAuth } from '../../../hooks/useAuth';
+import { Input } from '../inputs/Input';
+import { inputsData } from '../inputs/inputsData';
+
+export const LoginForm = () => {
+ const { login } = useAuth();
+ const { email } = inputsData;
+
+ return (
+
+ {(formik) => {
+ const { name, placeholder, type } = email;
+ const { path } = links.registration;
+ return (
+
+ );
+ }}
+
+ );
+};
diff --git a/src/components/forms/RegistrationForm/RegistrationForm.tsx b/src/components/forms/RegistrationForm/RegistrationForm.tsx
new file mode 100644
index 0000000..effe321
--- /dev/null
+++ b/src/components/forms/RegistrationForm/RegistrationForm.tsx
@@ -0,0 +1,83 @@
+import { Form, Formik } from 'formik';
+import { PasswordInput } from '../inputs/PasswordInput';
+import { SubmitBtn } from '../../buttons/SubmitBtn';
+import { AddressFieldSet } from '../inputs/AddressFieldSet';
+import { Link } from 'react-router-dom';
+import { links } from '../../../utils/links';
+import { useState } from 'react';
+import { FieldSetName, inputsData } from '../inputs/inputsData';
+import { Input } from '../inputs/Input';
+import type { RegistrationFormProps } from '../../../utils/types';
+import Checkbox from '@mui/material/Checkbox';
+import FormControlLabel from '@mui/material/FormControlLabel';
+
+export const RegistrationForm = ({ initialValues, getValidationSchema, onSubmit }: RegistrationFormProps) => {
+ const [isSameAddress, setIsSameAddress] = useState(true);
+ const handleCheckboxChange = () => {
+ setIsSameAddress(!isSameAddress);
+ };
+ const { firstName, lastName, date, email } = inputsData;
+
+ return (
+
+ {(formik) => {
+ return (
+
+ );
+ }}
+
+ );
+};
diff --git a/src/components/forms/customerProfile/CustomerPageForm.tsx b/src/components/forms/customerProfile/CustomerPageForm.tsx
new file mode 100644
index 0000000..691911b
--- /dev/null
+++ b/src/components/forms/customerProfile/CustomerPageForm.tsx
@@ -0,0 +1,93 @@
+import { Formik, Form } from 'formik';
+import { useState } from 'react';
+import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
+import IconButton from '@mui/material/IconButton';
+import DeleteIcon from '@mui/icons-material/Delete';
+import type { AddressesInitialValues, CustomerPageFormProps } from '../../../utils/types';
+import { Button } from '@mui/material';
+
+export const CustomerPageForm = ({
+ initialValues,
+ formInner,
+ onSubmit,
+ validationSchema,
+ addressExtraControls,
+ isEditable,
+ unsetNewForm,
+ onDelete
+}: CustomerPageFormProps) => {
+ const [editable, setEditable] = useState(isEditable ?? false);
+
+ return (
+
+ {(formik) => (
+
+ )}
+
+ );
+};
diff --git a/src/components/forms/customerProfile/customerFormData/Address.tsx b/src/components/forms/customerProfile/customerFormData/Address.tsx
new file mode 100644
index 0000000..9391fec
--- /dev/null
+++ b/src/components/forms/customerProfile/customerFormData/Address.tsx
@@ -0,0 +1,173 @@
+import type {
+ AddressComponentProps,
+ AddressExtraControls,
+ FormInnerComponent
+} from '../../../../utils/types';
+import { CustomerPageForm } from '../CustomerPageForm';
+import { useState } from 'react';
+import { Button, Checkbox, FormControlLabel } from '@mui/material';
+import { newAddressInitialValues } from '../util/getInitialValues';
+import { getCheckboxLabel } from '../util/getCheckboxLabel';
+import { AddressFieldSet } from '../../inputs/AddressFieldSet';
+
+const AddressInner: FormInnerComponent = (editable: boolean, formik) => {
+ return (
+
+ );
+};
+
+const AddressControls: AddressExtraControls = (editable: boolean, initialValues, formik) => {
+ const checkboxes = Object.entries(initialValues).filter(([key, value]) => {
+ if (
+ typeof value === 'boolean' &&
+ !((key === 'defaultShippingAddress' && value) || (key === 'defaultBillingAddress' && value))
+ ) {
+ return true;
+ }
+ return false;
+ });
+ if (!editable || checkboxes.length === 0) return null;
+
+ return (
+
+ {checkboxes.map(([name, value]) => (
+ {
+ formik.handleChange(e.nativeEvent);
+ }}
+ defaultChecked={value}
+ />
+ }
+ label={getCheckboxLabel(name)}
+ labelPlacement="end"
+ />
+ ))}
+
+ );
+};
+
+export const AddressComponent = ({
+ onSubmit,
+ initialValues,
+ validationSchema,
+ onDelete
+}: AddressComponentProps) => {
+ const [isNewAddress, setIsNewAddress] = useState(false);
+
+ const NewAddressModal = () => {
+ return (
+ <>
+ {!isNewAddress && (
+ {
+ setIsNewAddress(!isNewAddress);
+ }}
+ variant="contained"
+ color="secondary"
+ >
+ Add new address
+
+ )}
+ {isNewAddress && (
+ {
+ if (e.target === e.currentTarget) {
+ setIsNewAddress(false);
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') {
+ setIsNewAddress(false);
+ }
+ }}
+ role="textbox"
+ tabIndex={-1}
+ aria-labelledby="new-address"
+ >
+
+
+ New address
+
+ {
+ onSubmit(value);
+ setIsNewAddress(false);
+ }}
+ validationSchema={validationSchema}
+ formInner={AddressInner}
+ isEditable={true}
+ unsetNewForm={setIsNewAddress}
+ />
+
+
+ )}
+ >
+ );
+ };
+
+ if (initialValues.length === 0) {
+ return (
+ <>
+ You have no addresses yet...
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {initialValues.map((address) => (
+
+
+ {address.shippingStateChecked && (
+
+ Shipping address
+
+ )}
+ {address.billingStateChecked && (
+
+ Billing address
+
+ )}
+ {address.defaultShippingAddress && (
+
+ Default shipping address
+
+ )}
+ {address.defaultBillingAddress && (
+
+ Default billing address
+
+ )}
+
{/* filler */}
+
+
+
+ ))}
+
+ >
+ );
+};
diff --git a/src/components/forms/customerProfile/customerFormData/PasswordChange.tsx b/src/components/forms/customerProfile/customerFormData/PasswordChange.tsx
new file mode 100644
index 0000000..7fc3d0c
--- /dev/null
+++ b/src/components/forms/customerProfile/customerFormData/PasswordChange.tsx
@@ -0,0 +1,21 @@
+import type { FormInnerComponent } from '../../../../utils/types';
+import { PasswordInput } from '../../inputs/PasswordInput';
+
+export const PasswordChange: FormInnerComponent = (editable: boolean, formik) => {
+ return (
+
+ );
+};
diff --git a/src/components/forms/customerProfile/customerFormData/PersonalData.tsx b/src/components/forms/customerProfile/customerFormData/PersonalData.tsx
new file mode 100644
index 0000000..7ac73c1
--- /dev/null
+++ b/src/components/forms/customerProfile/customerFormData/PersonalData.tsx
@@ -0,0 +1,18 @@
+import type { FormInnerComponent } from '../../../../utils/types';
+import { Input } from '../../inputs/Input';
+import { inputsData } from '../../inputs/inputsData';
+
+export const PersonalData: FormInnerComponent = (editable: boolean, formik) => {
+ const { firstName, lastName, date, email } = inputsData;
+
+ return [firstName, lastName, date, email].map(({ name, placeholder, type }) => (
+
+ ));
+};
diff --git a/src/components/forms/customerProfile/util/getCheckboxLabel.ts b/src/components/forms/customerProfile/util/getCheckboxLabel.ts
new file mode 100644
index 0000000..fbea5fe
--- /dev/null
+++ b/src/components/forms/customerProfile/util/getCheckboxLabel.ts
@@ -0,0 +1,14 @@
+export const getCheckboxLabel = (name: string): string => {
+ switch (name) {
+ case 'defaultShippingAddress':
+ return 'default shipping';
+ case 'defaultBillingAddress':
+ return 'default billing';
+ case 'shippingStateChecked':
+ return 'shipping';
+ case 'billingStateChecked':
+ return 'billing';
+ default:
+ return name;
+ }
+};
diff --git a/src/components/forms/customerProfile/util/getInitialValues.ts b/src/components/forms/customerProfile/util/getInitialValues.ts
new file mode 100644
index 0000000..dae3144
--- /dev/null
+++ b/src/components/forms/customerProfile/util/getInitialValues.ts
@@ -0,0 +1,78 @@
+import type { Customer } from '@commercetools/platform-sdk';
+import type { AddressesInitialValues, PersonalDataInitialValues } from '../../../../utils/types';
+import { getCountryByCode } from '../../util/getCountry';
+
+export const newAddressInitialValues = {
+ id: '',
+ country: '',
+ streetName: '',
+ postalCode: '',
+ city: '',
+ shippingStateChecked: false,
+ billingStateChecked: false,
+ defaultShippingAddress: false,
+ defaultBillingAddress: false
+};
+
+export const getPersonalDataInitialValues = (customer: Customer): PersonalDataInitialValues => {
+ return {
+ firstName: customer.firstName ?? '',
+ lastName: customer.lastName ?? '',
+ date: customer.dateOfBirth ?? '',
+ email: customer.email
+ };
+};
+
+export const getAddressesInitialValues = (customer: Customer): AddressesInitialValues[] => {
+ const {
+ addresses,
+ shippingAddressIds,
+ billingAddressIds,
+ defaultShippingAddressId,
+ defaultBillingAddressId
+ } = customer;
+
+ if (addresses.length === 0) return [] as AddressesInitialValues[];
+
+ const result = addresses.map(({ id, country, streetName, postalCode, city }) => ({
+ id: id ?? '',
+ country: getCountryByCode(country),
+ streetName: streetName ?? '',
+ postalCode: postalCode ?? '',
+ city: city ?? '',
+ shippingStateChecked: false,
+ billingStateChecked: false,
+ defaultShippingAddress: false,
+ defaultBillingAddress: false
+ }));
+
+ if (shippingAddressIds !== undefined) {
+ const indexes = shippingAddressIds.map((id) => addresses.findIndex((address) => address.id === id));
+ result.forEach((address, index) => {
+ if (indexes.includes(index)) {
+ address.shippingStateChecked = true;
+ }
+ });
+ }
+
+ if (billingAddressIds !== undefined) {
+ const indexes = billingAddressIds.map((id) => addresses.findIndex((address) => address.id === id));
+ result.forEach((address, index) => {
+ if (indexes.includes(index)) {
+ address.billingStateChecked = true;
+ }
+ });
+ }
+
+ if (defaultShippingAddressId !== undefined) {
+ const index = addresses.findIndex((address) => address.id === defaultShippingAddressId);
+ result[index].defaultShippingAddress = true;
+ }
+
+ if (defaultBillingAddressId !== undefined) {
+ const index = addresses.findIndex((address) => address.id === defaultBillingAddressId);
+ result[index].defaultBillingAddress = true;
+ }
+
+ return result;
+};
diff --git a/src/components/forms/customerProfile/util/updateMessage.ts b/src/components/forms/customerProfile/util/updateMessage.ts
new file mode 100644
index 0000000..b7cc971
--- /dev/null
+++ b/src/components/forms/customerProfile/util/updateMessage.ts
@@ -0,0 +1,6 @@
+export enum UpdateMessage {
+ error = 'Something went wrong, please try again later',
+ success = 'Updated successfully',
+ addCart = 'Product successfully added to cart',
+ delCart = 'The product was successfully removed from the cart'
+}
diff --git a/src/components/forms/inputs/AddressFieldSet.tsx b/src/components/forms/inputs/AddressFieldSet.tsx
new file mode 100644
index 0000000..aa83d29
--- /dev/null
+++ b/src/components/forms/inputs/AddressFieldSet.tsx
@@ -0,0 +1,69 @@
+import { Input } from './Input';
+import { FieldSetName, inputsData } from './inputsData';
+import type { AddressFieldSetProps } from '../../../utils/types';
+import { CountryInput } from './CountryInput';
+import Checkbox from '@mui/material/Checkbox';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import { useState } from 'react';
+
+const formatName = (name: string): string => {
+ return name[0].toLowerCase() + name.slice(1);
+};
+
+export const AddressFieldSet = ({ fieldSet, formik, disabled }: AddressFieldSetProps) => {
+ const [postalCodeDisabled, setPostalCodeDisabled] = useState(true);
+ const { streetName, city, postalCode } = inputsData.addressFieldSet;
+ const hasFieldSetName = fieldSet !== undefined;
+
+ return (
+
+ {hasFieldSetName && (
+
+ {fieldSet === FieldSetName.Billing ? 'Billing address:' : 'Shipping address:'}
+
+ )}
+
+ {[streetName, city].map(({ name, placeholder, type }) => (
+
+ ))}
+
+
+
+ {hasFieldSetName && (
+ {
+ formik.handleChange(e.nativeEvent);
+ }}
+ />
+ }
+ label="as default"
+ labelPlacement="start"
+ />
+ )}
+
+ );
+};
diff --git a/src/components/forms/inputs/CountryInput.tsx b/src/components/forms/inputs/CountryInput.tsx
new file mode 100644
index 0000000..1a5474f
--- /dev/null
+++ b/src/components/forms/inputs/CountryInput.tsx
@@ -0,0 +1,61 @@
+import { ErrorMessage, Field } from 'formik';
+import type { CountryInputProps } from '../../../utils/types';
+import { countries } from '../util/countriesList';
+import { useState } from 'react';
+import { InputClasses } from './inputsData';
+
+export const CountryInput = ({ fieldSet, setPostalCodeDisabled, formik, disabled }: CountryInputProps) => {
+ const hasFieldSetName = fieldSet !== undefined;
+ const [isFoucus, setIsFoucus] = useState(false);
+
+ return (
+ {
+ setIsFoucus(true);
+ }}
+ onBlur={() => {
+ setIsFoucus(false);
+ }}
+ >
+
+ Country:
+
+ {
+ formik.handleChange(e);
+ setPostalCodeDisabled(false);
+ }}
+ disabled={disabled}
+ >
+
+ Select a country
+
+ {countries.map(({ country }) => (
+
+ {country}
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/components/forms/inputs/Input.tsx b/src/components/forms/inputs/Input.tsx
new file mode 100644
index 0000000..1a5e806
--- /dev/null
+++ b/src/components/forms/inputs/Input.tsx
@@ -0,0 +1,46 @@
+import { Field, ErrorMessage } from 'formik';
+import type { InputProps } from '../../../utils/types';
+import { useState } from 'react';
+import { InputClasses } from './inputsData';
+
+export const Input = ({ name, type, placeholder, formik, disabled }: InputProps) => {
+ const [isFoucus, setIsFoucus] = useState(false);
+ return (
+ {
+ setIsFoucus(true);
+ }}
+ onBlur={() => {
+ setIsFoucus(false);
+ }}
+ >
+
+ {placeholder}
+
+ {
+ formik.handleChange(e);
+ formik.setFieldTouched(name, true, false);
+ }
+ }
+ : {})}
+ disabled={disabled}
+ />
+
+
+ );
+};
diff --git a/src/components/forms/inputs/PasswordInput.tsx b/src/components/forms/inputs/PasswordInput.tsx
new file mode 100644
index 0000000..f303872
--- /dev/null
+++ b/src/components/forms/inputs/PasswordInput.tsx
@@ -0,0 +1,80 @@
+import { Field, ErrorMessage } from 'formik';
+import view from '../../../assets/view.svg';
+import noView from '../../../assets/no-view.svg';
+import { useState } from 'react';
+import type { InputProps, VisibilityIconProps } from '../../../utils/types';
+import { InputClasses } from './inputsData';
+
+const VisibilityIcon = ({ handleVisibility, passwordVisibility }: VisibilityIconProps) => {
+ return (
+
+
+
+ );
+};
+
+export const PasswordInput = ({
+ formik,
+ disabled,
+ placeholder,
+ name
+}: Pick) => {
+ const [passwordVisibility, setPasswordVisibility] = useState(false);
+ const [isFoucus, setIsFoucus] = useState(false);
+
+ const handleVisibility = () => {
+ setPasswordVisibility((prev: boolean) => !prev);
+ };
+
+ return (
+ {
+ setIsFoucus(true);
+ }}
+ onBlur={() => {
+ setIsFoucus(false);
+ }}
+ >
+
+
+ {placeholder}
+
+ {
+ formik.handleChange(e);
+ formik.setFieldTouched(name, true, false);
+ }
+ }
+ : {})}
+ disabled={disabled}
+ />
+
+
+
+
+ );
+};
diff --git a/src/components/forms/inputs/SimpleInput.tsx b/src/components/forms/inputs/SimpleInput.tsx
new file mode 100644
index 0000000..f4590c3
--- /dev/null
+++ b/src/components/forms/inputs/SimpleInput.tsx
@@ -0,0 +1,52 @@
+import { useState } from 'react';
+import { InputClasses } from './inputsData';
+import type { InputProps } from '../../../utils/types';
+
+export const SimpleInput = ({
+ name,
+ type,
+ placeholder,
+ formik,
+ disabled,
+ onChange,
+ value,
+ onKeyDown
+}: InputProps) => {
+ const [isFoucus, setIsFoucus] = useState(false);
+ return (
+ {
+ setIsFoucus(true);
+ }}
+ onBlur={() => {
+ setIsFoucus(false);
+ }}
+ >
+
+ {placeholder}
+
+ {
+ onChange?.(event.target.value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ onKeyDown?.().catch((e) => {
+ Error(e);
+ });
+ }
+ }}
+ />
+
+ );
+};
diff --git a/src/components/forms/inputs/inputsData.ts b/src/components/forms/inputs/inputsData.ts
new file mode 100644
index 0000000..bf944ab
--- /dev/null
+++ b/src/components/forms/inputs/inputsData.ts
@@ -0,0 +1,76 @@
+import { minAge } from '../util/validationSchema';
+
+export const inputsData = {
+ firstName: {
+ name: 'firstName',
+ type: 'text',
+ placeholder: 'First name'
+ },
+ lastName: {
+ name: 'lastName',
+ type: 'text',
+ placeholder: 'Last name'
+ },
+ email: {
+ name: 'email',
+ type: 'text',
+ placeholder: 'Email'
+ },
+ password: {
+ name: 'password',
+ type: 'password',
+ placeholder: 'Password'
+ },
+ date: {
+ name: 'date',
+ type: 'date',
+ placeholder: 'Date of Birth'
+ },
+ addressFieldSet: {
+ city: {
+ name: 'City',
+ type: 'text',
+ placeholder: 'City'
+ },
+ postalCode: {
+ name: 'PostalCode',
+ type: 'text',
+ placeholder: 'Postal Code'
+ },
+ streetName: {
+ name: 'StreetName',
+ type: 'text',
+ placeholder: 'Street'
+ }
+ }
+};
+
+export enum FieldSetName {
+ Shipping = 'shipping',
+ Billing = 'billing'
+}
+
+export const initialValuesRegistration = {
+ firstName: '',
+ lastName: '',
+ date: minAge,
+ email: '',
+ password: '',
+ shippingStreetName: '',
+ shippingCity: '',
+ shippingPostalCode: '',
+ shippingCountry: '',
+ billingStreetName: '',
+ billingCity: '',
+ billingPostalCode: '',
+ billingCountry: '',
+ billingStateChecked: true,
+ shippingStateChecked: true
+};
+
+export enum InputClasses {
+ wrapper = 'relative w-[16rem] mb-6',
+ error = 'absolute bottom-[-1rem] left-0 text-xs italic text-red-600',
+ labelWrapper = 'absolute left-3 top-0 text-xs text-gray-500',
+ input = 'leading-tight text-gray-700 transition-all focus:border-secondary focus:outline-none disabled:border-slate-500 disabled:bg-slate-200 w-full appearance-none border-0 border-b-[1px] border-gray-500 px-3 pt-4'
+}
diff --git a/src/components/forms/util/addressDataAdapter.ts b/src/components/forms/util/addressDataAdapter.ts
new file mode 100644
index 0000000..41f276e
--- /dev/null
+++ b/src/components/forms/util/addressDataAdapter.ts
@@ -0,0 +1,72 @@
+import type { HandleSubmitWithBoth, CustomerData } from '../../../utils/types';
+import { getCountryCode } from './getCountry';
+
+enum AddressSequence {
+ Shipping = 0,
+ Billing = 1
+}
+
+const applyBillingAddress = (
+ formData: HandleSubmitWithBoth,
+ dataWithShipping: CustomerData
+): CustomerData => {
+ const result = {
+ ...dataWithShipping
+ };
+ const billingAddress = {
+ country: getCountryCode(formData.billingCountry),
+ firstName: formData.firstName,
+ lastName: formData.lastName,
+ streetName: formData.billingStreetName,
+ postalCode: formData.billingPostalCode,
+ city: formData.billingCity
+ };
+
+ result.addresses.push(billingAddress);
+ result.billingAddresses = [AddressSequence.Billing];
+
+ if (formData.billingStateChecked) {
+ result.defaultBillingAddress = AddressSequence.Billing;
+ }
+
+ return result;
+};
+
+export const addressAdapter = (formData: HandleSubmitWithBoth): CustomerData => {
+ const { firstName, lastName, date, email, password } = formData;
+ const shippingAddress = {
+ country: getCountryCode(formData.shippingCountry),
+ firstName,
+ lastName,
+ streetName: formData.shippingStreetName,
+ postalCode: formData.shippingPostalCode,
+ city: formData.shippingCity
+ };
+
+ const addresses = [shippingAddress];
+ const shippingAddresses = [AddressSequence.Shipping];
+ const billingAddresses = shippingAddresses;
+ const salutation = Math.random() > 0.5 ? 'Mr' : 'Ms';
+
+ const result: CustomerData = {
+ firstName,
+ lastName,
+ dateOfBirth: date,
+ email,
+ password,
+ addresses,
+ shippingAddresses,
+ billingAddresses,
+ salutation
+ };
+
+ if (formData.shippingStateChecked) {
+ result.defaultShippingAddress = AddressSequence.Shipping;
+ }
+
+ if (formData.billingStreetName !== '') {
+ const resultWithBilling = applyBillingAddress(formData, result);
+ return resultWithBilling;
+ }
+ return result;
+};
diff --git a/src/components/forms/util/authMessages.ts b/src/components/forms/util/authMessages.ts
new file mode 100644
index 0000000..b517f46
--- /dev/null
+++ b/src/components/forms/util/authMessages.ts
@@ -0,0 +1,8 @@
+export enum AuthMessages {
+ successRegistrationMessage = 'Registration successful',
+ successLoginMessage = 'Login successful',
+ existingCustomerError = 'There is already an existing customer with the provided email.',
+ existingCustomerMessage = 'Customer with this email already exist, login or create new account',
+ failedLoginMessage = 'Invalid email or password, please try again',
+ otherErrorMessage = 'Registration failed, please try again later'
+}
diff --git a/src/components/forms/util/countriesList.ts b/src/components/forms/util/countriesList.ts
new file mode 100644
index 0000000..5ccb09e
--- /dev/null
+++ b/src/components/forms/util/countriesList.ts
@@ -0,0 +1,642 @@
+export const countries = [
+ {
+ code: 'AX',
+ country: 'Aland Islands'
+ },
+ {
+ code: 'DZ',
+ country: 'Algeria'
+ },
+ {
+ code: 'AS',
+ country: 'American Samoa'
+ },
+ {
+ code: 'AD',
+ country: 'Andorra'
+ },
+ {
+ code: 'AR',
+ country: 'Argentina'
+ },
+ {
+ code: 'AM',
+ country: 'Armenia'
+ },
+ {
+ code: 'AU',
+ country: 'Australia'
+ },
+ {
+ code: 'AT',
+ country: 'Austria'
+ },
+ {
+ code: 'AZ',
+ country: 'Azerbaijan'
+ },
+ {
+ code: 'BH',
+ country: 'Bahrain'
+ },
+ {
+ code: 'BD',
+ country: 'Bangladesh'
+ },
+ {
+ code: 'BB',
+ country: 'Barbados'
+ },
+ {
+ code: 'BY',
+ country: 'Belarus'
+ },
+ {
+ code: 'BE',
+ country: 'Belgium'
+ },
+ {
+ code: 'BM',
+ country: 'Bermuda'
+ },
+ {
+ code: 'BA',
+ country: 'Bosnia And Herzegovina'
+ },
+ {
+ code: 'BR',
+ country: 'Brazil'
+ },
+ {
+ code: 'IO',
+ country: 'British Indian Ocean Territory'
+ },
+ {
+ code: 'BN',
+ country: 'Brunei Darussalam'
+ },
+ {
+ code: 'BG',
+ country: 'Bulgaria'
+ },
+ {
+ code: 'KH',
+ country: 'Cambodia'
+ },
+ {
+ code: 'CA',
+ country: 'Canada'
+ },
+ {
+ code: 'CV',
+ country: 'Cape Verde'
+ },
+ {
+ code: 'KY',
+ country: 'Cayman Islands'
+ },
+ {
+ code: 'CL',
+ country: 'Chile'
+ },
+ {
+ code: 'CN',
+ country: 'China'
+ },
+ {
+ code: 'CX',
+ country: 'Christmas Island'
+ },
+ {
+ code: 'CC',
+ country: 'Cocos (Keeling) Islands'
+ },
+ {
+ code: 'CK',
+ country: 'Cook Islands'
+ },
+ {
+ code: 'CR',
+ country: 'Costa Rica'
+ },
+ {
+ code: 'HR',
+ country: 'Croatia'
+ },
+ {
+ code: 'CY',
+ country: 'Cyprus'
+ },
+ {
+ code: 'CZ',
+ country: 'Czech Republic'
+ },
+ {
+ code: 'DK',
+ country: 'Denmark'
+ },
+ {
+ code: 'DO',
+ country: 'Dominican Republic'
+ },
+ {
+ code: 'EC',
+ country: 'Ecuador'
+ },
+ {
+ code: 'EG',
+ country: 'Egypt'
+ },
+ {
+ code: 'EE',
+ country: 'Estonia'
+ },
+ {
+ code: 'ET',
+ country: 'Ethiopia'
+ },
+ {
+ code: 'FK',
+ country: 'Falkland Islands (Malvinas)'
+ },
+ {
+ code: 'FO',
+ country: 'Faroe Islands'
+ },
+ {
+ code: 'FI',
+ country: 'Finland'
+ },
+ {
+ code: 'FR',
+ country: 'France'
+ },
+ {
+ code: 'GF',
+ country: 'French Guiana'
+ },
+ {
+ code: 'PF',
+ country: 'French Polynesia'
+ },
+ {
+ code: 'GE',
+ country: 'Georgia'
+ },
+ {
+ code: 'DE',
+ country: 'Germany'
+ },
+ {
+ code: 'GR',
+ country: 'Greece'
+ },
+ {
+ code: 'GL',
+ country: 'Greenland'
+ },
+ {
+ code: 'GP',
+ country: 'Guadeloupe'
+ },
+ {
+ code: 'GU',
+ country: 'Guam'
+ },
+ {
+ code: 'GT',
+ country: 'Guatemala'
+ },
+ {
+ code: 'GG',
+ country: 'Guernsey'
+ },
+ {
+ code: 'GN',
+ country: 'Guinea'
+ },
+ {
+ code: 'GW',
+ country: 'Guinea-Bissau'
+ },
+ {
+ code: 'HT',
+ country: 'Haiti'
+ },
+ {
+ code: 'HM',
+ country: 'Heard Island & Mcdonald Islands'
+ },
+ {
+ code: 'VA',
+ country: 'Holy See (Vatican City State)'
+ },
+ {
+ code: 'HN',
+ country: 'Honduras'
+ },
+ {
+ code: 'HU',
+ country: 'Hungary'
+ },
+ {
+ code: 'IS',
+ country: 'Iceland'
+ },
+ {
+ code: 'IN',
+ country: 'India'
+ },
+ {
+ code: 'ID',
+ country: 'Indonesia'
+ },
+ {
+ code: 'IQ',
+ country: 'Iraq'
+ },
+ {
+ code: 'IE',
+ country: 'Ireland'
+ },
+ {
+ code: 'IM',
+ country: 'Isle Of Man'
+ },
+ {
+ code: 'IL',
+ country: 'Israel'
+ },
+ {
+ code: 'IT',
+ country: 'Italy'
+ },
+ {
+ code: 'JP',
+ country: 'Japan'
+ },
+ {
+ code: 'JE',
+ country: 'Jersey'
+ },
+ {
+ code: 'JO',
+ country: 'Jordan'
+ },
+ {
+ code: 'KZ',
+ country: 'Kazakhstan'
+ },
+ {
+ code: 'KE',
+ country: 'Kenya'
+ },
+ {
+ code: 'KR',
+ country: 'Korea'
+ },
+ {
+ code: 'KW',
+ country: 'Kuwait'
+ },
+ {
+ code: 'KG',
+ country: 'Kyrgyzstan'
+ },
+ {
+ code: 'LA',
+ country: "Lao People's Democratic Republic"
+ },
+ {
+ code: 'LV',
+ country: 'Latvia'
+ },
+ {
+ code: 'LB',
+ country: 'Lebanon'
+ },
+ {
+ code: 'LS',
+ country: 'Lesotho'
+ },
+ {
+ code: 'LR',
+ country: 'Liberia'
+ },
+ {
+ code: 'LI',
+ country: 'Liechtenstein'
+ },
+ {
+ code: 'LT',
+ country: 'Lithuania'
+ },
+ {
+ code: 'LU',
+ country: 'Luxembourg'
+ },
+ {
+ code: 'MK',
+ country: 'Macedonia'
+ },
+ {
+ code: 'MG',
+ country: 'Madagascar'
+ },
+ {
+ code: 'MY',
+ country: 'Malaysia'
+ },
+ {
+ code: 'MV',
+ country: 'Maldives'
+ },
+ {
+ code: 'MT',
+ country: 'Malta'
+ },
+ {
+ code: 'MH',
+ country: 'Marshall Islands'
+ },
+ {
+ code: 'MQ',
+ country: 'Martinique'
+ },
+ {
+ code: 'MU',
+ country: 'Mauritius'
+ },
+ {
+ code: 'YT',
+ country: 'Mayotte'
+ },
+ {
+ code: 'MX',
+ country: 'Mexico'
+ },
+ {
+ code: 'FM',
+ country: 'Micronesia, Federated States Of'
+ },
+ {
+ code: 'MD',
+ country: 'Moldova'
+ },
+ {
+ code: 'MC',
+ country: 'Monaco'
+ },
+ {
+ code: 'MN',
+ country: 'Mongolia'
+ },
+ {
+ code: 'ME',
+ country: 'Montenegro'
+ },
+ {
+ code: 'MA',
+ country: 'Morocco'
+ },
+ {
+ code: 'NP',
+ country: 'Nepal'
+ },
+ {
+ code: 'NL',
+ country: 'Netherlands'
+ },
+ {
+ code: 'NC',
+ country: 'New Caledonia'
+ },
+ {
+ code: 'NZ',
+ country: 'New Zealand'
+ },
+ {
+ code: 'NI',
+ country: 'Nicaragua'
+ },
+ {
+ code: 'NE',
+ country: 'Niger'
+ },
+ {
+ code: 'NG',
+ country: 'Nigeria'
+ },
+ {
+ code: 'NF',
+ country: 'Norfolk Island'
+ },
+ {
+ code: 'MP',
+ country: 'Northern Mariana Islands'
+ },
+ {
+ code: 'NO',
+ country: 'Norway'
+ },
+ {
+ code: 'OM',
+ country: 'Oman'
+ },
+ {
+ code: 'PK',
+ country: 'Pakistan'
+ },
+ {
+ code: 'PW',
+ country: 'Palau'
+ },
+ {
+ code: 'PA',
+ country: 'Panama'
+ },
+ {
+ code: 'PG',
+ country: 'Papua New Guinea'
+ },
+ {
+ code: 'PY',
+ country: 'Paraguay'
+ },
+ {
+ code: 'PE',
+ country: 'Peru'
+ },
+ {
+ code: 'PH',
+ country: 'Philippines'
+ },
+ {
+ code: 'PN',
+ country: 'Pitcairn'
+ },
+ {
+ code: 'PL',
+ country: 'Poland'
+ },
+ {
+ code: 'PT',
+ country: 'Portugal'
+ },
+ {
+ code: 'PR',
+ country: 'Puerto Rico'
+ },
+ {
+ code: 'RE',
+ country: 'Reunion'
+ },
+ {
+ code: 'RO',
+ country: 'Romania'
+ },
+ {
+ code: 'RU',
+ country: 'Russian Federation'
+ },
+ {
+ code: 'SH',
+ country: 'Saint Helena'
+ },
+ {
+ code: 'PM',
+ country: 'Saint Pierre And Miquelon'
+ },
+ {
+ code: 'SM',
+ country: 'San Marino'
+ },
+ {
+ code: 'SA',
+ country: 'Saudi Arabia'
+ },
+ {
+ code: 'SN',
+ country: 'Senegal'
+ },
+ {
+ code: 'RS',
+ country: 'Serbia'
+ },
+ {
+ code: 'SG',
+ country: 'Singapore'
+ },
+ {
+ code: 'SK',
+ country: 'Slovakia'
+ },
+ {
+ code: 'SI',
+ country: 'Slovenia'
+ },
+ {
+ code: 'SO',
+ country: 'Somalia'
+ },
+ {
+ code: 'ZA',
+ country: 'South Africa'
+ },
+ {
+ code: 'GS',
+ country: 'South Georgia And Sandwich Isl.'
+ },
+ {
+ code: 'ES',
+ country: 'Spain'
+ },
+ {
+ code: 'LK',
+ country: 'Sri Lanka'
+ },
+ {
+ code: 'SJ',
+ country: 'Svalbard And Jan Mayen'
+ },
+ {
+ code: 'SZ',
+ country: 'Swaziland'
+ },
+ {
+ code: 'SE',
+ country: 'Sweden'
+ },
+ {
+ code: 'CH',
+ country: 'Switzerland'
+ },
+ {
+ code: 'TW',
+ country: 'Taiwan'
+ },
+ {
+ code: 'TJ',
+ country: 'Tajikistan'
+ },
+ {
+ code: 'TH',
+ country: 'Thailand'
+ },
+ {
+ code: 'TN',
+ country: 'Tunisia'
+ },
+ {
+ code: 'TR',
+ country: 'Turkey'
+ },
+ {
+ code: 'TM',
+ country: 'Turkmenistan'
+ },
+ {
+ code: 'TC',
+ country: 'Turks And Caicos Islands'
+ },
+ {
+ code: 'UA',
+ country: 'Ukraine'
+ },
+ {
+ code: 'GB',
+ country: 'United Kingdom'
+ },
+ {
+ code: 'US',
+ country: 'United States'
+ },
+ {
+ code: 'UY',
+ country: 'Uruguay'
+ },
+ {
+ code: 'UZ',
+ country: 'Uzbekistan'
+ },
+ {
+ code: 'VE',
+ country: 'Venezuela'
+ },
+ {
+ code: 'VN',
+ country: 'Viet Nam'
+ },
+ {
+ code: 'VI',
+ country: 'Virgin Islands, U.S.'
+ },
+ {
+ code: 'WF',
+ country: 'Wallis And Futuna'
+ },
+ {
+ code: 'ZM',
+ country: 'Zambia'
+ }
+];
diff --git a/src/components/forms/util/getCountry.ts b/src/components/forms/util/getCountry.ts
new file mode 100644
index 0000000..3dd5c56
--- /dev/null
+++ b/src/components/forms/util/getCountry.ts
@@ -0,0 +1,17 @@
+import { countries } from './countriesList';
+
+export const getCountryCode = (country: string): string => {
+ const selectedCountry = countries.find((countryData) => countryData.country === country);
+ if (selectedCountry === undefined) {
+ return '';
+ }
+ return selectedCountry.code;
+};
+
+export const getCountryByCode = (code: string): string => {
+ const selectedCountry = countries.find((countryData) => countryData.code === code);
+ if (selectedCountry === undefined) {
+ return '';
+ }
+ return selectedCountry.country;
+};
diff --git a/src/components/forms/util/submitFakeData.ts b/src/components/forms/util/submitFakeData.ts
new file mode 100644
index 0000000..123f0a5
--- /dev/null
+++ b/src/components/forms/util/submitFakeData.ts
@@ -0,0 +1,23 @@
+import type { HandleSubmitWithBoth } from '../../../utils/types';
+
+export const submitDataWithShipping = {
+ email: 'email@example.com',
+ password: 'Password123#',
+ firstName: 'firstName',
+ lastName: 'lastName',
+ date: '2002-01-01',
+ shippingCountry: 'Cyprus',
+ shippingStreetName: 'shippingStreetName',
+ shippingPostalCode: '1234',
+ shippingCity: 'shippingCity',
+ shippingStateChecked: true
+};
+
+export const submitDataWithBilling: HandleSubmitWithBoth = {
+ ...submitDataWithShipping,
+ billingCountry: 'Greece',
+ billingStreetName: 'billingStreetName',
+ billingPostalCode: '12345',
+ billingCity: 'billingCity',
+ billingStateChecked: true
+};
diff --git a/src/components/forms/util/validationSchema.ts b/src/components/forms/util/validationSchema.ts
new file mode 100644
index 0000000..08c4696
--- /dev/null
+++ b/src/components/forms/util/validationSchema.ts
@@ -0,0 +1,136 @@
+import * as yup from 'yup';
+import { postcodeValidator } from 'postcode-validator';
+import dayjs from 'dayjs';
+import { getCountryCode } from './getCountry';
+
+export const minAge = dayjs().subtract(18, 'year').format('YYYY-MM-DD');
+
+const email = yup
+ .string()
+ .email('Should be correctly formatted')
+ .matches(/\.[A-Z]{2,4}$/i, 'Should be correctly formatted')
+ .required('Required field');
+
+const password = yup
+ .string()
+ .min(8, 'Should contain 8 characters')
+ .matches(/[A-Z]/, 'Should contain one uppercase letter')
+ .matches(/[a-z]/, 'Should contain one lowercase letter')
+ .matches(/[0-9]/, 'Should contain one digit')
+ .matches(/[!@#$%^&*]/, 'Should contain one special character')
+ .required('Required field')
+ .test('no-leading-or-trailing-whitespace', 'Leading or trailing whitespace not allowed', (value) => {
+ return value === value.trim();
+ });
+
+export const validationsSchemaLogin = {
+ email,
+ password
+};
+
+const firstName = yup
+ .string()
+ .min(1, 'Should contain at least 1 character')
+ .matches(/^[A-Za-z]+$/, 'Numbers and symbols not allowed')
+ .trim()
+ .required('Required field');
+
+const lastName = firstName;
+
+const date = yup.date().max(minAge, 'You should be older than 18 years').required('Required field');
+
+const shippingStreetName = yup
+ .string()
+ .min(1, 'Should contain at least 1 character')
+ .trim()
+ .required('Required field');
+
+const shippingCity = yup
+ .string()
+ .min(1, 'Should contain at least 1 character')
+ .matches(/^[A-Za-z]+$/, 'Numbers and symbols not allowed')
+ .trim()
+ .required('Required field');
+
+const shippingCountry = yup.string().required('Required field');
+
+const shippingPostalCode = yup.string().when('shippingCountry', (country: string[], schema) => {
+ const code = getCountryCode(country[0]);
+ return schema
+ .test('postal-validate', 'Invalid zip code format', (value) => {
+ const currentValue = value ?? '';
+ return postcodeValidator(currentValue, code);
+ })
+ .trim()
+ .required('Required field');
+});
+
+const validationsSchemaRegistrationShipping = {
+ ...validationsSchemaLogin,
+ firstName,
+ lastName,
+ date,
+ shippingStreetName,
+ shippingCity,
+ shippingCountry,
+ shippingPostalCode
+};
+
+const billingStreetName = shippingStreetName;
+
+const billingCity = shippingCity;
+
+const billingCountry = shippingCountry;
+
+const billingPostalCode = yup.string().when('billingCountry', (country: string[], schema) => {
+ const code = getCountryCode(country[0]);
+ return schema
+ .test('postal-validate', 'Invalid zip code format', (value) => {
+ const currentValue = value ?? '';
+ return postcodeValidator(currentValue, code);
+ })
+ .trim()
+ .required('Required field');
+});
+
+const validationsSchemaRegistrationBoth = {
+ ...validationsSchemaRegistrationShipping,
+ billingStreetName,
+ billingCity,
+ billingCountry,
+ billingPostalCode
+};
+
+export const getValidationSchema = (isSameAddress: boolean): yup.AnyObjectSchema => {
+ return yup.object(
+ isSameAddress ? validationsSchemaRegistrationShipping : validationsSchemaRegistrationBoth
+ );
+};
+
+export const customerPersonalDataSchema = yup.object({
+ firstName,
+ lastName,
+ date,
+ email
+});
+
+export const customerAddressSchema = yup.object({
+ streetName: shippingStreetName,
+ city: shippingCity,
+ country: shippingCountry,
+ postalCode: yup.string().when('country', (country: string[], schema) => {
+ const code = getCountryCode(country[0]);
+ return schema
+ .test('postal-validate', 'Invalid zip code format', (value) => {
+ const currentValue = value ?? '';
+ return postcodeValidator(currentValue, code);
+ })
+ .trim()
+ .required('Required field');
+ })
+});
+
+export const passwordChangeSchema = yup.object({
+ currentPassword: password,
+ newPassword: password
+});
diff --git a/src/components/header/BurgerMenu/BurgerCloseBtn/BurgerCloseBtn.tsx b/src/components/header/BurgerMenu/BurgerCloseBtn/BurgerCloseBtn.tsx
new file mode 100644
index 0000000..4c570e9
--- /dev/null
+++ b/src/components/header/BurgerMenu/BurgerCloseBtn/BurgerCloseBtn.tsx
@@ -0,0 +1,21 @@
+import { XMarkIcon } from '@heroicons/react/24/outline';
+import type { ClickHandlerInterface } from '../../../../utils/types';
+
+export const BurgerCloseBtn = ({ clickHandler }: ClickHandlerInterface) => {
+ return (
+ {
+ if (clickHandler === undefined) return;
+ clickHandler(false);
+ }}
+ >
+ Close menu
+
+
+ );
+};
diff --git a/src/components/header/BurgerMenu/BurgerMenu.tsx b/src/components/header/BurgerMenu/BurgerMenu.tsx
new file mode 100644
index 0000000..6d02739
--- /dev/null
+++ b/src/components/header/BurgerMenu/BurgerMenu.tsx
@@ -0,0 +1,42 @@
+import { Dialog } from '@headlessui/react';
+import { Logo } from '../../logo/logo';
+import { BurgerCloseBtn } from './BurgerCloseBtn/BurgerCloseBtn';
+import type { BurgerMenuProps, RootState } from '../../../utils/types';
+import { PrivateNavGroup } from '../NavBar/PrivateNavGroup';
+import { useAuth } from '../../../hooks/useAuth';
+import { useSelector } from 'react-redux';
+import { NavGroup } from '../NavBar/NavGroup';
+import { useCart } from '../../../hooks/useCart';
+
+export const BurgerMenu = ({ mobileMenuOpen, setMobileMenuOpen }: BurgerMenuProps) => {
+ const isLogged = useSelector((state: { authData: RootState }) => state.authData.isLogged);
+ const { logout } = useAuth();
+ const { cart } = useCart();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/header/NavBar/BurgerBtn/BurgerBtn.tsx b/src/components/header/NavBar/BurgerBtn/BurgerBtn.tsx
new file mode 100644
index 0000000..1acf828
--- /dev/null
+++ b/src/components/header/NavBar/BurgerBtn/BurgerBtn.tsx
@@ -0,0 +1,21 @@
+import { Bars3Icon } from '@heroicons/react/24/outline';
+import type { ClickHandlerInterface } from '../../../../utils/types';
+
+export const BurgerBtn = ({ clickHandler }: ClickHandlerInterface) => {
+ return (
+ {
+ if (clickHandler === undefined) return;
+ clickHandler(true);
+ }}
+ >
+ Open main menu
+
+
+ );
+};
diff --git a/src/components/header/NavBar/NavBar.tsx b/src/components/header/NavBar/NavBar.tsx
new file mode 100644
index 0000000..16f84af
--- /dev/null
+++ b/src/components/header/NavBar/NavBar.tsx
@@ -0,0 +1,35 @@
+import { useSelector } from 'react-redux';
+import { useAuth } from '../../../hooks/useAuth';
+import type { ClickHandlerInterface, RootState } from '../../../utils/types';
+import { Logo } from '../../logo/logo';
+import { BurgerBtn } from './BurgerBtn/BurgerBtn';
+import { PrivateNavGroup } from './PrivateNavGroup';
+import { NavGroup } from './NavGroup';
+import { useCart } from '../../../hooks/useCart';
+
+export const NavBar = ({ clickHandler }: ClickHandlerInterface) => {
+ const isLogged = useSelector((state: { authData: RootState }) => state.authData.isLogged);
+ const { cart } = useCart();
+ const { logout } = useAuth();
+
+ return (
+
+
+
+
+
+ void}
+ isLogged={isLogged}
+ logout={logout}
+ />
+
+
+ );
+};
diff --git a/src/components/header/NavBar/NavGroup.tsx b/src/components/header/NavBar/NavGroup.tsx
new file mode 100644
index 0000000..3fa51a9
--- /dev/null
+++ b/src/components/header/NavBar/NavGroup.tsx
@@ -0,0 +1,28 @@
+import { links } from '../../../utils/links';
+import { Link } from 'react-router-dom';
+import type { ClickHandlerInterface } from '../../../utils/types';
+
+export const NavGroup = ({ clickHandler }: ClickHandlerInterface) => {
+ const linksData = [links.main, links.catalog, links.about];
+ return (
+ <>
+ {linksData.map((item) => (
+
+ {
+ clickHandler(false);
+ }
+ }
+ : {})}
+ to={item.path}
+ className="hover:opacity-70"
+ >
+ {item.text}
+
+
+ ))}
+ >
+ );
+};
diff --git a/src/components/header/NavBar/PrivateNavGroup.tsx b/src/components/header/NavBar/PrivateNavGroup.tsx
new file mode 100644
index 0000000..b1c8d31
--- /dev/null
+++ b/src/components/header/NavBar/PrivateNavGroup.tsx
@@ -0,0 +1,53 @@
+import { links } from '../../../utils/links';
+import { Link } from 'react-router-dom';
+import type { PrivateNavGroupProps } from '../../../utils/types';
+import Badge from '@mui/material/Badge';
+
+export const PrivateNavGroup = ({ isLogged, logout, clickHandler, itemsCount }: PrivateNavGroupProps) => {
+ const linksData = isLogged
+ ? [links.cart, links.profile, links.logout]
+ : [links.cart, links.login, links.registration];
+
+ return (
+ <>
+ {linksData.map((item) => (
+
+ {
+ logout();
+ clickHandler(false);
+ }
+ : () => {
+ clickHandler(false);
+ }
+ }
+ >
+ {
+ /* prettier-ignore */
+ item.text === 'Cart'
+ ? (
+
+ {item.icon}
+
+ )
+ : (
+ item.icon
+ )
+ }
+
+
+ ))}
+ >
+ );
+};
diff --git a/src/components/header/header.tsx b/src/components/header/header.tsx
new file mode 100644
index 0000000..c2b1e69
--- /dev/null
+++ b/src/components/header/header.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useState } from 'react';
+import { NavBar } from './NavBar/NavBar';
+import { BurgerMenu } from './BurgerMenu/BurgerMenu';
+import { useSelector, useDispatch } from 'react-redux';
+import { getCartWithCustomerId } from '../../helpers/api/apiRoot';
+import { getCartFromLs, setCartToLs } from '../../pages/CartPage/utils/cartStorage';
+import { createCartHandler } from '../../pages/CartPage/utils/createCartHandler';
+import { setCartData } from '../../slices/cartSlice';
+import { type RootState } from '../../utils/types';
+
+export const Header = () => {
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const customer = useSelector((state: { authData: RootState }) => state.authData.customer);
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ if (customer === null) {
+ const cartInLs = getCartFromLs();
+
+ if (cartInLs !== null) {
+ dispatch(setCartData(cartInLs));
+ } else {
+ createCartHandler()
+ .then((cart) => {
+ dispatch(setCartData(cart));
+ setCartToLs(cart);
+ })
+ .catch((e) => {
+ Error(e);
+ });
+ }
+ } else {
+ getCartWithCustomerId(customer.id)
+ .then(async (cart) => {
+ dispatch(setCartData(cart.body));
+ })
+ .catch(async (e) => {
+ const newCart = await createCartHandler(customer.id);
+ dispatch(setCartData(newCart));
+ // Error(e);
+ });
+ }
+ }, [customer, dispatch]);
+
+ return (
+
+ );
+};
diff --git a/src/components/logo/logo.tsx b/src/components/logo/logo.tsx
new file mode 100644
index 0000000..0602bdf
--- /dev/null
+++ b/src/components/logo/logo.tsx
@@ -0,0 +1,21 @@
+import { Link } from 'react-router-dom';
+import { NavRoutes } from '../../utils/routes';
+import type { LogoParams } from '../../utils/types';
+import logo from '../../assets/logo.webp';
+
+export const Logo = ({ className }: LogoParams) => {
+ return (
+
+ Our shop name should be there
+
+
+
+ );
+};
diff --git a/src/components/searchInput/SearchInput.tsx b/src/components/searchInput/SearchInput.tsx
new file mode 100644
index 0000000..de3f761
--- /dev/null
+++ b/src/components/searchInput/SearchInput.tsx
@@ -0,0 +1,48 @@
+import { Autocomplete, TextField } from '@mui/material';
+import { useDispatch, useSelector } from 'react-redux';
+import { Form, Formik } from 'formik';
+import type { RootState } from '../../utils/types';
+import { handleSearch } from './handleSearch';
+import { useState } from 'react';
+
+export const SearchInput = () => {
+ const dispatch = useDispatch();
+ const [query, setQuery] = useState('');
+
+ const { cardsData } = useSelector((state: { productsData: RootState }) => state.productsData);
+ const labels = cardsData.map((card) => card.name);
+
+ return (
+ {
+ await handleSearch(query, dispatch);
+ resetForm();
+ }}
+ >
+ {({ submitForm, handleSubmit }) => (
+
+ )}
+
+ );
+};
diff --git a/src/components/searchInput/handleSearch.ts b/src/components/searchInput/handleSearch.ts
new file mode 100644
index 0000000..e7d7cd7
--- /dev/null
+++ b/src/components/searchInput/handleSearch.ts
@@ -0,0 +1,10 @@
+import type { Dispatch } from '@reduxjs/toolkit';
+import { updateExtremumsData, updateProductsData } from '../../pages/catalogPage/utils/updateData';
+import { fetchFilteredProducts } from '../../helpers/api/apiRoot';
+import { getExtremums } from '../../pages/catalogPage/utils/getExtremums';
+
+export const handleSearch = async (searchText: string, dispatch: Dispatch): Promise => {
+ const data = await fetchFilteredProducts({ 'text.en-US': searchText });
+ updateProductsData(dispatch, data);
+ await updateExtremumsData(dispatch, fetchFilteredProducts, getExtremums, { 'text.en-US': searchText });
+};
diff --git a/src/components/tabs/TabsPanel.tsx b/src/components/tabs/TabsPanel.tsx
new file mode 100644
index 0000000..bfd52cd
--- /dev/null
+++ b/src/components/tabs/TabsPanel.tsx
@@ -0,0 +1,58 @@
+import { Box, Tabs, Tab } from '@mui/material';
+import { useState } from 'react';
+import type { TabPanelProps, TabsPanelProps } from '../../utils/types';
+
+const CustomTabPanel = (props: TabPanelProps) => {
+ const { children, value, index } = props;
+
+ return (
+
+ {value === index && {children} }
+
+ );
+};
+
+export const TabsPanel = (props: TabsPanelProps) => {
+ const [tabValue, setTabValue] = useState(0);
+ const { children, titles } = props;
+ if (children === undefined || !Array.isArray(children) || children.length === 0) return null;
+
+ const handleChange = (event: React.SyntheticEvent, newValue: number): void => {
+ setTabValue(newValue);
+ };
+
+ return (
+
+
+
+ {titles.map((title) => (
+
+ ))}
+
+
+ {children.map((child, index) => (
+
+ {child}
+
+ ))}
+
+ );
+};
diff --git a/src/contexts/index.ts b/src/contexts/index.ts
new file mode 100644
index 0000000..172d8e8
--- /dev/null
+++ b/src/contexts/index.ts
@@ -0,0 +1,11 @@
+import { createContext } from 'react';
+import type { CategoriesContextInterface } from '../utils/types';
+
+export const CategoriesContext = createContext({
+ categoryId: '',
+ categoryName: '',
+ setCategoryId: (): void => {},
+ setCategoryName: (): void => {},
+ currentFilter: [],
+ setCurrentFilter: (): void => {}
+});
diff --git a/src/declarations/index.d.ts b/src/declarations/index.d.ts
new file mode 100644
index 0000000..33320dc
--- /dev/null
+++ b/src/declarations/index.d.ts
@@ -0,0 +1,6 @@
+declare module '*.svg';
+declare module '*.png';
+declare module '*.scss' {
+ const content: Record;
+ export default content;
+}
diff --git a/src/declarations/toastify.d.ts b/src/declarations/toastify.d.ts
new file mode 100644
index 0000000..ac9a217
--- /dev/null
+++ b/src/declarations/toastify.d.ts
@@ -0,0 +1 @@
+declare module 'toastify-js';
diff --git a/src/helpers/api/BuildClient.ts b/src/helpers/api/BuildClient.ts
new file mode 100644
index 0000000..1ee7344
--- /dev/null
+++ b/src/helpers/api/BuildClient.ts
@@ -0,0 +1,66 @@
+import {
+ ClientBuilder,
+ type AuthMiddlewareOptions,
+ type HttpMiddlewareOptions
+} from '@commercetools/sdk-client-v2';
+import { ClientApiData, Endpoints } from '../../utils/clientApiData';
+
+const authMiddlewareOptions: AuthMiddlewareOptions = {
+ host: Endpoints.auth,
+ projectKey: ClientApiData.projectKey,
+ credentials: {
+ clientId: ClientApiData.clientId,
+ clientSecret: ClientApiData.clientSecret
+ },
+ scopes: ClientApiData.scopes.split(' '),
+ fetch
+};
+
+const httpMiddlewareOptions: HttpMiddlewareOptions = {
+ host: Endpoints.base,
+ fetch
+};
+
+export const buildClientWithPasswordFlow = (username: string, password: string) => {
+ const options = {
+ host: Endpoints.auth,
+ projectKey: ClientApiData.projectKey,
+ credentials: {
+ clientId: ClientApiData.clientId,
+ clientSecret: ClientApiData.clientSecret,
+ user: {
+ username,
+ password
+ }
+ },
+ scopes: ClientApiData.scopes.split(' '),
+ fetch
+ };
+
+ return new ClientBuilder().withPasswordFlow(options).withHttpMiddleware(httpMiddlewareOptions).build();
+};
+
+export const buildClientWithClientCredentialsFlow = () => {
+ return new ClientBuilder()
+ .withClientCredentialsFlow(authMiddlewareOptions)
+ .withHttpMiddleware(httpMiddlewareOptions)
+ .build();
+};
+
+const oauthMiddlewareOptions: AuthMiddlewareOptions = {
+ host: Endpoints.auth,
+ projectKey: ClientApiData.projectKey,
+ credentials: {
+ clientId: ClientApiData.clientId,
+ clientSecret: ClientApiData.clientSecret
+ },
+ scopes: ClientApiData.scopes.split(' '),
+ fetch
+};
+
+export const buildClientWithAnonymousSessionFlow = () => {
+ return new ClientBuilder()
+ .withAnonymousSessionFlow(oauthMiddlewareOptions)
+ .withHttpMiddleware(httpMiddlewareOptions)
+ .build();
+};
diff --git a/src/helpers/api/apiRoot.ts b/src/helpers/api/apiRoot.ts
new file mode 100644
index 0000000..ab40a15
--- /dev/null
+++ b/src/helpers/api/apiRoot.ts
@@ -0,0 +1,228 @@
+import { ClientApiData } from '../../utils/clientApiData';
+import type { CustomerData, QueryArgs } from '../../utils/types';
+import {
+ buildClientWithAnonymousSessionFlow,
+ buildClientWithClientCredentialsFlow,
+ buildClientWithPasswordFlow
+} from './BuildClient';
+import {
+ createApiBuilderFromCtpClient,
+ type CustomerChangeAddressAction,
+ type CategoryPagedQueryResponse,
+ type ProductProjectionPagedQueryResponse,
+ type CustomerUpdateAction,
+ type CustomerPagedQueryResponse,
+ type ClientResponse,
+ type CustomerSignInResult,
+ type Cart,
+ type Customer,
+ type ProductProjection,
+ type CartPagedQueryResponse,
+ type MyCartDraft,
+ type CartUpdateAction,
+ type DiscountCodeDraft,
+ type DiscountCodePagedQueryResponse,
+ type DiscountCode,
+ type CartDiscount,
+ type CartDiscountUpdateAction
+} from '@commercetools/platform-sdk';
+
+const ctpClient = buildClientWithClientCredentialsFlow();
+const apiRoot = createApiBuilderFromCtpClient(ctpClient).withProjectKey({
+ projectKey: ClientApiData.projectKey
+});
+
+export const getCustomers = async (): Promise> => {
+ return await apiRoot.customers().get().execute();
+};
+
+export const getCategories = async (): Promise => {
+ const response = await apiRoot.categories().get().execute();
+ return response.body;
+};
+
+export const fetchProducts = async (): Promise => {
+ const response = await apiRoot.productProjections().get().execute();
+ return response.body;
+};
+
+export const fetchFilteredProducts = async (
+ queryArgs: QueryArgs
+): Promise => {
+ const limit = queryArgs.limit ?? 200;
+ const response = await apiRoot
+ .productProjections()
+ .search()
+ .get({
+ queryArgs: {
+ fuzzy: true,
+ markMatchingVariants: true,
+ ...queryArgs,
+ limit
+ }
+ })
+ .execute();
+
+ return response.body;
+};
+
+export const createCustomer = async (data: CustomerData): Promise> => {
+ return await apiRoot
+ .customers()
+ .post({
+ body: data
+ })
+ .execute();
+};
+
+export const customerLogIn = async (
+ email: string,
+ password: string
+): Promise> => {
+ const authClient = buildClientWithPasswordFlow(email, password);
+ const authApiRoot = createApiBuilderFromCtpClient(authClient).withProjectKey({
+ projectKey: ClientApiData.projectKey
+ });
+ return await authApiRoot
+ .login()
+ .post({
+ body: {
+ email,
+ password
+ }
+ })
+ .execute();
+};
+
+export const getCustomerCarts = async (id: string): Promise> => {
+ return await apiRoot
+ .carts()
+ .withCustomerId({
+ customerId: id
+ })
+ .get()
+ .execute();
+};
+
+export const updateCustomer = async (
+ id: string,
+ version: number,
+ actions: CustomerUpdateAction[] | CustomerChangeAddressAction[]
+): Promise> => {
+ return await apiRoot
+ .customers()
+ .withId({
+ ID: id
+ })
+ .post({
+ body: {
+ version,
+ actions
+ }
+ })
+ .execute();
+};
+
+export const getProduct = async (id: string): Promise => {
+ try {
+ const response = await apiRoot.productProjections().withId({ ID: id }).get().execute();
+ return response.body;
+ } catch (error) {
+ console.log(error);
+ }
+};
+
+export const updateCustomerPassword = async (body: {
+ id: string;
+ version: number;
+ currentPassword: string;
+ newPassword: string;
+}): Promise> => {
+ return await apiRoot
+ .customers()
+ .password()
+ .post({
+ body
+ })
+ .execute();
+};
+const anonymousClient = buildClientWithAnonymousSessionFlow();
+export const anonymousApiRoot = createApiBuilderFromCtpClient(anonymousClient).withProjectKey({
+ projectKey: ClientApiData.projectKey
+});
+
+// ัะพะทะดะฐัั ะบะพัะทะธะฝั (ะพะฑัะตะบั ะบะพัะทะธะฝั ัะพะดะตัะถะธั ะฒะฐะปััั ะธ ะบะฐััะพะผะตั ะฐะนะดะธ ะธะปะธ ะฐะฝะพะฝะธะผ ะฐะนะดะธ) ััะธ ะฟะพะปั ะผะพะณัั ะฟะตัะตะฝะฐะทะฝะฐัะฐัััั
+export const createCart = async (cart: MyCartDraft): Promise> => {
+ return await anonymousApiRoot
+ .me()
+ .carts()
+ .post({
+ body: cart
+ })
+ .execute();
+};
+
+export const getAllCarts = async (): Promise> => {
+ return await apiRoot.carts().get().execute();
+};
+
+export const getCartWithId = async (id: string): Promise> => {
+ return await apiRoot.carts().withId({ ID: id }).get().execute();
+};
+
+export const getCartWithCustomerId = async (customerId: string): Promise> => {
+ return await apiRoot.carts().withCustomerId({ customerId }).get().execute();
+};
+
+export const updateCart = async (
+ cartId: string,
+ version: number,
+ actions: CartUpdateAction[]
+): Promise> => {
+ return await apiRoot
+ .carts()
+ .withId({
+ ID: cartId
+ })
+ .post({
+ body: {
+ version,
+ actions
+ }
+ })
+ .execute();
+};
+
+// for development
+export const createDiscountCode = async (data: DiscountCodeDraft): Promise> => {
+ return await apiRoot
+ .discountCodes()
+ .post({
+ body: data
+ })
+ .execute();
+};
+
+export const queryDiscounts = async (): Promise> => {
+ return await apiRoot.discountCodes().get().execute();
+};
+
+// for development
+export const updateCartDiscount = async (
+ id: string,
+ version: number,
+ actions: CartDiscountUpdateAction[]
+): Promise> => {
+ return await apiRoot
+ .cartDiscounts()
+ .withId({
+ ID: id
+ })
+ .post({
+ body: {
+ version,
+ actions
+ }
+ })
+ .execute();
+};
diff --git a/src/helpers/api/cart/anonymousAuth.ts b/src/helpers/api/cart/anonymousAuth.ts
new file mode 100644
index 0000000..7525a80
--- /dev/null
+++ b/src/helpers/api/cart/anonymousAuth.ts
@@ -0,0 +1,14 @@
+import type { Cart, ClientResponse } from '@commercetools/platform-sdk';
+import { anonymousApiRoot } from '../apiRoot';
+
+export const anonymousAuth = async (): Promise> => {
+ return await anonymousApiRoot
+ .me()
+ .carts()
+ .post({
+ body: {
+ currency: 'EUR'
+ }
+ })
+ .execute();
+};
diff --git a/src/helpers/showToastMessage.ts b/src/helpers/showToastMessage.ts
new file mode 100644
index 0000000..8f0c392
--- /dev/null
+++ b/src/helpers/showToastMessage.ts
@@ -0,0 +1,19 @@
+import 'toastify-js/src/toastify.css';
+import Toastify from 'toastify-js';
+
+export const showToastMessage = (message: string, color: 'green' | 'red'): void => {
+ const background =
+ color === 'green'
+ ? 'linear-gradient(to right, #00b09b, #96c93d)'
+ : 'linear-gradient(to right, #ff758c, #ff9900)';
+
+ Toastify({
+ text: message,
+ duration: 3000,
+ newWindow: true,
+ close: true,
+ gravity: 'bottom',
+ position: 'right',
+ backgroundColor: background
+ }).showToast();
+};
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
new file mode 100644
index 0000000..adc08e5
--- /dev/null
+++ b/src/hooks/useAuth.ts
@@ -0,0 +1,75 @@
+import type { AuthReturnInterface, HandleSubmitWithBoth, LoginInterface } from '../utils/types';
+import { useNavigate } from 'react-router-dom';
+import { NavRoutes } from '../utils/routes';
+import { createCustomer, customerLogIn } from '../helpers/api/apiRoot';
+import { showToastMessage } from '../helpers/showToastMessage';
+import { addressAdapter } from '../components/forms/util/addressDataAdapter';
+import { AuthMessages } from '../components/forms/util/authMessages';
+import { setCustomer, setLogged } from '../slices/authSlice';
+import { useDispatch } from 'react-redux';
+import { type Customer } from '@commercetools/platform-sdk';
+import { StatusCodes } from '../utils/statusCodes';
+import { setCartData } from '../slices/cartSlice';
+import { useCart } from './useCart';
+
+export const useAuth = (): AuthReturnInterface => {
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const { cart, mergeAnonymousCartAfterSignUp } = useCart();
+
+ const successfulAuth = (customer: Customer, message: string) => {
+ showToastMessage(message, 'green');
+ localStorage.setItem('customer', JSON.stringify(customer));
+ navigate({ pathname: NavRoutes.mainPagePath });
+ dispatch(setLogged(true));
+ dispatch(setCustomer(customer));
+ };
+
+ const login = async ({ email, password }: LoginInterface): Promise => {
+ try {
+ const {
+ statusCode,
+ body: { customer }
+ } = await customerLogIn(email, password);
+
+ if (statusCode === StatusCodes.OK) {
+ successfulAuth(customer, AuthMessages.successLoginMessage);
+ }
+ } catch (error) {
+ showToastMessage(AuthMessages.failedLoginMessage, 'red');
+ }
+ };
+
+ const logout = (): void => {
+ localStorage.clear();
+ dispatch(setLogged(false));
+ dispatch(setCustomer(null));
+ dispatch(setCartData(null));
+ };
+
+ const signUp = async (values: HandleSubmitWithBoth): Promise => {
+ try {
+ const normalizedData = addressAdapter(values);
+ const {
+ statusCode,
+ body: { customer }
+ } = await createCustomer(normalizedData);
+
+ if (statusCode === StatusCodes.CREATED) {
+ showToastMessage(AuthMessages.successRegistrationMessage, 'green');
+ if (cart !== null) {
+ await mergeAnonymousCartAfterSignUp(cart.id, cart.version, customer.id);
+ }
+ await login({ email: values.email, password: values.password });
+ }
+ } catch (error) {
+ if (!(error instanceof Error)) return;
+
+ error.message === AuthMessages.existingCustomerError
+ ? showToastMessage(AuthMessages.existingCustomerMessage, 'red')
+ : showToastMessage(AuthMessages.otherErrorMessage, 'red');
+ }
+ };
+
+ return { login, logout, signUp };
+};
diff --git a/src/hooks/useCart.ts b/src/hooks/useCart.ts
new file mode 100644
index 0000000..4c2d1d2
--- /dev/null
+++ b/src/hooks/useCart.ts
@@ -0,0 +1,122 @@
+import { useDispatch, useSelector } from 'react-redux';
+import { updateCart } from '../helpers/api/apiRoot';
+import { setCartToLs } from '../pages/CartPage/utils/cartStorage';
+import { setCartData } from '../slices/cartSlice';
+import type { RootState, AddToCartParams, UpdateItemQuantity } from '../utils/types';
+import type { Cart, CartUpdateAction, LineItem } from '@commercetools/platform-sdk';
+
+const enum UpdateCartActions {
+ addLineItem = 'addLineItem',
+ changeLineItemQuantity = 'changeLineItemQuantity',
+ removeLineItem = 'removeLineItem',
+ setCustomerId = 'setCustomerId',
+ addDiscountCode = 'addDiscountCode'
+}
+
+export const useCart = () => {
+ const cart = useSelector((state: { cart: RootState }) => state.cart.cart);
+ const customer = useSelector((state: { authData: RootState }) => state.authData.customer);
+ const dispatch = useDispatch();
+
+ const setCart = (cart: Cart) => {
+ dispatch(setCartData(cart));
+
+ if (customer === null) {
+ setCartToLs(cart);
+ }
+ };
+
+ const addToCart = async ({
+ cartId,
+ cartVersion,
+ productId
+ }: AddToCartParams): Promise => {
+ const updatedCart = await updateCart(cartId, cartVersion, [
+ {
+ action: UpdateCartActions.addLineItem,
+ productId,
+ quantity: 1
+ }
+ ]);
+ setCart(updatedCart.body);
+ return updatedCart.statusCode;
+ };
+
+ const updateQuantity = async ({
+ cartId,
+ cartVersion,
+ lineItemId,
+ quantity
+ }: UpdateItemQuantity): Promise => {
+ const updatedCart = await updateCart(cartId, cartVersion, [
+ {
+ action: UpdateCartActions.changeLineItemQuantity,
+ lineItemId,
+ quantity
+ }
+ ]);
+ setCart(updatedCart.body);
+ };
+
+ const removeItemFromCart = async (
+ cartId: string,
+ cartVersion: number,
+ lineItemId: string,
+ quantity: number
+ ): Promise => {
+ const updatedCart = await updateCart(cartId, cartVersion, [
+ {
+ action: UpdateCartActions.removeLineItem,
+ lineItemId,
+ quantity
+ }
+ ]);
+ setCart(updatedCart.body);
+ };
+
+ const mergeAnonymousCartAfterSignUp = async (
+ cartId: string,
+ cartVersion: number,
+ customerId: string
+ ): Promise => {
+ const updatedCart = await updateCart(cartId, cartVersion, [
+ {
+ action: UpdateCartActions.setCustomerId,
+ customerId
+ }
+ ]);
+ setCart(updatedCart.body);
+ };
+
+ const clearCart = async (cartId: string, cartVersion: number, itemsList: LineItem[]): Promise => {
+ const removeActions: CartUpdateAction[] = itemsList.map((item) => ({
+ action: UpdateCartActions.removeLineItem,
+ lineItemId: item.id,
+ quantity: item.quantity
+ }));
+
+ const updatedCart = await updateCart(cartId, cartVersion, removeActions);
+ setCart(updatedCart.body);
+ };
+
+ const applyDiscount = async (cartId: string, cartVersion: number, discountCode: string): Promise => {
+ const updatedCart = await updateCart(cartId, cartVersion, [
+ {
+ action: UpdateCartActions.addDiscountCode,
+ code: discountCode
+ }
+ ]);
+
+ setCart(updatedCart.body);
+ };
+
+ return {
+ addToCart,
+ updateQuantity,
+ removeItemFromCart,
+ mergeAnonymousCartAfterSignUp,
+ applyDiscount,
+ clearCart,
+ cart
+ };
+};
diff --git a/src/hooks/useCategoryContext.ts b/src/hooks/useCategoryContext.ts
new file mode 100644
index 0000000..7e65ed7
--- /dev/null
+++ b/src/hooks/useCategoryContext.ts
@@ -0,0 +1,5 @@
+import { useContext } from 'react';
+import { CategoriesContext } from '../contexts';
+import type { CategoriesContextInterface } from '../utils/types';
+
+export const useCategoryContext = (): CategoriesContextInterface => useContext(CategoriesContext);
diff --git a/src/hooks/useUpdateCustomer.ts b/src/hooks/useUpdateCustomer.ts
new file mode 100644
index 0000000..4de0b00
--- /dev/null
+++ b/src/hooks/useUpdateCustomer.ts
@@ -0,0 +1,187 @@
+import type { InitialValuesCustomerPage, RootState, UseUpdateCustomer } from '../utils/types';
+import { updateCustomer, updateCustomerPassword } from '../helpers/api/apiRoot';
+import { showToastMessage } from '../helpers/showToastMessage';
+import { setCustomer } from '../slices/authSlice';
+import { useDispatch, useSelector } from 'react-redux';
+import type { CustomerUpdateAction, Customer, ClientResponse } from '@commercetools/platform-sdk';
+import {
+ getAddressesInitialValues,
+ getPersonalDataInitialValues
+} from '../components/forms/customerProfile/util/getInitialValues';
+import { StatusCodes } from '../utils/statusCodes';
+import { getCountryCode } from '../components/forms/util/getCountry';
+import { UpdateMessage } from '../components/forms/customerProfile/util/updateMessage';
+
+export const useUpdateCustomer = (): UseUpdateCustomer => {
+ const customer = useSelector((state: { authData: RootState }) => state.authData.customer) as Customer;
+ const dispatch = useDispatch();
+ const personalDataInitialValues = getPersonalDataInitialValues(customer);
+ const addressInitialValues = getAddressesInitialValues(customer);
+
+ const saveCustomer = (response: ClientResponse): void => {
+ const customer = response.body;
+ localStorage.setItem('customer', JSON.stringify(customer));
+ dispatch(setCustomer(customer));
+ showToastMessage(UpdateMessage.success, 'green');
+ };
+
+ const requestUpdateCustomer = async (
+ id: string,
+ version: number,
+ actions: CustomerUpdateAction[]
+ ): Promise => {
+ if (actions.length === 0) return;
+
+ try {
+ const response = await updateCustomer(id, version, actions);
+
+ if (response.statusCode === StatusCodes.OK) {
+ saveCustomer(response);
+ return;
+ }
+
+ showToastMessage(UpdateMessage.error, 'red');
+ } catch (error) {
+ showToastMessage(UpdateMessage.error, 'red');
+ }
+ };
+
+ const onPersonalDataSubmit = async (value: InitialValuesCustomerPage): Promise => {
+ if (!('firstName' in value)) return;
+
+ const actions = [] as CustomerUpdateAction[];
+ // todo: implement switch-case
+ if (value.firstName !== personalDataInitialValues.firstName) {
+ actions.push({
+ action: 'setFirstName',
+ firstName: value.firstName
+ });
+ }
+ if (value.lastName !== personalDataInitialValues.lastName) {
+ actions.push({
+ action: 'setLastName',
+ lastName: value.lastName
+ });
+ }
+ if (value.date !== personalDataInitialValues.date) {
+ actions.push({
+ action: 'setDateOfBirth',
+ dateOfBirth: value.date
+ });
+ }
+ if (value.email !== personalDataInitialValues.email) {
+ actions.push({
+ action: 'changeEmail',
+ email: value.email
+ });
+ }
+
+ await requestUpdateCustomer(customer.id, customer.version, actions);
+ };
+
+ const onAddressChangeSubmit = async (value: InitialValuesCustomerPage): Promise => {
+ if (!('streetName' in value)) return;
+ const {
+ streetName,
+ city,
+ country,
+ postalCode,
+ id,
+ billingStateChecked,
+ shippingStateChecked,
+ defaultShippingAddress,
+ defaultBillingAddress
+ } = value;
+ const currentAddress = addressInitialValues.find((address) => address.id === id);
+ const actions = [] as CustomerUpdateAction[];
+ const isNewAddress = currentAddress === undefined;
+
+ if (
+ streetName !== currentAddress?.streetName ||
+ city !== currentAddress?.city ||
+ country !== currentAddress?.country ||
+ postalCode !== currentAddress?.postalCode
+ ) {
+ const addressAction = {
+ ...(isNewAddress
+ ? { action: 'addAddress' as const }
+ : { action: 'changeAddress' as const, addressId: id }),
+ address: {
+ streetName,
+ city,
+ country: getCountryCode(country),
+ postalCode
+ }
+ };
+ actions.push(addressAction);
+ }
+
+ if (defaultBillingAddress && defaultBillingAddress !== currentAddress?.defaultBillingAddress) {
+ actions.push({
+ action: 'setDefaultBillingAddress' as const,
+ addressId: id
+ });
+ }
+
+ if (defaultShippingAddress && defaultShippingAddress !== currentAddress?.defaultShippingAddress) {
+ actions.push({
+ action: 'setDefaultShippingAddress' as const,
+ addressId: id
+ });
+ }
+
+ if (!isNewAddress && billingStateChecked !== currentAddress?.billingStateChecked) {
+ actions.push({
+ action: billingStateChecked ? ('addBillingAddressId' as const) : ('removeBillingAddressId' as const),
+ addressId: id
+ });
+ }
+
+ if (!isNewAddress && shippingStateChecked !== currentAddress?.shippingStateChecked) {
+ actions.push({
+ action: shippingStateChecked
+ ? ('addShippingAddressId' as const)
+ : ('removeShippingAddressId' as const),
+ addressId: id
+ });
+ }
+
+ await requestUpdateCustomer(customer.id, customer.version, actions);
+ };
+
+ const onPasswordChangeSubmit = async (value: InitialValuesCustomerPage): Promise => {
+ if (!('newPassword' in value)) return;
+
+ const body = {
+ id: customer.id,
+ version: customer.version,
+ currentPassword: value.currentPassword,
+ newPassword: value.newPassword
+ };
+
+ try {
+ const response = await updateCustomerPassword(body);
+ if (response.statusCode === StatusCodes.OK) {
+ saveCustomer(response);
+ return;
+ }
+
+ showToastMessage(UpdateMessage.error, 'red');
+ } catch (error) {
+ showToastMessage(UpdateMessage.error, 'red');
+ }
+ };
+
+ const onAddressDelete = async (id: string): Promise => {
+ const actions = [
+ {
+ action: 'removeAddress' as const,
+ addressId: id
+ }
+ ];
+
+ await requestUpdateCustomer(customer.id, customer.version, actions);
+ };
+
+ return { onPersonalDataSubmit, onAddressChangeSubmit, onPasswordChangeSubmit, onAddressDelete };
+};
diff --git a/src/i18n.ts b/src/i18n.ts
new file mode 100644
index 0000000..6000596
--- /dev/null
+++ b/src/i18n.ts
@@ -0,0 +1,16 @@
+import i18next from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import { en } from './locales/en';
+
+i18next
+ .use(initReactI18next)
+ .init({
+ debug: true,
+ fallbackLng: 'en',
+ resources: {
+ en
+ }
+ })
+ .catch((error) => {
+ throw error;
+ });
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644
index 0000000..be8bdfe
--- /dev/null
+++ b/src/index.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import './styles/index.css';
+import { Init } from './Init';
+
+const rootElement = document.getElementById('root') as HTMLElement;
+const root = ReactDOM.createRoot(rootElement);
+root.render(
+
+
+
+);
diff --git a/src/locales/en.ts b/src/locales/en.ts
new file mode 100644
index 0000000..2fbeb4e
--- /dev/null
+++ b/src/locales/en.ts
@@ -0,0 +1,58 @@
+export const en = {
+ translation: {
+ attributes: {
+ color: {
+ title: 'Color',
+ black: 'Black',
+ blue: 'Blue',
+ gray: 'Gray',
+ green: 'Green',
+ red: 'Red',
+ white: 'White',
+ yellow: 'Yellow'
+ },
+ details: {
+ title: 'Details',
+ 12: '12',
+ 24: '24',
+ 36: '36',
+ 54: '54'
+ },
+ model: {
+ title: 'Model',
+ 'FS-44': 'FS-44',
+ 'FT-6567': 'FT-6567',
+ G44: 'G44',
+ 'GP-45+': 'GP-45+',
+ WG47: 'WG47'
+ },
+ rating: {
+ title: 'Rating',
+ 2: '1 & Up',
+ 3: '2 & Up',
+ 4: '3 & Up',
+ 5: '4 & Up'
+ },
+ type: {
+ title: 'Type',
+ animal: 'Animal',
+ insects: 'Insects',
+ bird: 'Bird',
+ humanoid: 'Humanoid',
+ car: 'Car'
+ }
+ },
+ button: {
+ reset: 'RESET'
+ },
+ dropDown: {
+ sort: {
+ name: 'Sort:',
+ price: 'price',
+ rating: 'rating',
+ asc: 'Ascending',
+ desc: 'Descending'
+ }
+ }
+ }
+};
diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx
new file mode 100644
index 0000000..e4b9ff6
--- /dev/null
+++ b/src/pages/AboutPage.tsx
@@ -0,0 +1,201 @@
+import { Link } from 'react-router-dom';
+
+export const team = [
+ {
+ id: 1,
+ name: 'Vladislav',
+ foto: 'https://avatars.githubusercontent.com/u/121193837?v=4',
+ JobTitle: 'Frontend Developer',
+ location: 'Tรผrkiye - Marmaris',
+ github: 'https://github.com/glitch-surfer',
+ email: 'https://marker661@gmail.com',
+ telegram: 'https://t.me/VladislavGa',
+ biography:
+ 'I always liked technology and wanted to try my hand at programming. Also, I consider myself a visual person - a person who experiences the world through visual images. I see beauty in symmetry and graphic patterns. For these reasons, I didnโt think long about which development direction to choose - Front-end, of course. My other hobbies are medicine and biology, sports, finance, sci-fi and fantasy.'
+ },
+ {
+ id: 2,
+ name: 'Pavel Yudenka',
+ foto: 'https://avatars.githubusercontent.com/u/66093034?v=4',
+ JobTitle: 'Frontend Developer',
+ location: 'Belarus - Minsk',
+ github: 'https://github.com/Pavelvl21',
+ email: 'https://p.yudenko@gmail.com',
+ telegram: 'https://t.me/Pavel_vld',
+ biography:
+ "I'm 37 years old and I live and work in Minsk. I have a degree in economics, which I obtained from the International University MITSO. I have extensive experience working in teams. Currently, I work in the largest online store in Belarus๐, managing a product category.\nI have a passion for programming, which has become an integral part of both my hobby and professional activities. Solving challenging problems and finding effective solutions always excite me.\nIn addition, one of my unusual hobbies is growing trees from seeds.๐ณ It's a fascinating activity that teaches me to care for nature and the environment.\nMy dream is to make the world a little better by creating connections between people and technology. I believe that with the help of innovation and technological advancement, we can improve the quality of life and make our planet more harmonious and sustainable.๐"
+ },
+ {
+ id: 3,
+ name: 'Maksim Fil',
+ foto: 'https://avatars.githubusercontent.com/u/61318799?v=4',
+ JobTitle: 'Frontend Developer',
+ location: 'Russia - Moscow',
+ github: 'https://github.com/FilMaxim',
+ email: 'https://fil.maxim13@gmail.com',
+ telegram: 'https://t.me/max_fil13',
+ biography:
+ 'I was born in Donetsk on November 15, 1992. Graduated from Donetsk National Technical University in 2011 year๐ซ, I have a higher technical education, mechanical engineering. I work as a chief engineer and programmer of CNC machines.๐ต๏ธโโ๏ธ \n Iโve been doing front-end development at RS School for a year now, itโs very interesting and addictive like a drug. \n I also travel a lot and am not tied to my place of residence. Visited many countries. I love football โฝ๏ธ. And I want to fly to Mars or the Moon.๐'
+ }
+];
+
+export const AboutPage = () => {
+ return (
+ <>
+
+
+ Meet the team members
+
+ {team.map((member, index) => {
+ let classBlock = 'flex gap-10 items-center pb-2 lg:mb-0 max-sm:flex-col max-sm:gap-4';
+ if (index === 1) {
+ classBlock += ' flex-row-reverse';
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {member.name} ({member.JobTitle})
+
+
{member.location}
+
+ {member.biography.split('\n').map((line, index) => (
+
{line}
+ ))}
+
+
+
+
+
+ );
+ })}
+
+
+
+ The difficult path to success
+
+
Our 'console.log' team are masters, who love what they do.
+
+ We are all students of the wonderful
+
+ {' '}
+ RS Shool.{' '}
+
+ We were found and united into a single team by a wonderful mentor,
+
+ {' '}
+ Vladislav Kutarkin
+
+ . One final project, three unique student developers, four sprints and very little time to solve
+ problems.
+
+
+ Why am I talking about unique students? At first, collaboration between us seemed difficult. We
+ are all very different people: different in character, we all have our own distinct ambitions,
+ our own presentation of information, our own different views on solving problems, different
+ levels of knowledge, etc. And it seemed that it was impossible to achieve goals in this team.
+ Conflicts often arose. This difference in potential caused disruptions in the team's work,
+ and it seemed that we would not reach the finish line. But when we fell, we got up and moved on,
+ or simply crawled along the hot sand. And when it seemed that everything was lost, we did not
+ give up. Perseverance and thoroughness helped us overcome any technical difficulties.
+
+
+ However, over time we realized that our diversity of characters and ambitions could become a
+ strength. It was a wonderful experience working in a difficult team. We were able to replace the
+ shortcomings of one team member with the advantages of another member. As a result of joint
+ efforts, we have created an interesting project. Different characters and ambitions began to
+ complement each other, make the team stronger and more successful.
+
+
+ This experience showed that in the world of software development, diversity can be the key to
+ success, and that even three completely different people can create something great by joining
+ forces.
+
+
+ Each participant made a huge contribution to the project. The distribution of roles was carried
+ out jointly by all members of the online discussion team. We tried to divide the scope of work
+ into three equal parts so that each participant was equally involved in the project process.{' '}
+ {team[0].name} was developing user pages, registrations and shopping carts. {team[1].name} wrote
+ the catalog and login page. {team[2].name} โ product page, page about us. In this way, each
+ project participant will make an important contribution to achieving the common goal. Joint
+ efforts and cooperation of all participants as a unified approach to creating an innovative and
+ successful product. We gained a lot of experience working with js libraries and experience
+ working in a team.
+
+
+ Thank you{' '}
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/pages/CartPage/CartPage.tsx b/src/pages/CartPage/CartPage.tsx
new file mode 100644
index 0000000..7985d45
--- /dev/null
+++ b/src/pages/CartPage/CartPage.tsx
@@ -0,0 +1,158 @@
+import { useCart } from '../../hooks/useCart';
+import { queryDiscounts } from '../../helpers/api/apiRoot';
+import { useState } from 'react';
+import { showToastMessage } from '../../helpers/showToastMessage';
+import { ProgressBar } from '../../components/ProgressBar/ProgressBar';
+import Button from '@mui/material/Button';
+import IconButton from '@mui/material/IconButton';
+import DeleteIcon from '@mui/icons-material/Delete';
+import { SimpleInput } from '../../components/forms/inputs/SimpleInput';
+import { EmptyCart } from '../../components/cart/emptyCart';
+import { getInitialCartTotalPrice } from './utils/utils';
+import { Link } from 'react-router-dom';
+
+const priceDelim = 100;
+
+export const CartPage = () => {
+ const { updateQuantity, removeItemFromCart, cart, clearCart, applyDiscount } = useCart();
+ const [discounts, setDiscounts] = useState('');
+
+ if (cart === null) return ;
+ if (cart.lineItems.length === 0) return ;
+
+ const initialPrice = getInitialCartTotalPrice(cart.lineItems);
+
+ const discountHandler = async () => {
+ const discountsList = (await queryDiscounts()).body.results;
+ const currentDiscount = discountsList.find((discount) => discount.code === discounts);
+ setDiscounts('');
+
+ if (currentDiscount !== undefined) {
+ await applyDiscount(cart.id, cart.version, currentDiscount.code);
+ showToastMessage(`Promocode ${currentDiscount.code} applied!`, 'green');
+ return;
+ }
+ showToastMessage('Invalid promocode', 'red');
+ };
+
+ return (
+
+
{
+ const confirm = window.confirm('Delete all items?');
+ if (confirm) {
+ clearCart(cart.id, cart.version, cart.lineItems).catch((e) => {
+ Error(e);
+ });
+ }
+ }}
+ color="secondary"
+ variant="text"
+ className="self-end"
+ >
+ Clear Cart
+
+
+ {cart.lineItems.map((item) => {
+ const price =
+ item.variant.prices?.[0].discounted !== undefined
+ ? item.variant.prices?.[0].discounted?.value.centAmount
+ : item.variant.prices?.[0].value.centAmount;
+ if (price === undefined) throw new Error('Price is undefined');
+
+ const normalizedPrice = price / priceDelim;
+
+ return (
+
+
+
+ {item.name['en-US']}
+
+ {
+ removeItemFromCart(cart.id, cart.version, item.id, item.quantity).catch((e) => {
+ Error(e);
+ });
+ }}
+ className="self-center hover:text-secondary sm:col-span-3"
+ >
+
+
+
+ Price:
+
โฌ {normalizedPrice}
+
+ {
+ updateQuantity({
+ cartId: cart.id,
+ cartVersion: cart.version,
+ lineItemId: item.id,
+ centAmount: item.variant.prices?.[0].value.centAmount as number,
+ quantity: parseInt(e.target.value)
+ }).catch((e) => {
+ Error(e);
+ });
+ }}
+ min={1}
+ />
+
+ Subtotal:
+
+ โฌ {item.quantity * normalizedPrice}
+
+
+
+ );
+ })}
+
+
+ {
+ discountHandler().catch((e) => {
+ Error(e);
+ });
+ }}
+ >
+ Apply
+
+
+
+ Total price: โฌ{' '}
+ {cart.totalPrice.centAmount / priceDelim}{' '}
+ {cart.discountCodes.length > 0 && (
+ {initialPrice / priceDelim}
+ )}
+
+
+
+ );
+};
diff --git a/src/pages/CartPage/utils/cartStorage.ts b/src/pages/CartPage/utils/cartStorage.ts
new file mode 100644
index 0000000..009fabc
--- /dev/null
+++ b/src/pages/CartPage/utils/cartStorage.ts
@@ -0,0 +1,9 @@
+import { type Cart } from '@commercetools/platform-sdk';
+
+export const getCartFromLs = (): Cart | null => {
+ return JSON.parse(localStorage.getItem('cart') ?? 'null');
+};
+
+export const setCartToLs = (cart: Cart): void => {
+ localStorage.setItem('cart', JSON.stringify(cart));
+};
diff --git a/src/pages/CartPage/utils/createCartHandler.ts b/src/pages/CartPage/utils/createCartHandler.ts
new file mode 100644
index 0000000..1202a31
--- /dev/null
+++ b/src/pages/CartPage/utils/createCartHandler.ts
@@ -0,0 +1,10 @@
+import { type Cart } from '@commercetools/platform-sdk';
+import { createCart } from '../../../helpers/api/apiRoot';
+
+export const createCartHandler = async (customerId?: string): Promise => {
+ if (customerId !== undefined) {
+ const cartDraft = { customerId, currency: 'EUR' };
+ return (await createCart(cartDraft)).body;
+ }
+ return (await createCart({ currency: 'EUR' })).body;
+};
diff --git a/src/pages/CartPage/utils/utils.ts b/src/pages/CartPage/utils/utils.ts
new file mode 100644
index 0000000..770ead3
--- /dev/null
+++ b/src/pages/CartPage/utils/utils.ts
@@ -0,0 +1,7 @@
+import type { LineItem } from '@commercetools/platform-sdk';
+
+export const getInitialCartTotalPrice = (cartItemsList: LineItem[]) => {
+ return cartItemsList.reduce((acc, item) => {
+ return acc + item.price.value.centAmount * item.quantity;
+ }, 0);
+};
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..eddb8a6
--- /dev/null
+++ b/src/pages/LoginPage.tsx
@@ -0,0 +1,10 @@
+import { LoginForm } from '../components/forms/LoginForm/LoginForm';
+
+export const LoginPage = () => {
+ return (
+
+
Log In
+
+
+ );
+};
diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx
new file mode 100644
index 0000000..4c72151
--- /dev/null
+++ b/src/pages/MainPage.tsx
@@ -0,0 +1,296 @@
+import { useNavigate } from 'react-router-dom';
+import mainFoto from '../assets/main.webp';
+
+const HeroSection = () => {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+ Robots Unleashed. Shop the Future.
+
+
+ We are an online store for the latest robot designs, offering a wide selection of affordable and
+ fashionable devices that can make your life easier. Use our solutions and your life will take on
+ new colors.
+
+
{
+ navigate({ pathname: '/catalog/' });
+ }}
+ >
+ Catalog
+
+
+
+
+
+
+ );
+};
+
+const OurIdeas = () => {
+ return (
+
+
+
+ Our ideas
+
+
+
+
+
Human-made robots of the future
+
September 16, 2023
+
+ Our task is to share with all people in the World information about innovative robots and
+ robotic technologies that shape the modern and future future of our great Planet Earth.
+
+
+ We are here to inspire, inform, and empower a growing community of robot consumers like you,
+ whether they are hobbyists, scientists, enthusiasts, or curious shoppers.
+
+
+ Our friendly team of editors collects all the information from around the world you need to fuel
+ your high-tech lifestyle: what robots are on the market, where to buy them, how to use them and
+ how they get into competing products, their future in human development.
+
+
+ In addition, on our website you have the opportunity to study new wireless nanotechnologies, the
+ stages of humanitys creation of artificial intelligence, and many other interesting things
+ designed to keep you one step ahead.
+
+
+ We are ready to inform you about new robots of the future that will change your lifestyle!
+
+
+
+
+
+ );
+};
+
+const WhatWeDoSection = () => {
+ return (
+
+
+
+
+
+ What We Do?
+
+
+ Our team is committed to being at the top of their game, meeting the needs of our clients and
+ helping new developers.
+
+
+
+
+
+
+
+
+ Searching of decisions
+
+
Selection of products to suit your requirements and goals.
+
+
+
+
+
+
Support
+
Feedback 24 hours throughout the entire period of operation.
+
+
+
+
+
+
Individual approach
+
Personalized service for each client.
+
fillerfiller
+
+
+
+
+
+
Brand
+
+ We cooperate with the most famous global manufacturers in the world.
+
+
+
+
+
+
+
Economically
+
+ Low prices. In search of the best on the market in terms of price - quality.
+
+
+
+
+
+
+
Optimization
+
+ We think about cost optimization in every process from start to finish.
+
+
+
+
+
+
+
+ );
+};
+
+const SpecialOfferSection = () => {
+ return (
+
+
+
+
+
+
just now
+
+
+ Get 20% off
+
+ On your next order over $50
+
+
+
+ With Code:
+ vlad20
+
+
+ Offer valid until 30th September, 2023 *
+
+
+
+
+ );
+};
+
+export const MainPage = () => {
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..c272b5e
--- /dev/null
+++ b/src/pages/NotFoundPage.tsx
@@ -0,0 +1,19 @@
+import { Link } from 'react-router-dom';
+import error404 from '../assets/404.webp';
+import { NavRoutes } from '../utils/routes';
+export const NotFoundPage = () => (
+
+
+
Sorry, the page you requested was not found...
+
+ Go To Home
+
+
+);
diff --git a/src/pages/ProductPage/ProductPage.tsx b/src/pages/ProductPage/ProductPage.tsx
new file mode 100644
index 0000000..2e427d5
--- /dev/null
+++ b/src/pages/ProductPage/ProductPage.tsx
@@ -0,0 +1,86 @@
+import { useEffect, useState } from 'react';
+import { Carousel } from 'react-responsive-carousel';
+import 'react-responsive-carousel/lib/styles/carousel.css';
+import { getProduct } from '../../helpers/api/apiRoot';
+import { useParams } from 'react-router-dom';
+import { getProductParams } from '../catalogPage/utils/getProductParams';
+import { type ProductsDataInterface } from '../../utils/types';
+import { ModalPreview } from './component/ModalPreview';
+import { ProductDetails } from './component/ProductDetails';
+import { ProgressBar } from '../../components/ProgressBar/ProgressBar';
+
+export const Product = () => {
+ const { id } = useParams();
+ const [product, setProduct] = useState(null);
+ const [currentImageIndex, setCurrentImageIndex] = useState(0);
+ const [modalPreviewOpen, setModalPreviewOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const fetchProduct = async (id: string) => {
+ setIsLoading(true);
+ const newProduct = await getProduct(id);
+ if (newProduct === undefined) return;
+ setProduct(getProductParams(newProduct, newProduct.masterVariant));
+ setIsLoading(false);
+ };
+
+ useEffect(() => {
+ if (id !== undefined) {
+ fetchProduct(id).catch((error) => {
+ if (!(error instanceof Error)) return;
+ throw new Error(error.message);
+ });
+ }
+ }, [id]);
+
+ if (isLoading) return ;
+
+ if (product === null) return Can't find product
;
+ const rating = product.attributes?.find((obj) => obj.name === 'rating');
+ const color = product.attributes?.find((obj) => obj.name === 'color');
+ return (
+
+
+ {product.images?.map((image) => (
+ {
+ setModalPreviewOpen(true);
+ }}
+ >
+
+
+ ))}
+
+
+ {modalPreviewOpen && (
+
+ )}
+
+ );
+};
diff --git a/src/pages/ProductPage/component/ModalPreview.tsx b/src/pages/ProductPage/component/ModalPreview.tsx
new file mode 100644
index 0000000..4671b15
--- /dev/null
+++ b/src/pages/ProductPage/component/ModalPreview.tsx
@@ -0,0 +1,66 @@
+import { Carousel } from 'react-responsive-carousel';
+import React from 'react';
+import { type ModalPreviewProps } from '../../../utils/types';
+
+export const ModalPreview: React.FC = ({
+ product,
+ currentImageIndex,
+ setCurrentImageIndex,
+ setModalPreviewOpen
+}) => {
+ return (
+ {
+ if (e.target === e.currentTarget) {
+ setModalPreviewOpen(false);
+ }
+ }}
+ role="presentation"
+ tabIndex={-1}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') {
+ setModalPreviewOpen(false);
+ }
+ }}
+ >
+
+
{
+ setModalPreviewOpen(false);
+ }}
+ >
+ X
+
+
+ {product.images?.map((img) => (
+ {
+ setModalPreviewOpen(false);
+ }}
+ >
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/pages/ProductPage/component/ProductDetails.tsx b/src/pages/ProductPage/component/ProductDetails.tsx
new file mode 100644
index 0000000..563d370
--- /dev/null
+++ b/src/pages/ProductPage/component/ProductDetails.tsx
@@ -0,0 +1,111 @@
+import React from 'react';
+import { Button, Rating } from '@mui/material';
+import { PriceTag } from '../../../components/cards/productCard/PriceTag';
+import Box from '@mui/material/Box';
+import { type ProductDetailsProps } from '../../../utils/types';
+import { useCart } from '../../../hooks/useCart';
+import { showToastMessage } from '../../../helpers/showToastMessage';
+import { UpdateMessage } from '../../../components/forms/customerProfile/util/updateMessage';
+
+export const ProductDetails: React.FC = ({ product, rating, color, id }) => {
+ const { addToCart, removeItemFromCart, cart } = useCart();
+ if (cart == null || id == null) {
+ return Can't find cart
;
+ }
+ const productLineItem = cart.lineItems.find((lineItem) => lineItem.productId === id);
+
+ const onAdd = () => {
+ if (id == null || cart == null) return;
+
+ void addToCart({
+ cartId: cart.id,
+ cartVersion: cart.version,
+ productId: id
+ })
+ .then(() => {
+ showToastMessage(UpdateMessage.addCart, 'green');
+ })
+ .catch(() => {
+ showToastMessage(UpdateMessage.error, 'red');
+ });
+ };
+ const onRemove = () => {
+ if (productLineItem == null || cart == null) return;
+
+ void removeItemFromCart(cart.id, cart.version, productLineItem.id, productLineItem.quantity)
+ .then(() => {
+ showToastMessage(UpdateMessage.delCart, 'green');
+ })
+ .catch(() => {
+ showToastMessage(UpdateMessage.error, 'red');
+ });
+ };
+
+ return (
+
+
+
{product.name}
+ {rating !== undefined && (
+
+
+
+ )}
+ {product.priceTag !== undefined && (
+
+ )}
+
{product.description}
+
+
+ {color !== undefined && (
+
+ Colours:
+ {color.value.map((colorName: string) => {
+ return (
+
+ );
+ })}
+
+ )}
+ {productLineItem == null && (
+
+ Buy Now
+
+ )}
+ {productLineItem != null && (
+
+ Remove From Card
+
+ )}
+
+ );
+};
diff --git a/src/pages/RegistrationPage.tsx b/src/pages/RegistrationPage.tsx
new file mode 100644
index 0000000..5b774b7
--- /dev/null
+++ b/src/pages/RegistrationPage.tsx
@@ -0,0 +1,19 @@
+import { RegistrationForm } from '../components/forms/RegistrationForm/RegistrationForm';
+import { initialValuesRegistration } from '../components/forms/inputs/inputsData';
+import { getValidationSchema } from '../components/forms/util/validationSchema';
+import { useAuth } from '../hooks/useAuth';
+
+export const RegistrationPage = () => {
+ const { signUp } = useAuth();
+
+ return (
+
+
Sign Up
+
+
+ );
+};
diff --git a/src/pages/UserProfilePage.tsx b/src/pages/UserProfilePage.tsx
new file mode 100644
index 0000000..bcdf063
--- /dev/null
+++ b/src/pages/UserProfilePage.tsx
@@ -0,0 +1,68 @@
+import type { Customer } from '@commercetools/platform-sdk';
+import { useSelector } from 'react-redux';
+import { CustomerPageForm } from '../components/forms/customerProfile/CustomerPageForm';
+import { AddressComponent } from '../components/forms/customerProfile/customerFormData/Address';
+import { PasswordChange } from '../components/forms/customerProfile/customerFormData/PasswordChange';
+import { PersonalData } from '../components/forms/customerProfile/customerFormData/PersonalData';
+import {
+ getPersonalDataInitialValues,
+ getAddressesInitialValues
+} from '../components/forms/customerProfile/util/getInitialValues';
+import {
+ customerPersonalDataSchema,
+ customerAddressSchema,
+ passwordChangeSchema
+} from '../components/forms/util/validationSchema';
+import { TabsPanel } from '../components/tabs/TabsPanel';
+import { useUpdateCustomer } from '../hooks/useUpdateCustomer';
+import type { RootState } from '../utils/types';
+
+export const UserProfilePage = () => {
+ const customer = useSelector((state: { authData: RootState }) => state.authData.customer) as Customer;
+ const { onPersonalDataSubmit, onAddressChangeSubmit, onPasswordChangeSubmit, onAddressDelete } =
+ useUpdateCustomer();
+ const personalDataInitialValues = getPersonalDataInitialValues(customer);
+ const passwordChangeInitialValues = {
+ currentPassword: '',
+ newPassword: ''
+ };
+ const addressInitialValues = getAddressesInitialValues(customer);
+
+ return (
+
+
+ {
+ onPersonalDataSubmit(value).catch((e) => {
+ Error(e);
+ });
+ }}
+ validationSchema={customerPersonalDataSchema}
+ formInner={PersonalData}
+ />
+ {
+ onAddressChangeSubmit(values).catch((err) => {
+ Error(err);
+ });
+ }}
+ onDelete={onAddressDelete}
+ />
+ {
+ onPasswordChangeSubmit(value).catch((e) => {
+ Error(e);
+ });
+ }}
+ validationSchema={passwordChangeSchema}
+ formInner={PasswordChange}
+ />
+
+
+ );
+};
diff --git a/src/pages/catalogPage/CatalogContent.tsx b/src/pages/catalogPage/CatalogContent.tsx
new file mode 100644
index 0000000..d0beaaf
--- /dev/null
+++ b/src/pages/catalogPage/CatalogContent.tsx
@@ -0,0 +1,132 @@
+import { useDispatch, useSelector } from 'react-redux';
+import { useCategoryContext } from '../../hooks/useCategoryContext';
+import type { RootState } from '../../utils/types';
+import { fetchFilteredProducts } from '../../helpers/api/apiRoot';
+import { updateProductsData } from './utils/updateData';
+import { type ChangeEvent, useEffect, useState, useMemo } from 'react';
+import { ProductCard } from '../../components/cards/productCard/ProductCard';
+import { trimText } from './utils/trimText';
+import { useLocation } from 'react-router-dom';
+import { Pagination, Stack } from '@mui/material';
+import { SkeletonCard } from '../../components/ProgressBar/SkeletonCard';
+import { ProgressBar } from '../../components/ProgressBar/ProgressBar';
+
+export const CatalogContent = () => {
+ const { setCategoryName, currentFilter, setCategoryId, setCurrentFilter } = useCategoryContext();
+ const dispatch = useDispatch();
+
+ const { total, cardsData } = useSelector((state: { productsData: RootState }) => state.productsData);
+ const categories = useSelector((state: { categoriesData: RootState }) => state.categoriesData.categories);
+ const [page, setPage] = useState(1);
+ const [pagesCount, setPagesCount] = useState(0);
+
+ const handleChange = (event: ChangeEvent, value: number) => {
+ setPage(value);
+ };
+ const { pathname } = useLocation();
+ const endpoint = pathname.substring(pathname.lastIndexOf('/') + 1);
+
+ const ednpointName = endpoint === 'catalog' ? '' : endpoint;
+
+ const cardsPerPage = 8;
+ const offset = useMemo(() => (page - 1) * cardsPerPage, [page, cardsPerPage]);
+
+ const eindpointData = categories.filter((item) => {
+ return item.name.toLowerCase() === ednpointName;
+ });
+ const id = eindpointData.length > 0 ? eindpointData[0].id : '';
+ const categoryFilter = id.length > 0 ? `categories.id:"${id}"` : '';
+
+ useEffect(() => {
+ setCategoryName(ednpointName);
+ setCategoryId(id);
+ setCurrentFilter([]);
+ }, [ednpointName, setCategoryName, setCategoryId, id, setCurrentFilter]);
+
+ const [isUpdated, setUpdated] = useState(false);
+ const [isLoaded, setLoaded] = useState(false);
+
+ useEffect(() => {
+ const updateData = async () => {
+ const filter = [categoryFilter];
+ const productsData = await fetchFilteredProducts({ filter, limit: 8 });
+ updateProductsData(dispatch, productsData);
+ setUpdated(true);
+ };
+
+ if (categories.length > 0) {
+ updateData().catch((error) => {
+ console.error(error);
+ });
+ }
+ }, [dispatch, categories, categoryFilter]);
+
+ useEffect(() => {
+ const updateData = async () => {
+ const filter = [categoryFilter, ...currentFilter];
+ const updatedProductsData = await fetchFilteredProducts({ filter, offset, limit: 8 });
+ updateProductsData(dispatch, updatedProductsData);
+ setUpdated(true);
+ setLoaded(true);
+ };
+ if (categories.length > 0) {
+ setLoaded(false);
+ updateData().catch((error) => {
+ console.error(error);
+ });
+ }
+ }, [offset, dispatch, categories.length]);
+
+ useEffect(() => {
+ const pagesCount = Math.ceil((total ?? 0) / cardsPerPage);
+ setPagesCount(pagesCount);
+ }, [total]);
+
+ useEffect(() => {
+ setPage(1);
+ }, [categoryFilter]);
+
+ if (!isUpdated) {
+ return ;
+ }
+ return (
+ isUpdated && (
+
+
+ {cardsData.map((item) => {
+ const { url, name, description, priceTag, id, attributes } = item;
+ const rating = attributes?.find((obj) => obj.name === 'rating');
+ const { price, discount } = priceTag;
+ const formattedDescription = trimText(description);
+ // prettier-ignore
+ return isLoaded
+ ? (
)
+ :
;
+ })}
+
+
+
+
+
+ )
+ );
+};
diff --git a/src/pages/catalogPage/CatalogPage.tsx b/src/pages/catalogPage/CatalogPage.tsx
new file mode 100644
index 0000000..90dab92
--- /dev/null
+++ b/src/pages/catalogPage/CatalogPage.tsx
@@ -0,0 +1,31 @@
+import '../../i18n';
+import { Container } from '../../components/container/Container';
+import { FilterBar } from '../../components/filter/FilterBar';
+import { CategoriesProveder } from '../../components/CategoriesProvider';
+import { TemporaryDrawer } from '../../components/drawer/Drawer';
+import { SortBar } from './utils/SortBar';
+import { CatalogContent } from './CatalogContent';
+import { Categories } from '../../components/categories/Categories';
+
+export const CatalogPage = () => {
+ return (
+
+
+
+ );
+};
diff --git a/src/pages/catalogPage/utils/SortBar.tsx b/src/pages/catalogPage/utils/SortBar.tsx
new file mode 100644
index 0000000..3e390c1
--- /dev/null
+++ b/src/pages/catalogPage/utils/SortBar.tsx
@@ -0,0 +1,90 @@
+import '../../../i18n';
+import { MenuItem, Select, type SelectChangeEvent, Typography, FormControl } from '@mui/material';
+import { useState } from 'react';
+import { updateProductsData } from './updateData';
+import { useDispatch } from 'react-redux';
+import { fetchFilteredProducts } from '../../../helpers/api/apiRoot';
+import { useTranslation } from 'react-i18next';
+import { useCategoryContext } from '../../../hooks/useCategoryContext';
+
+export const SortBar = () => {
+ const { currentFilter } = useCategoryContext();
+ const dispatch = useDispatch();
+ const [priceSort, setPriceSort] = useState('');
+ const [ratingSort, setRatingSort] = useState('');
+
+ const handleSort = async (
+ { target }: SelectChangeEvent,
+ filter: string[],
+ param: string,
+ setter: (value: string) => void
+ ): Promise => {
+ const { value } = target;
+ setter(value);
+
+ const sort = `${param} ${value}`;
+ const data = await fetchFilteredProducts({ filter, sort });
+ updateProductsData(dispatch, data);
+ };
+ const { t } = useTranslation();
+ return (
+
+
+
+ {
+ const param = 'price';
+ handleSort(e, currentFilter, param, setPriceSort).catch((error) => {
+ throw error;
+ });
+ }}
+ >
+
+ {t('dropDown.sort.price')}
+
+ {t('dropDown.sort.asc')}
+ {t('dropDown.sort.desc')}
+
+
+
+ {
+ const param = 'variants.attributes.rating';
+ handleSort(e, currentFilter, param, setRatingSort).catch((error) => {
+ throw error;
+ });
+ }}
+ >
+
+ {t('dropDown.sort.rating')}
+
+ {t('dropDown.sort.asc')}
+ {t('dropDown.sort.desc')}
+
+
+
+ );
+};
diff --git a/src/pages/catalogPage/utils/fetchCategories.ts b/src/pages/catalogPage/utils/fetchCategories.ts
new file mode 100644
index 0000000..7f2a5ec
--- /dev/null
+++ b/src/pages/catalogPage/utils/fetchCategories.ts
@@ -0,0 +1,14 @@
+import { getCategories } from '../../../helpers/api/apiRoot';
+import type { CategoriesList } from '../../../utils/types';
+
+export const fetchCategories = async (): Promise => {
+ const categoriesData = await getCategories();
+ const categories = categoriesData.results.map((item) => {
+ const [name] = Object.values(item.name);
+ const { id } = item;
+
+ return { name, id };
+ });
+
+ return categories;
+};
diff --git a/src/pages/catalogPage/utils/getExtremums.ts b/src/pages/catalogPage/utils/getExtremums.ts
new file mode 100644
index 0000000..3c36f5f
--- /dev/null
+++ b/src/pages/catalogPage/utils/getExtremums.ts
@@ -0,0 +1,29 @@
+import type { ProductProjectionPagedQueryResponse } from '@commercetools/platform-sdk';
+import { getProductParams } from './getProductParams';
+
+export const getExtremums = (productsData: ProductProjectionPagedQueryResponse): number[] => {
+ const values = productsData.results.flatMap((product) => {
+ const isVariants = product.variants.length > 0;
+ // prettier-ignore
+ const variatsPriceTags = isVariants
+ ? product.variants.map((variant) => {
+ const { priceTag } = getProductParams(product, variant);
+
+ return priceTag;
+ })
+ : [];
+
+ const { priceTag } = getProductParams(product, product.masterVariant);
+
+ const prices = [...variatsPriceTags, priceTag].map(({ price, discount }) => {
+ return discount > 0 ? discount : price;
+ });
+
+ return prices;
+ });
+
+ const minValue = Math.floor(Math.min(...values));
+ const maxValue = Math.ceil(Math.max(...values));
+
+ return [minValue, maxValue];
+};
diff --git a/src/pages/catalogPage/utils/getNormalizedNumber.ts b/src/pages/catalogPage/utils/getNormalizedNumber.ts
new file mode 100644
index 0000000..6ec78c9
--- /dev/null
+++ b/src/pages/catalogPage/utils/getNormalizedNumber.ts
@@ -0,0 +1,5 @@
+export const getNormalizedNumber = (number: number, devider: number): number => {
+ const normalizedNumber = number / devider;
+
+ return normalizedNumber;
+};
diff --git a/src/pages/catalogPage/utils/getProductParams.ts b/src/pages/catalogPage/utils/getProductParams.ts
new file mode 100644
index 0000000..6128ad9
--- /dev/null
+++ b/src/pages/catalogPage/utils/getProductParams.ts
@@ -0,0 +1,37 @@
+import type { ProductProjection, ProductVariant } from '@commercetools/platform-sdk';
+import type { ProductsDataInterface } from '../../../utils/types';
+import { getNormalizedNumber } from './getNormalizedNumber';
+
+export const getProductParams = (
+ product: ProductProjection,
+ variant: ProductVariant
+): ProductsDataInterface => {
+ const imagesData = variant.images ?? [];
+ const [imageData] = imagesData.filter((item) => item.label === 'card-logo') ?? [];
+
+ const { url } = imageData ?? { url: '' };
+
+ const [productName] = Object.values(product.name);
+ const { sku } = variant;
+ const name = `${productName} ${sku ?? ''}`;
+
+ const rawDescription = product.description ?? '';
+ const [description] = Object.values(rawDescription);
+ const { id } = product;
+ const [prices] = variant.prices ?? [];
+ const centsPerEur = 100;
+
+ const rawPrice = prices?.value.centAmount ?? 0;
+ const rawDiscount = prices?.discounted?.value.centAmount ?? 0;
+
+ const normalizedPrice = getNormalizedNumber(rawPrice, centsPerEur);
+ const normalizedDiscount = getNormalizedNumber(rawDiscount, centsPerEur);
+
+ const priceTag = { price: normalizedPrice, discount: normalizedDiscount };
+
+ const images = variant.images;
+
+ const attributes = variant.attributes;
+
+ return { url, name, description, priceTag, id, images, attributes };
+};
diff --git a/src/pages/catalogPage/utils/normalizeData.ts b/src/pages/catalogPage/utils/normalizeData.ts
new file mode 100644
index 0000000..49461ee
--- /dev/null
+++ b/src/pages/catalogPage/utils/normalizeData.ts
@@ -0,0 +1,16 @@
+import type { ProductProjectionPagedQueryResponse } from '@commercetools/platform-sdk';
+import { getProductParams } from './getProductParams';
+import type { ProductsDataInterface } from '../../../utils/types';
+
+export const normalizeData = (productsData: ProductProjectionPagedQueryResponse): ProductsDataInterface[] => {
+ return productsData.results.flatMap((product) => {
+ const isVariants = product.variants.length > 0;
+
+ const productParams = isVariants
+ ? product.variants.map((variant) => getProductParams(product, variant))
+ : [];
+ const masterVariantParams = getProductParams(product, product.masterVariant);
+
+ return [...productParams, masterVariantParams];
+ });
+};
diff --git a/src/pages/catalogPage/utils/trimText.ts b/src/pages/catalogPage/utils/trimText.ts
new file mode 100644
index 0000000..c240357
--- /dev/null
+++ b/src/pages/catalogPage/utils/trimText.ts
@@ -0,0 +1,5 @@
+export const trimText = (text: string): string => {
+ const periodIndex = text.search(/[.\n]/);
+
+ return periodIndex !== -1 ? text.substring(0, periodIndex + 1) : text;
+};
diff --git a/src/pages/catalogPage/utils/updateData.ts b/src/pages/catalogPage/utils/updateData.ts
new file mode 100644
index 0000000..7f4b7f2
--- /dev/null
+++ b/src/pages/catalogPage/utils/updateData.ts
@@ -0,0 +1,25 @@
+import type { Dispatch } from '@reduxjs/toolkit';
+import { setExtremums, setProducts } from '../../../slices/productCardsSlice';
+import type { NormolizeDataType, FetchDataType, QueryArgs } from '../../../utils/types';
+import type { ProductProjectionPagedQueryResponse } from '@commercetools/platform-sdk';
+
+export const updateProductsData = (
+ dispatch: Dispatch,
+ productsData: ProductProjectionPagedQueryResponse
+): void => {
+ const setProductsData = setProducts(productsData);
+ dispatch(setProductsData);
+};
+
+export const updateExtremumsData = async (
+ dispatch: Dispatch,
+ fetchData: FetchDataType,
+ normalizeData: NormolizeDataType,
+ queryArgs: QueryArgs
+): Promise => {
+ const productsData = await fetchData(queryArgs);
+
+ const normalisedData = normalizeData(productsData);
+ const setExtremumsData = setExtremums(normalisedData);
+ dispatch(setExtremumsData);
+};
diff --git a/src/setupTests.ts b/src/setupTests.ts
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/src/setupTests.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/src/slices/MycartSlice.ts b/src/slices/MycartSlice.ts
new file mode 100644
index 0000000..5d1a853
--- /dev/null
+++ b/src/slices/MycartSlice.ts
@@ -0,0 +1,42 @@
+import { createSlice } from '@reduxjs/toolkit';
+import type { LineItem, CentPrecisionMoney } from '@commercetools/platform-sdk';
+
+export interface CartInitialstate {
+ anonymousId: string | undefined;
+ id: string;
+ lineItems: LineItem[];
+ version: number;
+ totalLineItemQuantity: number | undefined;
+ totalPrice: CentPrecisionMoney;
+}
+
+const cartData: CartInitialstate = {
+ anonymousId: '',
+ id: '',
+ lineItems: [],
+ version: 0,
+ totalLineItemQuantity: 0,
+ totalPrice: {
+ type: 'centPrecision',
+ currencyCode: '',
+ centAmount: 0,
+ fractionDigits: 0
+ }
+};
+
+const initialState = {
+ cartData
+};
+
+const cartSlice = createSlice({
+ name: 'categoriesData',
+ initialState,
+ reducers: {
+ setCartData: (state, { payload }) => {
+ state.cartData = payload;
+ }
+ }
+});
+
+export const { setCartData } = cartSlice.actions;
+export const cartReducer = cartSlice.reducer;
diff --git a/src/slices/authSlice.ts b/src/slices/authSlice.ts
new file mode 100644
index 0000000..a9aeea9
--- /dev/null
+++ b/src/slices/authSlice.ts
@@ -0,0 +1,22 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+const initialState = {
+ isLogged: Boolean(localStorage.getItem('customer')),
+ customer: JSON.parse(localStorage.getItem('customer') ?? 'null')
+};
+
+const authSlice = createSlice({
+ name: 'authData',
+ initialState,
+ reducers: {
+ setLogged: (state, { payload }) => {
+ state.isLogged = payload;
+ },
+ setCustomer: (state, { payload }) => {
+ state.customer = payload;
+ }
+ }
+});
+
+export const { setLogged, setCustomer } = authSlice.actions;
+export const authReducer = authSlice.reducer;
diff --git a/src/slices/cartSlice.ts b/src/slices/cartSlice.ts
new file mode 100644
index 0000000..ffa4061
--- /dev/null
+++ b/src/slices/cartSlice.ts
@@ -0,0 +1,19 @@
+import { type Cart } from '@commercetools/platform-sdk';
+import { createSlice } from '@reduxjs/toolkit';
+
+const initialState: { cart: Cart | null } = {
+ cart: null
+};
+
+const cartSlice = createSlice({
+ name: 'cart',
+ initialState,
+ reducers: {
+ setCartData: (state, { payload }) => {
+ state.cart = payload;
+ }
+ }
+});
+
+export const { setCartData } = cartSlice.actions;
+export const cartReducer = cartSlice.reducer;
diff --git a/src/slices/categoriesSlice.ts b/src/slices/categoriesSlice.ts
new file mode 100644
index 0000000..a7a9c61
--- /dev/null
+++ b/src/slices/categoriesSlice.ts
@@ -0,0 +1,22 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+interface CategoriesInitialState {
+ categories: Array<{ name: string; id: string }>;
+}
+
+const initialState: CategoriesInitialState = {
+ categories: []
+};
+
+const catagoriesSlice = createSlice({
+ name: 'categoriesData',
+ initialState,
+ reducers: {
+ setCategoriesData: (state, { payload }) => {
+ state.categories = payload;
+ }
+ }
+});
+
+export const { setCategoriesData } = catagoriesSlice.actions;
+export const categoriesReducer = catagoriesSlice.reducer;
diff --git a/src/slices/index.ts b/src/slices/index.ts
new file mode 100644
index 0000000..cc8a996
--- /dev/null
+++ b/src/slices/index.ts
@@ -0,0 +1,14 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { authReducer } from './authSlice';
+import { productCardsReducer } from './productCardsSlice';
+import { categoriesReducer } from './categoriesSlice';
+import { cartReducer } from './cartSlice';
+
+export const store = configureStore({
+ reducer: {
+ authData: authReducer,
+ productsData: productCardsReducer,
+ categoriesData: categoriesReducer,
+ cart: cartReducer
+ }
+});
diff --git a/src/slices/productCardsSlice.ts b/src/slices/productCardsSlice.ts
new file mode 100644
index 0000000..a706c29
--- /dev/null
+++ b/src/slices/productCardsSlice.ts
@@ -0,0 +1,27 @@
+import { createSlice } from '@reduxjs/toolkit';
+import type { InitialProductsStateInterace } from '../utils/types';
+import { normalizeData } from '../pages/catalogPage/utils/normalizeData';
+
+const initialState: InitialProductsStateInterace = {
+ cardsData: [],
+ extremums: [0, 0],
+ total: 0
+};
+
+const productCardsSlice = createSlice({
+ name: 'productsData',
+ initialState,
+ reducers: {
+ setProducts: (state, { payload }) => {
+ const normalisedData = normalizeData(payload);
+ state.cardsData = normalisedData;
+ state.total = payload.total;
+ },
+ setExtremums: (state, { payload }) => {
+ state.extremums = payload;
+ }
+ }
+});
+
+export const { setProducts, setExtremums } = productCardsSlice.actions;
+export const productCardsReducer = productCardsSlice.reducer;
diff --git a/src/styles/index.css b/src/styles/index.css
new file mode 100644
index 0000000..b2b812f
--- /dev/null
+++ b/src/styles/index.css
@@ -0,0 +1,29 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@media only screen and (max-width: 640px) {
+ html {
+ font-size: 14px;
+ }
+}
+
+input[type]:focus {
+ --tw-ring-inset: none;
+}
+
+select[name]:focus {
+ --tw-ring-inset: none;
+}
+
+.MuiInputBase-input[type="number"]::-webkit-inner-spin-button,
+.MuiInputBase-input[type="number"]::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.slide {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/src/utils/clientApiData.ts b/src/utils/clientApiData.ts
new file mode 100644
index 0000000..50b6940
--- /dev/null
+++ b/src/utils/clientApiData.ts
@@ -0,0 +1,13 @@
+export enum ClientApiData {
+ region = 'europe-west1.gcp',
+ projectKey = 'rs-online-shop',
+ clientId = 'YgEIYJRC8uJAKruDbYR5O6Ve',
+ clientSecret = 'ENMHemgotWNA38FC6TBFnX0Q6S-1bEwO',
+ scopes = 'create_anonymous_token:rs-online-shop manage_orders:rs-online-shop view_categories:rs-online-shop manage_my_shopping_lists:rs-online-shop manage_my_quote_requests:rs-online-shop manage_my_orders:rs-online-shop manage_my_profile:rs-online-shop manage_my_business_units:rs-online-shop manage_customers:rs-online-shop manage_my_quotes:rs-online-shop manage_order_edits:rs-online-shop manage_my_payments:rs-online-shop view_published_products:rs-online-shop'
+}
+
+export enum Endpoints {
+ base = 'https://api.europe-west1.gcp.commercetools.com/',
+ auth = 'https://auth.europe-west1.gcp.commercetools.com',
+ oauth = 'https://auth.europe-west1.gcp.commercetools.com/oauth/token'
+}
diff --git a/src/utils/links.tsx b/src/utils/links.tsx
new file mode 100644
index 0000000..6f7d814
--- /dev/null
+++ b/src/utils/links.tsx
@@ -0,0 +1,47 @@
+import HowToRegOutlinedIcon from '@mui/icons-material/HowToRegOutlined';
+import { NavRoutes } from './routes';
+import PermIdentityOutlinedIcon from '@mui/icons-material/PermIdentityOutlined';
+import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
+import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined';
+import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined';
+
+export const links = {
+ main: {
+ text: 'Home',
+ path: NavRoutes.mainPagePath
+ },
+ about: {
+ text: 'About',
+ path: NavRoutes.aboutPage
+ },
+ catalog: {
+ text: 'Catalog',
+ path: NavRoutes.catalogPage
+ },
+ login: {
+ text: 'LogIn',
+ path: NavRoutes.loginPagePath,
+ icon:
+ },
+ logout: {
+ text: 'Logout',
+ path: NavRoutes.mainPagePath,
+ icon:
+ },
+ profile: {
+ text: 'Profile',
+ path: NavRoutes.profilePagePath,
+ icon:
+ },
+ registration: {
+ text: 'Registration',
+ path: NavRoutes.registrationPagePath,
+ icon:
+ },
+ product: { text: 'Product', path: NavRoutes.productPage },
+ cart: {
+ text: 'Cart',
+ path: NavRoutes.cartPagePath,
+ icon:
+ }
+};
diff --git a/src/utils/routes.ts b/src/utils/routes.ts
new file mode 100644
index 0000000..7462910
--- /dev/null
+++ b/src/utils/routes.ts
@@ -0,0 +1,17 @@
+export enum NavRoutes {
+ mainPagePath = '/',
+ aboutPage = '/about',
+ loginPagePath = '/login',
+ registrationPagePath = '/signup',
+ productPage = '/product/:id',
+ notFoundPagePath = '*',
+ catalogPage = '/catalog',
+ profilePagePath = '/profile',
+ cartPagePath = '/cart',
+ companionsPagePath = '/catalog/companions',
+ cleanersPagePath = '/catalog/cleaners',
+ petsPagePath = '/catalog/pets',
+ kitchensPagePath = '/catalog/kitchens',
+ gardensPagePath = '/catalog/gardens',
+ deliveriesPagePath = '/catalog/deliveries'
+}
diff --git a/src/utils/routesConfig.tsx b/src/utils/routesConfig.tsx
new file mode 100644
index 0000000..26a3368
--- /dev/null
+++ b/src/utils/routesConfig.tsx
@@ -0,0 +1,88 @@
+import { PrivateOutlet } from '../components/PrivateOutlet';
+import { LoginPage } from '../pages/LoginPage';
+import { MainPage } from '../pages/MainPage';
+import { RegistrationPage } from '../pages/RegistrationPage';
+import { NotFoundPage } from '../pages/NotFoundPage';
+import { NavRoutes } from './routes';
+import { AboutPage } from '../pages/AboutPage';
+import { Product } from '../pages/ProductPage/ProductPage';
+import { CatalogPage } from '../pages/catalogPage/CatalogPage';
+import { UserProfilePage } from '../pages/UserProfilePage';
+import { AnonymOutlet } from '../components/AnonymOutlet';
+import { CartPage } from '../pages/CartPage/CartPage';
+
+export const routesConfig = [
+ {
+ path: NavRoutes.mainPagePath,
+ element:
+ },
+ {
+ path: NavRoutes.aboutPage,
+ element:
+ },
+ {
+ path: NavRoutes.catalogPage,
+ element: ,
+ name: 'catalog'
+ },
+ {
+ path: NavRoutes.loginPagePath,
+ element: (
+
+
+
+ )
+ },
+ {
+ path: NavRoutes.registrationPagePath,
+ element: (
+
+
+
+ )
+ },
+ {
+ path: NavRoutes.productPage,
+ element:
+ },
+ {
+ path: NavRoutes.profilePagePath,
+ element: (
+
+
+
+ )
+ },
+ {
+ path: NavRoutes.notFoundPagePath,
+ element:
+ },
+ {
+ path: NavRoutes.cartPagePath,
+ element:
+ },
+ {
+ path: NavRoutes.companionsPagePath,
+ element:
+ },
+ {
+ path: NavRoutes.cleanersPagePath,
+ element:
+ },
+ {
+ path: NavRoutes.petsPagePath,
+ element:
+ },
+ {
+ path: NavRoutes.kitchensPagePath,
+ element:
+ },
+ {
+ path: NavRoutes.gardensPagePath,
+ element:
+ },
+ {
+ path: NavRoutes.deliveriesPagePath,
+ element:
+ }
+];
diff --git a/src/utils/statusCodes.ts b/src/utils/statusCodes.ts
new file mode 100644
index 0000000..11a2329
--- /dev/null
+++ b/src/utils/statusCodes.ts
@@ -0,0 +1,4 @@
+export enum StatusCodes {
+ OK = 200,
+ CREATED = 201
+}
diff --git a/src/utils/types.ts b/src/utils/types.ts
new file mode 100644
index 0000000..eb2311a
--- /dev/null
+++ b/src/utils/types.ts
@@ -0,0 +1,330 @@
+import type { initialValuesRegistration } from '../components/forms/inputs/inputsData';
+import type * as yup from 'yup';
+import type { FieldInputProps } from 'formik';
+import type {
+ Image,
+ Customer,
+ Attribute,
+ ProductProjectionPagedQueryResponse,
+ Cart
+} from '@commercetools/platform-sdk';
+
+interface UserName {
+ firstName: string;
+ lastName: string;
+}
+
+interface ShippingAddress {
+ shippingCountry: string;
+ shippingCity: string;
+ shippingStreetName: string;
+ shippingPostalCode: string;
+ shippingStateChecked: boolean;
+}
+
+interface BillingAddress {
+ billingCountry: string;
+ billingCity: string;
+ billingStreetName: string;
+ billingPostalCode: string;
+ billingStateChecked: boolean;
+}
+
+interface Address extends UserName {
+ country: string;
+ city: string;
+ streetName: string;
+ postalCode: string;
+}
+
+export type AddressInterface = UserName & (ShippingAddress | BillingAddress);
+
+export interface LoginInterface {
+ email: string;
+ password: string;
+}
+
+export interface CustomerData extends LoginInterface, UserName {
+ dateOfBirth: string;
+ addresses: Address[];
+ shippingAddresses: number[];
+ billingAddresses: number[];
+ defaultShippingAddress?: number;
+ defaultBillingAddress?: number;
+ salutation: string;
+}
+
+export interface HandleSubmitWithBoth extends LoginInterface, UserName, BillingAddress, ShippingAddress {
+ date: string;
+}
+export interface LogoParams {
+ className?: string;
+}
+
+export interface ClickHandlerInterface {
+ clickHandler?: (open: boolean) => void;
+}
+
+export interface BurgerMenuProps {
+ mobileMenuOpen: boolean;
+ setMobileMenuOpen: (open: boolean) => void;
+}
+
+export interface VisibilityIconProps {
+ handleVisibility: () => void;
+ passwordVisibility: boolean;
+}
+
+export type AuthLogin = (userData: LoginInterface) => void;
+
+export interface OutletProps {
+ children: JSX.Element;
+}
+
+export interface AuthReturnInterface {
+ login: (userData: LoginInterface) => Promise;
+ logout: () => void;
+ signUp: (userData: HandleSubmitWithBoth) => Promise;
+}
+
+export interface UseUpdateCustomer {
+ onPersonalDataSubmit: (value: InitialValuesCustomerPage) => Promise;
+ onPasswordChangeSubmit: (value: InitialValuesCustomerPage) => Promise;
+ onAddressDelete: (id: string) => Promise;
+ onAddressChangeSubmit: (value: InitialValuesCustomerPage) => Promise;
+}
+
+export interface RootState {
+ isLogged: boolean;
+ cardsData: ProductsDataInterface[];
+ customer: Customer | null;
+ extremums: number[];
+ categories: CategoriesList[];
+ cart: Cart | null;
+ total: number;
+}
+
+export interface AddressFieldComponent {
+ label: string;
+ name: string;
+ placeholder: string;
+}
+
+export interface NameInput {
+ name: string;
+}
+
+export interface FormikProps {
+ handleChange: (e: Event) => void;
+ setFieldTouched: (field: string, isTouched?: boolean, shouldValidate?: boolean) => void;
+ getFieldProps: (value: string) => FieldInputProps;
+}
+
+export interface InputProps {
+ name: string;
+ type: string;
+ placeholder: string;
+ formik?: FormikProps;
+ disabled?: boolean;
+ onChange?: (e: string) => void;
+ value?: string;
+ onKeyDown?: () => Promise;
+}
+
+export interface AddressFieldSetProps {
+ fieldSet?: 'shipping' | 'billing';
+ formik: FormikProps;
+ disabled?: boolean;
+}
+
+export interface CountryInputProps extends AddressFieldSetProps {
+ setPostalCodeDisabled: (editable: boolean) => void;
+}
+
+export interface PostalcodeInterface extends InputProps {
+ fieldSet: 'shipping' | 'billing';
+}
+
+export type InitialValuesRegistration = typeof initialValuesRegistration;
+
+export interface RegistrationFormProps {
+ initialValues: InitialValuesRegistration;
+ getValidationSchema: (isSameAddress: boolean) => yup.Schema;
+ onSubmit: (values: HandleSubmitWithBoth) => Promise;
+}
+
+export interface PrivateNavGroupProps {
+ isLogged: boolean;
+ logout: () => void;
+ clickHandler: (open: boolean) => void;
+ itemsCount?: number;
+}
+
+export type FakeOnSubmit = (values: HandleSubmitWithBoth) => Promise;
+
+export interface PriceTagInterface {
+ price: number;
+ discount: number;
+}
+
+export interface ProductsDataInterface {
+ url: string;
+ name: string;
+ description: string;
+ id: string;
+ priceTag: PriceTagInterface;
+ attributes: Attribute[] | undefined;
+ images: Image[] | undefined;
+}
+
+export interface InitialProductsStateInterace {
+ cardsData: ProductsDataInterface[];
+ extremums: number[];
+ total: number;
+}
+
+export interface ProductCardInterface extends PriceTagInterface {
+ imageUrl: string;
+ title: string;
+ titleName: string;
+ description: string;
+ id: string;
+ rating: number;
+}
+
+export interface CategoriesList {
+ name: string;
+ id: string;
+}
+
+export interface ContainerProps {
+ titleName: string;
+ titleDescription?: string;
+}
+
+export type Mapping = Record;
+
+export interface TabPanelProps {
+ children?: React.ReactNode;
+ index: number;
+ value: number;
+}
+
+export interface PersonalDataInitialValues {
+ firstName: string;
+ lastName: string;
+ date: string;
+ email: string;
+}
+
+export interface AddressesInitialValues {
+ id: string;
+ streetName: string;
+ city: string;
+ country: string;
+ postalCode: string;
+ shippingStateChecked: boolean;
+ billingStateChecked: boolean;
+ defaultShippingAddress: boolean;
+ defaultBillingAddress: boolean;
+}
+
+export interface TabsPanelProps {
+ children: React.ReactNode | React.ReactNode[];
+ titles: string[];
+}
+
+export type FormInnerComponent = (editable: boolean, formik: FormikProps) => JSX.Element[] | JSX.Element;
+
+export type AddressExtraControls = (
+ editable: boolean,
+ initialValues: AddressesInitialValues,
+ formik: FormikProps
+) => JSX.Element[] | JSX.Element | null;
+
+export interface PasswordChangeInitialValues {
+ currentPassword: string;
+ newPassword: string;
+}
+
+export type InitialValuesCustomerPage =
+ | PersonalDataInitialValues
+ | PasswordChangeInitialValues
+ | AddressesInitialValues;
+
+export interface CustomerPageFormProps {
+ initialValues: InitialValuesCustomerPage;
+ formInner: FormInnerComponent;
+ onSubmit: (value: InitialValuesCustomerPage) => void;
+ validationSchema: yup.Schema;
+ addressExtraControls?: AddressExtraControls;
+ isEditable?: boolean;
+ unsetNewForm?: (value: boolean) => void;
+ onDelete?: (id: string) => Promise;
+}
+
+export interface AddressComponentProps {
+ onSubmit: (values: InitialValuesCustomerPage) => void;
+ initialValues: AddressesInitialValues[];
+ validationSchema: yup.Schema;
+ onDelete?: (id: string) => Promise;
+}
+
+export interface CategoriesContextInterface {
+ categoryId: string;
+ categoryName: string;
+ setCategoryId: (id: string) => void;
+ setCategoryName: (id: string) => void;
+ currentFilter: string[];
+ setCurrentFilter: (filter: string[]) => void;
+}
+export interface QueryArgs {
+ offset?: number;
+ filter?: string | string[];
+ sort?: string;
+ 'text.en-US'?: string;
+ limit?: number;
+}
+export type FetchDataType = (queryArgs: QueryArgs) => Promise;
+
+export type NormolizeDataType = (
+ productData: ProductProjectionPagedQueryResponse
+) => number[] | ProductsDataInterface[];
+
+export interface AttributesList {
+ name: string;
+ attributes: Attribute[];
+}
+
+export interface SelectedAttribute {
+ name: string;
+ value: string;
+}
+
+export interface AddToCartParams {
+ cartId: string;
+ cartVersion: number;
+ productId: string;
+}
+
+export interface UpdateItemQuantity {
+ cartId: string;
+ cartVersion: number;
+ lineItemId: string;
+ quantity: number;
+ centAmount: number;
+}
+
+export interface ModalPreviewProps {
+ product: ProductsDataInterface;
+ currentImageIndex: number;
+ setCurrentImageIndex: (index: number) => void;
+ setModalPreviewOpen: (isOpen: boolean) => void;
+}
+
+export interface ProductDetailsProps {
+ product: ProductsDataInterface;
+ rating?: Attribute;
+ color?: Attribute;
+ id: string | undefined;
+}
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..1e5787f
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,25 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ['./src/**/*.{js,jsx,ts,tsx}'],
+ theme: {
+ extend: {
+ colors: {
+ 'primary': '#000000',
+ 'secondary': '#DB4444',
+ 'secondary-light': '#f8cece'
+ },
+ backgroundImage: {
+ 'burger-bender': "url('/src/assets/burger-bender.webp')",
+ 'footer-bender': "url('/src/assets/footer-bender.webp')",
+ },
+ gridTemplateRows: {
+ 'catalog-cards': '14rem 9rem 2.5rem 2.5rem',
+ },
+ gridTemplateColumns: {
+ 'filters': 'auto auto',
+ 'catalog-cards': 'repeat(auto-fill, minmax(14rem, auto))',
+ }
+ }
+ },
+ plugins: [require('@tailwindcss/forms')]
+};
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..a273b0c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": [
+ "src"
+ ]
+}