Compare commits
	
		
			No commits in common. "main" and "feature/stubsArm" have entirely different histories.
		
	
	
		
			main
			...
			feature/st
		
	
		
							
								
								
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -130,10 +130,4 @@ dist | |||||||
| .yarn/install-state.gz | .yarn/install-state.gz | ||||||
| .pnp.* | .pnp.* | ||||||
| 
 | 
 | ||||||
| .idea | .idea | ||||||
| 
 |  | ||||||
| # Playwright |  | ||||||
| /test-results/ |  | ||||||
| /playwright-report/ |  | ||||||
| /blob-report/ |  | ||||||
| /playwright/.cache/ |  | ||||||
							
								
								
									
										5
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -1,5 +0,0 @@ | |||||||
| { |  | ||||||
|     "i18n-ally.localesPaths": [ |  | ||||||
|         "locales" |  | ||||||
|     ] |  | ||||||
| } |  | ||||||
							
								
								
									
										28
									
								
								Jenkinsfile
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -1,7 +1,7 @@ | |||||||
| pipeline { | pipeline { | ||||||
|     agent { |     agent { | ||||||
|         docker { |         docker { | ||||||
|             image 'node:22' |             image 'node:20' | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -30,21 +30,19 @@ pipeline { | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         stage('eslint') { |         stage('checks') { | ||||||
|             steps { |             parallel { | ||||||
|                 sh 'npm run eslint' |                 stage('eslint') { | ||||||
|             } |                     steps { | ||||||
|         } |                         sh 'npm run eslint' | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
| 
 | 
 | ||||||
|         stage('test') { |                 stage('build') { | ||||||
|             steps { |                     steps { | ||||||
|                 sh 'npm run test' |                         sh 'npm run build' | ||||||
|             } |                     } | ||||||
|         } |                 } | ||||||
| 
 |  | ||||||
|         stage('build') { |  | ||||||
|             steps { |  | ||||||
|                 sh 'npm run build' |  | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @ -45,12 +45,10 @@ | |||||||
| ### MVP1 | ### MVP1 | ||||||
| 
 | 
 | ||||||
| **1. Landing** | **1. Landing** | ||||||
| 
 |  | ||||||
| - преимущества сервиса | - преимущества сервиса | ||||||
|   - оставить заявку (редирект на Страницу оформления заказа) |   - оставить заявку (редирект на Страницу оформления заказа) | ||||||
| 
 | 
 | ||||||
| **2. Страница для оформления заказа** | **2. Страница для оформления заказа** | ||||||
| 
 |  | ||||||
| - форма | - форма | ||||||
|   - номер машины (mask input) |   - номер машины (mask input) | ||||||
|   - цвет машины |   - цвет машины | ||||||
| @ -60,12 +58,10 @@ | |||||||
| - после заполнения редирект на страницу с деталями заказа | - после заполнения редирект на страницу с деталями заказа | ||||||
| 
 | 
 | ||||||
| **3. Страница с деталями заказа** | **3. Страница с деталями заказа** | ||||||
| 
 |  | ||||||
| - описание заказа | - описание заказа | ||||||
| - детали заказа (id, статус) | - детали заказа (id, статус) | ||||||
| 
 | 
 | ||||||
| **3. АРМ оператора** | **3. АРМ оператора** | ||||||
| 
 |  | ||||||
| - список заказов (RUD) | - список заказов (RUD) | ||||||
|   - id заказа |   - id заказа | ||||||
|   - статус заказа (готово / не готово) |   - статус заказа (готово / не готово) | ||||||
| @ -76,6 +72,7 @@ | |||||||
|   - кнопка "Добавить" |   - кнопка "Добавить" | ||||||
|   - кнопка "Удалить" |   - кнопка "Удалить" | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| ### Built With | ### Built With | ||||||
| 
 | 
 | ||||||
| [![React][React.js]][React-url] | [![React][React.js]][React-url] | ||||||
| @ -106,14 +103,6 @@ | |||||||
| 
 | 
 | ||||||
| <p align="right">(<a href="#readme-top">back to top</a>)</p> | <p align="right">(<a href="#readme-top">back to top</a>)</p> | ||||||
| 
 | 
 | ||||||
| ## Instructions |  | ||||||
| ### Stubs types generation |  | ||||||
| 1. generate types with json-literal-typer (should be installed globally) |  | ||||||
|    ```sh |  | ||||||
|    npx json-literal-typer -i <path to json> -o <path to output ts-file> |  | ||||||
|    ``` |  | ||||||
| 2. export default type from output file |  | ||||||
| 
 |  | ||||||
| <!-- PARTICIPANTS --> | <!-- PARTICIPANTS --> | ||||||
| 
 | 
 | ||||||
| ## Participants | ## Participants | ||||||
|  | |||||||
| @ -1,18 +0,0 @@ | |||||||
| import React, { PropsWithChildren } from 'react'; |  | ||||||
| import { jest } from '@jest/globals'; |  | ||||||
| import { BrowserRouter } from 'react-router-dom'; |  | ||||||
| import { ChakraProvider, theme as chakraTheme } from '@chakra-ui/react'; |  | ||||||
| import { Provider } from 'react-redux'; |  | ||||||
| 
 |  | ||||||
| import ErrorBoundary from '../src/components/ErrorBoundary'; |  | ||||||
| import { store } from '../src/__data__/store'; |  | ||||||
| 
 |  | ||||||
| export const AppContext = jest.fn(({ children }: PropsWithChildren) => ( |  | ||||||
|   <Provider store={store}> |  | ||||||
|     <ChakraProvider theme={chakraTheme}> |  | ||||||
|       <ErrorBoundary> |  | ||||||
|         <BrowserRouter>{children}</BrowserRouter> |  | ||||||
|       </ErrorBoundary> |  | ||||||
|     </ChakraProvider> |  | ||||||
|   </Provider> |  | ||||||
| )); |  | ||||||
| @ -1,20 +0,0 @@ | |||||||
| import { jest } from '@jest/globals'; |  | ||||||
| 
 |  | ||||||
| jest.mock('@brojs/cli', () => ({ |  | ||||||
|   getConfigValue: jest.fn(() => '/api'), |  | ||||||
|   getFeatures: jest.fn(() => ({ |  | ||||||
|     ['order-view-status-polling']: { value: '3000' }, |  | ||||||
|     ['car-img-upload']: { value: 'true' }, |  | ||||||
|     ['order-cost']: { value: '1000' }, |  | ||||||
|   })), |  | ||||||
|   getNavigationValue: jest.fn((navKey: string) => { |  | ||||||
|     switch (navKey) { |  | ||||||
|       case 'dry-wash.main': |  | ||||||
|         return '/dry-wash'; |  | ||||||
|       case 'dry-wash.order.create': |  | ||||||
|         return '/order'; |  | ||||||
|       case 'dry-wash.order.view': |  | ||||||
|         return '/order/:orderId'; |  | ||||||
|     } |  | ||||||
|   }), |  | ||||||
| })); |  | ||||||
| @ -1 +0,0 @@ | |||||||
| module.exports = 'file'; |  | ||||||
| @ -1,6 +0,0 @@ | |||||||
| import { jest } from '@jest/globals'; |  | ||||||
| import React from 'react'; |  | ||||||
| 
 |  | ||||||
| jest.mock('@lottiefiles/react-lottie-player', () => ({ |  | ||||||
|   Player: jest.fn(() => <></>), |  | ||||||
| })); |  | ||||||
| @ -1,13 +0,0 @@ | |||||||
| import localeRu from '../locales/ru.json'; |  | ||||||
| 
 |  | ||||||
| module.exports = { |  | ||||||
|   useTranslation: (_, options) => { |  | ||||||
|     const { keyPrefix } = options ?? {}; |  | ||||||
|     return { |  | ||||||
|       t: keyPrefix ? (key: string) => localeRu[`${keyPrefix}.${key}`] : undefined, |  | ||||||
|       i18n: { |  | ||||||
|         language: 'ru' |  | ||||||
|       } |  | ||||||
|     }; |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| @ -1,11 +0,0 @@ | |||||||
| import { jest } from '@jest/globals'; |  | ||||||
| import React from 'react'; |  | ||||||
| 
 |  | ||||||
| jest.mock('@pbe/react-yandex-maps', () => ({ |  | ||||||
|   YMaps: jest.fn(() => <></>), |  | ||||||
|   Map: jest.fn(() => <></>), |  | ||||||
|   Placemark: jest.fn(() => <></>), |  | ||||||
|   GeolocationControl: jest.fn(() => <></>), |  | ||||||
|   ZoomControl: jest.fn(() => <></>), |  | ||||||
|   withYMaps: jest.fn(() => <></>), |  | ||||||
| })); |  | ||||||
| @ -1,20 +0,0 @@ | |||||||
| import { http, delay, HttpResponse } from 'msw'; |  | ||||||
| 
 |  | ||||||
| import OrderPendingMock from '../../stubs/json/landing-order-view/id1-success-pending.json'; |  | ||||||
| import OrderErrorMock from '../../stubs/json/landing-order-view/id1-error.json'; |  | ||||||
| 
 |  | ||||||
| export const handlers = [ |  | ||||||
|   http.get('/api/order/:id', async ({ params }) => { |  | ||||||
|     await delay(); |  | ||||||
| 
 |  | ||||||
|     const { id } = params; |  | ||||||
|     if (id === 'id1') { |  | ||||||
|       return HttpResponse.json(OrderPendingMock); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return new HttpResponse(null, { |  | ||||||
|       status: 500, |  | ||||||
|       statusText: OrderErrorMock.message |  | ||||||
|     }); |  | ||||||
|   }) |  | ||||||
| ]; |  | ||||||
| @ -1,5 +0,0 @@ | |||||||
| import { setupServer } from 'msw/node'; |  | ||||||
| 
 |  | ||||||
| import { handlers } from './handlers'; |  | ||||||
|   |  | ||||||
| export const server = setupServer(...handlers); |  | ||||||
| @ -1 +0,0 @@ | |||||||
| module.exports = {}; |  | ||||||
| @ -1,7 +0,0 @@ | |||||||
| module.exports = { |  | ||||||
|   presets: [ |  | ||||||
|     '@babel/preset-env', |  | ||||||
|     '@babel/preset-typescript', |  | ||||||
|     ['@babel/preset-react', { runtime: 'automatic' }], |  | ||||||
|   ], |  | ||||||
| }; |  | ||||||
| @ -1,9 +1,9 @@ | |||||||
| /* eslint-disable no-undef */ | /* eslint-disable no-undef */ | ||||||
| /* eslint-disable @typescript-eslint/no-require-imports */ | /* eslint-disable @typescript-eslint/no-require-imports */ | ||||||
| const pkg = require('./package'); | const pkg = require("./package"); | ||||||
| 
 | 
 | ||||||
| module.exports = { | module.exports = { | ||||||
|   apiPath: 'stubs/api', |   apiPath: "stubs/api", | ||||||
|   webpackConfig: { |   webpackConfig: { | ||||||
|     output: { |     output: { | ||||||
|       publicPath: `/static/${pkg.name}/${process.env.VERSION || pkg.version}/`, |       publicPath: `/static/${pkg.name}/${process.env.VERSION || pkg.version}/`, | ||||||
| @ -11,35 +11,17 @@ module.exports = { | |||||||
|   }, |   }, | ||||||
|   /* use https://admin.bro-js.ru/ to create config, navigations and features */ |   /* use https://admin.bro-js.ru/ to create config, navigations and features */ | ||||||
|   navigations: { |   navigations: { | ||||||
|     'dry-wash.main': '/dry-wash', |     "dry-wash.main": "/dry-wash", | ||||||
|     'dry-wash.order.create': '/order', |     "dry-wash.create": "/order", | ||||||
|     'dry-wash.order.view': '/order/:orderId', |     "dry-wash.view.order": "/order/:orderId", | ||||||
|     'dry-wash.arm.master': 'master', |     "dry-wash.arm": "/arm", | ||||||
|     'dry-wash.arm.order': 'order', |  | ||||||
|     'dry-wash.arm.map': 'map', |  | ||||||
|     'dry-wash.arm': '/arm/*', |  | ||||||
|   }, |   }, | ||||||
|   features: { |   features: { | ||||||
|     'dry-wash': { |     "dry-wash-pl": { | ||||||
|       // add your features here in the format [featureName]: { value: string }
 |       // add your features here in the format [featureName]: { value: string }
 | ||||||
|       'order-view-status-polling': { |  | ||||||
|         on: true, |  | ||||||
|         value: '3000', |  | ||||||
|         key: 'order-view-status-polling', |  | ||||||
|       }, |  | ||||||
|       'car-img-upload': { |  | ||||||
|         on: true, |  | ||||||
|         value: 'true', |  | ||||||
|         key: 'car-img-upload', |  | ||||||
|       }, |  | ||||||
|       'order-cost': { |  | ||||||
|         on: true, |  | ||||||
|         value: '1000', |  | ||||||
|         key: 'order-cost', |  | ||||||
|       }, |  | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   config: { |   config: { | ||||||
|     'dry-wash.api': '/api', |     "dry-wash-pl.api": "/api", | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -1,26 +0,0 @@ | |||||||
| import { test, expect } from '@playwright/test'; |  | ||||||
| 
 |  | ||||||
| test.beforeEach('check server is up', async ({ page }) => { |  | ||||||
|   try { |  | ||||||
|     await page.goto('http://localhost:8099/dry-wash'); |  | ||||||
|     const makeOrderText = page.getByText('Сделать заказ', { exact: true }); |  | ||||||
|     await expect(makeOrderText).toBeVisible(); |  | ||||||
|   } catch (error) { |  | ||||||
|     console.error('server not up', error); |  | ||||||
|     test.skip(); |  | ||||||
|   } |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| test('login', async ({ page }) => { |  | ||||||
|   await page.goto('http://localhost:8099/dry-wash/arm'); |  | ||||||
|   await page.getByRole('textbox', { name: 'Username or email' }).click(); |  | ||||||
|   await page |  | ||||||
|     .getByRole('textbox', { name: 'Username or email' }) |  | ||||||
|     .fill('237x237'); |  | ||||||
|   await page.getByRole('textbox', { name: 'Password' }).click(); |  | ||||||
|   await page.getByRole('textbox', { name: 'Password' }).fill(''); |  | ||||||
|   await page.getByRole('button', { name: 'Sign In' }).click(); |  | ||||||
|   await page.getByRole('heading', { name: 'Заказы' }).click(); |  | ||||||
|   await page.getByRole('link', { name: 'Мастера' }).click(); |  | ||||||
|   await page.getByRole('link', { name: 'Заказы' }).click(); |  | ||||||
| }); |  | ||||||
| @ -1,13 +1,12 @@ | |||||||
| import globals from 'globals'; | import globals from "globals"; | ||||||
| import pluginJs from '@eslint/js'; | import pluginJs from "@eslint/js"; | ||||||
| import tseslint from 'typescript-eslint'; | import tseslint from "typescript-eslint"; | ||||||
| import pluginReact from 'eslint-plugin-react'; | import pluginReact from "eslint-plugin-react"; | ||||||
| import stylistic from '@stylistic/eslint-plugin'; | import stylistic from '@stylistic/eslint-plugin'; | ||||||
| import pluginImport from 'eslint-plugin-import'; | import pluginImport from 'eslint-plugin-import'; | ||||||
| 
 | 
 | ||||||
| export default [ | export default [ | ||||||
|   { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] }, |   { files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"] }, | ||||||
| 
 |  | ||||||
|   { languageOptions: { globals: globals.browser } }, |   { languageOptions: { globals: globals.browser } }, | ||||||
|   pluginJs.configs.recommended, |   pluginJs.configs.recommended, | ||||||
|   ...tseslint.configs.recommended, |   ...tseslint.configs.recommended, | ||||||
| @ -15,38 +14,35 @@ export default [ | |||||||
|   { |   { | ||||||
|     plugins: { |     plugins: { | ||||||
|       '@stylistic': stylistic, |       '@stylistic': stylistic, | ||||||
|       import: pluginImport, |       'import': pluginImport, | ||||||
|     }, |     }, | ||||||
|     rules: { |     "rules": { | ||||||
|       'no-unused-vars': 'off', |       "no-unused-vars": "off", | ||||||
|       '@typescript-eslint/no-unused-vars': [ |       "@typescript-eslint/no-unused-vars": [ | ||||||
|         'warn', // or "error"
 |         "warn", // or "error"
 | ||||||
|         { |         { | ||||||
|           argsIgnorePattern: '^_', |           "argsIgnorePattern": "^_", | ||||||
|           varsIgnorePattern: '^_', |           "varsIgnorePattern": "^_", | ||||||
|           caughtErrorsIgnorePattern: '^_', |           "caughtErrorsIgnorePattern": "^_" | ||||||
|         }, |         } | ||||||
|       ], |       ], | ||||||
|       'sort-imports': ['off'], |       "sort-imports": ["off"], | ||||||
|       'import/order': [ |       "import/order": [ | ||||||
|         'error', |         "error", | ||||||
|         { |         { | ||||||
|           groups: [ |           "groups": [ | ||||||
|             'builtin', |             "builtin", | ||||||
|             'external', |             "external", | ||||||
|             'internal', |             "internal", | ||||||
|             'parent', |             "parent", | ||||||
|             ['sibling', 'index'], |             ["sibling", "index"] | ||||||
|           ], |           ], | ||||||
|           'newlines-between': 'always', |           "newlines-between": "always", | ||||||
|         }, |         } | ||||||
|       ], |       ], | ||||||
|       semi: ['error', 'always'], |       semi: ["error", "always"], | ||||||
|       '@stylistic/indent': ['error', 2], |       '@stylistic/indent': ['error', 2], | ||||||
|       'react/prop-types': 'off', |       'react/prop-types': 'off' | ||||||
|     }, |     }, | ||||||
|   }, |   } | ||||||
|   { |  | ||||||
|     ignores: ['babel.config.js'], |  | ||||||
|   }, |  | ||||||
| ]; | ]; | ||||||
|  | |||||||
| @ -1,22 +0,0 @@ | |||||||
| module.exports = { |  | ||||||
|   preset: 'ts-jest', |  | ||||||
|   transform: { |  | ||||||
|     '^.+\\.tsx?$': 'babel-jest', |  | ||||||
|   }, |  | ||||||
|   coverageProvider: 'v8', |  | ||||||
|   coverageDirectory: 'coverage', |  | ||||||
|   collectCoverageFrom: ['**/src/**/*.{ts,tsx}', '!**/src/app.tsx', '!**/src/**/types.ts', '!**/src/**/*.d.ts', '!**/src/models/**/*'], |  | ||||||
|   collectCoverage: true, |  | ||||||
|   clearMocks: true, |  | ||||||
|   moduleNameMapper: { |  | ||||||
|     '\\.(svg|webp)$': '<rootDir>/__mocks__/file-mock', |  | ||||||
|     '\\.(css|scss)$': '<rootDir>/__mocks__/style-mock', |  | ||||||
|     'react-i18next': '<rootDir>/__mocks__/react-i18next', |  | ||||||
|   }, |  | ||||||
|   testEnvironmentOptions: { |  | ||||||
|     customExportConditions: [''], |  | ||||||
|   }, |  | ||||||
|   testEnvironment: 'jest-fixed-jsdom', |  | ||||||
|   testPathIgnorePatterns: ['/node_modules/', '<rootDir>/e2e'], |  | ||||||
|   setupFilesAfterEnv: ['<rootDir>/jest-preset-it/jest.setup.js', '<rootDir>/__mocks__/brojs-cli-mock.ts', '<rootDir>/__mocks__/lottiefiles-mock.tsx'], |  | ||||||
| }; |  | ||||||
| @ -1,5 +0,0 @@ | |||||||
| // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef
 |  | ||||||
| require('@testing-library/jest-dom'); |  | ||||||
| 
 |  | ||||||
| // eslint-disable-next-line no-undef
 |  | ||||||
| global.__webpack_public_path__ = ''; |  | ||||||
| @ -14,92 +14,26 @@ | |||||||
|   "dry-wash.landing.make-order-button": "Make order", |   "dry-wash.landing.make-order-button": "Make order", | ||||||
|   "dry-wash.landing.site-logo": "The logo of the \"Dry Master\" company", |   "dry-wash.landing.site-logo": "The logo of the \"Dry Master\" company", | ||||||
|   "dry-wash.landing.social-proof-section.heading": "We are being chosen", |   "dry-wash.landing.social-proof-section.heading": "We are being chosen", | ||||||
|   "dry-wash.order-create.title": "Order a car wash", |  | ||||||
|   "dry-wash.order-create.form.field.validation.required": "This field is required", |  | ||||||
|   "dry-wash.order-create.form.phone-field.label": "Phone number", |  | ||||||
|   "dry-wash.order-create.form.phone-field.invalid": "Enter the valid phone number", |  | ||||||
|   "dry-wash.order-create.form.car-number-field.label": "Car number", |  | ||||||
|   "dry-wash.order-create.form.car-number-field.invalid": "Enter the valid vehicle number", |  | ||||||
|   "dry-wash.order-create.form.car-color-field.label": "The color of the car", |  | ||||||
|   "dry-wash.order-create.form.car-body-field.label": "Car body type", |  | ||||||
|   "dry-wash.order-create.form.washing-datetime-field.label": "What time is the car available?", |  | ||||||
|   "dry-wash.order-create.form.washing-location-field.label": "Where is the car located?", |  | ||||||
|   "dry-wash.order-create.form.washing-location-field.placeholder": "Enter the address or select on the map", |  | ||||||
|   "dry-wash.order-create.form.washing-location-field.help": "For example, 55.754364, 48.743295 Universitetskaya Street, 1, Innopolis, Verkhneuslonsky district, Republic of Tatarstan (Tatarstan), 420500", |  | ||||||
|   "dry-wash.order-create.car-color-select.placeholder": "Input color", |  | ||||||
|   "dry-wash.order-create.car-color-select.custom": "Custom", |  | ||||||
|   "dry-wash.order-create.car-color-select.custom-label": "Custom:", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.white": "White", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.black": "Black", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.silver": "Silver", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.gray": "Gray", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.beige-brown": "Beige Brown", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.red": "Red", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.blue": "Blue", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.green": "Green", |  | ||||||
|   "dry-wash.order-create.car-body-select.placeholder": "Not specified", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.sedan": "Sedan", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.hatchback" : "Hatchback", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.crossover" : "Crossover", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.suv" : "Sport utility vehicle", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.station-wagon" : "Station wagon", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.coupe" : "Coupe", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.minivan" : "Minivan", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.pickup" : "Pickup", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.liftback" : "Liftback", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.sports-car" : "Sports-car", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.other": "Other", |  | ||||||
|   "dry-wash.order-create.form.submit-button.label": "Submit", |  | ||||||
|   "dry-wash.order-create.order-creation-title": "Creating order ...", |  | ||||||
|   "dry-wash.order-create.create-order-query.success.title": "The order is successfully created", |  | ||||||
|   "dry-wash.order-create.create-order-query.error.title": "Failed to create an order", |  | ||||||
|   "dry-wash.order-view.title": "Your order", |  | ||||||
|   "dry-wash.order-view.get-order-query.error.title": "Failed to fetch the details of order", |  | ||||||
|   "dry-wash.order-view.details.title": "Order #{{number}}", |  | ||||||
|   "dry-wash.order-view.details.owner": "Owner", |  | ||||||
|   "dry-wash.order-view.details.car": "Car", |  | ||||||
|   "dry-wash.order-view.details.location": "Where", |  | ||||||
|   "dry-wash.order-view.details.datetime-range": "When", |  | ||||||
|   "dry-wash.order-view.details.alert": "The operator will contact you about the payment at the specified phone number", |  | ||||||
|   "dry-wash.order-view.upload-car-image.field.label": "Upload a photo of your car, and our service will quickly calculate the pre-order price!", |  | ||||||
|   "dry-wash.order-view.upload-car-image.field.help": "Allowed formats: .jpg, .png. Maximum size: 14MB", |  | ||||||
|   "dry-wash.order-view.upload-car-image.file-input.placeholder": "Upload a file", |  | ||||||
|   "dry-wash.order-view.upload-car-image.file-input.button": "Upload", |  | ||||||
|   "dry-wash.order-view.upload-car-image-query.success.title": "The car image is successfully uploaded", |  | ||||||
|   "dry-wash.order-view.upload-car-image-query.error.title": "Failed to upload the car image", |  | ||||||
|   "dry-wash.order-view.price-car.title": "The level of car contamination:", |  | ||||||
|   "dry-wash.order-view.price-car.description": "The cost of washing:", |  | ||||||
|   "dry-wash.order-view.price-car.error": "Failed to determine the level of car contamination", |  | ||||||
|   "dry-wash.arm.master.add": "Add", |   "dry-wash.arm.master.add": "Add", | ||||||
|   "dry-wash.arm.order.title": "Orders", |   "dry-wash.arm.order.title": "Orders", | ||||||
|   "dry-wash.arm.order.table.empty": "Table empty", |  | ||||||
|   "dry-wash.arm.order.error.title": "Error loading data", |  | ||||||
|   "dry-wash.arm.order.status.progress": "In Progress", |   "dry-wash.arm.order.status.progress": "In Progress", | ||||||
|   "dry-wash.arm.order.status.complete": "Completed", |   "dry-wash.arm.order.status.complete": "Completed", | ||||||
|   "dry-wash.arm.order.status.pending": "Pending", |   "dry-wash.arm.order.status.pending": "Pending", | ||||||
|   "dry-wash.arm.order.status.working": "Working", |   "dry-wash.arm.order.status.working": "Working", | ||||||
|   "dry-wash.arm.order.status.cancelled": "Canceled", |   "dry-wash.arm.order.status.canceled": "Canceled", | ||||||
|   "dry-wash.arm.order.status.placeholder": "Select status", |   "dry-wash.arm.order.status.placeholder": "Select Status", | ||||||
|   "dry-wash.arm.order.master.placeholder": "Select master", |  | ||||||
|   "dry-wash.arm.order.table.header.carNumber": "Car Number", |   "dry-wash.arm.order.table.header.carNumber": "Car Number", | ||||||
|   "dry-wash.arm.order.table.header.washingTime": "Washing Time", |   "dry-wash.arm.order.table.header.washingTime": "Washing Time", | ||||||
|   "dry-wash.arm.order.table.header.orderDate": "Order Date", |   "dry-wash.arm.order.table.header.orderDate": "Order Date", | ||||||
|   "dry-wash.arm.order.table.header.status": "Status", |   "dry-wash.arm.order.table.header.status": "Status", | ||||||
|   "dry-wash.arm.order.table.header.masters": "Master", |  | ||||||
|   "dry-wash.arm.order.table.header.telephone": "Telephone", |   "dry-wash.arm.order.table.header.telephone": "Telephone", | ||||||
|   "dry-wash.arm.order.table.header.location": "Location", |   "dry-wash.arm.order.table.header.location": "Location", | ||||||
|   "dry-wash.arm.master.title": "Masters", |   "dry-wash.arm.master.title": "Masters", | ||||||
|   "dry-wash.arm.master.table.header.name": "Name", |   "dry-wash.arm.master.table.header.name": "Name", | ||||||
|   "dry-wash.arm.master.table.empty": "Table empty", |  | ||||||
|   "dry-wash.arm.master.error.title": "Error loading data", |  | ||||||
|   "dry-wash.arm.master.table.header.currentJob": "Current Job", |   "dry-wash.arm.master.table.header.currentJob": "Current Job", | ||||||
|   "dry-wash.arm.master.table.header.phone": "Phone", |   "dry-wash.arm.master.table.header.phone": "Phone", | ||||||
|   "dry-wash.arm.master.table.header.actions": "Actions", |   "dry-wash.arm.master.table.header.actions": "Actions", | ||||||
|   "dry-wash.arm.master.table.actionsMenu.delete": "Delete Master", |   "dry-wash.arm.master.table.actionsMenu.delete": "Delete Master", | ||||||
|   "dry-wash.arm.master.schedule.empty": "free", |  | ||||||
|   "dry-wash.arm.master.editable.aria.cancel": "Undo changes", |  | ||||||
|   "dry-wash.arm.master.editable.aria.save": "Save changes ", |  | ||||||
|   "dry-wash.arm.master.editable.aria.edit": "Edit", |  | ||||||
|   "dry-wash.arm.master.drawer.title": "Add New Master", |   "dry-wash.arm.master.drawer.title": "Add New Master", | ||||||
|   "dry-wash.arm.master.drawer.inputName.label": "Full Name", |   "dry-wash.arm.master.drawer.inputName.label": "Full Name", | ||||||
|   "dry-wash.arm.master.drawer.inputName.placeholder": "Enter Full Name", |   "dry-wash.arm.master.drawer.inputName.placeholder": "Enter Full Name", | ||||||
| @ -107,15 +41,6 @@ | |||||||
|   "dry-wash.arm.master.drawer.inputPhone.placeholder": "Enter Phone Number", |   "dry-wash.arm.master.drawer.inputPhone.placeholder": "Enter Phone Number", | ||||||
|   "dry-wash.arm.master.drawer.button.save": "Save", |   "dry-wash.arm.master.drawer.button.save": "Save", | ||||||
|   "dry-wash.arm.master.drawer.button.cancel": "Cancel", |   "dry-wash.arm.master.drawer.button.cancel": "Cancel", | ||||||
|   "dry-wash.arm.master.drawer.toast.create-master": "Master created", |  | ||||||
|   "dry-wash.arm.master.drawer.toast.error.empty-fields": "Fields cannot be empty", |  | ||||||
|   "dry-wash.arm.master.drawer.toast.error.base": "Error", |  | ||||||
|   "dry-wash.arm.master.drawer.toast.error.create-master": "Error creating master", |  | ||||||
|   "dry-wash.arm.master.drawer.toast.error.create-master-details": "Failed to add master. Please try again", |  | ||||||
|   "dry-wash.arm.master.drawer.form.name.required": "Master name is required", |  | ||||||
|   "dry-wash.arm.master.drawer.form.phone.required": "Phone number is required", |  | ||||||
|   "dry-wash.arm.master.drawer.form.phone.pattern": "Invalid phone number", |  | ||||||
|   "dry-wash.arm.master.drawer.form.name.minLength": "Name must contain at least 2 characters", |  | ||||||
|   "dry-wash.arm.master.sideBar.orders": "Orders", |   "dry-wash.arm.master.sideBar.orders": "Orders", | ||||||
|   "dry-wash.arm.master.sideBar.master": "Masters", |   "dry-wash.arm.master.sideBar.master": "Masters", | ||||||
|   "dry-wash.arm.master.sideBar.title": "Dry Master", |   "dry-wash.arm.master.sideBar.title": "Dry Master", | ||||||
| @ -124,9 +49,5 @@ | |||||||
|   "dry-wash.notFound.button.back": "Back to Home", |   "dry-wash.notFound.button.back": "Back to Home", | ||||||
|   "dry-wash.errorBoundary.title": "Something went wrong", |   "dry-wash.errorBoundary.title": "Something went wrong", | ||||||
|   "dry-wash.errorBoundary.description": "We are already working on fixing the issue", |   "dry-wash.errorBoundary.description": "We are already working on fixing the issue", | ||||||
|   "dry-wash.errorBoundary.button.reload": "Reload Page", |   "dry-wash.errorBoundary.button.reload": "Reload Page" | ||||||
|   "dry-wash.washTime.timeSlot": "{{start}} - {{end}}", |  | ||||||
|   "dry-wash.arm.map.title": "Map of orders", |  | ||||||
|   "dry-wash.arm.map.carNumber": "Car Number", |  | ||||||
|   "dry-wash.arm.map.status": "Status" |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -5,36 +5,20 @@ | |||||||
|   "dry-wash.arm.order.status.complete": "Завершено", |   "dry-wash.arm.order.status.complete": "Завершено", | ||||||
|   "dry-wash.arm.order.status.pending": "В ожидании", |   "dry-wash.arm.order.status.pending": "В ожидании", | ||||||
|   "dry-wash.arm.order.status.working": "В работе", |   "dry-wash.arm.order.status.working": "В работе", | ||||||
|   "dry-wash.arm.order.status.cancelled": "Отменено", |   "dry-wash.arm.order.status.canceled": "Отменено", | ||||||
|   "dry-wash.arm.order.status.placeholder": "Выберите статус", |   "dry-wash.arm.order.status.placeholder": "Выберите статус", | ||||||
|   "dry-wash.arm.order.master.placeholder": "Выберите мастера", |  | ||||||
|   "dry-wash.arm.order.table.header.carNumber": "Номер машины", |   "dry-wash.arm.order.table.header.carNumber": "Номер машины", | ||||||
|   "dry-wash.arm.order.table.header.washingTime": "Время мойки", |   "dry-wash.arm.order.table.header.washingTime": "Время мойки", | ||||||
|   "dry-wash.arm.order.table.header.orderDate": "Дата заказа", |   "dry-wash.arm.order.table.header.orderDate": "Дата заказа", | ||||||
|   "dry-wash.arm.order.table.header.status": "Статус", |   "dry-wash.arm.order.table.header.status": "Статус", | ||||||
|   "dry-wash.arm.order.table.header.masters": "Мастер", |  | ||||||
|   "dry-wash.arm.order.table.header.telephone": "Телефон", |   "dry-wash.arm.order.table.header.telephone": "Телефон", | ||||||
|   "dry-wash.arm.order.table.header.location": "Расположение", |   "dry-wash.arm.order.table.header.location": "Расположение", | ||||||
|   "dry-wash.arm.order.table.empty": "Список пуст", |  | ||||||
|   "dry-wash.arm.order.error.title": "Ошибка при загрузке данных", |  | ||||||
|   "dry-wash.arm.master.title": "Мастера", |   "dry-wash.arm.master.title": "Мастера", | ||||||
|   "dry-wash.arm.master.table.empty": "Список пуст", |  | ||||||
|   "dry-wash.arm.master.error.title": "Ошибка при загрузке данных", |  | ||||||
|   "dry-wash.arm.master.table.header.name": "Имя", |   "dry-wash.arm.master.table.header.name": "Имя", | ||||||
|   "dry-wash.arm.master.table.header.currentJob": "Актуальная занятость", |   "dry-wash.arm.master.table.header.currentJob": "Актуальная занятость", | ||||||
|   "dry-wash.arm.master.table.header.phone": "Телефон", |   "dry-wash.arm.master.table.header.phone": "Телефон", | ||||||
|   "dry-wash.arm.master.table.header.actions": "Действия", |   "dry-wash.arm.master.table.header.actions": "Действия", | ||||||
|   "dry-wash.arm.master.table.actionsMenu.delete": "Удалить мастера", |   "dry-wash.arm.master.table.actionsMenu.delete": "Удалить мастера", | ||||||
|   "dry-wash.arm.master.table.actionsMenu.toast.success": "Мастер удалён", |  | ||||||
|   "dry-wash.arm.master.table.actionsMenu.toast.error.title": "Ошибка!", |  | ||||||
|   "dry-wash.arm.master.table.actionsMenu.toast.error.description": "Не удалось удалить мастера. Попробуйте ещё раз.", |  | ||||||
|   "dry-wash.arm.master.schedule.empty": "Свободен", |  | ||||||
|   "dry-wash.arm.master.editable.aria.cancel": "Отменить изменения", |  | ||||||
|   "dry-wash.arm.master.editable.aria.save": "Сохранить изменения", |  | ||||||
|   "dry-wash.arm.master.editable.aria.edit": "Редактировать", |  | ||||||
|   "dry-wash.arm.master.editable.toast.success": "Успешно!", |  | ||||||
|   "dry-wash.arm.master.editable.toast.error.description": "Не удалось обновить данные", |  | ||||||
|   "dry-wash.arm.master.editable.toast.error.title": "Ошибка!", |  | ||||||
|   "dry-wash.arm.master.drawer.title": "Добавить нового мастера", |   "dry-wash.arm.master.drawer.title": "Добавить нового мастера", | ||||||
|   "dry-wash.arm.master.drawer.inputName.label": "ФИО", |   "dry-wash.arm.master.drawer.inputName.label": "ФИО", | ||||||
|   "dry-wash.arm.master.drawer.inputName.placeholder": "Введите ФИО", |   "dry-wash.arm.master.drawer.inputName.placeholder": "Введите ФИО", | ||||||
| @ -42,15 +26,6 @@ | |||||||
|   "dry-wash.arm.master.drawer.inputPhone.placeholder": "Введите номер телефона", |   "dry-wash.arm.master.drawer.inputPhone.placeholder": "Введите номер телефона", | ||||||
|   "dry-wash.arm.master.drawer.button.save": "Сохранить", |   "dry-wash.arm.master.drawer.button.save": "Сохранить", | ||||||
|   "dry-wash.arm.master.drawer.button.cancel": "Отменить", |   "dry-wash.arm.master.drawer.button.cancel": "Отменить", | ||||||
|   "dry-wash.arm.master.drawer.toast.create-master": "Мастер создан", |  | ||||||
|   "dry-wash.arm.master.drawer.toast.error.empty-fields": "Поля не могут быть пустыми", |  | ||||||
|   "dry-wash.arm.master.drawer.toast.error.base": "Ошибка", |  | ||||||
|   "dry-wash.arm.master.drawer.toast.error.create-master": "Ошибка при создании мастера", |  | ||||||
|   "dry-wash.arm.master.drawer.toast.error.create-master-details": "Не удалось добавить мастера. Попробуйте еще раз", |  | ||||||
|   "dry-wash.arm.master.drawer.form.name.required": "Имя мастера обязательно", |  | ||||||
|   "dry-wash.arm.master.drawer.form.phone.required": "Телефон обязателен", |  | ||||||
|   "dry-wash.arm.master.drawer.form.phone.pattern": "Некорректный номер телефона", |  | ||||||
|   "dry-wash.arm.master.drawer.form.name.minLength": "Имя должно содержать минимум 2 символа", |  | ||||||
|   "dry-wash.arm.master.sideBar.orders": "Заказы", |   "dry-wash.arm.master.sideBar.orders": "Заказы", | ||||||
|   "dry-wash.arm.master.sideBar.master": "Мастера", |   "dry-wash.arm.master.sideBar.master": "Мастера", | ||||||
|   "dry-wash.arm.master.sideBar.title": "Сухой мастер", |   "dry-wash.arm.master.sideBar.title": "Сухой мастер", | ||||||
| @ -69,70 +44,11 @@ | |||||||
|   "dry-wash.landing.make-order-button": "Сделать заказ", |   "dry-wash.landing.make-order-button": "Сделать заказ", | ||||||
|   "dry-wash.landing.site-logo": "Логотип компании \u00ABDry Master\u00BB", |   "dry-wash.landing.site-logo": "Логотип компании \u00ABDry Master\u00BB", | ||||||
|   "dry-wash.landing.social-proof-section.heading": "Нас выбирают", |   "dry-wash.landing.social-proof-section.heading": "Нас выбирают", | ||||||
|   "dry-wash.order-create.title": "Заказать мойку", |  | ||||||
|   "dry-wash.order-create.form.field.validation.required": "Это поле обязательно для заполнения", |  | ||||||
|   "dry-wash.order-create.form.phone-field.label": "Номер телефона", |  | ||||||
|   "dry-wash.order-create.form.phone-field.invalid": "Введите корректный номер телефона", |  | ||||||
|   "dry-wash.order-create.form.car-number-field.label": "Номер автомобиля", |  | ||||||
|   "dry-wash.order-create.form.car-number-field.invalid": "Введите корректный номер автомобиля", |  | ||||||
|   "dry-wash.order-create.form.car-color-field.label": "Цвет автомобиля", |  | ||||||
|   "dry-wash.order-create.form.car-body-field.label": "Тип кузова автомобиля", |  | ||||||
|   "dry-wash.order-create.form.washing-datetime-field.label": "В какое время автомобиль доступен?", |  | ||||||
|   "dry-wash.order-create.form.washing-location-field.label": "Где находится автомобиль?", |  | ||||||
|   "dry-wash.order-create.form.washing-location-field.placeholder": "Введите адрес или выберите на карте", |  | ||||||
|   "dry-wash.order-create.form.washing-location-field.help": "Например, 55.754364, 48.743295 Университетская улица, 1, Иннополис, Верхнеуслонский район, Республика Татарстан (Татарстан), 420500", |  | ||||||
|   "dry-wash.order-create.car-color-select.placeholder": "Введите цвет", |  | ||||||
|   "dry-wash.order-create.car-color-select.custom": "Другой", |  | ||||||
|   "dry-wash.order-create.car-color-select.custom-label": "Другой:", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.white": "Белый", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.black": "Черный", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.silver": "Серебристый", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.gray": "Серый", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.beige-brown": "Бежево-коричневый", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.red": "Красный", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.blue": "Синий", |  | ||||||
|   "dry-wash.order-create.car-color-select.colors.green": "Зеленый", |  | ||||||
|   "dry-wash.order-create.car-body-select.placeholder": "Не указан", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.sedan": "Седан", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.hatchback": "Хэтчбек", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.crossover": "Кроссовер", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.suv": "Внедорожник", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.station-wagon": "Универсал", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.coupe": "Купе", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.minivan": "Минивэн", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.pickup": "Пикап", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.liftback": "Лифтбек", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.sports-car": "Спорткар", |  | ||||||
|   "dry-wash.order-create.car-body-select.options.other": "Другой", |  | ||||||
|   "dry-wash.order-create.form.submit-button.label": "Отправить", |  | ||||||
|   "dry-wash.order-create.order-creation-title": "Создаем заказ ...", |  | ||||||
|   "dry-wash.order-create.create-order-query.success.title": "Заказ успешно создан", |  | ||||||
|   "dry-wash.order-create.create-order-query.error.title": "Не удалось создать заказ", |  | ||||||
|   "dry-wash.order-view.title": "Ваш заказ", |  | ||||||
|   "dry-wash.order-view.get-order-query.error.title": "Не удалось загрузить детали заказа", |  | ||||||
|   "dry-wash.order-view.details.title": "Заказ №{{number}}", |  | ||||||
|   "dry-wash.order-view.details.owner": "Владелец", |  | ||||||
|   "dry-wash.order-view.details.car": "Автомобиль", |  | ||||||
|   "dry-wash.order-view.details.location": "Где", |  | ||||||
|   "dry-wash.order-view.details.datetime-range": "Когда", |  | ||||||
|   "dry-wash.order-view.details.alert": "С вами свяжется оператор насчет оплаты по указанному номеру телефона", |  | ||||||
|   "dry-wash.order-view.upload-car-image.field.label": "Загрузите фото вашего автомобиля, и наш сервис быстро рассчитает предварительную стоимость заказа!", |  | ||||||
|   "dry-wash.order-view.upload-car-image.field.help": "Допустимые форматы: .jpg, .png. Максимальный размер: 14МБ", |  | ||||||
|   "dry-wash.order-view.upload-car-image.file-input.placeholder": "Загрузите файл", |  | ||||||
|   "dry-wash.order-view.upload-car-image.file-input.button": "Загрузить", |  | ||||||
|   "dry-wash.order-view.upload-car-image-query.success.title": "Изображение автомобиля успешно загружено", |  | ||||||
|   "dry-wash.order-view.upload-car-image-query.error.title": "Не удалось загрузить изображение автомобиля", |  | ||||||
|   "dry-wash.order-view.price-car.title": "Уровень загрязнения машины:", |  | ||||||
|   "dry-wash.order-view.price-car.description": "Стоимость мойки:", |  | ||||||
|   "dry-wash.order-view.price-car.error": "Не удалось определить уровень загрязнения машины", |  | ||||||
|   "dry-wash.notFound.title": "Страница не найдена", |   "dry-wash.notFound.title": "Страница не найдена", | ||||||
|   "dry-wash.notFound.description": "К сожалению, запрашиваемая вами страница не существует.", |   "dry-wash.notFound.description": "К сожалению, запрашиваемая вами страница не существует.", | ||||||
|   "dry-wash.notFound.button.back": "Вернуться на главную", |   "dry-wash.notFound.button.back": "Вернуться на главную", | ||||||
|   "dry-wash.errorBoundary.title": "Что-то пошло не так", |   "dry-wash.errorBoundary.title":"Что-то пошло не так", | ||||||
|   "dry-wash.errorBoundary.description": "Мы уже работаем над исправлением проблемы", |   "dry-wash.errorBoundary.description": "Мы уже работаем над исправлением проблемы", | ||||||
|   "dry-wash.errorBoundary.button.reload": "Перезагрузить страницу", |   "dry-wash.errorBoundary.button.reload": "Перезагрузить страницу", | ||||||
|   "dry-wash.washTime.timeSlot": "{{start}} - {{end}}", |   "dry-wash.washTime.timeSlot": "{{start}} - {{end}}" | ||||||
|   "dry-wash.arm.map.title": " Карта заказов", |  | ||||||
|   "dry-wash.arm.map.carNumber": " Номер автомобиля", |  | ||||||
|   "dry-wash.arm.map.status": "Статус" |  | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										26971
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										122
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @ -1,77 +1,49 @@ | |||||||
| { | { | ||||||
|   "name": "dry-wash", |     "name": "dry-wash", | ||||||
|   "version": "0.12.0", |     "version": "0.1.0", | ||||||
|   "description": "<a id=\"readme-top\"></a>", |     "description": "<a id=\"readme-top\"></a>", | ||||||
|   "main": "./src/index.tsx", |     "main": "./src/index.tsx", | ||||||
|   "scripts": { |     "scripts": { | ||||||
|     "test": "jest -u", |         "test": "echo \"Error: no test specified\" && exit 1", | ||||||
|     "start": "brojs server --port=8099 --with-open-browser", |         "start": "brojs server --port=8099 --with-open-browser", | ||||||
|     "build": "npm run clean && brojs build --dev", |         "build": "npm run clean && brojs build --dev", | ||||||
|     "build:prod": "npm run clean && brojs build", |         "build:prod": "npm run clean && brojs build", | ||||||
|     "clean": "rimraf dist", |         "clean": "rimraf dist", | ||||||
|     "eslint": "npx eslint src", |         "eslint": "npx eslint .", | ||||||
|     "eslint:fix": "npx eslint src --fix", |         "eslint:fix": "npx eslint . --fix", | ||||||
|     "preversion": "npm run eslint" |         "preversion": "npm run eslint" | ||||||
|   }, |     }, | ||||||
|   "keywords": [], |     "keywords": [], | ||||||
|   "author": "", |     "author": "", | ||||||
|   "license": "ISC", |     "license": "ISC", | ||||||
|   "dependencies": { |     "dependencies": { | ||||||
|     "@babel/core": "^7.26.7", |         "@brojs/cli": "^1.6.3", | ||||||
|     "@babel/preset-env": "^7.26.7", |         "@chakra-ui/icons": "^2.2.4", | ||||||
|     "@babel/preset-react": "^7.26.3", |         "@chakra-ui/react": "^2.4.2", | ||||||
|     "@babel/preset-typescript": "^7.26.0", |         "@emotion/react": "^11.4.1", | ||||||
|     "@brojs/cli": "^1.8.4", |         "@emotion/styled": "^11.3.0", | ||||||
|     "@chakra-ui/icons": "^2.2.4", |         "@fontsource/open-sans": "^5.1.0", | ||||||
|     "@chakra-ui/react": "^2.10.5", |         "@lottiefiles/react-lottie-player": "^3.5.4", | ||||||
|     "@emotion/react": "^11.4.1", |         "@types/react": "^18.3.12", | ||||||
|     "@emotion/styled": "^11.3.0", |         "dayjs": "^1.11.13", | ||||||
|     "@fontsource/open-sans": "^5.1.0", |         "express": "^4.21.1", | ||||||
|     "@lottiefiles/react-lottie-player": "^3.5.4", |         "framer-motion": "^6.2.8", | ||||||
|     "@pbe/react-yandex-maps": "^1.2.5", |         "i18next": "^23.16.4", | ||||||
|     "@reduxjs/toolkit": "^2.5.0", |         "react": "^18.3.1", | ||||||
|     "@testing-library/dom": "^10.4.0", |         "react-dom": "^18.3.1", | ||||||
|     "@testing-library/react": "^16.2.0", |         "react-i18next": "^15.1.1", | ||||||
|     "@types/react": "^18.3.12", |         "react-icons": "^5.3.0", | ||||||
|     "babel-jest": "^29.7.0", |         "react-router-dom": "^6.27.0" | ||||||
|     "dayjs": "^1.11.13", |     }, | ||||||
|     "express": "^4.21.1", |     "devDependencies": { | ||||||
|     "framer-motion": "^6.2.8", |         "@eslint/js": "^9.14.0", | ||||||
|     "i18next": "^23.16.4", |         "@stylistic/eslint-plugin": "^2.10.1", | ||||||
|     "jest": "^29.7.0", |         "@types/react-dom": "^18.3.1", | ||||||
|     "jest-environment-jsdom": "^29.7.0", |         "eslint": "^9.14.0", | ||||||
|     "jest-fixed-jsdom": "^0.0.9", |         "eslint-plugin-import": "^2.31.0", | ||||||
|     "keycloak-js": "^23.0.7", |         "eslint-plugin-react": "^7.37.2", | ||||||
|     "msw": "^2.7.0", |         "globals": "^15.11.0", | ||||||
|     "react": "^18.3.1", |         "prettier": "3.3.3", | ||||||
|     "react-dom": "^18.3.1", |         "typescript-eslint": "^8.12.2" | ||||||
|     "react-hook-form": "^7.53.2", |     } | ||||||
|     "react-i18next": "^15.1.1", |  | ||||||
|     "react-icons": "^5.3.0", |  | ||||||
|     "react-phone-number-input": "^3.4.9", |  | ||||||
|     "react-redux": "^9.2.0", |  | ||||||
|     "react-router-dom": "^6.27.0", |  | ||||||
|     "ts-jest": "^29.2.5", |  | ||||||
|     "ts-node": "^10.9.2" |  | ||||||
|   }, |  | ||||||
|   "devDependencies": { |  | ||||||
|     "@eslint/js": "^9.14.0", |  | ||||||
|     "@playwright/test": "^1.50.1", |  | ||||||
|     "@stylistic/eslint-plugin": "^2.10.1", |  | ||||||
|     "@testing-library/jest-dom": "^6.6.3", |  | ||||||
|     "@types/jest": "^29.5.14", |  | ||||||
|     "@types/node": "^22.13.1", |  | ||||||
|     "@types/react-dom": "^18.3.1", |  | ||||||
|     "@types/testing-library__jest-dom": "^5.14.9", |  | ||||||
|     "eslint": "^9.14.0", |  | ||||||
|     "eslint-plugin-import": "^2.31.0", |  | ||||||
|     "eslint-plugin-react": "^7.37.2", |  | ||||||
|     "globals": "^15.11.0", |  | ||||||
|     "prettier": "3.3.3", |  | ||||||
|     "typescript": "^5.7.3", |  | ||||||
|     "typescript-eslint": "^8.12.2" |  | ||||||
|   }, |  | ||||||
|   "jest": { |  | ||||||
|     "preset": "./jest-preset-it/jest-preset.ts" |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,79 +0,0 @@ | |||||||
| import { defineConfig, devices } from '@playwright/test'; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Read environment variables from file. |  | ||||||
|  * https://github.com/motdotla/dotenv
 |  | ||||||
|  */ |  | ||||||
| // import dotenv from 'dotenv';
 |  | ||||||
| // import path from 'path';
 |  | ||||||
| // dotenv.config({ path: path.resolve(__dirname, '.env') });
 |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * See https://playwright.dev/docs/test-configuration.
 |  | ||||||
|  */ |  | ||||||
| export default defineConfig({ |  | ||||||
|   testDir: './tests', |  | ||||||
|   /* Run tests in files in parallel */ |  | ||||||
|   fullyParallel: true, |  | ||||||
|   /* Fail the build on CI if you accidentally left test.only in the source code. */ |  | ||||||
|   forbidOnly: !!process.env.CI, |  | ||||||
|   /* Retry on CI only */ |  | ||||||
|   retries: process.env.CI ? 2 : 0, |  | ||||||
|   /* Opt out of parallel tests on CI. */ |  | ||||||
|   workers: process.env.CI ? 1 : undefined, |  | ||||||
|   /* Reporter to use. See https://playwright.dev/docs/test-reporters */ |  | ||||||
|   reporter: 'html', |  | ||||||
|   /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ |  | ||||||
|   use: { |  | ||||||
|     /* Base URL to use in actions like `await page.goto('/')`. */ |  | ||||||
|     // baseURL: 'http://127.0.0.1:3000',
 |  | ||||||
| 
 |  | ||||||
|     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ |  | ||||||
|     trace: 'on-first-retry', |  | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   /* Configure projects for major browsers */ |  | ||||||
|   projects: [ |  | ||||||
|     { |  | ||||||
|       name: 'chromium', |  | ||||||
|       use: { ...devices['Desktop Chrome'] }, |  | ||||||
|     }, |  | ||||||
| 
 |  | ||||||
|     { |  | ||||||
|       name: 'firefox', |  | ||||||
|       use: { ...devices['Desktop Firefox'] }, |  | ||||||
|     }, |  | ||||||
| 
 |  | ||||||
|     { |  | ||||||
|       name: 'webkit', |  | ||||||
|       use: { ...devices['Desktop Safari'] }, |  | ||||||
|     }, |  | ||||||
| 
 |  | ||||||
|     /* Test against mobile viewports. */ |  | ||||||
|     // {
 |  | ||||||
|     //   name: 'Mobile Chrome',
 |  | ||||||
|     //   use: { ...devices['Pixel 5'] },
 |  | ||||||
|     // },
 |  | ||||||
|     // {
 |  | ||||||
|     //   name: 'Mobile Safari',
 |  | ||||||
|     //   use: { ...devices['iPhone 12'] },
 |  | ||||||
|     // },
 |  | ||||||
| 
 |  | ||||||
|     /* Test against branded browsers. */ |  | ||||||
|     // {
 |  | ||||||
|     //   name: 'Microsoft Edge',
 |  | ||||||
|     //   use: { ...devices['Desktop Edge'], channel: 'msedge' },
 |  | ||||||
|     // },
 |  | ||||||
|     // {
 |  | ||||||
|     //   name: 'Google Chrome',
 |  | ||||||
|     //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },
 |  | ||||||
|     // },
 |  | ||||||
|   ], |  | ||||||
| 
 |  | ||||||
|   /* Run your local dev server before starting the tests */ |  | ||||||
|   // webServer: {
 |  | ||||||
|   //   command: 'npm run start',
 |  | ||||||
|   //   url: 'http://127.0.0.1:3000',
 |  | ||||||
|   //   reuseExistingServer: !process.env.CI,
 |  | ||||||
|   // },
 |  | ||||||
| }); |  | ||||||
| @ -1,18 +0,0 @@ | |||||||
| import { getFeatures } from "@brojs/cli"; |  | ||||||
| 
 |  | ||||||
| const features = getFeatures('dry-wash'); |  | ||||||
| 
 |  | ||||||
| export const FEATURE = { |  | ||||||
|   orderViewStatusPolling: { |  | ||||||
|     isOn: Boolean(features?.['order-view-status-polling']), |  | ||||||
|     getValue: () => { |  | ||||||
|       const interval = parseInt(features?.['order-view-status-polling']?.value); |  | ||||||
|       if (!Number.isNaN(interval)) { |  | ||||||
|         return interval; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   carImageUpload: { |  | ||||||
|     isOn: Boolean(features?.['car-img-upload']) |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| @ -1,92 +0,0 @@ | |||||||
| import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; |  | ||||||
| import { getConfigValue } from '@brojs/cli'; |  | ||||||
| import dayjs from 'dayjs'; |  | ||||||
| 
 |  | ||||||
| import { Master, OrderArm } from '../../models/api'; |  | ||||||
| 
 |  | ||||||
| import { extractBodyFromResponse } from './utils'; |  | ||||||
| 
 |  | ||||||
| export type UpdateMasterPayload = Required<Pick<Master, 'id'>> & |  | ||||||
|   Partial<Omit<Master, 'id'>>; |  | ||||||
| 
 |  | ||||||
| type UpdateOrderProps = Required<Pick<OrderArm, 'id'>> & |  | ||||||
|   Partial<Pick<OrderArm, 'status' | 'notes'>> & { |  | ||||||
|     master?: string; |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
| export const api = createApi({ |  | ||||||
|   reducerPath: 'api', |  | ||||||
|   baseQuery: fetchBaseQuery({ |  | ||||||
|     baseUrl: new URL(getConfigValue('dry-wash.api'), location.origin).href, |  | ||||||
|   }), |  | ||||||
|   tagTypes: ['Masters', 'Orders'], |  | ||||||
|   endpoints: (builder) => ({ |  | ||||||
|     getMasters: builder.query<Master[], { date: Date }>({ |  | ||||||
|       query: ({ date }) => { |  | ||||||
|         const startDate = dayjs(date).startOf('day').toISOString(); |  | ||||||
|         const endDate = dayjs(date).endOf('day').toISOString(); |  | ||||||
|         return { |  | ||||||
|           url: '/arm/masters/list', |  | ||||||
|           method: 'POST', |  | ||||||
|           body: { startDate, endDate }, |  | ||||||
|         }; |  | ||||||
|       }, |  | ||||||
|       transformResponse: extractBodyFromResponse<Master[]>, |  | ||||||
|       providesTags: ['Masters'], |  | ||||||
|     }), |  | ||||||
|     updateOrders: builder.mutation<void, UpdateOrderProps>({ |  | ||||||
|       query: ({ id, status, notes, master }) => ({ |  | ||||||
|         url: `/order/${id}`, |  | ||||||
|         method: 'PATCH', |  | ||||||
|         body: { status, notes, master }, |  | ||||||
|       }), |  | ||||||
|       invalidatesTags: ['Orders', 'Masters'], |  | ||||||
|     }), |  | ||||||
|     getOrders: builder.query<OrderArm[], { date: Date }>({ |  | ||||||
|       query: ({ date }) => { |  | ||||||
|         const startDate = dayjs(date).startOf('day').toISOString(); |  | ||||||
|         const endDate = dayjs(date).endOf('day').toISOString(); |  | ||||||
|         return { |  | ||||||
|           url: '/arm/orders', |  | ||||||
|           method: 'POST', |  | ||||||
|           body: { startDate, endDate }, |  | ||||||
|         }; |  | ||||||
|       }, |  | ||||||
|       transformResponse: extractBodyFromResponse<OrderArm[]>, |  | ||||||
|       providesTags: ['Orders'], |  | ||||||
|     }), |  | ||||||
| 
 |  | ||||||
|     addMaster: builder.mutation<void, Pick<Master, 'name' | 'phone'>>({ |  | ||||||
|       query: (master) => ({ |  | ||||||
|         url: '/arm/masters', |  | ||||||
|         method: 'POST', |  | ||||||
|         body: master, |  | ||||||
|       }), |  | ||||||
|       invalidatesTags: ['Masters', 'Orders'], |  | ||||||
|     }), |  | ||||||
|     deleteMaster: builder.mutation<void, { id: string }>({ |  | ||||||
|       query: ({ id }) => ({ |  | ||||||
|         url: `/arm/masters/${id}`, |  | ||||||
|         method: 'DELETE', |  | ||||||
|       }), |  | ||||||
|       invalidatesTags: ['Masters', 'Orders'], |  | ||||||
|     }), |  | ||||||
|     updateMaster: builder.mutation<void, UpdateMasterPayload>({ |  | ||||||
|       query: ({ id, name, phone }) => ({ |  | ||||||
|         url: `/arm/masters/${id}`, |  | ||||||
|         method: 'PATCH', |  | ||||||
|         body: { name, phone }, |  | ||||||
|       }), |  | ||||||
|       invalidatesTags: ['Masters', 'Orders'], |  | ||||||
|     }), |  | ||||||
|   }), |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| export const { |  | ||||||
|   useGetMastersQuery, |  | ||||||
|   useAddMasterMutation, |  | ||||||
|   useDeleteMasterMutation, |  | ||||||
|   useUpdateMasterMutation, |  | ||||||
|   useGetOrdersQuery, |  | ||||||
|   useUpdateOrdersMutation, |  | ||||||
| } = api; |  | ||||||
| @ -1,31 +0,0 @@ | |||||||
| import { GetOrder, CreateOrder, UploadCarImage } from "../../models/api"; |  | ||||||
| 
 |  | ||||||
| import { api } from "./api"; |  | ||||||
| import { extractBodyFromResponse, extractErrorMessageFromResponse } from "./utils"; |  | ||||||
| 
 |  | ||||||
| export const landingApi = api.injectEndpoints({ |  | ||||||
|   endpoints: ({ mutation, query }) => ({ |  | ||||||
|     getOrder: query<GetOrder.Response, GetOrder.Params>({ |  | ||||||
|       query: ({ orderId }) => `/order/${orderId}`, |  | ||||||
|       transformResponse: extractBodyFromResponse<GetOrder.Response>, |  | ||||||
|       transformErrorResponse: extractErrorMessageFromResponse, |  | ||||||
|     }), |  | ||||||
|     createOrder: mutation<CreateOrder.Response, CreateOrder.Params>({ |  | ||||||
|       query: ({ body }) => ({ |  | ||||||
|         url: `/order/create`, |  | ||||||
|         body, |  | ||||||
|         method: 'POST' |  | ||||||
|       }), |  | ||||||
|       transformResponse: extractBodyFromResponse<CreateOrder.Response>, |  | ||||||
|       transformErrorResponse: extractErrorMessageFromResponse, |  | ||||||
|     }), |  | ||||||
|     uploadCarImage: mutation<UploadCarImage.Response, UploadCarImage.Params>({ |  | ||||||
|       query: ({ orderId, body }) => ({ |  | ||||||
|         url: `/order/${orderId}/upload-car-img`, |  | ||||||
|         body, |  | ||||||
|         method: 'POST' |  | ||||||
|       }), |  | ||||||
|       transformErrorResponse: extractErrorMessageFromResponse, |  | ||||||
|     }), |  | ||||||
|   }) |  | ||||||
| }); |  | ||||||
| @ -1,22 +0,0 @@ | |||||||
| import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; |  | ||||||
| 
 |  | ||||||
| import { BaseResponse } from '../../models/api'; |  | ||||||
| 
 |  | ||||||
| export const extractBodyFromResponse = <Body>(response: BaseResponse<Body>) => { |  | ||||||
|   if (response.success) { |  | ||||||
|     return response.body; |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const extractErrorMessageFromResponse = ({ |  | ||||||
|   data, |  | ||||||
| }: FetchBaseQueryError) => { |  | ||||||
|   if ( |  | ||||||
|     typeof data === 'object' && |  | ||||||
|     data !== null && |  | ||||||
|     'error' in data && |  | ||||||
|     typeof data.error === 'string' |  | ||||||
|   ) { |  | ||||||
|     return data.error; |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| @ -1,16 +0,0 @@ | |||||||
| import { configureStore } from '@reduxjs/toolkit'; |  | ||||||
| 
 |  | ||||||
| import { api } from './service/api'; |  | ||||||
| 
 |  | ||||||
| export const store = configureStore({ |  | ||||||
|   reducer: { |  | ||||||
|     [api.reducerPath]: api.reducer, |  | ||||||
|   }, |  | ||||||
|   middleware: (getDefaultMiddleware) => |  | ||||||
|     getDefaultMiddleware({ |  | ||||||
|       serializableCheck: false |  | ||||||
|     }).concat(api.middleware), |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| export type RootState = ReturnType<typeof store.getState>; |  | ||||||
| export type AppDispatch = typeof store.dispatch; |  | ||||||
| @ -1,52 +1,25 @@ | |||||||
| import { generatePath } from 'react-router-dom'; | import { generatePath } from "react-router-dom"; | ||||||
| import { getNavigationValue } from '@brojs/cli'; | import { getNavigationValue } from "@brojs/cli"; | ||||||
| 
 | 
 | ||||||
| import { Order } from '../models/landing'; | import { Order } from "../models"; | ||||||
| 
 |  | ||||||
| const getFullUrls = (url: string) => |  | ||||||
|   `${getNavigationValue('dry-wash.main')}${url}`; |  | ||||||
| 
 | 
 | ||||||
| export const URLs = { | export const URLs = { | ||||||
|   landing: { |   landing: { | ||||||
|     url: getNavigationValue('dry-wash.main'), |     url: getNavigationValue("dry-wash.main"), | ||||||
|     getUrl() { |     getUrl() { | ||||||
|       return this.url; |       return this.url; | ||||||
|     }, |     } | ||||||
|   }, |   }, | ||||||
|   orderCreate: { |   orderForm: { | ||||||
|     url: getFullUrls(getNavigationValue('dry-wash.order.create')), |     url: getNavigationValue("dry-wash.create"), | ||||||
|     getUrl() { |     getUrl() { | ||||||
|       return this.url; |       return this.url; | ||||||
|     }, |     } | ||||||
|   }, |   }, | ||||||
|   orderView: { |   orderView: { | ||||||
|     url: getFullUrls(getNavigationValue('dry-wash.order.view')), |     url: getNavigationValue("dry-wash.view.order"), | ||||||
|     getUrl(orderId: Order.Id) { |     getUrl(orderId: Order.Id) { | ||||||
|       return generatePath(this.url, { orderId }); |       return generatePath(this.url, { orderId }); | ||||||
|     }, |     } | ||||||
|   }, |   } | ||||||
|   armMaster: { | }; | ||||||
|     url: getNavigationValue('dry-wash.arm.master'), |  | ||||||
|     isOn: Boolean(getNavigationValue('dry-wash.arm.master')), |  | ||||||
|   }, |  | ||||||
|   armOrder: { |  | ||||||
|     url: getNavigationValue('dry-wash.arm.order'), |  | ||||||
|     isOn: Boolean(getNavigationValue('dry-wash.arm.order')), |  | ||||||
|   }, |  | ||||||
|   armMap: { |  | ||||||
|     url: getNavigationValue('dry-wash.arm.map'), |  | ||||||
|     isOn: Boolean(getNavigationValue('dry-wash.arm.map')), |  | ||||||
|     getUrl({ lat, lon, currentDate }) { |  | ||||||
|       return ( |  | ||||||
|         getFullUrls('/arm') + |  | ||||||
|         '/' + |  | ||||||
|         getNavigationValue('dry-wash.arm.map') + |  | ||||||
|         `?lat=${lat}&lon=${lon}¤tDate=${currentDate}` |  | ||||||
|       ); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   armBase: { |  | ||||||
|     url: getFullUrls(getNavigationValue('dry-wash.arm')), |  | ||||||
|     isOn: Boolean(getNavigationValue('dry-wash.arm')), |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
							
								
								
									
										18
									
								
								src/app.tsx
									
									
									
									
									
								
							
							
						
						| @ -1,23 +1,19 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { BrowserRouter } from 'react-router-dom'; | import { BrowserRouter } from 'react-router-dom'; | ||||||
| import { ChakraProvider, theme as chakraTheme } from '@chakra-ui/react'; | import { ChakraProvider, theme as chakraTheme } from '@chakra-ui/react'; | ||||||
| import { Provider } from 'react-redux'; |  | ||||||
| 
 | 
 | ||||||
| import Routers from './routes'; | import Routers from './routes'; | ||||||
| import ErrorBoundary from './components/ErrorBoundary'; | import ErrorBoundary from './components/ErrorBoundary'; | ||||||
| import { store } from './__data__/store'; |  | ||||||
| 
 | 
 | ||||||
| const App = () => { | const App = () => { | ||||||
|   return ( |   return ( | ||||||
|     <Provider store={store}> |     <ChakraProvider theme={chakraTheme}> | ||||||
|       <ChakraProvider theme={chakraTheme}> |       <ErrorBoundary> | ||||||
|         <ErrorBoundary> |         <BrowserRouter> | ||||||
|           <BrowserRouter> |           <Routers /> | ||||||
|             <Routers /> |         </BrowserRouter> | ||||||
|           </BrowserRouter> |       </ErrorBoundary> | ||||||
|         </ErrorBoundary> |     </ChakraProvider> | ||||||
|       </ChakraProvider> |  | ||||||
|     </Provider> |  | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1 +0,0 @@ | |||||||
| export { default as OrderCreationAnimation } from './order-creation.json'; |  | ||||||
| Before Width: | Height: | Size: 1.9 KiB | 
| Before Width: | Height: | Size: 2.2 KiB | 
| Before Width: | Height: | Size: 1.9 KiB | 
| @ -1,11 +0,0 @@ | |||||||
| export { default as CoupeImg } from './coupe.webp'; |  | ||||||
| export { default as CrossoverImg } from './crossover.webp'; |  | ||||||
| export { default as HatchbackImg } from './hatchback.webp'; |  | ||||||
| export { default as LiftbackImg } from './liftback.webp'; |  | ||||||
| export { default as MinivanImg } from './minivan.webp'; |  | ||||||
| export { default as OtherImg } from './other.webp'; |  | ||||||
| export { default as PickupImg } from './pickup.webp'; |  | ||||||
| export { default as SedanImg } from './sedan.webp'; |  | ||||||
| export { default as SportsCarImg } from './sports-car.webp'; |  | ||||||
| export { default as StationWagonImg } from './station-wagon.webp'; |  | ||||||
| export { default as SuvImg } from './suv.webp'; |  | ||||||
| Before Width: | Height: | Size: 1.7 KiB | 
| Before Width: | Height: | Size: 1.9 KiB | 
| Before Width: | Height: | Size: 1.2 KiB | 
| Before Width: | Height: | Size: 2.0 KiB | 
| Before Width: | Height: | Size: 1.8 KiB | 
| Before Width: | Height: | Size: 1.9 KiB | 
| Before Width: | Height: | Size: 1.9 KiB | 
| Before Width: | Height: | Size: 2.0 KiB | 
| @ -1,2 +1 @@ | |||||||
| export * from './car-body-type'; |  | ||||||
| export { default as DemoVideoPosterImg } from './demo-video-poster.webp'; | export { default as DemoVideoPosterImg } from './demo-video-poster.webp'; | ||||||
| @ -1,32 +0,0 @@ | |||||||
| import React from 'react'; |  | ||||||
| import { Box, Button, Text } from '@chakra-ui/react'; |  | ||||||
| import { ArrowBackIcon, ArrowForwardIcon } from '@chakra-ui/icons'; |  | ||||||
| import dayjs from 'dayjs'; |  | ||||||
| 
 |  | ||||||
| interface DateNavigatorProps { |  | ||||||
|   currentDate: Date; |  | ||||||
|   onPreviousDate: () => void; |  | ||||||
|   onNextDate: () => void; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const DateNavigator = ({ |  | ||||||
|   currentDate, |  | ||||||
|   onPreviousDate, |  | ||||||
|   onNextDate, |  | ||||||
| }: DateNavigatorProps) => { |  | ||||||
|   return ( |  | ||||||
|     <Box display='flex' alignItems='center' justifyContent='flex-start' mb='5'> |  | ||||||
|       <Button onClick={onPreviousDate}> |  | ||||||
|         <ArrowBackIcon /> |  | ||||||
|       </Button> |  | ||||||
|       <Text mx='4' fontSize='lg' fontWeight='bold'> |  | ||||||
|         {dayjs(currentDate).format('DD.MM.YYYY')} |  | ||||||
|       </Text> |  | ||||||
|       <Button onClick={onNextDate}> |  | ||||||
|         <ArrowForwardIcon /> |  | ||||||
|       </Button> |  | ||||||
|     </Box> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default DateNavigator; |  | ||||||
| @ -1 +0,0 @@ | |||||||
| export { default } from './DateNavigator'; |  | ||||||
| @ -1,107 +0,0 @@ | |||||||
| import React, { useEffect, useState } from 'react'; |  | ||||||
| import { |  | ||||||
|   Editable, |  | ||||||
|   EditableInput, |  | ||||||
|   EditablePreview, |  | ||||||
|   Flex, |  | ||||||
|   IconButton, |  | ||||||
|   Input, |  | ||||||
|   useEditableControls, |  | ||||||
|   ButtonGroup, |  | ||||||
|   Stack, |  | ||||||
| } from '@chakra-ui/react'; |  | ||||||
| import { CheckIcon, CloseIcon, EditIcon } from '@chakra-ui/icons'; |  | ||||||
| import { useTranslation } from 'react-i18next'; |  | ||||||
| 
 |  | ||||||
| import { useUpdateMasterMutation } from '../../__data__/service/api'; |  | ||||||
| import useShowToast from '../../hooks/useShowToast'; |  | ||||||
| 
 |  | ||||||
| interface EditableWrapperProps { |  | ||||||
|   value: string; |  | ||||||
|   fieldName: 'phone' | 'name'; |  | ||||||
|   id: string; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const EditableWrapper = ({ value, fieldName, id }: EditableWrapperProps) => { |  | ||||||
|   const [updateMaster, { isError, isSuccess, error }] = |  | ||||||
|     useUpdateMasterMutation(); |  | ||||||
| 
 |  | ||||||
|   const { t } = useTranslation('~', { |  | ||||||
|     keyPrefix: 'dry-wash.arm.master.editable', |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   const showToast = useShowToast(); |  | ||||||
|   const [currentValue, setCurrentValue] = useState<string>(value); |  | ||||||
| 
 |  | ||||||
|   const handleSubmit = async (newValue: string) => { |  | ||||||
|     if (currentValue === newValue) return; |  | ||||||
| 
 |  | ||||||
|     await updateMaster({ id, [fieldName]: newValue }); |  | ||||||
| 
 |  | ||||||
|     setCurrentValue(newValue); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (isSuccess) { |  | ||||||
|       showToast(t('toast.success'), 'success'); |  | ||||||
|     } |  | ||||||
|   }, [isSuccess]); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (isError) { |  | ||||||
|       showToast(t('toast.error.title'), 'error', t('toast.error.description')); |  | ||||||
|       console.error(t('toast.error.description'), error); |  | ||||||
|     } |  | ||||||
|   }, [isError, error]); |  | ||||||
| 
 |  | ||||||
|   function EditableControls() { |  | ||||||
|     const { |  | ||||||
|       isEditing, |  | ||||||
|       getSubmitButtonProps, |  | ||||||
|       getCancelButtonProps, |  | ||||||
|       getEditButtonProps, |  | ||||||
|     } = useEditableControls(); |  | ||||||
| 
 |  | ||||||
|     return isEditing ? ( |  | ||||||
|       <ButtonGroup justifyContent='center' size='sm'> |  | ||||||
|         <IconButton |  | ||||||
|           aria-label={t('aria.save')} |  | ||||||
|           icon={<CheckIcon />} |  | ||||||
|           {...getSubmitButtonProps()} |  | ||||||
|         /> |  | ||||||
|         <IconButton |  | ||||||
|           aria-label={t('aria.cancel')} |  | ||||||
|           icon={<CloseIcon />} |  | ||||||
|           {...getCancelButtonProps()} |  | ||||||
|         /> |  | ||||||
|       </ButtonGroup> |  | ||||||
|     ) : ( |  | ||||||
|       <Flex justifyContent='center'> |  | ||||||
|         <IconButton |  | ||||||
|           aria-label={t('aria.edit')} |  | ||||||
|           size='sm' |  | ||||||
|           icon={<EditIcon />} |  | ||||||
|           {...getEditButtonProps()} |  | ||||||
|         /> |  | ||||||
|       </Flex> |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <Editable |  | ||||||
|       textAlign='center' |  | ||||||
|       defaultValue={currentValue} |  | ||||||
|       fontSize='2xl' |  | ||||||
|       isPreviewFocusable={false} |  | ||||||
|       onSubmit={handleSubmit} |  | ||||||
|     > |  | ||||||
|       <Stack direction={['column', 'row']} spacing='15px'> |  | ||||||
|         <EditablePreview /> |  | ||||||
|         <Input as={EditableInput} /> |  | ||||||
|         <EditableControls /> |  | ||||||
|       </Stack> |  | ||||||
|     </Editable> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default EditableWrapper; |  | ||||||
| @ -33,22 +33,22 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { | |||||||
| 
 | 
 | ||||||
|   render() { |   render() { | ||||||
|     const { hasError } = this.state; |     const { hasError } = this.state; | ||||||
| 
 |     //TODO: добавить анимацию после залива 404 страницы
 | ||||||
|  |     //TODO: может сделать обертку для хука, чтоб язык менялся без перезагрузки
 | ||||||
|     if (hasError) { |     if (hasError) { | ||||||
|       return ( |       return ( | ||||||
|         <Center minH='100vh' data-testid='error-boundary'> |         <Center minH='100vh'> | ||||||
|           <VStack spacing={4} textAlign='center'> |           <VStack spacing={4} textAlign='center'> | ||||||
|             <Heading as='h1' size='2xl' data-testid='error-title'> |             <Heading as='h1' size='2xl'> | ||||||
|               {i18next.t('~:dry-wash.errorBoundary.title')} |               {i18next.t('~:dry-wash.errorBoundary.title')} | ||||||
|             </Heading> |             </Heading> | ||||||
|             <Text fontSize='lg' data-testid='error-description'> |             <Text fontSize='lg'> | ||||||
|               {i18next.t('~:dry-wash.errorBoundary.description')} |               {i18next.t('~:dry-wash.errorBoundary.description')} | ||||||
|             </Text> |             </Text> | ||||||
|             <Button |             <Button | ||||||
|               colorScheme='teal' |               colorScheme='teal' | ||||||
|               size='lg' |               size='lg' | ||||||
|               variant='outline' |               variant='outline' | ||||||
|               data-testid='error-reload-button' |  | ||||||
|               onClick={() => window.location.reload()} |               onClick={() => window.location.reload()} | ||||||
|             > |             > | ||||||
|               {i18next.t('~:dry-wash.errorBoundary.button.reload')} |               {i18next.t('~:dry-wash.errorBoundary.button.reload')} | ||||||
|  | |||||||
| @ -1,51 +0,0 @@ | |||||||
| import * as React from 'react'; |  | ||||||
| import { describe, expect, it, jest } from '@jest/globals'; |  | ||||||
| import { render, screen, waitFor } from '@testing-library/react'; |  | ||||||
| import { BrowserRouter } from 'react-router-dom'; |  | ||||||
| import { Provider } from 'react-redux'; |  | ||||||
| import { useEffect } from 'react'; |  | ||||||
| 
 |  | ||||||
| import ErrorBoundary from '../ErrorBoundary'; |  | ||||||
| import { store } from '../../../__data__/store'; |  | ||||||
| 
 |  | ||||||
| const ProblematicComponent = () => { |  | ||||||
|   useEffect(() => { |  | ||||||
|     throw new Error('Test Error'); |  | ||||||
|   }, []); |  | ||||||
| 
 |  | ||||||
|   return <div>Этот текст не должен появиться</div>; |  | ||||||
| }; |  | ||||||
| jest.mock('@brojs/cli', () => { |  | ||||||
|   return { |  | ||||||
|     getNavigationValue: () => '/auth/login', |  | ||||||
|     getConfigValue: () => '/api', |  | ||||||
|   }; |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| describe('ErrorBoundary', () => { |  | ||||||
|   it('должен отобразить запасной UI при ошибке', async () => { |  | ||||||
|     // Подавляем вывод ошибки в консоль во время теста
 |  | ||||||
|     const consoleSpy = jest.spyOn(console, 'error'); |  | ||||||
|     consoleSpy.mockImplementation(() => {}); |  | ||||||
| 
 |  | ||||||
|     const { container } = render( |  | ||||||
|       <Provider store={store}> |  | ||||||
|         <ErrorBoundary> |  | ||||||
|           <BrowserRouter> |  | ||||||
|             <ProblematicComponent /> |  | ||||||
|           </BrowserRouter> |  | ||||||
|         </ErrorBoundary> |  | ||||||
|       </Provider>, |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     const button = await waitFor(() => |  | ||||||
|       screen.getByTestId('error-reload-button'), |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     expect(button).not.toBeNull(); |  | ||||||
|     expect(container).toMatchSnapshot(); |  | ||||||
| 
 |  | ||||||
|     // Восстанавливаем console.error после теста
 |  | ||||||
|     consoleSpy.mockRestore(); |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
| @ -1,28 +0,0 @@ | |||||||
| // Jest Snapshot v1, https://goo.gl/fbAQLP |  | ||||||
| 
 |  | ||||||
| exports[`ErrorBoundary должен отобразить запасной UI при ошибке 1`] = ` |  | ||||||
| <div> |  | ||||||
|   <div |  | ||||||
|     class="css-1o0ed15" |  | ||||||
|     data-testid="error-boundary" |  | ||||||
|   > |  | ||||||
|     <div |  | ||||||
|       class="chakra-stack css-zefqyp" |  | ||||||
|     > |  | ||||||
|       <h1 |  | ||||||
|         class="chakra-heading css-0" |  | ||||||
|         data-testid="error-title" |  | ||||||
|       /> |  | ||||||
|       <p |  | ||||||
|         class="chakra-text css-1ezsviu" |  | ||||||
|         data-testid="error-description" |  | ||||||
|       /> |  | ||||||
|       <button |  | ||||||
|         class="chakra-button css-4xx2wk" |  | ||||||
|         data-testid="error-reload-button" |  | ||||||
|         type="button" |  | ||||||
|       /> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </div> |  | ||||||
| `; |  | ||||||
| @ -1,61 +0,0 @@ | |||||||
| import { Box, Button, Heading, HStack, Flex } from '@chakra-ui/react'; |  | ||||||
| import React from 'react'; |  | ||||||
| import { useLocation, Link } from 'react-router-dom'; |  | ||||||
| import { useTranslation } from 'react-i18next'; |  | ||||||
| 
 |  | ||||||
| import { URLs } from '../../__data__/urls'; |  | ||||||
| 
 |  | ||||||
| const Header = () => { |  | ||||||
|   const location = useLocation(); |  | ||||||
|   const isActive = (keyword: string) => location.pathname.includes(keyword); |  | ||||||
| 
 |  | ||||||
|   const { t } = useTranslation('~', { |  | ||||||
|     keyPrefix: 'dry-wash.arm.master.sideBar', |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <Box as='header' bg='gray.50' boxShadow='md' px={6} py={4} w='100%'> |  | ||||||
|       <Flex gap={50} align='center'> |  | ||||||
|         <Heading color='green' size='lg'> |  | ||||||
|           {t('title')} |  | ||||||
|         </Heading> |  | ||||||
|         <HStack spacing={4}> |  | ||||||
|           {URLs.armOrder.isOn && ( |  | ||||||
|             <Button |  | ||||||
|               as={Link} |  | ||||||
|               to={URLs.armOrder.url} |  | ||||||
|               colorScheme={isActive(URLs.armOrder.url) ? 'green' : 'blue'} |  | ||||||
|               variant={isActive(URLs.armOrder.url) ? 'outline' : 'ghost'} |  | ||||||
|             > |  | ||||||
|               {t('orders')} |  | ||||||
|             </Button> |  | ||||||
|           )} |  | ||||||
|           {URLs.armMaster.isOn && ( |  | ||||||
|             <Button |  | ||||||
|               as={Link} |  | ||||||
|               to={URLs.armMaster.url} |  | ||||||
|               colorScheme={isActive(URLs.armMaster.url) ? 'green' : 'blue'} |  | ||||||
|               variant={isActive(URLs.armMaster.url) ? 'outline' : 'ghost'} |  | ||||||
|               data-testid='master-button' |  | ||||||
|             > |  | ||||||
|               {t('master')} |  | ||||||
|             </Button> |  | ||||||
|           )} |  | ||||||
| 
 |  | ||||||
|           {URLs.armMap.isOn && ( |  | ||||||
|             <Button |  | ||||||
|               as={Link} |  | ||||||
|               to={URLs.armMap.url} |  | ||||||
|               colorScheme={isActive(URLs.armMap.url) ? 'green' : 'blue'} |  | ||||||
|               variant={isActive(URLs.armMap.url) ? 'outline' : 'ghost'} |  | ||||||
|             > |  | ||||||
|               Карта заказов |  | ||||||
|             </Button> |  | ||||||
|           )} |  | ||||||
|         </HStack> |  | ||||||
|       </Flex> |  | ||||||
|     </Box> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default Header; |  | ||||||
| @ -1 +0,0 @@ | |||||||
| export { default } from './Header'; |  | ||||||
| @ -2,40 +2,23 @@ import { Box, Flex } from '@chakra-ui/react'; | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { Navigate, Route, Routes } from 'react-router-dom'; | import { Navigate, Route, Routes } from 'react-router-dom'; | ||||||
| 
 | 
 | ||||||
|  | import Sidebar from '../Sidebar'; | ||||||
| import Orders from '../Orders'; | import Orders from '../Orders'; | ||||||
| import Masters from '../Masters'; | import Masters from '../Masters'; | ||||||
| import { URLs } from '../../__data__/urls'; |  | ||||||
| import Header from '../Header'; |  | ||||||
| import OrdersMap from '../Map'; |  | ||||||
| 
 | 
 | ||||||
| const LayoutArm = () => { | const LayoutArm = () => ( | ||||||
|   let defaultRedirect = null; |   <Flex h='100vh'> | ||||||
| 
 |     <Sidebar /> | ||||||
|   if (URLs.armOrder.isOn) { |     <Box flex='1' bg='gray.50'> | ||||||
|     defaultRedirect = URLs.armOrder.url; |       <Routes> | ||||||
|   } else if (URLs.armMaster.isOn) { |         <Route> | ||||||
|     defaultRedirect = URLs.armMaster.url; |           <Route index element={<Navigate to='orders' replace />} /> | ||||||
|   } |           <Route path='orders' element={<Orders />} /> | ||||||
| 
 |           <Route path='masters' element={<Masters />} /> | ||||||
|   return ( |         </Route> | ||||||
|     <Flex flexDirection='column' h='100vh'> |       </Routes> | ||||||
|       <Header /> |     </Box> | ||||||
|       <Box flex='1' bg='gray.50'> |   </Flex> | ||||||
|         <Routes> | ); | ||||||
|           <Route index element={<Navigate to={defaultRedirect} replace />} /> |  | ||||||
|           {URLs.armOrder.isOn && ( |  | ||||||
|             <Route path={URLs.armOrder.url} element={<Orders />} /> |  | ||||||
|           )} |  | ||||||
|           {URLs.armMaster.isOn && ( |  | ||||||
|             <Route path={URLs.armMaster.url} element={<Masters />} /> |  | ||||||
|           )} |  | ||||||
|           {URLs.armMap.isOn && ( |  | ||||||
|             <Route path={URLs.armMap.url} element={<OrdersMap />} /> |  | ||||||
|           )} |  | ||||||
|         </Routes> |  | ||||||
|       </Box> |  | ||||||
|     </Flex> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| export default LayoutArm; | export default LayoutArm; | ||||||
|  | |||||||
| @ -1,95 +0,0 @@ | |||||||
| import React, { useState } from 'react'; |  | ||||||
| import { Box, Flex, Heading, Spinner } from '@chakra-ui/react'; |  | ||||||
| import { useTranslation } from 'react-i18next'; |  | ||||||
| import { YMaps, Map, Placemark } from '@pbe/react-yandex-maps'; |  | ||||||
| import { useLocation } from 'react-router-dom'; |  | ||||||
| import dayjs from 'dayjs'; |  | ||||||
| 
 |  | ||||||
| import { useGetOrdersQuery } from '../../__data__/service/api'; |  | ||||||
| import getCoordinates from '../../utils/getCoordinates'; |  | ||||||
| import DateNavigator from '../DateNavigator'; |  | ||||||
| 
 |  | ||||||
| const OrdersMap = () => { |  | ||||||
|   const { t } = useTranslation('~', { |  | ||||||
|     keyPrefix: 'dry-wash.arm.map', |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   const location = useLocation(); |  | ||||||
|   const params = new URLSearchParams(location.search); |  | ||||||
|   const latFromUrl = parseFloat(params.get('lat') || '55.78'); |  | ||||||
|   const lonFromUrl = parseFloat(params.get('lon') || '49.12'); |  | ||||||
| 
 |  | ||||||
|   const [currentDate, setCurrentDate] = useState( |  | ||||||
|     params.get('currentDate') |  | ||||||
|       ? new Date(params.get('currentDate')) |  | ||||||
|       : new Date(), |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   const { |  | ||||||
|     data: ordersData, |  | ||||||
|     isLoading, |  | ||||||
|     isSuccess, |  | ||||||
|   } = useGetOrdersQuery({ date: currentDate }); |  | ||||||
| 
 |  | ||||||
|   // Получаем координаты из location
 |  | ||||||
|   const orders = ordersData |  | ||||||
|     ?.map((order) => { |  | ||||||
|       const coords = getCoordinates(order.location); |  | ||||||
|       return coords ? { ...order, ...coords } : null; |  | ||||||
|     }) |  | ||||||
|     .filter(Boolean); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <Box p='8'> |  | ||||||
|       <Heading size='lg' mb='5'> |  | ||||||
|         {t('title')} |  | ||||||
|       </Heading> |  | ||||||
| 
 |  | ||||||
|       <DateNavigator |  | ||||||
|         currentDate={currentDate} |  | ||||||
|         onPreviousDate={() => |  | ||||||
|           setCurrentDate((prevDate) => |  | ||||||
|             dayjs(prevDate).subtract(1, 'day').toDate(), |  | ||||||
|           ) |  | ||||||
|         } |  | ||||||
|         onNextDate={() => |  | ||||||
|           setCurrentDate((prevDate) => dayjs(prevDate).add(1, 'day').toDate()) |  | ||||||
|         } |  | ||||||
|       /> |  | ||||||
| 
 |  | ||||||
|       {isLoading && ( |  | ||||||
|         <Flex justifyContent='center' alignItems='center'> |  | ||||||
|           <Spinner size='lg' /> |  | ||||||
|         </Flex> |  | ||||||
|       )} |  | ||||||
| 
 |  | ||||||
|       {isSuccess && ( |  | ||||||
|         <YMaps> |  | ||||||
|           <Map |  | ||||||
|             defaultState={{ center: [latFromUrl, lonFromUrl], zoom: 12 }} |  | ||||||
|             width='100%' |  | ||||||
|             height='70vh' |  | ||||||
|             modules={['geoObject.addon.balloon']} |  | ||||||
|           > |  | ||||||
|             {orders.map(({ id, lat, lon, carNumber, status }) => ( |  | ||||||
|               <Placemark |  | ||||||
|                 key={id} |  | ||||||
|                 geometry={[lat, lon]} |  | ||||||
|                 options={{ |  | ||||||
|                   preset: 'islands#blueAutoIcon', |  | ||||||
|                   iconColor: 'blue', |  | ||||||
|                   balloonPanelMaxMapArea: 0, |  | ||||||
|                 }} |  | ||||||
|                 properties={{ |  | ||||||
|                   balloonContent: `<strong>${t('carNumber')}</strong> ${carNumber}<br/><strong>${t('status')}</strong> ${status}`, |  | ||||||
|                 }} |  | ||||||
|               /> |  | ||||||
|             ))} |  | ||||||
|           </Map> |  | ||||||
|         </YMaps> |  | ||||||
|       )} |  | ||||||
|     </Box> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default OrdersMap; |  | ||||||
| @ -1 +0,0 @@ | |||||||
| export { default } from './Map'; |  | ||||||
| @ -1,4 +1,4 @@ | |||||||
| import React, { useEffect } from 'react'; | import React from 'react'; | ||||||
| import { | import { | ||||||
|   Menu, |   Menu, | ||||||
|   MenuButton, |   MenuButton, | ||||||
| @ -9,47 +9,16 @@ import { | |||||||
| import { EditIcon } from '@chakra-ui/icons'; | import { EditIcon } from '@chakra-ui/icons'; | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| 
 | 
 | ||||||
| import { useDeleteMasterMutation } from '../../__data__/service/api'; | const MasterActionsMenu = () => { | ||||||
| import useShowToast from '../../hooks/useShowToast'; |  | ||||||
| 
 |  | ||||||
| interface MasterActionsMenu { |  | ||||||
|   id: string; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const MasterActionsMenu = ({ id }: MasterActionsMenu) => { |  | ||||||
|   const { t } = useTranslation('~', { |   const { t } = useTranslation('~', { | ||||||
|     keyPrefix: 'dry-wash.arm.master.table.actionsMenu', |     keyPrefix: 'dry-wash.arm.master.table.actionsMenu', | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const [deleteMaster, { isSuccess, isError, error, isLoading }] = |  | ||||||
|     useDeleteMasterMutation(); |  | ||||||
| 
 |  | ||||||
|   const showToast = useShowToast(); |  | ||||||
| 
 |  | ||||||
|   const handleClickDelete = async () => { |  | ||||||
|     await deleteMaster({ id }); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (isSuccess) { |  | ||||||
|       showToast(t('toast.success'), 'success'); |  | ||||||
|     } |  | ||||||
|   }, [isSuccess]); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (isError) { |  | ||||||
|       showToast(t('toast.error.title'), 'error', t('toast.error.description')); |  | ||||||
|       console.error(error); |  | ||||||
|     } |  | ||||||
|   }, [isError]); |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <Menu> |     <Menu> | ||||||
|       <MenuButton icon={<EditIcon />} as={IconButton} variant='outline' /> |       <MenuButton icon={<EditIcon />} as={IconButton} variant='outline' /> | ||||||
|       <MenuList> |       <MenuList> | ||||||
|         <MenuItem onClick={handleClickDelete} isDisabled={isLoading}> |         <MenuItem>{t('delete')}</MenuItem> | ||||||
|           {t('delete')} |  | ||||||
|         </MenuItem> |  | ||||||
|       </MenuList> |       </MenuList> | ||||||
|     </Menu> |     </Menu> | ||||||
|   ); |   ); | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| import React, { useEffect } from 'react'; | import React, { useState } from 'react'; | ||||||
| import { useForm, SubmitHandler } from 'react-hook-form'; |  | ||||||
| import { | import { | ||||||
|   Button, |   Button, | ||||||
|   FormControl, |   FormControl, | ||||||
| @ -12,128 +11,57 @@ import { | |||||||
|   DrawerFooter, |   DrawerFooter, | ||||||
|   DrawerHeader, |   DrawerHeader, | ||||||
|   DrawerOverlay, |   DrawerOverlay, | ||||||
|   InputGroup, |  | ||||||
|   InputLeftElement, |  | ||||||
|   FormErrorMessage, |  | ||||||
| } from '@chakra-ui/react'; | } from '@chakra-ui/react'; | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| import { PhoneIcon } from '@chakra-ui/icons'; |  | ||||||
| 
 | 
 | ||||||
| import { useAddMasterMutation } from '../../__data__/service/api'; | const MasterDrawer = ({ isOpen, onClose }) => { | ||||||
| import { DrawerInputs } from '../../models/arm/form'; |   const [newMaster, setNewMaster] = useState({ name: '', phone: '' }); | ||||||
| import useShowToast from '../../hooks/useShowToast'; |  | ||||||
| 
 | 
 | ||||||
| interface MasterDrawerProps { |   const handleSave = () => { | ||||||
|   isOpen: boolean; |     console.log(`Сохранение мастера: ${newMaster}`); | ||||||
|   onClose: () => void; |     onClose(); | ||||||
| } |   }; | ||||||
| 
 |  | ||||||
| const MasterDrawer = ({ isOpen, onClose }: MasterDrawerProps) => { |  | ||||||
|   const { |  | ||||||
|     register, |  | ||||||
|     handleSubmit, |  | ||||||
|     reset, |  | ||||||
|     formState: { errors }, |  | ||||||
|   } = useForm<DrawerInputs>(); |  | ||||||
| 
 | 
 | ||||||
|   const { t } = useTranslation('~', { |   const { t } = useTranslation('~', { | ||||||
|     keyPrefix: 'dry-wash.arm.master.drawer', |     keyPrefix: 'dry-wash.arm.master.drawer', | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const onSubmit: SubmitHandler<DrawerInputs> = async (data) => { |  | ||||||
|     const trimMaster = { |  | ||||||
|       name: data.name.trim(), |  | ||||||
|       phone: data.phone.trim(), |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const isEmptyFields = trimMaster.name === '' || trimMaster.phone === ''; |  | ||||||
| 
 |  | ||||||
|     if (isEmptyFields) { |  | ||||||
|       showToast(t('toast.error.base'), 'error', t('toast.error.empty-fields')); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     await addMaster(trimMaster); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const [addMaster, { error, isSuccess }] = useAddMasterMutation(); |  | ||||||
|   const showToast = useShowToast(); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (isSuccess) { |  | ||||||
|       showToast(t('toast.create-master'), 'success'); |  | ||||||
|       reset(); |  | ||||||
|       onClose(); |  | ||||||
|     } |  | ||||||
|   }, [isSuccess]); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (error) { |  | ||||||
|       showToast( |  | ||||||
|         t('toast.error.create-master'), |  | ||||||
|         'error', |  | ||||||
|         t('toast.error.create-master-details'), |  | ||||||
|       ); |  | ||||||
|       console.error(error); |  | ||||||
|     } |  | ||||||
|   }, [error]); |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <Drawer isOpen={isOpen} onClose={onClose} size='md'> |     <Drawer isOpen={isOpen} onClose={onClose} size='md'> | ||||||
|       <DrawerOverlay /> |       <DrawerOverlay /> | ||||||
|       <DrawerContent> |       <DrawerContent> | ||||||
|         <form onSubmit={handleSubmit(onSubmit)}> |         <DrawerCloseButton /> | ||||||
|           <DrawerCloseButton /> |         <DrawerHeader>{t('title')}</DrawerHeader> | ||||||
|           <DrawerHeader>{t('title')}</DrawerHeader> |         <DrawerBody> | ||||||
|           <DrawerBody> |           <FormControl mb='4'> | ||||||
|             <FormControl mb='4' isInvalid={!!errors.name}> |             <FormLabel>{t('inputName.label')}</FormLabel> | ||||||
|               <FormLabel>{t('inputName.label')}</FormLabel> |             <Input | ||||||
|               <Input |               value={newMaster.name} | ||||||
|                 {...register('name', { |               onChange={(e) => | ||||||
|                   required: t('form.name.required'), |                 setNewMaster({ ...newMaster, name: e.target.value }) | ||||||
|                   minLength: { |               } | ||||||
|                     value: 2, |               placeholder={t('inputName.placeholder')} | ||||||
|                     message: t('form.name.minLength'), |             /> | ||||||
|                   }, |           </FormControl> | ||||||
|                 })} |           <FormControl> | ||||||
|                 placeholder={t('inputName.placeholder')} |             <FormLabel> {t('inputPhone.label')}</FormLabel> | ||||||
|               /> |             <Input | ||||||
|               <FormErrorMessage> |               value={newMaster.phone} | ||||||
|                 {errors.name && errors.name.message} |               onChange={(e) => | ||||||
|               </FormErrorMessage> |                 setNewMaster({ ...newMaster, phone: e.target.value }) | ||||||
|             </FormControl> |               } | ||||||
|             <FormControl isInvalid={!!errors.phone}> |               placeholder={t('inputPhone.placeholder')} | ||||||
|               <FormLabel>{t('inputPhone.label')}</FormLabel> |             /> | ||||||
|               <InputGroup> |           </FormControl> | ||||||
|                 <InputLeftElement pointerEvents='none'> |         </DrawerBody> | ||||||
|                   <PhoneIcon color='gray.300' /> |         <DrawerFooter> | ||||||
|                 </InputLeftElement> |           <Button colorScheme='teal' mr={3} onClick={handleSave}> | ||||||
|                 <Input |             {t('button.save')} | ||||||
|                   {...register('phone', { |           </Button> | ||||||
|                     required: t('form.phone.required'), |           <Button variant='ghost' onClick={onClose}> | ||||||
|                     pattern: { |             {t('button.cancel')} | ||||||
|                       value: /^(\+7|8)\d{10}$/, |           </Button> | ||||||
|                       message: t('form.phone.pattern'), |         </DrawerFooter> | ||||||
|                     }, |  | ||||||
|                     setValueAs: (value) => value.replace(/[^\d+]/g, ''), |  | ||||||
|                   })} |  | ||||||
|                   placeholder={t('inputPhone.placeholder')} |  | ||||||
|                 /> |  | ||||||
|               </InputGroup> |  | ||||||
|               <FormErrorMessage> |  | ||||||
|                 {errors.phone && errors.phone.message} |  | ||||||
|               </FormErrorMessage> |  | ||||||
|             </FormControl> |  | ||||||
|           </DrawerBody> |  | ||||||
|           <DrawerFooter> |  | ||||||
|             <Button colorScheme='teal' mr={3} type='submit'> |  | ||||||
|               {t('button.save')} |  | ||||||
|             </Button> |  | ||||||
|             <Button variant='ghost' onClick={onClose}> |  | ||||||
|               {t('button.cancel')} |  | ||||||
|             </Button> |  | ||||||
|           </DrawerFooter> |  | ||||||
|         </form> |  | ||||||
|       </DrawerContent> |       </DrawerContent> | ||||||
|     </Drawer> |     </Drawer> | ||||||
|   ); |   ); | ||||||
|  | |||||||
| @ -1,39 +1,27 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { Badge, Stack, Td, Tr, Text } from '@chakra-ui/react'; | import { Badge, Link, Stack, Td, Tr } from '@chakra-ui/react'; | ||||||
| import { useTranslation } from 'react-i18next'; |  | ||||||
| 
 | 
 | ||||||
| import MasterActionsMenu from '../MasterActionsMenu'; | import MasterActionsMenu from '../MasterActionsMenu'; | ||||||
| import { getTimeSlot } from '../../lib'; | import { getTimeSlot } from '../../lib/date-helpers'; | ||||||
| import EditableWrapper from '../Editable/Editable'; |  | ||||||
| 
 |  | ||||||
| const MasterItem = ({ name, phone, id, schedule }) => { |  | ||||||
|   const { t } = useTranslation('~', { |  | ||||||
|     keyPrefix: 'dry-wash.arm.master', |  | ||||||
|   }); |  | ||||||
| 
 | 
 | ||||||
|  | const MasterItem = ({ name, schedule, phone }) => { | ||||||
|   return ( |   return ( | ||||||
|     <Tr> |     <Tr> | ||||||
|  |       <Td>{name}</Td> | ||||||
|       <Td> |       <Td> | ||||||
|         <EditableWrapper id={id} fieldName={'name'} value={name} /> |         <Stack direction='row'> | ||||||
|  |           {schedule.map(({ startWashTime, endWashTime }, index) => ( | ||||||
|  |             <Badge colorScheme={'green'} key={index}> | ||||||
|  |               {getTimeSlot(startWashTime, endWashTime)} | ||||||
|  |             </Badge> | ||||||
|  |           ))} | ||||||
|  |         </Stack> | ||||||
|       </Td> |       </Td> | ||||||
|       <Td> |       <Td> | ||||||
|         {schedule?.length > 0 ? ( |         <Link href='tel:'>{phone}</Link> | ||||||
|           <Stack direction='row'> |  | ||||||
|             {schedule?.map(({ startWashTime, endWashTime }, index: number) => ( |  | ||||||
|               <Badge colorScheme={'green'} key={index}> |  | ||||||
|                 {getTimeSlot(startWashTime, endWashTime)} |  | ||||||
|               </Badge> |  | ||||||
|             ))} |  | ||||||
|           </Stack> |  | ||||||
|         ) : ( |  | ||||||
|           <Text color='gray.500'>{t('schedule.empty')}</Text> |  | ||||||
|         )} |  | ||||||
|       </Td> |       </Td> | ||||||
|       <Td> |       <Td> | ||||||
|         <EditableWrapper id={id} fieldName={'phone'} value={phone} /> |         <MasterActionsMenu /> | ||||||
|       </Td> |  | ||||||
|       <Td> |  | ||||||
|         <MasterActionsMenu id={id} /> |  | ||||||
|       </Td> |       </Td> | ||||||
|     </Tr> |     </Tr> | ||||||
|   ); |   ); | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import React, { useEffect, useState } from 'react'; | import React from 'react'; | ||||||
| import { | import { | ||||||
|   Box, |   Box, | ||||||
|   Heading, |   Heading, | ||||||
| @ -10,18 +10,12 @@ import { | |||||||
|   Button, |   Button, | ||||||
|   useDisclosure, |   useDisclosure, | ||||||
|   Flex, |   Flex, | ||||||
|   Td, |  | ||||||
|   Text, |  | ||||||
|   Spinner, |  | ||||||
| } from '@chakra-ui/react'; | } from '@chakra-ui/react'; | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| import dayjs from 'dayjs'; |  | ||||||
| 
 | 
 | ||||||
| import MasterItem from '../MasterItem'; | import MasterItem from '../MasterItem'; | ||||||
| import MasterDrawer from '../MasterDrawer'; | import MasterDrawer from '../MasterDrawer'; | ||||||
| import { useGetMastersQuery } from '../../__data__/service/api'; | import data from '../../../stubs/json/arm-masters/success.json'; | ||||||
| import useShowToast from '../../hooks/useShowToast'; |  | ||||||
| import DateNavigator from '../DateNavigator'; |  | ||||||
| 
 | 
 | ||||||
| const TABLE_HEADERS = [ | const TABLE_HEADERS = [ | ||||||
|   'name' as const, |   'name' as const, | ||||||
| @ -32,46 +26,19 @@ const TABLE_HEADERS = [ | |||||||
| 
 | 
 | ||||||
| const Masters = () => { | const Masters = () => { | ||||||
|   const { isOpen, onOpen, onClose } = useDisclosure(); |   const { isOpen, onOpen, onClose } = useDisclosure(); | ||||||
|   const showToast = useShowToast(); |  | ||||||
|   const [currentDate, setCurrentDate] = useState(new Date()); |  | ||||||
| 
 | 
 | ||||||
|   const { t } = useTranslation('~', { |   const { t } = useTranslation('~', { | ||||||
|     keyPrefix: 'dry-wash.arm.master', |     keyPrefix: 'dry-wash.arm.master', | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const { |  | ||||||
|     data: masters, |  | ||||||
|     error, |  | ||||||
|     isLoading, |  | ||||||
|     isSuccess, |  | ||||||
|   } = useGetMastersQuery({ date: currentDate }); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (error) { |  | ||||||
|       showToast(t('error.title'), 'error'); |  | ||||||
|     } |  | ||||||
|   }, [error]); |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <Box p='8'> |     <Box p='8'> | ||||||
|       <Flex justifyContent='space-between' alignItems='baseline' mb='5'> |       <Flex justifyContent='space-between' alignItems='center' mb='5'> | ||||||
|         <Heading size='lg'> {t('title')}</Heading> |         <Heading size='lg'> {t('title')}</Heading> | ||||||
| 
 |  | ||||||
|         <Button colorScheme='green' onClick={onOpen}> |         <Button colorScheme='green' onClick={onOpen}> | ||||||
|           + {t('add')} |           + {t('add')} | ||||||
|         </Button> |         </Button> | ||||||
|       </Flex> |       </Flex> | ||||||
|       <DateNavigator |  | ||||||
|         currentDate={currentDate} |  | ||||||
|         onPreviousDate={() => |  | ||||||
|           setCurrentDate((prevDate) => |  | ||||||
|             dayjs(prevDate).subtract(1, 'day').toDate(), |  | ||||||
|           ) |  | ||||||
|         } |  | ||||||
|         onNextDate={() => |  | ||||||
|           setCurrentDate((prevDate) => dayjs(prevDate).add(1, 'day').toDate()) |  | ||||||
|         } |  | ||||||
|       /> |  | ||||||
|       <Table variant='simple' colorScheme='blackAlpha'> |       <Table variant='simple' colorScheme='blackAlpha'> | ||||||
|         <Thead> |         <Thead> | ||||||
|           <Tr> |           <Tr> | ||||||
| @ -81,24 +48,9 @@ const Masters = () => { | |||||||
|           </Tr> |           </Tr> | ||||||
|         </Thead> |         </Thead> | ||||||
|         <Tbody> |         <Tbody> | ||||||
|           {isLoading && ( |           {data.body.map((master, index) => ( | ||||||
|             <Tr> |             <MasterItem key={index} {...master} /> | ||||||
|               <Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'> |           ))} | ||||||
|                 <Spinner size='lg' /> |  | ||||||
|               </Td> |  | ||||||
|             </Tr> |  | ||||||
|           )} |  | ||||||
|           {isSuccess && masters.length === 0 && ( |  | ||||||
|             <Tr> |  | ||||||
|               <Td colSpan={TABLE_HEADERS.length}> |  | ||||||
|                 <Text>{t('table.empty')}</Text> |  | ||||||
|               </Td> |  | ||||||
|             </Tr> |  | ||||||
|           )} |  | ||||||
|           {isSuccess && |  | ||||||
|             masters.map((master, index) => ( |  | ||||||
|               <MasterItem key={index} {...master} /> |  | ||||||
|             ))} |  | ||||||
|         </Tbody> |         </Tbody> | ||||||
|       </Table> |       </Table> | ||||||
|       <MasterDrawer isOpen={isOpen} onClose={onClose} /> |       <MasterDrawer isOpen={isOpen} onClose={onClose} /> | ||||||
|  | |||||||
| @ -1,21 +1,29 @@ | |||||||
| import React, { ChangeEvent, useState } from 'react'; | import React, { useState } from 'react'; | ||||||
| import { Td, Tr, Link, Select, Button } from '@chakra-ui/react'; | import { Td, Tr, Link, Select } from '@chakra-ui/react'; | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| import dayjs from 'dayjs'; | import dayjs from 'dayjs'; | ||||||
| import { ViewIcon } from '@chakra-ui/icons'; |  | ||||||
| import { Link as LinkRouter } from 'react-router-dom'; |  | ||||||
| 
 | 
 | ||||||
| import { getTimeSlot } from '../../lib'; | import { getTimeSlot } from '../../lib/date-helpers'; | ||||||
| import { useUpdateOrdersMutation } from '../../__data__/service/api'; |  | ||||||
| import { OrderArm, Status, statuses } from '../../models/api'; |  | ||||||
| import getCoordinates from '../../utils/getCoordinates'; |  | ||||||
| import { URLs } from '../../__data__/urls'; |  | ||||||
| 
 | 
 | ||||||
| const statusColors: Record<Status, string> = { | const statuses = [ | ||||||
|   pending: 'yellow.100', |   'pending' as const, | ||||||
|   progress: 'blue.100', |   'progress' as const, | ||||||
|   cancelled: 'red.100', |   'working' as const, | ||||||
|   complete: 'green.100', |   'canceled' as const, | ||||||
|  |   'complete' as const, | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | type GetArrItemType<ArrType> = | ||||||
|  |   ArrType extends Array<infer ItemType> ? ItemType : never; | ||||||
|  | 
 | ||||||
|  | export type OrderProps = { | ||||||
|  |   carNumber?: string; | ||||||
|  |   startWashTime?: string; | ||||||
|  |   endWashTime?: string; | ||||||
|  |   orderDate?: string; | ||||||
|  |   status?: GetArrItemType<typeof statuses>; | ||||||
|  |   phone?: string; | ||||||
|  |   location?: string; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const OrderItem = ({ | const OrderItem = ({ | ||||||
| @ -26,59 +34,23 @@ const OrderItem = ({ | |||||||
|   status, |   status, | ||||||
|   phone, |   phone, | ||||||
|   location, |   location, | ||||||
|   master, | }: OrderProps) => { | ||||||
|   allMasters, |  | ||||||
|   id, |  | ||||||
|   currentDate, |  | ||||||
| }: OrderArm) => { |  | ||||||
|   const [updateOrders] = useUpdateOrdersMutation(); |  | ||||||
|   const { t } = useTranslation('~', { |   const { t } = useTranslation('~', { | ||||||
|     keyPrefix: 'dry-wash.arm.order', |     keyPrefix: 'dry-wash.arm.order', | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const [statusSelect, setStatus] = useState(status); |   const [statusSelect, setStatus] = useState(status); | ||||||
|   const bgColor = statusColors[statusSelect]; |  | ||||||
|   const [masterSelectId, setMasterSelectId] = useState(master); |  | ||||||
| 
 |  | ||||||
|   const handelChangeMasters = (e: ChangeEvent<HTMLSelectElement>) => { |  | ||||||
|     const masterName = e.target.value; |  | ||||||
|     const selectedMaster = allMasters.find( |  | ||||||
|       (master) => master.name === masterName, |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     if (selectedMaster) { |  | ||||||
|       setMasterSelectId(selectedMaster.id); |  | ||||||
|       updateOrders({ id, master: selectedMaster.id }); |  | ||||||
|     } else { |  | ||||||
|       console.error('Master not found'); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const handeChangeStatus = (e: ChangeEvent<HTMLSelectElement>) => { |  | ||||||
|     const status = e.target.value as OrderArm['status']; |  | ||||||
|     updateOrders({ id, status }); |  | ||||||
|     setStatus(status); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const masterSelectChange = allMasters.find( |  | ||||||
|     (master) => master.id === masterSelectId, |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   const { lat = 55.78, lon = 49.12 } = getCoordinates(location); |  | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Tr> |     <Tr> | ||||||
|       <Td>{carNumber}</Td> |       <Td>{carNumber}</Td> | ||||||
|       <Td> |       <Td>{getTimeSlot(startWashTime, endWashTime)}</Td> | ||||||
|         {dayjs(orderDate).format('DD.MM.YYYY')} <br /> |       <Td>{dayjs(orderDate).format('DD.MM.YYYY')}</Td> | ||||||
|         {getTimeSlot(startWashTime, endWashTime)} |  | ||||||
|       </Td> |  | ||||||
|       <Td> |       <Td> | ||||||
|         <Select |         <Select | ||||||
|           value={statusSelect} |           value={statusSelect} | ||||||
|           onChange={handeChangeStatus} |           onChange={(e) => setStatus(e.target.value as OrderProps['status'])} | ||||||
|           placeholder={t(`status.placeholder`)} |           placeholder={t(`status.placeholder`)} | ||||||
|           bg={bgColor} |  | ||||||
|         > |         > | ||||||
|           {statuses.map((status) => ( |           {statuses.map((status) => ( | ||||||
|             <option key={status} value={status}> |             <option key={status} value={status}> | ||||||
| @ -87,30 +59,10 @@ const OrderItem = ({ | |||||||
|           ))} |           ))} | ||||||
|         </Select> |         </Select> | ||||||
|       </Td> |       </Td> | ||||||
|       <Td> |  | ||||||
|         <Select |  | ||||||
|           value={masterSelectChange?.name} |  | ||||||
|           onChange={handelChangeMasters} |  | ||||||
|           placeholder={t(`master.placeholder`)} |  | ||||||
|         > |  | ||||||
|           {allMasters.map((item) => ( |  | ||||||
|             <option key={item.id} value={item.name}> |  | ||||||
|               {item.name} |  | ||||||
|             </option> |  | ||||||
|           ))} |  | ||||||
|         </Select> |  | ||||||
|       </Td> |  | ||||||
|       <Td> |       <Td> | ||||||
|         <Link href='tel:'>{phone}</Link> |         <Link href='tel:'>{phone}</Link> | ||||||
|       </Td> |       </Td> | ||||||
|       <Td> |       <Td>{location}</Td> | ||||||
|         <Button |  | ||||||
|           as={LinkRouter} |  | ||||||
|           to={URLs.armMap.getUrl({ lat, lon, currentDate })} |  | ||||||
|         > |  | ||||||
|           <ViewIcon /> |  | ||||||
|         </Button> |  | ||||||
|       </Td> |  | ||||||
|     </Tr> |     </Tr> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -1,87 +1,30 @@ | |||||||
| import React, { useEffect, useState } from 'react'; | import React from 'react'; | ||||||
| import { | import { Box, Heading, Table, Thead, Tbody, Tr, Th } from '@chakra-ui/react'; | ||||||
|   Box, |  | ||||||
|   Heading, |  | ||||||
|   Table, |  | ||||||
|   Thead, |  | ||||||
|   Tbody, |  | ||||||
|   Tr, |  | ||||||
|   Th, |  | ||||||
|   Spinner, |  | ||||||
|   Text, |  | ||||||
|   Td, |  | ||||||
| } from '@chakra-ui/react'; |  | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| import dayjs from 'dayjs'; |  | ||||||
| 
 | 
 | ||||||
| import OrderItem from '../OrderItem'; | import OrderItem from '../OrderItem'; | ||||||
| import DateNavigator from '../DateNavigator'; | import { OrderProps } from '../OrderItem/OrderItem'; | ||||||
| import { | import data from '../../../stubs/json/arm-orders/success.json'; | ||||||
|   useGetMastersQuery, |  | ||||||
|   useGetOrdersQuery, |  | ||||||
| } from '../../__data__/service/api'; |  | ||||||
| import useShowToast from '../../hooks/useShowToast'; |  | ||||||
| import { OrderArm } from '../../models/api'; |  | ||||||
| 
 |  | ||||||
| const TABLE_HEADERS = [ |  | ||||||
|   'carNumber' as const, |  | ||||||
|   'orderDate' as const, |  | ||||||
|   'status' as const, |  | ||||||
|   'masters' as const, |  | ||||||
|   'telephone' as const, |  | ||||||
|   'location' as const, |  | ||||||
| ]; |  | ||||||
| 
 | 
 | ||||||
| const Orders = () => { | const Orders = () => { | ||||||
|   const { t } = useTranslation('~', { |   const { t } = useTranslation('~', { | ||||||
|     keyPrefix: 'dry-wash.arm.order', |     keyPrefix: 'dry-wash.arm.order', | ||||||
|   }); |   }); | ||||||
|   const showToast = useShowToast(); |  | ||||||
| 
 | 
 | ||||||
|   const [currentDate, setCurrentDate] = useState(new Date()); |   const TABLE_HEADERS = [ | ||||||
|   const { |     'carNumber' as const, | ||||||
|     data: orders, |     'washingTime' as const, | ||||||
|     isLoading: isOrdersLoading, |     'orderDate' as const, | ||||||
|     isSuccess: isOrdersSuccess, |     'status' as const, | ||||||
|     isError: isOrdersError, |     'telephone' as const, | ||||||
|     error: ordersError, |     'location' as const, | ||||||
|   } = useGetOrdersQuery({ date: currentDate }); |   ]; | ||||||
| 
 |  | ||||||
|   const { |  | ||||||
|     data: masters, |  | ||||||
|     isLoading: isMastersLoading, |  | ||||||
|     isSuccess: isMastersSuccess, |  | ||||||
|     isError: isMastersError, |  | ||||||
|     error: mastersError, |  | ||||||
|   } = useGetMastersQuery({ date: currentDate }); |  | ||||||
| 
 |  | ||||||
|   const isLoading = isOrdersLoading || isMastersLoading; |  | ||||||
|   const isSuccess = isOrdersSuccess && isMastersSuccess; |  | ||||||
|   const isError = isOrdersError || isMastersError; |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (isError) { |  | ||||||
|       showToast(t('error.title'), 'error'); |  | ||||||
|     } |  | ||||||
|   }, [isError, ordersError, mastersError, t]); |  | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Box p='8'> |     <Box p='8'> | ||||||
|       <Heading size='lg' mb='5'> |       <Heading size='lg' mb='5'> | ||||||
|         {t('title')} |         {t('title')} | ||||||
|       </Heading> |       </Heading> | ||||||
|       <DateNavigator |  | ||||||
|         currentDate={currentDate} |  | ||||||
|         onPreviousDate={() => |  | ||||||
|           setCurrentDate((prevDate) => |  | ||||||
|             dayjs(prevDate).subtract(1, 'day').toDate(), |  | ||||||
|           ) |  | ||||||
|         } |  | ||||||
|         onNextDate={() => |  | ||||||
|           setCurrentDate((prevDate) => dayjs(prevDate).add(1, 'day').toDate()) |  | ||||||
|         } |  | ||||||
|       /> |  | ||||||
| 
 |  | ||||||
|       <Table variant='simple' colorScheme='blackAlpha'> |       <Table variant='simple' colorScheme='blackAlpha'> | ||||||
|         <Thead> |         <Thead> | ||||||
|           <Tr> |           <Tr> | ||||||
| @ -91,30 +34,13 @@ const Orders = () => { | |||||||
|           </Tr> |           </Tr> | ||||||
|         </Thead> |         </Thead> | ||||||
|         <Tbody> |         <Tbody> | ||||||
|           {isLoading && ( |           {data.body.map((order, index) => ( | ||||||
|             <Tr> |             <OrderItem | ||||||
|               <Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'> |               key={index} | ||||||
|                 <Spinner size='lg' /> |               {...order} | ||||||
|               </Td> |               status={order.status as OrderProps['status']} | ||||||
|             </Tr> |             /> | ||||||
|           )} |           ))} | ||||||
|           {isSuccess && orders.length === 0 && ( |  | ||||||
|             <Tr> |  | ||||||
|               <Td colSpan={TABLE_HEADERS.length}> |  | ||||||
|                 <Text>{t('table.empty')}</Text> |  | ||||||
|               </Td> |  | ||||||
|             </Tr> |  | ||||||
|           )} |  | ||||||
|           {isSuccess && |  | ||||||
|             orders.map((order, index) => ( |  | ||||||
|               <OrderItem |  | ||||||
|                 allMasters={masters} |  | ||||||
|                 key={index} |  | ||||||
|                 {...order} |  | ||||||
|                 status={order.status as OrderArm['status']} |  | ||||||
|                 currentDate={currentDate} |  | ||||||
|               /> |  | ||||||
|             ))} |  | ||||||
|         </Tbody> |         </Tbody> | ||||||
|       </Table> |       </Table> | ||||||
|     </Box> |     </Box> | ||||||
|  | |||||||
| @ -5,7 +5,6 @@ export const PageSpinner: FC = () => { | |||||||
|   return ( |   return ( | ||||||
|     <Flex w='full' h='100vh' justifyContent='center' alignItems='center'> |     <Flex w='full' h='100vh' justifyContent='center' alignItems='center'> | ||||||
|       <Spinner |       <Spinner | ||||||
|         data-testid='spinner' |  | ||||||
|         thickness='5px' |         thickness='5px' | ||||||
|         speed='0.65s' |         speed='0.65s' | ||||||
|         emptyColor='gray.200' |         emptyColor='gray.200' | ||||||
|  | |||||||
| @ -1,35 +0,0 @@ | |||||||
| import React from 'react'; |  | ||||||
| import { |  | ||||||
|   Button, |  | ||||||
|   Popover, |  | ||||||
|   PopoverArrow, |  | ||||||
|   PopoverBody, |  | ||||||
|   PopoverCloseButton, |  | ||||||
|   PopoverContent, |  | ||||||
|   PopoverHeader, |  | ||||||
|   PopoverTrigger, |  | ||||||
| } from '@chakra-ui/react'; |  | ||||||
| 
 |  | ||||||
| interface Props { |  | ||||||
|   title?: string; |  | ||||||
|   description: string; |  | ||||||
|   trigger?: React.ReactNode; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const PopoverTemplate = ({ title, description, trigger }: Props) => { |  | ||||||
|   return ( |  | ||||||
|     <Popover> |  | ||||||
|       <PopoverTrigger> |  | ||||||
|         <Button>{trigger}</Button> |  | ||||||
|       </PopoverTrigger> |  | ||||||
|       <PopoverContent> |  | ||||||
|         <PopoverArrow /> |  | ||||||
|         <PopoverCloseButton /> |  | ||||||
|         {title && <PopoverHeader>{title}!</PopoverHeader>} |  | ||||||
|         <PopoverBody mr={5}>{description}</PopoverBody> |  | ||||||
|       </PopoverContent> |  | ||||||
|     </Popover> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default PopoverTemplate; |  | ||||||
| @ -1 +0,0 @@ | |||||||
| export { default } from './PopoverTemplate'; |  | ||||||
| @ -1,75 +0,0 @@ | |||||||
| import { Box, Image, Progress, Text, VStack } from '@chakra-ui/react'; |  | ||||||
| import React from 'react'; |  | ||||||
| import { getFeatures } from '@brojs/cli'; |  | ||||||
| import { useTranslation } from 'react-i18next'; |  | ||||||
| 
 |  | ||||||
| import { formatPrice, getProgressColor } from './helper'; |  | ||||||
| 
 |  | ||||||
| const PRICE_INCREASE_PERCENT_PER_RATING = 10; |  | ||||||
| 
 |  | ||||||
| export const PriceCar = ({ image, rating, description }) => { |  | ||||||
|   const BASE_WASH_PRICE: number = Number( |  | ||||||
|     getFeatures('dry-wash')['order-cost']?.value || 1000, |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   const calculateWashPrice = (rating: number) => { |  | ||||||
|     const priceIncrease = |  | ||||||
|       (BASE_WASH_PRICE * PRICE_INCREASE_PERCENT_PER_RATING * rating) / 100; |  | ||||||
|     return BASE_WASH_PRICE + priceIncrease; |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const { i18n, t } = useTranslation('~', { |  | ||||||
|     keyPrefix: 'dry-wash.order-view.price-car', |  | ||||||
|   }); |  | ||||||
|   const washPrice = calculateWashPrice(rating); |  | ||||||
|   const formattedPrice = formatPrice(washPrice, i18n.language); |  | ||||||
| 
 |  | ||||||
|   const progressValue = (rating / 10) * 100; |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <Box |  | ||||||
|       gap={5} |  | ||||||
|       width='100%' |  | ||||||
|       display='flex' |  | ||||||
|       justifyContent='center' |  | ||||||
|       alignItems='flex-start' |  | ||||||
|       flexWrap='wrap' |  | ||||||
|     > |  | ||||||
|       <Image |  | ||||||
|         maxWidth='600px' |  | ||||||
|         width='100%' |  | ||||||
|         objectFit='contain' |  | ||||||
|         borderRadius='md' |  | ||||||
|         src={image} |  | ||||||
|         alt='' |  | ||||||
|       /> |  | ||||||
|       <Box flex='1 1 40%'> |  | ||||||
|         {!Number.isNaN(progressValue) ? ( |  | ||||||
|           <VStack alignItems='stretch'> |  | ||||||
|             <Box> |  | ||||||
|               <Text>{t('title')}</Text> |  | ||||||
|               <Progress |  | ||||||
|                 value={progressValue} |  | ||||||
|                 size='sm' |  | ||||||
|                 sx={{ |  | ||||||
|                   '& > div': { |  | ||||||
|                     backgroundColor: getProgressColor(progressValue), |  | ||||||
|                   }, |  | ||||||
|                 }} |  | ||||||
|                 mt={2} |  | ||||||
|               /> |  | ||||||
|               <Text mt={2}> |  | ||||||
|                 {t('description')} <b>{formattedPrice}</b> |  | ||||||
|               </Text> |  | ||||||
|             </Box> |  | ||||||
|             <Text fontStyle='italic'>{description}</Text> |  | ||||||
|           </VStack> |  | ||||||
|         ) : ( |  | ||||||
|           <Text>{t('error')}</Text> |  | ||||||
|         )} |  | ||||||
|       </Box> |  | ||||||
|     </Box> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default PriceCar; |  | ||||||
| @ -1,15 +0,0 @@ | |||||||
| export const formatPrice = (price: number, locale = 'ru-RU', currency = 'RUB') => { |  | ||||||
|   return new Intl.NumberFormat(locale, { |  | ||||||
|     style: 'currency', |  | ||||||
|     currency: currency, |  | ||||||
|     minimumFractionDigits: 2, |  | ||||||
|     maximumFractionDigits: 2, |  | ||||||
|   }).format(price); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const getProgressColor = (value: number) => { |  | ||||||
|   const normalizedValue = value / 100; |  | ||||||
|   const hue = 120 - normalizedValue * 120; |  | ||||||
| 
 |  | ||||||
|   return `hsl(${hue}, 100%, 50%)`; |  | ||||||
| }; |  | ||||||
| @ -1 +0,0 @@ | |||||||
| export { default } from './PriceCar'; |  | ||||||
							
								
								
									
										51
									
								
								src/components/Sidebar/Sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,51 @@ | |||||||
|  | import { Box, Button, Heading, VStack, Divider } from '@chakra-ui/react'; | ||||||
|  | import React from 'react'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
|  | 
 | ||||||
|  | const Sidebar = () => { | ||||||
|  |   const { t } = useTranslation('~', { | ||||||
|  |     keyPrefix: 'dry-wash.arm.master.sideBar', | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <Box | ||||||
|  |       borderRight='1px solid black' | ||||||
|  |       bg='gray.50' | ||||||
|  |       color='white' | ||||||
|  |       w='250px' | ||||||
|  |       p='5' | ||||||
|  |       pt='8' | ||||||
|  |     > | ||||||
|  |       <Heading color='green' size='lg' mb='5'> | ||||||
|  |         {t('title')} | ||||||
|  |       </Heading> | ||||||
|  | 
 | ||||||
|  |       <VStack align='start' spacing='4'> | ||||||
|  |         <Divider /> | ||||||
|  |         <Button | ||||||
|  |           as={Link} | ||||||
|  |           to='orders' | ||||||
|  |           w='100%' | ||||||
|  |           colorScheme='green' | ||||||
|  |           variant='ghost' | ||||||
|  |         > | ||||||
|  |           {t('orders')} | ||||||
|  |         </Button> | ||||||
|  |         <Divider /> | ||||||
|  |         <Button | ||||||
|  |           as={Link} | ||||||
|  |           to='masters' | ||||||
|  |           w='100%' | ||||||
|  |           colorScheme='green' | ||||||
|  |           variant='ghost' | ||||||
|  |         > | ||||||
|  |           {t('master')} | ||||||
|  |         </Button> | ||||||
|  |         <Divider /> | ||||||
|  |       </VStack> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default Sidebar; | ||||||
							
								
								
									
										1
									
								
								src/components/Sidebar/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1 @@ | |||||||
|  | export { default } from './Sidebar'; | ||||||
| @ -1,29 +1,52 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
|  | import { | ||||||
|  |   MdEco, | ||||||
|  |   MdMiscellaneousServices, | ||||||
|  |   MdPlace, | ||||||
|  |   MdHandshake, | ||||||
|  | } from 'react-icons/md'; | ||||||
| import { Heading, HStack, List, Text, VStack } from '@chakra-ui/react'; | import { Heading, HStack, List, Text, VStack } from '@chakra-ui/react'; | ||||||
| 
 | 
 | ||||||
| import { CtaButton, PageSection } from '../'; | import { CtaButton, PageSection } from '../'; | ||||||
| 
 | 
 | ||||||
| import { ListItem } from './ListItem'; | import { ListItem } from './ListItem'; | ||||||
| import { BenefitsSectionProps } from './types'; |  | ||||||
| import { iconsMap } from './helper'; |  | ||||||
| 
 | 
 | ||||||
| export const BenefitsSection: FC<BenefitsSectionProps> = ({ | export const BenefitsSection: FC = () => { | ||||||
|   data: { heading, description, list } = {}, ...props |   const { t } = useTranslation('~', { | ||||||
| }) => { |     keyPrefix: 'dry-wash.landing.benefits-section', | ||||||
|   const { t } = useTranslation('~', { keyPrefix: 'dry-wash.landing' }); |   }); | ||||||
|  | 
 | ||||||
|  |   const listData = [ | ||||||
|  |     { | ||||||
|  |       Icon: MdEco, | ||||||
|  |       children: t('list.0'), | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       Icon: MdMiscellaneousServices, | ||||||
|  |       children: t('list.1'), | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       Icon: MdPlace, | ||||||
|  |       children: t('list.2'), | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       Icon: MdHandshake, | ||||||
|  |       children: t('list.3'), | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <PageSection {...props}> |     <PageSection> | ||||||
|       <VStack w='full' spacing={2}> |       <VStack w='full' spacing={2}> | ||||||
|         <Heading as='h2'>{t(heading)}</Heading> |         <Heading as='h2'>{t('heading')}</Heading> | ||||||
|         <Text>{t(description)}</Text> |         <Text> | ||||||
|  |           {t('description')} | ||||||
|  |         </Text> | ||||||
|       </VStack> |       </VStack> | ||||||
|       <List display='flex' flexDirection='column' spacing={3}> |       <List display='flex' flexDirection='column' spacing={3}> | ||||||
|         {list.map((itemKey, i) => ( |         {listData.map((props, i) => ( | ||||||
|           <ListItem key={i} Icon={iconsMap[itemKey]}> |           <ListItem key={i} {...props} /> | ||||||
|             {t(itemKey)} |  | ||||||
|           </ListItem> |  | ||||||
|         ))} |         ))} | ||||||
|       </List> |       </List> | ||||||
|       <HStack w='full' justify='flex-end'> |       <HStack w='full' justify='flex-end'> | ||||||
|  | |||||||
| @ -1,13 +0,0 @@ | |||||||
| import { IconType } from "react-icons"; |  | ||||||
| import { MdEco, MdMiscellaneousServices, MdPlace, MdHandshake } from "react-icons/md"; |  | ||||||
| 
 |  | ||||||
| import { ArrElement } from "../../../lib"; |  | ||||||
| 
 |  | ||||||
| import { BenefitsList } from "./types"; |  | ||||||
| 
 |  | ||||||
| export const iconsMap: Record<ArrElement<BenefitsList>, IconType> = { |  | ||||||
|   "benefits-section.list.0": MdEco, |  | ||||||
|   "benefits-section.list.1": MdMiscellaneousServices, |  | ||||||
|   "benefits-section.list.2": MdPlace, |  | ||||||
|   "benefits-section.list.3": MdHandshake, |  | ||||||
| }; |  | ||||||
| @ -1,2 +1 @@ | |||||||
| export type { BenefitsSectionProps } from './types'; |  | ||||||
| export { BenefitsSection } from './BenefitsSection'; | export { BenefitsSection } from './BenefitsSection'; | ||||||
| @ -1,14 +0,0 @@ | |||||||
| export type BenefitsList = [ |  | ||||||
|   'benefits-section.list.0', |  | ||||||
|   'benefits-section.list.1', |  | ||||||
|   'benefits-section.list.2', |  | ||||||
|   'benefits-section.list.3', |  | ||||||
| ]; |  | ||||||
| 
 |  | ||||||
| export type BenefitsSectionProps = { |  | ||||||
|   data: { |  | ||||||
|     heading: 'benefits-section.heading'; |  | ||||||
|     description: 'benefits-section.description'; |  | ||||||
|     list: BenefitsList; |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| @ -6,16 +6,16 @@ import { ButtonProps, Button } from '@chakra-ui/react'; | |||||||
| import { URLs } from '../../../__data__/urls'; | import { URLs } from '../../../__data__/urls'; | ||||||
| 
 | 
 | ||||||
| export const CtaButton: FC<ButtonProps> = (props) => { | export const CtaButton: FC<ButtonProps> = (props) => { | ||||||
|   const { t } = useTranslation('~', { keyPrefix: 'dry-wash.landing' }); |   const { t } = useTranslation(); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Button |     <Button | ||||||
|       as={RouterLink} |       as={RouterLink} | ||||||
|       to={URLs.orderCreate.getUrl()} |       to={URLs.orderForm.getUrl()} | ||||||
|       colorScheme='primary' |       colorScheme='primary' | ||||||
|       {...props} |       {...props} | ||||||
|     > |     > | ||||||
|       {t('make-order-button')} |       {t('~:dry-wash.landing.make-order-button')} | ||||||
|     </Button> |     </Button> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -5,9 +5,7 @@ import { Text } from '@chakra-ui/react'; | |||||||
| const currentYear = new Date().getFullYear(); | const currentYear = new Date().getFullYear(); | ||||||
| 
 | 
 | ||||||
| export const Copyright: FC = () => { | export const Copyright: FC = () => { | ||||||
|   const { t } = useTranslation('~', { |   const { t } = useTranslation(); | ||||||
|     keyPrefix: 'dry-wash.landing.footer' |  | ||||||
|   }); |  | ||||||
| 
 | 
 | ||||||
|   return <Text color='whiteAlpha.500'>{t('copyright', { currentYear })}</Text>; |   return <Text color='whiteAlpha.500'>{t('dry-wash.landing.footer.copyright', { currentYear })}</Text>; | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ import { SiteLogo, PageSection } from '../'; | |||||||
| 
 | 
 | ||||||
| import { Copyright } from './Copyright'; | import { Copyright } from './Copyright'; | ||||||
| 
 | 
 | ||||||
| export const Footer: FC = (props) => { | export const Footer: FC = () => { | ||||||
|   const { t } = useTranslation('~', { |   const { t } = useTranslation('~', { | ||||||
|     keyPrefix: 'dry-wash.landing.footer.links', |     keyPrefix: 'dry-wash.landing.footer.links', | ||||||
|   }); |   }); | ||||||
| @ -15,17 +15,11 @@ export const Footer: FC = (props) => { | |||||||
|   const listData = [ |   const listData = [ | ||||||
|     { to: '#', label: t('privacy-policy') }, |     { to: '#', label: t('privacy-policy') }, | ||||||
|     { to: '#', label: t('service-terms') }, |     { to: '#', label: t('service-terms') }, | ||||||
|     { to: '#', label: t('faq') }, |     { to: '#', label: t('faq')  }, | ||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <PageSection |     <PageSection as='footer' py={5} bg='gray.700' color='white'> | ||||||
|       as='footer' |  | ||||||
|       py={5} |  | ||||||
|       bg='gray.700' |  | ||||||
|       color='white' |  | ||||||
|       {...props} |  | ||||||
|     > |  | ||||||
|       <SiteLogo /> |       <SiteLogo /> | ||||||
|       <Copyright /> |       <Copyright /> | ||||||
|       <List spacing={2}> |       <List spacing={2}> | ||||||
|  | |||||||
| @ -1,30 +1,22 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| import { Box, Heading, Text, Center, VStack } from '@chakra-ui/react'; | import { Box, Heading, Text, Center, VStack, BoxProps } from '@chakra-ui/react'; | ||||||
| 
 | 
 | ||||||
| import { DemoVideoPosterImg } from '../../../assets/images'; | import { DemoVideoPosterImg } from '../../../assets/images'; | ||||||
| import { CtaButton, SiteLogo, PageSection } from '../'; | import { CtaButton, SiteLogo, PageSection } from '../'; | ||||||
| 
 | 
 | ||||||
| import { HeroSectionProps } from './types'; | type HeroSectionProps = Pick<BoxProps, 'flexShrink'>; | ||||||
| 
 | 
 | ||||||
| export const HeroSection: FC<HeroSectionProps> = ({ | export const HeroSection: FC<HeroSectionProps> = ({ flexShrink }) => { | ||||||
|   data: { headline, description, video } = {}, |   const { t } = useTranslation('~', { | ||||||
|   flexShrink, |     keyPrefix: 'dry-wash.landing.hero-section', | ||||||
|   ...props |   }); | ||||||
| }) => { |  | ||||||
|   const { t } = useTranslation('~', { keyPrefix: 'dry-wash.landing' }); |  | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Box |     <Box flexShrink={flexShrink} as='header' pos='relative' zIndex={0}> | ||||||
|       flexShrink={flexShrink} |  | ||||||
|       as='header' |  | ||||||
|       pos='relative' |  | ||||||
|       zIndex={0} |  | ||||||
|       {...props} |  | ||||||
|     > |  | ||||||
|       <Box |       <Box | ||||||
|         as='video' |         as='video' | ||||||
|         src={`${__webpack_public_path__}/remote-assets/${video}`} |         src={`${__webpack_public_path__}/remote-assets/demo.mp4`} | ||||||
|         poster={DemoVideoPosterImg} |         poster={DemoVideoPosterImg} | ||||||
|         autoPlay |         autoPlay | ||||||
|         loop |         loop | ||||||
| @ -55,14 +47,14 @@ export const HeroSection: FC<HeroSectionProps> = ({ | |||||||
|             color='white' |             color='white' | ||||||
|             __css={{ textWrap: 'balance' }} |             __css={{ textWrap: 'balance' }} | ||||||
|           > |           > | ||||||
|             {t(headline)} |             {t('headline')} | ||||||
|           </Heading> |           </Heading> | ||||||
|           <Text |           <Text | ||||||
|             textAlign='center' |             textAlign='center' | ||||||
|             __css={{ textWrap: 'balance' }} |             __css={{ textWrap: 'balance' }} | ||||||
|             color='white' |             color='white' | ||||||
|           > |           > | ||||||
|             {t(description)} |             {t('description')} | ||||||
|           </Text> |           </Text> | ||||||
|         </VStack> |         </VStack> | ||||||
|         <CtaButton size='lg' /> |         <CtaButton size='lg' /> | ||||||
|  | |||||||
| @ -1,9 +0,0 @@ | |||||||
| import { BoxProps } from "@chakra-ui/react"; |  | ||||||
| 
 |  | ||||||
| export type HeroSectionProps = { |  | ||||||
|   data: { |  | ||||||
|     headline: 'hero-section.headline'; |  | ||||||
|     description: 'hero-section.description'; |  | ||||||
|     video: string; |  | ||||||
|   }; |  | ||||||
| } & Pick<BoxProps, 'flexShrink'>; |  | ||||||
| @ -5,9 +5,9 @@ import { Image } from '@chakra-ui/react'; | |||||||
| import { LogoSvg } from '../../../assets/icons'; | import { LogoSvg } from '../../../assets/icons'; | ||||||
| 
 | 
 | ||||||
| export const SiteLogo: FC = () => { | export const SiteLogo: FC = () => { | ||||||
|   const { t } = useTranslation('~', { keyPrefix: 'dry-wash.landing' }); |   const { t } = useTranslation(); | ||||||
| 
 | 
 | ||||||
|   return <Image src={LogoSvg} alt={t('site-logo')} w={40} />; |   return <Image src={LogoSvg} alt={t('~:dry-wash.landing.site-logo')} w={40} />; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| // todo: replace Image by SVG React component
 | // todo: replace Image by SVG React component
 | ||||||
|  | |||||||
| @ -5,17 +5,15 @@ import { Heading, HStack } from '@chakra-ui/react'; | |||||||
| import { CtaButton, PageSection } from '../'; | import { CtaButton, PageSection } from '../'; | ||||||
| 
 | 
 | ||||||
| import { ReviewsSlider } from './ReviewsSlider'; | import { ReviewsSlider } from './ReviewsSlider'; | ||||||
| import { SocialProofSectionProps } from './types'; |  | ||||||
| 
 | 
 | ||||||
| export const SocialProofSection: FC<SocialProofSectionProps> = ({ | export const SocialProofSection: FC = () => { | ||||||
|   data: { heading } = {}, |   const { t } = useTranslation('~', { | ||||||
|   ...props |     keyPrefix: 'dry-wash.landing.social-proof-section', | ||||||
| }) => { |   }); | ||||||
|   const { t } = useTranslation('~', { keyPrefix: 'dry-wash.landing' }); |  | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <PageSection {...props}> |     <PageSection> | ||||||
|       <Heading as='h2'>{t(heading)}</Heading> |       <Heading as='h2'>{t('heading')}</Heading> | ||||||
|       <ReviewsSlider /> |       <ReviewsSlider /> | ||||||
|       <HStack w='full' justify='flex-end'> |       <HStack w='full' justify='flex-end'> | ||||||
|         <CtaButton /> |         <CtaButton /> | ||||||
|  | |||||||
| @ -1,2 +1 @@ | |||||||
| export type { SocialProofSectionProps } from './types'; |  | ||||||
| export { SocialProofSection } from './SocialProofSection'; | export { SocialProofSection } from './SocialProofSection'; | ||||||
| @ -1,5 +0,0 @@ | |||||||
| export type SocialProofSectionProps = { |  | ||||||
|   data: { |  | ||||||
|     heading: 'social-proof-section.heading'; |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| @ -1,85 +0,0 @@ | |||||||
| import React, { forwardRef } from 'react'; |  | ||||||
| import { |  | ||||||
|   Input, |  | ||||||
|   Box, |  | ||||||
|   Popover, |  | ||||||
|   PopoverAnchor, |  | ||||||
|   PopoverContent, |  | ||||||
|   PopoverBody, |  | ||||||
|   useRadioGroup, |  | ||||||
|   Grid, |  | ||||||
|   GridItem, |  | ||||||
|   UseRadioGroupProps, |  | ||||||
|   useDisclosure, |  | ||||||
| } from '@chakra-ui/react'; |  | ||||||
| import { useTranslation } from 'react-i18next'; |  | ||||||
| 
 |  | ||||||
| import { carBodySelectOptions, getInputValue } from './helper'; |  | ||||||
| import { CarBodyOption } from './option'; |  | ||||||
| import { CarBodySelectProps } from './types'; |  | ||||||
| 
 |  | ||||||
| export const CarBodySelect = forwardRef<HTMLInputElement, CarBodySelectProps>( |  | ||||||
|   function CarBodySelect(props, ref) { |  | ||||||
|     const handleOptionClick: UseRadioGroupProps['onChange'] = (value) => { |  | ||||||
|       props.onChange({ |  | ||||||
|         target: { value }, |  | ||||||
|       } as React.ChangeEvent<HTMLInputElement>); |  | ||||||
|       onClose(); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const { value, getRadioProps, getRootProps } = useRadioGroup({ |  | ||||||
|       defaultValue: props.value, |  | ||||||
|       value: props.value, |  | ||||||
|       onChange: handleOptionClick, |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     const { isOpen, onOpen, onClose } = useDisclosure(); |  | ||||||
| 
 |  | ||||||
|     const { t } = useTranslation('~', { |  | ||||||
|       keyPrefix: 'dry-wash.order-create.car-body-select', |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     return ( |  | ||||||
|       <Box width='100%' pos='relative'> |  | ||||||
|         <Popover |  | ||||||
|           isOpen={isOpen} |  | ||||||
|           autoFocus={false} |  | ||||||
|           placement='bottom-start' |  | ||||||
|           matchWidth |  | ||||||
|           gutter={2} |  | ||||||
|           strategy="fixed" |  | ||||||
|         > |  | ||||||
|           <PopoverAnchor> |  | ||||||
|             <Input |  | ||||||
|               {...props} |  | ||||||
|               ref={ref} |  | ||||||
|               value={getInputValue(Number(value), t) ?? props.value} |  | ||||||
|               readOnly |  | ||||||
|               onClick={onOpen} |  | ||||||
|               onBlur={onClose} |  | ||||||
|               placeholder={t('placeholder')} |  | ||||||
|             /> |  | ||||||
|           </PopoverAnchor> |  | ||||||
|           <PopoverContent width='100%' maxWidth='100%'> |  | ||||||
|             <PopoverBody border='1px' borderColor='gray.300' p={0}> |  | ||||||
|               <Grid |  | ||||||
|                 templateColumns='repeat(auto-fit, minmax(150px, 1fr))' |  | ||||||
|                 {...getRootProps()} |  | ||||||
|               > |  | ||||||
|                 {carBodySelectOptions.map(({ value, img, labelTKey }) => ( |  | ||||||
|                   <GridItem key={value}> |  | ||||||
|                     <CarBodyOption |  | ||||||
|                       image={img} |  | ||||||
|                       label={t(`options.${labelTKey}`)} |  | ||||||
|                       {...getRadioProps({ value: String(value) })} |  | ||||||
|                     /> |  | ||||||
|                   </GridItem> |  | ||||||
|                 ))} |  | ||||||
|               </Grid> |  | ||||||
|             </PopoverBody> |  | ||||||
|           </PopoverContent> |  | ||||||
|         </Popover> |  | ||||||
|       </Box> |  | ||||||
|     ); |  | ||||||
|   }, |  | ||||||
| ); |  | ||||||
| @ -1,87 +0,0 @@ | |||||||
| import { TFunction } from "i18next"; |  | ||||||
| import { InputProps } from "@chakra-ui/react"; |  | ||||||
| 
 |  | ||||||
| import { |  | ||||||
|   CoupeImg, |  | ||||||
|   CrossoverImg, |  | ||||||
|   HatchbackImg, |  | ||||||
|   LiftbackImg, |  | ||||||
|   MinivanImg, |  | ||||||
|   PickupImg, |  | ||||||
|   SedanImg, |  | ||||||
|   SportsCarImg, |  | ||||||
|   StationWagonImg, |  | ||||||
|   SuvImg, |  | ||||||
|   OtherImg |  | ||||||
| } from "../../../../assets/images"; |  | ||||||
| import { Car } from "../../../../models/landing"; |  | ||||||
| 
 |  | ||||||
| import { CarBodySelectOption } from "./types"; |  | ||||||
| 
 |  | ||||||
| export const carBodySelectOptions: CarBodySelectOption[] = [ |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.SEDAN, |  | ||||||
|     labelTKey: 'sedan', |  | ||||||
|     img: SedanImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.HATCHBACK, |  | ||||||
|     labelTKey: 'hatchback', |  | ||||||
|     img: HatchbackImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.CROSSOVER, |  | ||||||
|     labelTKey: 'crossover', |  | ||||||
|     img: CrossoverImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.SUV, |  | ||||||
|     labelTKey: 'suv', |  | ||||||
|     img: SuvImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.STATION_WAGON, |  | ||||||
|     labelTKey: 'station-wagon', |  | ||||||
|     img: StationWagonImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.COUPE, |  | ||||||
|     labelTKey: 'coupe', |  | ||||||
|     img: CoupeImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.MINIVAN, |  | ||||||
|     labelTKey: 'minivan', |  | ||||||
|     img: MinivanImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.PICKUP, |  | ||||||
|     labelTKey: 'pickup', |  | ||||||
|     img: PickupImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.LIFTBACK, |  | ||||||
|     labelTKey: 'liftback', |  | ||||||
|     img: LiftbackImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.SPORTS_CAR, |  | ||||||
|     labelTKey: 'sports-car', |  | ||||||
|     img: SportsCarImg |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.BodyStyle.OTHER, |  | ||||||
|     labelTKey: 'other', |  | ||||||
|     img: OtherImg |  | ||||||
|   }, |  | ||||||
| ]; |  | ||||||
| 
 |  | ||||||
| export const getInputValue = (value: Car.BodyStyle, t: TFunction<"~", "dry-wash.order-create.car-body-select">): InputProps['value'] => { |  | ||||||
|   const { labelTKey } = carBodySelectOptions.find((option) => value === option.value) ?? {}; |  | ||||||
| 
 |  | ||||||
|   if (labelTKey) { |  | ||||||
|     return t(`options.${labelTKey}`); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return; |  | ||||||
| }; |  | ||||||
| @ -1,2 +0,0 @@ | |||||||
| export { CarBodySelect } from './car-body-select'; |  | ||||||
| export { carBodySelectOptions } from './helper'; |  | ||||||
| @ -1,39 +0,0 @@ | |||||||
| import React from 'react'; |  | ||||||
| import { |  | ||||||
|   ImageProps, |  | ||||||
|   StackProps, |  | ||||||
|   Image, |  | ||||||
|   useRadio, |  | ||||||
|   chakra, |  | ||||||
|   Box, |  | ||||||
|   UseRadioProps, |  | ||||||
|   Flex, |  | ||||||
| } from '@chakra-ui/react'; |  | ||||||
| 
 |  | ||||||
| import { getPropsByState } from './helper'; |  | ||||||
| 
 |  | ||||||
| type CarBodyOptionProps = { |  | ||||||
|   image: ImageProps['src']; |  | ||||||
|   label: StackProps['children']; |  | ||||||
| } & UseRadioProps; |  | ||||||
| 
 |  | ||||||
| export const CarBodyOption = ({ |  | ||||||
|   image, |  | ||||||
|   label, |  | ||||||
|   ...radioProps |  | ||||||
| }: CarBodyOptionProps) => { |  | ||||||
|   const { state, getInputProps, getRadioProps, htmlProps, getLabelProps } = |  | ||||||
|     useRadio(radioProps); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <chakra.label {...htmlProps} cursor='pointer'> |  | ||||||
|       <input {...getInputProps({})} hidden /> |  | ||||||
|       <Box {...getRadioProps()} p={2} {...getPropsByState(state)}> |  | ||||||
|         <Flex direction='column' alignItems='center' {...getLabelProps()}> |  | ||||||
|           <Image src={image} rounded={4} /> |  | ||||||
|           {label} |  | ||||||
|         </Flex> |  | ||||||
|       </Box> |  | ||||||
|     </chakra.label> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| @ -1,19 +0,0 @@ | |||||||
| import { |  | ||||||
|   BoxProps, |  | ||||||
| } from '@chakra-ui/react'; |  | ||||||
| import { RadioState } from '@chakra-ui/react/dist/types/radio/use-radio'; |  | ||||||
| 
 |  | ||||||
| export const getPropsByState = ({ isChecked }: RadioState): BoxProps => { |  | ||||||
|   if (isChecked) { |  | ||||||
|     return { |  | ||||||
|       bgColor: 'primary.200', |  | ||||||
|       _hover: { bgColor: 'primary.100' }, |  | ||||||
|       _active: { bgColor: 'primary.300' }, |  | ||||||
|     }; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return { |  | ||||||
|     _hover: { bgColor: 'primary.50' }, |  | ||||||
|     _active: { bgColor: 'primary.100' }, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| @ -1 +0,0 @@ | |||||||
| export { CarBodyOption } from './car-body-option'; |  | ||||||
| @ -1,24 +0,0 @@ | |||||||
| import { InputProps } from "@chakra-ui/react"; |  | ||||||
| 
 |  | ||||||
| import { Car } from "../../../../models/landing"; |  | ||||||
| 
 |  | ||||||
| export type CarBodySelectOption = { |  | ||||||
|   value: Car.BodyStyle; |  | ||||||
|   labelTKey: |  | ||||||
|   'sedan' | |  | ||||||
|   'hatchback' | |  | ||||||
|   'crossover' | |  | ||||||
|   'suv' | |  | ||||||
|   'station-wagon' | |  | ||||||
|   'coupe' | |  | ||||||
|   'minivan' | |  | ||||||
|   'pickup' | |  | ||||||
|   'liftback' | |  | ||||||
|   'sports-car' | |  | ||||||
|   'other'; |  | ||||||
|   img?: string; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export type CarBodySelectProps = { |  | ||||||
|   value?: string; |  | ||||||
| } & Pick<InputProps, 'onChange'>; |  | ||||||
| @ -1,75 +0,0 @@ | |||||||
| import React from 'react'; |  | ||||||
| import { render, screen, fireEvent } from '@testing-library/react'; |  | ||||||
| import '@testing-library/jest-dom'; |  | ||||||
| 
 |  | ||||||
| import { CarColorSelect } from './car-color-select'; |  | ||||||
| 
 |  | ||||||
| // Mock the translation hook
 |  | ||||||
| jest.mock('react-i18next', () => ({ |  | ||||||
|   useTranslation: () => ({ |  | ||||||
|     t: (key: string) => { |  | ||||||
|       // Return the last part of the key as that's what component is using
 |  | ||||||
|       const keyParts = key.split('.'); |  | ||||||
|       return keyParts[keyParts.length - 1]; |  | ||||||
|     }, |  | ||||||
|   }), |  | ||||||
| })); |  | ||||||
| 
 |  | ||||||
| describe('CarColorSelect', () => { |  | ||||||
|   it('renders color options correctly', () => { |  | ||||||
|     const onChange = jest.fn(); |  | ||||||
|     render(<CarColorSelect onChange={onChange} />); |  | ||||||
|      |  | ||||||
|     // Check if color buttons are rendered
 |  | ||||||
|     const colorButtons = screen.getAllByRole('button'); |  | ||||||
|     expect(colorButtons.length).toBeGreaterThan(0); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   it('handles color selection', () => { |  | ||||||
|     const onChange = jest.fn(); |  | ||||||
|     render(<CarColorSelect onChange={onChange} />); |  | ||||||
|      |  | ||||||
|     // Click the first color button
 |  | ||||||
|     const colorButtons = screen.getAllByRole('button'); |  | ||||||
|     fireEvent.click(colorButtons[0]); |  | ||||||
|      |  | ||||||
|     expect(onChange).toHaveBeenCalled(); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   it('handles custom color selection', () => { |  | ||||||
|     const onChange = jest.fn(); |  | ||||||
|     render(<CarColorSelect onChange={onChange} />); |  | ||||||
|      |  | ||||||
|     // Find and click the custom color button
 |  | ||||||
|     const customButton = screen.getByText('custom'); |  | ||||||
|     fireEvent.click(customButton); |  | ||||||
|      |  | ||||||
|     // Check if custom color input appears
 |  | ||||||
|     const customInput = screen.getByPlaceholderText('placeholder'); |  | ||||||
|     expect(customInput).toBeInTheDocument(); |  | ||||||
|      |  | ||||||
|     // Test custom color input
 |  | ||||||
|     fireEvent.change(customInput, { target: { value: '#FF0000' } }); |  | ||||||
|     expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ |  | ||||||
|       target: { value: '#FF0000' }, |  | ||||||
|     })); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   it('shows selected color label when color is selected', () => { |  | ||||||
|     const onChange = jest.fn(); |  | ||||||
|     render(<CarColorSelect value="black" onChange={onChange} />); |  | ||||||
|      |  | ||||||
|     // Since the color label might not be immediately visible,
 |  | ||||||
|     // we'll verify the component renders without crashing
 |  | ||||||
|     const buttons = screen.getAllByRole('button'); |  | ||||||
|     expect(buttons.length).toBeGreaterThan(0); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   it('handles invalid state', () => { |  | ||||||
|     render(<CarColorSelect isInvalid={true} />); |  | ||||||
|      |  | ||||||
|     // Since the component doesn't show explicit invalid state UI,
 |  | ||||||
|     // we'll verify that the component renders without crashing
 |  | ||||||
|     expect(screen.getAllByRole('button').length).toBeGreaterThan(0); |  | ||||||
|   }); |  | ||||||
| });  |  | ||||||
| @ -1,177 +0,0 @@ | |||||||
| import React, { forwardRef, useState } from 'react'; |  | ||||||
| import { Input, Box, Stack, Text, Flex } from '@chakra-ui/react'; |  | ||||||
| import { useTranslation } from 'react-i18next'; |  | ||||||
| 
 |  | ||||||
| import { Car } from '../../../../models'; |  | ||||||
| 
 |  | ||||||
| import { carColorSelectOptions } from './helper'; |  | ||||||
| 
 |  | ||||||
| interface CarColorSelectProps { |  | ||||||
|   value?: string; |  | ||||||
|   onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void; |  | ||||||
|   name?: string; |  | ||||||
|   isInvalid?: boolean; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export const CarColorSelect = forwardRef<HTMLInputElement, CarColorSelectProps>( |  | ||||||
|   function CarColorSelect(props, ref) { |  | ||||||
|     const [customColor, setCustomColor] = useState(''); |  | ||||||
|     const [isCustom, setIsCustom] = useState(false); |  | ||||||
| 
 |  | ||||||
|     const handleColorChange = (value: Car.Color | string) => { |  | ||||||
|       if (value === 'custom') { |  | ||||||
|         setIsCustom(true); |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|       setIsCustom(false); |  | ||||||
|       props.onChange?.({ |  | ||||||
|         target: { value }, |  | ||||||
|       } as React.ChangeEvent<HTMLInputElement>); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const handleCustomColorChange = ( |  | ||||||
|       e: React.ChangeEvent<HTMLInputElement>, |  | ||||||
|     ) => { |  | ||||||
|       const value = e.target.value; |  | ||||||
|       setCustomColor(value); |  | ||||||
|       props.onChange?.({ |  | ||||||
|         target: { value }, |  | ||||||
|       } as React.ChangeEvent<HTMLInputElement>); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     const { t } = useTranslation('~', { |  | ||||||
|       keyPrefix: 'dry-wash.order-create.car-color-select', |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     const currentValue = isCustom ? 'custom' : props.value; |  | ||||||
| 
 |  | ||||||
|     return ( |  | ||||||
|       <Stack spacing={4} width='100%' ref={ref}> |  | ||||||
|         <Flex gap={2} wrap='wrap' pb={2}> |  | ||||||
|           {carColorSelectOptions.map(({ value, labelTKey, code }) => ( |  | ||||||
|             <Box |  | ||||||
|               key={value} |  | ||||||
|               flexShrink={0} |  | ||||||
|               as='button' |  | ||||||
|               type='button' |  | ||||||
|               onClick={() => handleColorChange(value)} |  | ||||||
|             > |  | ||||||
|               <Flex |  | ||||||
|                 align='center' |  | ||||||
|                 gap={2} |  | ||||||
|                 p={1} |  | ||||||
|                 borderRadius='full' |  | ||||||
|                 borderWidth='2px' |  | ||||||
|                 borderColor='gray.200' |  | ||||||
|                 bg='white' |  | ||||||
|                 _hover={{ |  | ||||||
|                   borderColor: 'primary.500', |  | ||||||
|                   bg: 'gray.50', |  | ||||||
|                 }} |  | ||||||
|                 justify='center' |  | ||||||
|                 transition='all 0.2s' |  | ||||||
|                 {...(currentValue === value && { |  | ||||||
|                   borderColor: 'primary.500', |  | ||||||
|                   bg: 'primary.50', |  | ||||||
|                   paddingInlineEnd: 3, |  | ||||||
|                   _hover: { |  | ||||||
|                     bg: 'primary.50', |  | ||||||
|                   }, |  | ||||||
|                 })} |  | ||||||
|               > |  | ||||||
|                 <Flex align='center' gap={2}> |  | ||||||
|                   <Box |  | ||||||
|                     w='32px' |  | ||||||
|                     h='32px' |  | ||||||
|                     borderRadius='full' |  | ||||||
|                     bg={code} |  | ||||||
|                     border='1px' |  | ||||||
|                     borderColor='gray.200' |  | ||||||
|                     transition='all 0.2s' |  | ||||||
|                     boxShadow='none' |  | ||||||
|                     {...(currentValue === value && { |  | ||||||
|                       borderColor: 'primary.500', |  | ||||||
|                       boxShadow: 'sm', |  | ||||||
|                     })} |  | ||||||
|                   /> |  | ||||||
|                   {currentValue === value && ( |  | ||||||
|                     <Text fontSize='xs' color='primary.700' fontWeight='medium'> |  | ||||||
|                       {t(`colors.${labelTKey}`)} |  | ||||||
|                     </Text> |  | ||||||
|                   )} |  | ||||||
|                 </Flex> |  | ||||||
|               </Flex> |  | ||||||
|             </Box> |  | ||||||
|           ))} |  | ||||||
|           <Box |  | ||||||
|             flexShrink={0} |  | ||||||
|             as='button' |  | ||||||
|             type='button' |  | ||||||
|             onClick={() => handleColorChange('custom')} |  | ||||||
|           > |  | ||||||
|             <Flex |  | ||||||
|               align='center' |  | ||||||
|               gap={2} |  | ||||||
|               p={1} |  | ||||||
|               paddingInlineEnd={3} |  | ||||||
|               borderRadius='full' |  | ||||||
|               borderWidth='2px' |  | ||||||
|               borderColor='gray.200' |  | ||||||
|               bg='white' |  | ||||||
|               _hover={{ |  | ||||||
|                 borderColor: 'primary.500', |  | ||||||
|                 bg: 'gray.50', |  | ||||||
|               }} |  | ||||||
|               justify='center' |  | ||||||
|               transition='all 0.2s' |  | ||||||
|               {...(isCustom && { |  | ||||||
|                 borderColor: 'primary.500', |  | ||||||
|                 paddingInlineStart: 3, |  | ||||||
|                 bg: 'primary.50', |  | ||||||
|                 _hover: { |  | ||||||
|                   bg: 'primary.50', |  | ||||||
|                 }, |  | ||||||
|               })} |  | ||||||
|             > |  | ||||||
|               {isCustom ? ( |  | ||||||
|                 <Flex gap={2} align='center'> |  | ||||||
|                   <Text fontSize='xs' color='primary.700' fontWeight='medium'> |  | ||||||
|                     {t('custom-label')} |  | ||||||
|                   </Text> |  | ||||||
|                   <Input |  | ||||||
|                     size='sm' |  | ||||||
|                     width='120px' |  | ||||||
|                     value={customColor} |  | ||||||
|                     onChange={handleCustomColorChange} |  | ||||||
|                     placeholder={t('placeholder')} |  | ||||||
|                     onClick={(e) => e.stopPropagation()} |  | ||||||
|                     borderColor='primary.200' |  | ||||||
|                     _focus={{ |  | ||||||
|                       borderColor: 'primary.500', |  | ||||||
|                       boxShadow: '0 0 0 1px var(--chakra-colors-primary-500)', |  | ||||||
|                     }} |  | ||||||
|                   /> |  | ||||||
|                 </Flex> |  | ||||||
|               ) : ( |  | ||||||
|                 <Flex align='center' gap={2}> |  | ||||||
|                   <Box |  | ||||||
|                     w='32px' |  | ||||||
|                     h='32px' |  | ||||||
|                     borderRadius='full' |  | ||||||
|                     bg='gray.100' |  | ||||||
|                     border='1px' |  | ||||||
|                     borderColor='gray.200' |  | ||||||
|                     transition='all 0.2s' |  | ||||||
|                   /> |  | ||||||
|                   <Text fontSize='xs' color='gray.500'> |  | ||||||
|                     {t('custom')} |  | ||||||
|                   </Text> |  | ||||||
|                 </Flex> |  | ||||||
|               )} |  | ||||||
|             </Flex> |  | ||||||
|           </Box> |  | ||||||
|         </Flex> |  | ||||||
|       </Stack> |  | ||||||
|     ); |  | ||||||
|   }, |  | ||||||
| ); |  | ||||||
| @ -1,44 +0,0 @@ | |||||||
| import { Car } from "../../../../models"; |  | ||||||
| 
 |  | ||||||
| export const carColorSelectOptions: { value: Car.Color | string; labelTKey: 'white' | 'black' | 'silver' | 'gray' | 'beige-brown' | 'red' | 'blue' | 'green'; code: string }[] = [ |  | ||||||
|   { |  | ||||||
|     value: Car.Color.WHITE, |  | ||||||
|     labelTKey: 'white', |  | ||||||
|     code: '#ffffff' |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.Color.BLACK, |  | ||||||
|     labelTKey: 'black', |  | ||||||
|     code: '#000000' |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.Color.SILVER, |  | ||||||
|     labelTKey: 'silver', |  | ||||||
|     code: '#c0c0c0' |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.Color.GRAY, |  | ||||||
|     labelTKey: 'gray', |  | ||||||
|     code: '#808080' |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.Color.BEIGE_BROWN, |  | ||||||
|     labelTKey: 'beige-brown', |  | ||||||
|     code: '#796745' |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.Color.RED, |  | ||||||
|     labelTKey: 'red', |  | ||||||
|     code: '#b90000' |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.Color.BLUE, |  | ||||||
|     labelTKey: 'blue', |  | ||||||
|     code: '#003B62' |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     value: Car.Color.GREEN, |  | ||||||
|     labelTKey: 'green', |  | ||||||
|     code: '#078d51' |  | ||||||
|   }, |  | ||||||
| ]; |  | ||||||
| @ -1,2 +0,0 @@ | |||||||
| export { CarColorSelect } from './car-color-select'; |  | ||||||
| export { carColorSelectOptions } from './helper'; |  | ||||||
| @ -1,22 +0,0 @@ | |||||||
| import React, { forwardRef } from 'react'; |  | ||||||
| import { Input, InputProps } from '@chakra-ui/react'; |  | ||||||
| 
 |  | ||||||
| import { handleInputChange } from './helper'; |  | ||||||
| 
 |  | ||||||
| export const CarNumberInput = forwardRef<HTMLInputElement, InputProps>( |  | ||||||
|   function CarNumberInput({ onChange, ...props }, ref) { |  | ||||||
|     return ( |  | ||||||
|       <Input |  | ||||||
|         {...props} |  | ||||||
|         ref={ref} |  | ||||||
|         onChange={(e) => { |  | ||||||
|           const formattedValue = handleInputChange(e.target.value); |  | ||||||
|           // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 |  | ||||||
|           // @ts-ignore
 |  | ||||||
|           onChange?.(formattedValue); |  | ||||||
|         }} |  | ||||||
|         maxLength={12} |  | ||||||
|       /> |  | ||||||
|     ); |  | ||||||
|   }, |  | ||||||
| ); |  | ||||||
| @ -1,33 +0,0 @@ | |||||||
| const VALID_LETTER = 'а|в|е|к|м|н|о|р|с|т|у|х'; |  | ||||||
| 
 |  | ||||||
| const invalidCharsRe = new RegExp(`[^(${VALID_LETTER})0-9]`, 'gi'); |  | ||||||
| const cleanValue = (value: string) => value.replace(invalidCharsRe, ''); |  | ||||||
| 
 |  | ||||||
| const validCarNumberInputRe = new RegExp(`^([${VALID_LETTER}]{1}|$)((?:[0-9]|$)(?:[0-9]|$)(?:[0-9]|$))([${VALID_LETTER}]{1,2}|$)((?:[0-9]|$)(?:[0-9]|$)(?:[0-9]|$))$`, 'gi'); |  | ||||||
| const isValidInput = (cleanedValue: string) => validCarNumberInputRe.test(cleanedValue); |  | ||||||
| 
 |  | ||||||
| const formatAsCarNumber = (cleanedValue: string) => { |  | ||||||
|   return cleanedValue.replace(validCarNumberInputRe, (_, p1, p2, p3, p4) => [p1, p2, p3, p4].join(' ')).toUpperCase(); |  | ||||||
| }; |  | ||||||
| const getWithoutLastChar = (value: string) => value.substring(0, value.length - 1); |  | ||||||
| 
 |  | ||||||
| export const handleInputChange = (value: string | undefined | null) => { |  | ||||||
|   const cleanedValue = cleanValue(value); |  | ||||||
| 
 |  | ||||||
|   if (!cleanedValue) { |  | ||||||
|     return ''; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (isValidInput(cleanedValue)) { |  | ||||||
|     return formatAsCarNumber(cleanedValue).trim(); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return getWithoutLastChar(value).trim(); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const validCarNumberRe = new RegExp(`^[${VALID_LETTER}][0-9]{3}[${VALID_LETTER}]{2}[0-9]{2,3}$`, 'i'); |  | ||||||
| 
 |  | ||||||
| export const isValidCarNumber = (value: string) => { |  | ||||||
|   const cleanedValue = cleanValue(value); |  | ||||||
|   return validCarNumberRe.test(cleanedValue); |  | ||||||
| }; |  | ||||||
| @ -1,2 +0,0 @@ | |||||||
| export { CarNumberInput } from './car-number-input'; |  | ||||||
| export { isValidCarNumber } from './helper'; |  | ||||||
| @ -1,12 +0,0 @@ | |||||||
| import React, { forwardRef } from 'react'; |  | ||||||
| import { Input, InputProps } from '@chakra-ui/react'; |  | ||||||
| 
 |  | ||||||
| export type DateTimeInputProps = InputProps; |  | ||||||
| 
 |  | ||||||
| export const DateTimeInput = forwardRef<HTMLInputElement, DateTimeInputProps>( |  | ||||||
|   function DateTimeInput(props, ref) { |  | ||||||
|     return <Input ref={ref} {...props} type='datetime-local' />; |  | ||||||
|   }, |  | ||||||
| ); |  | ||||||
| 
 |  | ||||||
| // todo: apply brand styles to popover
 |  | ||||||
| @ -1,26 +0,0 @@ | |||||||
| import dayjs from 'dayjs'; |  | ||||||
| import customParseFormat from 'dayjs/plugin/customParseFormat'; |  | ||||||
| 
 |  | ||||||
| dayjs.extend(customParseFormat); |  | ||||||
| 
 |  | ||||||
| export const getMinDatetime = (dateTimeString: string, minDateTimeString: string) => { |  | ||||||
|   const time = dayjs(dateTimeString); |  | ||||||
| 
 |  | ||||||
|   const minDate = dayjs(minDateTimeString).format('YYYY-MM-DD'); |  | ||||||
|   const minTime = dayjs(minDateTimeString); |  | ||||||
| 
 |  | ||||||
|   const newTime = (time && time.isAfter(minTime)) ? time : minTime; |  | ||||||
| 
 |  | ||||||
|   return [minDate, newTime.format('HH:mm')].join('T'); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const getMaxDatetime = (dateTimeString: string, maxDateTimeString: string) => { |  | ||||||
|   const time = dayjs(dateTimeString); |  | ||||||
| 
 |  | ||||||
|   const maxDate = dayjs(maxDateTimeString).format('YYYY-MM-DD'); |  | ||||||
|   const maxTime = dayjs(maxDateTimeString); |  | ||||||
| 
 |  | ||||||
|   const newTime = (time && time.isBefore(maxTime)) ? time : maxTime; |  | ||||||
| 
 |  | ||||||
|   return [maxDate, newTime.format('HH:mm')].join('T'); |  | ||||||
| }; |  | ||||||