Compare commits
	
		
			No commits in common. "main" and "feature/arm-keycloack" have entirely different histories.
		
	
	
		
			main
			...
			feature/ar
		
	
		
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -131,9 +131,3 @@ dist | |||||||
| .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" |  | ||||||
|     ] |  | ||||||
| } |  | ||||||
							
								
								
									
										12
									
								
								Jenkinsfile
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -1,7 +1,7 @@ | |||||||
| pipeline { | pipeline { | ||||||
|     agent { |     agent { | ||||||
|         docker { |         docker { | ||||||
|             image 'node:22' |             image 'node:20' | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -30,23 +30,21 @@ pipeline { | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |         stage('checks') { | ||||||
|  |             parallel { | ||||||
|                 stage('eslint') { |                 stage('eslint') { | ||||||
|                     steps { |                     steps { | ||||||
|                         sh 'npm run eslint' |                         sh 'npm run eslint' | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|         stage('test') { |  | ||||||
|             steps { |  | ||||||
|                 sh 'npm run test' |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|                 stage('build') { |                 stage('build') { | ||||||
|                     steps { |                     steps { | ||||||
|                         sh 'npm run build' |                         sh 'npm run build' | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
|         stage('clean-all') { |         stage('clean-all') { | ||||||
|             steps { |             steps { | ||||||
|  | |||||||
| @ -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' }], |  | ||||||
|   ], |  | ||||||
| }; |  | ||||||
| @ -16,27 +16,11 @@ module.exports = { | |||||||
|     'dry-wash.order.view': '/order/:orderId', |     'dry-wash.order.view': '/order/:orderId', | ||||||
|     'dry-wash.arm.master': 'master', |     'dry-wash.arm.master': 'master', | ||||||
|     'dry-wash.arm.order': 'order', |     'dry-wash.arm.order': 'order', | ||||||
|     'dry-wash.arm.map': 'map', |  | ||||||
|     'dry-wash.arm': '/arm/*', |     '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: { | ||||||
|  | |||||||
| @ -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__ = ''; |  | ||||||
| @ -26,17 +26,6 @@ | |||||||
|   "dry-wash.order-create.form.washing-location-field.label": "Where is the car located?", |   "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.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.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.placeholder": "Not specified", | ||||||
|   "dry-wash.order-create.car-body-select.options.sedan": "Sedan", |   "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.hatchback" : "Hatchback", | ||||||
| @ -50,26 +39,6 @@ | |||||||
|   "dry-wash.order-create.car-body-select.options.sports-car" : "Sports-car", |   "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.car-body-select.options.other": "Other", | ||||||
|   "dry-wash.order-create.form.submit-button.label": "Submit", |   "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.table.empty": "Table empty", | ||||||
| @ -78,14 +47,12 @@ | |||||||
|   "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", | ||||||
| @ -96,10 +63,6 @@ | |||||||
|   "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 +70,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", | ||||||
| @ -125,8 +79,5 @@ | |||||||
|   "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.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,14 +5,12 @@ | |||||||
|   "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.table.empty": "Список пуст", | ||||||
| @ -25,16 +23,6 @@ | |||||||
|   "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 +30,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": "Сухой мастер", | ||||||
| @ -81,17 +60,6 @@ | |||||||
|   "dry-wash.order-create.form.washing-location-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.placeholder": "Введите адрес или выберите на карте", | ||||||
|   "dry-wash.order-create.form.washing-location-field.help": "Например, 55.754364, 48.743295 Университетская улица, 1, Иннополис, Верхнеуслонский район, Республика Татарстан (Татарстан), 420500", |   "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.placeholder": "Не указан", | ||||||
|   "dry-wash.order-create.car-body-select.options.sedan": "Седан", |   "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.hatchback" : "Хэтчбек", | ||||||
| @ -105,34 +73,11 @@ | |||||||
|   "dry-wash.order-create.car-body-select.options.sports-car" : "Спорткар", |   "dry-wash.order-create.car-body-select.options.sports-car" : "Спорткар", | ||||||
|   "dry-wash.order-create.car-body-select.options.other": "Другой", |   "dry-wash.order-create.car-body-select.options.other": "Другой", | ||||||
|   "dry-wash.order-create.form.submit-button.label": "Отправить", |   "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": "Статус" |  | ||||||
| } | } | ||||||
							
								
								
									
										7174
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										38
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @ -1,77 +1,53 @@ | |||||||
| { | { | ||||||
|     "name": "dry-wash", |     "name": "dry-wash", | ||||||
|   "version": "0.12.0", |     "version": "0.2.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", |  | ||||||
|     "@babel/preset-react": "^7.26.3", |  | ||||||
|     "@babel/preset-typescript": "^7.26.0", |  | ||||||
|     "@brojs/cli": "^1.8.4", |  | ||||||
|         "@chakra-ui/icons": "^2.2.4", |         "@chakra-ui/icons": "^2.2.4", | ||||||
|     "@chakra-ui/react": "^2.10.5", |         "@chakra-ui/react": "^2.4.2", | ||||||
|         "@emotion/react": "^11.4.1", |         "@emotion/react": "^11.4.1", | ||||||
|         "@emotion/styled": "^11.3.0", |         "@emotion/styled": "^11.3.0", | ||||||
|         "@fontsource/open-sans": "^5.1.0", |         "@fontsource/open-sans": "^5.1.0", | ||||||
|         "@lottiefiles/react-lottie-player": "^3.5.4", |         "@lottiefiles/react-lottie-player": "^3.5.4", | ||||||
|         "@pbe/react-yandex-maps": "^1.2.5", |         "@pbe/react-yandex-maps": "^1.2.5", | ||||||
|     "@reduxjs/toolkit": "^2.5.0", |  | ||||||
|     "@testing-library/dom": "^10.4.0", |  | ||||||
|     "@testing-library/react": "^16.2.0", |  | ||||||
|         "@types/react": "^18.3.12", |         "@types/react": "^18.3.12", | ||||||
|     "babel-jest": "^29.7.0", |  | ||||||
|         "dayjs": "^1.11.13", |         "dayjs": "^1.11.13", | ||||||
|         "express": "^4.21.1", |         "express": "^4.21.1", | ||||||
|         "framer-motion": "^6.2.8", |         "framer-motion": "^6.2.8", | ||||||
|         "i18next": "^23.16.4", |         "i18next": "^23.16.4", | ||||||
|     "jest": "^29.7.0", |  | ||||||
|     "jest-environment-jsdom": "^29.7.0", |  | ||||||
|     "jest-fixed-jsdom": "^0.0.9", |  | ||||||
|         "keycloak-js": "^23.0.7", |         "keycloak-js": "^23.0.7", | ||||||
|     "msw": "^2.7.0", |  | ||||||
|         "react": "^18.3.1", |         "react": "^18.3.1", | ||||||
|         "react-dom": "^18.3.1", |         "react-dom": "^18.3.1", | ||||||
|         "react-hook-form": "^7.53.2", |         "react-hook-form": "^7.53.2", | ||||||
|         "react-i18next": "^15.1.1", |         "react-i18next": "^15.1.1", | ||||||
|         "react-icons": "^5.3.0", |         "react-icons": "^5.3.0", | ||||||
|         "react-phone-number-input": "^3.4.9", |         "react-phone-number-input": "^3.4.9", | ||||||
|     "react-redux": "^9.2.0", |         "react-router-dom": "^6.27.0" | ||||||
|     "react-router-dom": "^6.27.0", |  | ||||||
|     "ts-jest": "^29.2.5", |  | ||||||
|     "ts-node": "^10.9.2" |  | ||||||
|     }, |     }, | ||||||
|     "devDependencies": { |     "devDependencies": { | ||||||
|         "@eslint/js": "^9.14.0", |         "@eslint/js": "^9.14.0", | ||||||
|     "@playwright/test": "^1.50.1", |  | ||||||
|         "@stylistic/eslint-plugin": "^2.10.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/react-dom": "^18.3.1", | ||||||
|     "@types/testing-library__jest-dom": "^5.14.9", |  | ||||||
|         "eslint": "^9.14.0", |         "eslint": "^9.14.0", | ||||||
|         "eslint-plugin-import": "^2.31.0", |         "eslint-plugin-import": "^2.31.0", | ||||||
|         "eslint-plugin-react": "^7.37.2", |         "eslint-plugin-react": "^7.37.2", | ||||||
|         "globals": "^15.11.0", |         "globals": "^15.11.0", | ||||||
|         "prettier": "3.3.3", |         "prettier": "3.3.3", | ||||||
|     "typescript": "^5.7.3", |  | ||||||
|         "typescript-eslint": "^8.12.2" |         "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; |  | ||||||
| @ -33,18 +33,6 @@ export const URLs = { | |||||||
|     url: getNavigationValue('dry-wash.arm.order'), |     url: getNavigationValue('dry-wash.arm.order'), | ||||||
|     isOn: Boolean(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: { |   armBase: { | ||||||
|     url: getFullUrls(getNavigationValue('dry-wash.arm')), |     url: getFullUrls(getNavigationValue('dry-wash.arm')), | ||||||
|     isOn: Boolean(getNavigationValue('dry-wash.arm')), |     isOn: Boolean(getNavigationValue('dry-wash.arm')), | ||||||
|  | |||||||
							
								
								
									
										40
									
								
								src/api/arm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,40 @@ | |||||||
|  | import { getConfigValue } from '@brojs/cli'; | ||||||
|  | 
 | ||||||
|  | enum ArmEndpoints { | ||||||
|  |   ORDERS = '/arm/orders', | ||||||
|  |   MASTERS = '/arm/masters', | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const armService = () => { | ||||||
|  |   const endpoint = getConfigValue('dry-wash.api'); | ||||||
|  | 
 | ||||||
|  |   const fetchOrders = async ({ date }: { date: Date }) => { | ||||||
|  |     const response = await fetch(`${endpoint}${ArmEndpoints.ORDERS}`, { | ||||||
|  |       method: 'POST', | ||||||
|  |       headers: { | ||||||
|  |         'Content-Type': 'application/json', | ||||||
|  |       }, | ||||||
|  |       body: JSON.stringify({ date }), | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if (!response.ok) { | ||||||
|  |       throw new Error(`Failed to fetch orders: ${response.status}`); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return await response.json(); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const fetchMasters = async () => { | ||||||
|  |     const response = await fetch(`${endpoint}${ArmEndpoints.MASTERS}`); | ||||||
|  | 
 | ||||||
|  |     if (!response.ok) { | ||||||
|  |       throw new Error(`Failed to fetch masters: ${response.status}`); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return await response.json(); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   return { fetchOrders, fetchMasters }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export { armService, ArmEndpoints }; | ||||||
| @ -1,15 +1,12 @@ | |||||||
| 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> | ||||||
| @ -17,7 +14,6 @@ const App = () => { | |||||||
|         </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,7 +1,6 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { Box, Button, Text } from '@chakra-ui/react'; | import { Box, Button, Text } from '@chakra-ui/react'; | ||||||
| import { ArrowBackIcon, ArrowForwardIcon } from '@chakra-ui/icons'; | import { ArrowBackIcon, ArrowForwardIcon } from '@chakra-ui/icons'; | ||||||
| import dayjs from 'dayjs'; |  | ||||||
| 
 | 
 | ||||||
| interface DateNavigatorProps { | interface DateNavigatorProps { | ||||||
|   currentDate: Date; |   currentDate: Date; | ||||||
| @ -20,7 +19,7 @@ const DateNavigator = ({ | |||||||
|         <ArrowBackIcon /> |         <ArrowBackIcon /> | ||||||
|       </Button> |       </Button> | ||||||
|       <Text mx='4' fontSize='lg' fontWeight='bold'> |       <Text mx='4' fontSize='lg' fontWeight='bold'> | ||||||
|         {dayjs(currentDate).format('DD.MM.YYYY')} |         {currentDate.toLocaleDateString()} | ||||||
|       </Text> |       </Text> | ||||||
|       <Button onClick={onNextDate}> |       <Button onClick={onNextDate}> | ||||||
|         <ArrowForwardIcon /> |         <ArrowForwardIcon /> | ||||||
|  | |||||||
| @ -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,11 +2,10 @@ 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 { URLs } from '../../__data__/urls'; | ||||||
| import Header from '../Header'; |  | ||||||
| import OrdersMap from '../Map'; |  | ||||||
| 
 | 
 | ||||||
| const LayoutArm = () => { | const LayoutArm = () => { | ||||||
|   let defaultRedirect = null; |   let defaultRedirect = null; | ||||||
| @ -18,8 +17,8 @@ const LayoutArm = () => { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Flex flexDirection='column' h='100vh'> |     <Flex h='100vh'> | ||||||
|       <Header /> |       <Sidebar /> | ||||||
|       <Box flex='1' bg='gray.50'> |       <Box flex='1' bg='gray.50'> | ||||||
|         <Routes> |         <Routes> | ||||||
|           <Route index element={<Navigate to={defaultRedirect} replace />} /> |           <Route index element={<Navigate to={defaultRedirect} replace />} /> | ||||||
| @ -29,9 +28,6 @@ const LayoutArm = () => { | |||||||
|           {URLs.armMaster.isOn && ( |           {URLs.armMaster.isOn && ( | ||||||
|             <Route path={URLs.armMaster.url} element={<Masters />} /> |             <Route path={URLs.armMaster.url} element={<Masters />} /> | ||||||
|           )} |           )} | ||||||
|           {URLs.armMap.isOn && ( |  | ||||||
|             <Route path={URLs.armMap.url} element={<OrdersMap />} /> |  | ||||||
|           )} |  | ||||||
|         </Routes> |         </Routes> | ||||||
|       </Box> |       </Box> | ||||||
|     </Flex> |     </Flex> | ||||||
|  | |||||||
| @ -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' isInvalid={!!errors.name}> |           <FormControl mb='4'> | ||||||
|             <FormLabel>{t('inputName.label')}</FormLabel> |             <FormLabel>{t('inputName.label')}</FormLabel> | ||||||
|             <Input |             <Input | ||||||
|                 {...register('name', { |               value={newMaster.name} | ||||||
|                   required: t('form.name.required'), |               onChange={(e) => | ||||||
|                   minLength: { |                 setNewMaster({ ...newMaster, name: e.target.value }) | ||||||
|                     value: 2, |               } | ||||||
|                     message: t('form.name.minLength'), |  | ||||||
|                   }, |  | ||||||
|                 })} |  | ||||||
|               placeholder={t('inputName.placeholder')} |               placeholder={t('inputName.placeholder')} | ||||||
|             /> |             /> | ||||||
|               <FormErrorMessage> |  | ||||||
|                 {errors.name && errors.name.message} |  | ||||||
|               </FormErrorMessage> |  | ||||||
|           </FormControl> |           </FormControl> | ||||||
|             <FormControl isInvalid={!!errors.phone}> |           <FormControl> | ||||||
|             <FormLabel> {t('inputPhone.label')}</FormLabel> |             <FormLabel> {t('inputPhone.label')}</FormLabel> | ||||||
|               <InputGroup> |  | ||||||
|                 <InputLeftElement pointerEvents='none'> |  | ||||||
|                   <PhoneIcon color='gray.300' /> |  | ||||||
|                 </InputLeftElement> |  | ||||||
|             <Input |             <Input | ||||||
|                   {...register('phone', { |               value={newMaster.phone} | ||||||
|                     required: t('form.phone.required'), |               onChange={(e) => | ||||||
|                     pattern: { |                 setNewMaster({ ...newMaster, phone: e.target.value }) | ||||||
|                       value: /^(\+7|8)\d{10}$/, |               } | ||||||
|                       message: t('form.phone.pattern'), |  | ||||||
|                     }, |  | ||||||
|                     setValueAs: (value) => value.replace(/[^\d+]/g, ''), |  | ||||||
|                   })} |  | ||||||
|               placeholder={t('inputPhone.placeholder')} |               placeholder={t('inputPhone.placeholder')} | ||||||
|             /> |             /> | ||||||
|               </InputGroup> |  | ||||||
|               <FormErrorMessage> |  | ||||||
|                 {errors.phone && errors.phone.message} |  | ||||||
|               </FormErrorMessage> |  | ||||||
|           </FormControl> |           </FormControl> | ||||||
|         </DrawerBody> |         </DrawerBody> | ||||||
|         <DrawerFooter> |         <DrawerFooter> | ||||||
|             <Button colorScheme='teal' mr={3} type='submit'> |           <Button colorScheme='teal' mr={3} onClick={handleSave}> | ||||||
|             {t('button.save')} |             {t('button.save')} | ||||||
|           </Button> |           </Button> | ||||||
|           <Button variant='ghost' onClick={onClose}> |           <Button variant='ghost' onClick={onClose}> | ||||||
|             {t('button.cancel')} |             {t('button.cancel')} | ||||||
|           </Button> |           </Button> | ||||||
|         </DrawerFooter> |         </DrawerFooter> | ||||||
|         </form> |  | ||||||
|       </DrawerContent> |       </DrawerContent> | ||||||
|     </Drawer> |     </Drawer> | ||||||
|   ); |   ); | ||||||
|  | |||||||
| @ -1,39 +1,40 @@ | |||||||
| 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 }) => { | export interface Schedule { | ||||||
|   const { t } = useTranslation('~', { |   id: string; | ||||||
|     keyPrefix: 'dry-wash.arm.master', |   startWashTime: string; | ||||||
|   }); |   endWashTime: string; | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|  | export type MasterProps = { | ||||||
|  |   id: string; | ||||||
|  |   name: string; | ||||||
|  |   schedule: Schedule[]; | ||||||
|  |   phone: string; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const MasterItem = ({ name, schedule, phone }) => { | ||||||
|   return ( |   return ( | ||||||
|     <Tr> |     <Tr> | ||||||
|  |       <Td>{name}</Td> | ||||||
|       <Td> |       <Td> | ||||||
|         <EditableWrapper id={id} fieldName={'name'} value={name} /> |  | ||||||
|       </Td> |  | ||||||
|       <Td> |  | ||||||
|         {schedule?.length > 0 ? ( |  | ||||||
|         <Stack direction='row'> |         <Stack direction='row'> | ||||||
|             {schedule?.map(({ startWashTime, endWashTime }, index: number) => ( |           {schedule.map(({ startWashTime, endWashTime }, index) => ( | ||||||
|             <Badge colorScheme={'green'} key={index}> |             <Badge colorScheme={'green'} key={index}> | ||||||
|               {getTimeSlot(startWashTime, endWashTime)} |               {getTimeSlot(startWashTime, endWashTime)} | ||||||
|             </Badge> |             </Badge> | ||||||
|           ))} |           ))} | ||||||
|         </Stack> |         </Stack> | ||||||
|         ) : ( |  | ||||||
|           <Text color='gray.500'>{t('schedule.empty')}</Text> |  | ||||||
|         )} |  | ||||||
|       </Td> |       </Td> | ||||||
|       <Td> |       <Td> | ||||||
|         <EditableWrapper id={id} fieldName={'phone'} value={phone} /> |         <Link href='tel:'>{phone}</Link> | ||||||
|       </Td> |       </Td> | ||||||
|       <Td> |       <Td> | ||||||
|         <MasterActionsMenu id={id} /> |         <MasterActionsMenu /> | ||||||
|       </Td> |       </Td> | ||||||
|     </Tr> |     </Tr> | ||||||
|   ); |   ); | ||||||
|  | |||||||
| @ -10,18 +10,17 @@ import { | |||||||
|   Button, |   Button, | ||||||
|   useDisclosure, |   useDisclosure, | ||||||
|   Flex, |   Flex, | ||||||
|  |   useToast, | ||||||
|   Td, |   Td, | ||||||
|   Text, |   Text, | ||||||
|   Spinner, |   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 { armService } from '../../api/arm'; | ||||||
| import useShowToast from '../../hooks/useShowToast'; | import { MasterProps } from '../MasterItem/MasterItem'; | ||||||
| import DateNavigator from '../DateNavigator'; |  | ||||||
| 
 | 
 | ||||||
| const TABLE_HEADERS = [ | const TABLE_HEADERS = [ | ||||||
|   'name' as const, |   'name' as const, | ||||||
| @ -32,46 +31,49 @@ const TABLE_HEADERS = [ | |||||||
| 
 | 
 | ||||||
| const Masters = () => { | const Masters = () => { | ||||||
|   const { isOpen, onOpen, onClose } = useDisclosure(); |   const { isOpen, onOpen, onClose } = useDisclosure(); | ||||||
|   const showToast = useShowToast(); |   const toast = useToast(); | ||||||
|   const [currentDate, setCurrentDate] = useState(new Date()); |  | ||||||
| 
 |  | ||||||
|   const { t } = useTranslation('~', { |   const { t } = useTranslation('~', { | ||||||
|     keyPrefix: 'dry-wash.arm.master', |     keyPrefix: 'dry-wash.arm.master', | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const { |   const [masters, setMasters] = useState<MasterProps[]>([]); | ||||||
|     data: masters, |   const [loading, setLoading] = useState(false); | ||||||
|     error, |   const [error, setError] = useState<string | null>(null); | ||||||
|     isLoading, | 
 | ||||||
|     isSuccess, |   const { fetchMasters } = armService(); | ||||||
|   } = useGetMastersQuery({ date: currentDate }); |  | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (error) { |     const loadMasters = async () => { | ||||||
|       showToast(t('error.title'), 'error'); |       setLoading(true); | ||||||
|  | 
 | ||||||
|  |       try { | ||||||
|  |         const data = await fetchMasters(); | ||||||
|  |         setMasters(data.body); | ||||||
|  |       } catch (err) { | ||||||
|  |         setError(err.message); | ||||||
|  |         toast({ | ||||||
|  |           title: t('error.title'), | ||||||
|  |           status: 'error', | ||||||
|  |           duration: 5000, | ||||||
|  |           isClosable: true, | ||||||
|  |           position: 'bottom-right', | ||||||
|  |         }); | ||||||
|  |       } finally { | ||||||
|  |         setLoading(false); | ||||||
|       } |       } | ||||||
|   }, [error]); |     }; | ||||||
|  | 
 | ||||||
|  |     loadMasters(); | ||||||
|  |   }, [toast, t]); | ||||||
| 
 | 
 | ||||||
|   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,21 +83,22 @@ const Masters = () => { | |||||||
|           </Tr> |           </Tr> | ||||||
|         </Thead> |         </Thead> | ||||||
|         <Tbody> |         <Tbody> | ||||||
|           {isLoading && ( |           {loading && ( | ||||||
|             <Tr> |             <Tr> | ||||||
|               <Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'> |               <Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'> | ||||||
|                 <Spinner size='lg' /> |                 <Spinner size='lg' /> | ||||||
|               </Td> |               </Td> | ||||||
|             </Tr> |             </Tr> | ||||||
|           )} |           )} | ||||||
|           {isSuccess && masters.length === 0 && ( |           {!loading && masters.length === 0 && !error && ( | ||||||
|             <Tr> |             <Tr> | ||||||
|               <Td colSpan={TABLE_HEADERS.length}> |               <Td colSpan={TABLE_HEADERS.length}> | ||||||
|                 <Text>{t('table.empty')}</Text> |                 <Text>{t('table.empty')}</Text> | ||||||
|               </Td> |               </Td> | ||||||
|             </Tr> |             </Tr> | ||||||
|           )} |           )} | ||||||
|           {isSuccess && |           {!loading && | ||||||
|  |             !error && | ||||||
|             masters.map((master, index) => ( |             masters.map((master, index) => ( | ||||||
|               <MasterItem key={index} {...master} /> |               <MasterItem key={index} {...master} /> | ||||||
|             ))} |             ))} | ||||||
|  | |||||||
| @ -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> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -10,24 +10,21 @@ import { | |||||||
|   Spinner, |   Spinner, | ||||||
|   Text, |   Text, | ||||||
|   Td, |   Td, | ||||||
|  |   useToast, | ||||||
| } from '@chakra-ui/react'; | } from '@chakra-ui/react'; | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| import dayjs from 'dayjs'; | import dayjs from 'dayjs'; | ||||||
| 
 | 
 | ||||||
| import OrderItem from '../OrderItem'; | import OrderItem from '../OrderItem'; | ||||||
|  | import { OrderProps } from '../OrderItem/OrderItem'; | ||||||
|  | import { armService } from '../../api/arm'; | ||||||
| import DateNavigator from '../DateNavigator'; | import DateNavigator from '../DateNavigator'; | ||||||
| import { |  | ||||||
|   useGetMastersQuery, |  | ||||||
|   useGetOrdersQuery, |  | ||||||
| } from '../../__data__/service/api'; |  | ||||||
| import useShowToast from '../../hooks/useShowToast'; |  | ||||||
| import { OrderArm } from '../../models/api'; |  | ||||||
| 
 | 
 | ||||||
| const TABLE_HEADERS = [ | const TABLE_HEADERS = [ | ||||||
|   'carNumber' as const, |   'carNumber' as const, | ||||||
|  |   'washingTime' as const, | ||||||
|   'orderDate' as const, |   'orderDate' as const, | ||||||
|   'status' as const, |   'status' as const, | ||||||
|   'masters' as const, |  | ||||||
|   'telephone' as const, |   'telephone' as const, | ||||||
|   'location' as const, |   'location' as const, | ||||||
| ]; | ]; | ||||||
| @ -36,40 +33,46 @@ const Orders = () => { | |||||||
|   const { t } = useTranslation('~', { |   const { t } = useTranslation('~', { | ||||||
|     keyPrefix: 'dry-wash.arm.order', |     keyPrefix: 'dry-wash.arm.order', | ||||||
|   }); |   }); | ||||||
|   const showToast = useShowToast(); |  | ||||||
| 
 | 
 | ||||||
|  |   const { fetchOrders } = armService(); | ||||||
|  | 
 | ||||||
|  |   const toast = useToast(); | ||||||
|  | 
 | ||||||
|  |   const [orders, setOrders] = useState<OrderProps[]>([]); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [error, setError] = useState<string | null>(null); | ||||||
|   const [currentDate, setCurrentDate] = useState(new Date()); |   const [currentDate, setCurrentDate] = useState(new Date()); | ||||||
|   const { |  | ||||||
|     data: orders, |  | ||||||
|     isLoading: isOrdersLoading, |  | ||||||
|     isSuccess: isOrdersSuccess, |  | ||||||
|     isError: isOrdersError, |  | ||||||
|     error: ordersError, |  | ||||||
|   } = 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(() => { |   useEffect(() => { | ||||||
|     if (isError) { |     const loadOrders = async () => { | ||||||
|       showToast(t('error.title'), 'error'); |       setLoading(true); | ||||||
|  | 
 | ||||||
|  |       try { | ||||||
|  |         const data = await fetchOrders({ date: currentDate }); | ||||||
|  |         setOrders(data.body); | ||||||
|  |       } catch (err) { | ||||||
|  |         setError(err.message); | ||||||
|  |         toast({ | ||||||
|  |           title: t('error.title'), | ||||||
|  |           status: 'error', | ||||||
|  |           duration: 5000, | ||||||
|  |           isClosable: true, | ||||||
|  |           position: 'bottom-right', | ||||||
|  |         }); | ||||||
|  |       } finally { | ||||||
|  |         setLoading(false); | ||||||
|       } |       } | ||||||
|   }, [isError, ordersError, mastersError, t]); |     }; | ||||||
|  | 
 | ||||||
|  |     loadOrders(); | ||||||
|  |   }, [toast, t, currentDate]); | ||||||
| 
 | 
 | ||||||
|   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 |       <DateNavigator | ||||||
|         currentDate={currentDate} |         currentDate={currentDate} | ||||||
|         onPreviousDate={() => |         onPreviousDate={() => | ||||||
| @ -91,28 +94,27 @@ const Orders = () => { | |||||||
|           </Tr> |           </Tr> | ||||||
|         </Thead> |         </Thead> | ||||||
|         <Tbody> |         <Tbody> | ||||||
|           {isLoading && ( |           {loading && ( | ||||||
|             <Tr> |             <Tr> | ||||||
|               <Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'> |               <Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'> | ||||||
|                 <Spinner size='lg' /> |                 <Spinner size='lg' /> | ||||||
|               </Td> |               </Td> | ||||||
|             </Tr> |             </Tr> | ||||||
|           )} |           )} | ||||||
|           {isSuccess && orders.length === 0 && ( |           {!loading && orders.length === 0 && !error && ( | ||||||
|             <Tr> |             <Tr> | ||||||
|               <Td colSpan={TABLE_HEADERS.length}> |               <Td colSpan={TABLE_HEADERS.length}> | ||||||
|                 <Text>{t('table.empty')}</Text> |                 <Text>{t('table.empty')}</Text> | ||||||
|               </Td> |               </Td> | ||||||
|             </Tr> |             </Tr> | ||||||
|           )} |           )} | ||||||
|           {isSuccess && |           {!loading && | ||||||
|  |             !error && | ||||||
|             orders.map((order, index) => ( |             orders.map((order, index) => ( | ||||||
|               <OrderItem |               <OrderItem | ||||||
|                 allMasters={masters} |  | ||||||
|                 key={index} |                 key={index} | ||||||
|                 {...order} |                 {...order} | ||||||
|                 status={order.status as OrderArm['status']} |                 status={order.status as OrderProps['status']} | ||||||
|                 currentDate={currentDate} |  | ||||||
|               /> |               /> | ||||||
|             ))} |             ))} | ||||||
|         </Tbody> |         </Tbody> | ||||||
|  | |||||||
| @ -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'; |  | ||||||
							
								
								
									
										60
									
								
								src/components/Sidebar/Sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,60 @@ | |||||||
|  | import { Box, Button, Heading, VStack, Divider } 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 Sidebar = () => { | ||||||
|  |   const location = useLocation(); | ||||||
|  |   const isActive = (keyword: string) => location.pathname.includes(keyword); | ||||||
|  | 
 | ||||||
|  |   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 /> | ||||||
|  |         {URLs.armOrder.isOn && ( | ||||||
|  |           <Button | ||||||
|  |             as={Link} | ||||||
|  |             to={URLs.armOrder.url} | ||||||
|  |             w='100%' | ||||||
|  |             colorScheme={isActive(URLs.armOrder.url) ? 'green' : 'blue'} | ||||||
|  |             variant={isActive(URLs.armOrder.url) ? 'solid' : 'ghost'} | ||||||
|  |           > | ||||||
|  |             {t('orders')} | ||||||
|  |           </Button> | ||||||
|  |         )} | ||||||
|  |         <Divider /> | ||||||
|  |         {URLs.armMaster.isOn && ( | ||||||
|  |           <Button | ||||||
|  |             as={Link} | ||||||
|  |             to={URLs.armMaster.url} | ||||||
|  |             w='100%' | ||||||
|  |             colorScheme={isActive(URLs.armMaster.url) ? 'green' : 'blue'} | ||||||
|  |             variant={isActive(URLs.armMaster.url) ? 'solid' : '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'; | ||||||
| @ -9,12 +9,12 @@ import { BenefitsSectionProps } from './types'; | |||||||
| import { iconsMap } from './helper'; | import { iconsMap } from './helper'; | ||||||
| 
 | 
 | ||||||
| export const BenefitsSection: FC<BenefitsSectionProps> = ({ | export const BenefitsSection: FC<BenefitsSectionProps> = ({ | ||||||
|   data: { heading, description, list } = {}, ...props |   data: { heading, description, list } = {}, | ||||||
| }) => { | }) => { | ||||||
|   const { t } = useTranslation('~', { keyPrefix: 'dry-wash.landing' }); |   const { t } = useTranslation('~', { keyPrefix: 'dry-wash.landing' }); | ||||||
| 
 | 
 | ||||||
|   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> | ||||||
|  | |||||||
| @ -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', | ||||||
|   }); |   }); | ||||||
| @ -19,13 +19,7 @@ export const Footer: FC = (props) => { | |||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   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}> | ||||||
|  | |||||||
| @ -10,18 +10,11 @@ import { HeroSectionProps } from './types'; | |||||||
| export const HeroSection: FC<HeroSectionProps> = ({ | export const HeroSection: FC<HeroSectionProps> = ({ | ||||||
|   data: { headline, description, video } = {}, |   data: { headline, description, video } = {}, | ||||||
|   flexShrink, |   flexShrink, | ||||||
|   ...props |  | ||||||
| }) => { | }) => { | ||||||
|   const { t } = useTranslation('~', { keyPrefix: 'dry-wash.landing' }); |   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/${video}`} | ||||||
|  | |||||||
| @ -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
 | ||||||
|  | |||||||
| @ -9,12 +9,11 @@ import { SocialProofSectionProps } from './types'; | |||||||
| 
 | 
 | ||||||
| export const SocialProofSection: FC<SocialProofSectionProps> = ({ | export const SocialProofSection: FC<SocialProofSectionProps> = ({ | ||||||
|   data: { heading } = {}, |   data: { heading } = {}, | ||||||
|   ...props |  | ||||||
| }) => { | }) => { | ||||||
|   const { t } = useTranslation('~', { keyPrefix: 'dry-wash.landing' }); |   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'> | ||||||
|  | |||||||
| @ -1,85 +1,23 @@ | |||||||
| import React, { forwardRef } from 'react'; | import React, { forwardRef } from 'react'; | ||||||
| import { | import { Select, SelectProps } from '@chakra-ui/react'; | ||||||
|   Input, |  | ||||||
|   Box, |  | ||||||
|   Popover, |  | ||||||
|   PopoverAnchor, |  | ||||||
|   PopoverContent, |  | ||||||
|   PopoverBody, |  | ||||||
|   useRadioGroup, |  | ||||||
|   Grid, |  | ||||||
|   GridItem, |  | ||||||
|   UseRadioGroupProps, |  | ||||||
|   useDisclosure, |  | ||||||
| } from '@chakra-ui/react'; |  | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| 
 | 
 | ||||||
| import { carBodySelectOptions, getInputValue } from './helper'; | import { carBodySelectOptions } from './helper'; | ||||||
| import { CarBodyOption } from './option'; |  | ||||||
| import { CarBodySelectProps } from './types'; |  | ||||||
| 
 | 
 | ||||||
| export const CarBodySelect = forwardRef<HTMLInputElement, CarBodySelectProps>( | export const CarBodySelect = forwardRef<HTMLSelectElement, SelectProps>( | ||||||
|   function CarBodySelect(props, ref) { |   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('~', { |     const { t } = useTranslation('~', { | ||||||
|       keyPrefix: 'dry-wash.order-create.car-body-select', |       keyPrefix: 'dry-wash.order-create.car-body-select', | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <Box width='100%' pos='relative'> |       <Select ref={ref} placeholder={t('placeholder')} {...props}> | ||||||
|         <Popover |         {carBodySelectOptions.map(({ value, labelTKey }, i) => ( | ||||||
|           isOpen={isOpen} |           <option key={i} value={value}> | ||||||
|           autoFocus={false} |             {t(`options.${labelTKey}`)} | ||||||
|           placement='bottom-start' |           </option> | ||||||
|           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> |       </Select> | ||||||
|             </PopoverBody> |  | ||||||
|           </PopoverContent> |  | ||||||
|         </Popover> |  | ||||||
|       </Box> |  | ||||||
|     ); |     ); | ||||||
|   }, |   }, | ||||||
| ); | ); | ||||||
|  | |||||||
| @ -1,19 +1,3 @@ | |||||||
| 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 { Car } from "../../../../models/landing"; | ||||||
| 
 | 
 | ||||||
| import { CarBodySelectOption } from "./types"; | import { CarBodySelectOption } from "./types"; | ||||||
| @ -21,67 +5,46 @@ import { CarBodySelectOption } from "./types"; | |||||||
| export const carBodySelectOptions: CarBodySelectOption[] = [ | export const carBodySelectOptions: CarBodySelectOption[] = [ | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.SEDAN, |     value: Car.BodyStyle.SEDAN, | ||||||
|     labelTKey: 'sedan', |     labelTKey: 'sedan' | ||||||
|     img: SedanImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.HATCHBACK, |     value: Car.BodyStyle.HATCHBACK, | ||||||
|     labelTKey: 'hatchback', |     labelTKey: 'hatchback' | ||||||
|     img: HatchbackImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.CROSSOVER, |     value: Car.BodyStyle.CROSSOVER, | ||||||
|     labelTKey: 'crossover', |     labelTKey: 'crossover' | ||||||
|     img: CrossoverImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.SUV, |     value: Car.BodyStyle.SUV, | ||||||
|     labelTKey: 'suv', |     labelTKey: 'suv' | ||||||
|     img: SuvImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.STATION_WAGON, |     value: Car.BodyStyle.STATION_WAGON, | ||||||
|     labelTKey: 'station-wagon', |     labelTKey: 'station-wagon' | ||||||
|     img: StationWagonImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.COUPE, |     value: Car.BodyStyle.COUPE, | ||||||
|     labelTKey: 'coupe', |     labelTKey: 'coupe' | ||||||
|     img: CoupeImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.MINIVAN, |     value: Car.BodyStyle.MINIVAN, | ||||||
|     labelTKey: 'minivan', |     labelTKey: 'minivan' | ||||||
|     img: MinivanImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.PICKUP, |     value: Car.BodyStyle.PICKUP, | ||||||
|     labelTKey: 'pickup', |     labelTKey: 'pickup' | ||||||
|     img: PickupImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.LIFTBACK, |     value: Car.BodyStyle.LIFTBACK, | ||||||
|     labelTKey: 'liftback', |     labelTKey: 'liftback' | ||||||
|     img: LiftbackImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.SPORTS_CAR, |     value: Car.BodyStyle.SPORTS_CAR, | ||||||
|     labelTKey: 'sports-car', |     labelTKey: 'sports-car' | ||||||
|     img: SportsCarImg |  | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.BodyStyle.OTHER, |     value: Car.BodyStyle.OTHER, | ||||||
|     labelTKey: '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 +1 @@ | |||||||
| export { CarBodySelect } from './car-body-select'; | 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,5 +1,3 @@ | |||||||
| import { InputProps } from "@chakra-ui/react"; |  | ||||||
| 
 |  | ||||||
| import { Car } from "../../../../models/landing"; | import { Car } from "../../../../models/landing"; | ||||||
| 
 | 
 | ||||||
| export type CarBodySelectOption = { | export type CarBodySelectOption = { | ||||||
| @ -16,9 +14,4 @@ export type CarBodySelectOption = { | |||||||
|   'liftback' | |   'liftback' | | ||||||
|   'sports-car' | |   'sports-car' | | ||||||
|   'other'; |   'other'; | ||||||
|   img?: string; |  | ||||||
| }; | }; | ||||||
| 
 |  | ||||||
| export type CarBodySelectProps = { |  | ||||||
|   value?: string; |  | ||||||
| } & Pick<InputProps, 'onChange'>; |  | ||||||
							
								
								
									
										23
									
								
								src/components/order-form/form/car-color/car-color-input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,23 @@ | |||||||
|  | import React, { forwardRef, useId } from 'react'; | ||||||
|  | import { Input, InputProps } from '@chakra-ui/react'; | ||||||
|  | 
 | ||||||
|  | import { CAR_COLORS } from './helper'; | ||||||
|  | 
 | ||||||
|  | export const CarColorInput = forwardRef<HTMLInputElement, InputProps>( | ||||||
|  |   function CarColorInput(props, ref) { | ||||||
|  |     const listId = useId(); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <> | ||||||
|  |         <Input ref={ref} list={listId} {...props} /> | ||||||
|  |         <datalist id={listId}> | ||||||
|  |           {CAR_COLORS.map(({ code, name }) => ( | ||||||
|  |             <option key={code} label={name} value={code}>{name}</option> | ||||||
|  |           ))} | ||||||
|  |         </datalist> | ||||||
|  |       </> | ||||||
|  |     ); | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | // todo: add option color visual indication
 | ||||||
| @ -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 +1,34 @@ | |||||||
| import { Car } from "../../../../models"; | export const CAR_COLORS: Record<'name' | 'code', string>[] = [ | ||||||
| 
 |  | ||||||
| export const carColorSelectOptions: { value: Car.Color | string; labelTKey: 'white' | 'black' | 'silver' | 'gray' | 'beige-brown' | 'red' | 'blue' | 'green'; code: string }[] = [ |  | ||||||
|   { |   { | ||||||
|     value: Car.Color.WHITE, |     name: 'white', | ||||||
|     labelTKey: 'white', |  | ||||||
|     code: '#ffffff' |     code: '#ffffff' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.Color.BLACK, |     name: 'black', | ||||||
|     labelTKey: 'black', |  | ||||||
|     code: '#000000' |     code: '#000000' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.Color.SILVER, |     name: 'silver', | ||||||
|     labelTKey: 'silver', |  | ||||||
|     code: '#c0c0c0' |     code: '#c0c0c0' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.Color.GRAY, |     name: 'gray', | ||||||
|     labelTKey: 'gray', |  | ||||||
|     code: '#808080' |     code: '#808080' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.Color.BEIGE_BROWN, |     name: 'beige-brown', | ||||||
|     labelTKey: 'beige-brown', |  | ||||||
|     code: '#796745' |     code: '#796745' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.Color.RED, |     name: 'red', | ||||||
|     labelTKey: 'red', |  | ||||||
|     code: '#b90000' |     code: '#b90000' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.Color.BLUE, |     name: 'blue', | ||||||
|     labelTKey: 'blue', |  | ||||||
|     code: '#003B62' |     code: '#003B62' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     value: Car.Color.GREEN, |     name: 'green', | ||||||
|     labelTKey: 'green', |  | ||||||
|     code: '#078d51' |     code: '#078d51' | ||||||
|   }, |   }, | ||||||
| ]; | ]; | ||||||
| @ -1,2 +1 @@ | |||||||
| export { CarColorSelect } from './car-color-select'; | export { CarColorInput } from './car-color-input'; | ||||||
| export { carColorSelectOptions } from './helper'; |  | ||||||
| @ -15,7 +15,7 @@ export const CarNumberInput = forwardRef<HTMLInputElement, InputProps>( | |||||||
|           // @ts-ignore
 |           // @ts-ignore
 | ||||||
|           onChange?.(formattedValue); |           onChange?.(formattedValue); | ||||||
|         }} |         }} | ||||||
|         maxLength={12} |         maxLength={8} | ||||||
|       /> |       /> | ||||||
|     ); |     ); | ||||||
|   }, |   }, | ||||||
|  | |||||||
| @ -3,11 +3,11 @@ const VALID_LETTER = 'а|в|е|к|м|н|о|р|с|т|у|х'; | |||||||
| const invalidCharsRe = new RegExp(`[^(${VALID_LETTER})0-9]`, 'gi'); | const invalidCharsRe = new RegExp(`[^(${VALID_LETTER})0-9]`, 'gi'); | ||||||
| const cleanValue = (value: string) => value.replace(invalidCharsRe, ''); | 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 validCarNumberInputRe = new RegExp(`^([${VALID_LETTER}]{1}|$)((?:[0-9]|$)(?:[0-9]|$)(?:[0-9]|$))([${VALID_LETTER}]{1,2}|$)$`, 'gi'); | ||||||
| const isValidInput = (cleanedValue: string) => validCarNumberInputRe.test(cleanedValue); | const isValidInput = (cleanedValue: string) => validCarNumberInputRe.test(cleanedValue); | ||||||
| 
 | 
 | ||||||
| const formatAsCarNumber = (cleanedValue: string) => { | const formatAsCarNumber = (cleanedValue: string) => { | ||||||
|   return cleanedValue.replace(validCarNumberInputRe, (_, p1, p2, p3, p4) => [p1, p2, p3, p4].join(' ')).toUpperCase(); |   return cleanedValue.replace(validCarNumberInputRe, (_, p1, p2, p3) => [p1, p2, p3].join(' ')).toUpperCase(); | ||||||
| }; | }; | ||||||
| const getWithoutLastChar = (value: string) => value.substring(0, value.length - 1); | const getWithoutLastChar = (value: string) => value.substring(0, value.length - 1); | ||||||
| 
 | 
 | ||||||
| @ -25,7 +25,7 @@ export const handleInputChange = (value: string | undefined | null) => { | |||||||
|   return getWithoutLastChar(value).trim(); |   return getWithoutLastChar(value).trim(); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const validCarNumberRe = new RegExp(`^[${VALID_LETTER}][0-9]{3}[${VALID_LETTER}]{2}[0-9]{2,3}$`, 'i'); | const validCarNumberRe = new RegExp(`^[${VALID_LETTER}][0-9]{3}[${VALID_LETTER}]{2}$`, 'i'); | ||||||
| 
 | 
 | ||||||
| export const isValidCarNumber = (value: string) => { | export const isValidCarNumber = (value: string) => { | ||||||
|   const cleanedValue = cleanValue(value); |   const cleanedValue = cleanValue(value); | ||||||
|  | |||||||
| @ -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'); |  | ||||||
| }; |  | ||||||
| @ -1,2 +1 @@ | |||||||
| export { type DateTimeInputProps, DateTimeInput } from './date-time'; | export { type DateTimeInputProps, DateTimeInput } from './date-time'; | ||||||
| export { getMinDatetime, getMaxDatetime } from './helper'; |  | ||||||
| @ -1,4 +1,7 @@ | |||||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||||
|  | import { InputProps, SelectProps } from "@chakra-ui/react"; | ||||||
|  | 
 | ||||||
|  | import { Order } from "../../../models/landing"; | ||||||
| 
 | 
 | ||||||
| import { FormFieldProps } from "./field"; | import { FormFieldProps } from "./field"; | ||||||
| import { OrderFormValues } from "./types"; | import { OrderFormValues } from "./types"; | ||||||
| @ -9,7 +12,6 @@ export const defaultValues: Partial<OrderFormValues> = { | |||||||
|   phone: '', |   phone: '', | ||||||
|   carNumber: '', |   carNumber: '', | ||||||
|   carColor: '', |   carColor: '', | ||||||
|   carBody: '', |  | ||||||
|   availableDatetimeBegin: '', |   availableDatetimeBegin: '', | ||||||
|   availableDatetimeEnd: '', |   availableDatetimeEnd: '', | ||||||
| }; | }; | ||||||
| @ -28,3 +30,38 @@ export const useGetValidationRules = () => { | |||||||
|     }, |     }, | ||||||
|   } satisfies Record<string, FormFieldProps['rules']>; |   } satisfies Record<string, FormFieldProps['rules']>; | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | const removeAllSpaces = (str: string) => str.replace(/\s+/g, ''); | ||||||
|  | 
 | ||||||
|  | const getValidCarBodyStyle = (fieldValue: string) => { | ||||||
|  |   const carBodyAsNumber = Number(fieldValue); | ||||||
|  |   return Number.isNaN(carBodyAsNumber) ? undefined : carBodyAsNumber; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const formatFormValues = ({ phone, carNumber, carBody, carColor, carLocation, availableDatetimeBegin, availableDatetimeEnd }: OrderFormValues): Order.Create => { | ||||||
|  |   return { | ||||||
|  |     customer: { | ||||||
|  |       phone | ||||||
|  |     }, | ||||||
|  |     car: { | ||||||
|  |       number: removeAllSpaces(carNumber), | ||||||
|  |       body: getValidCarBodyStyle(carBody), | ||||||
|  |       color: carColor | ||||||
|  |     }, | ||||||
|  |     washing: { | ||||||
|  |       location: carLocation, | ||||||
|  |       begin: availableDatetimeBegin, | ||||||
|  |       end: availableDatetimeEnd, | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const onSubmit = (values: OrderFormValues) => { | ||||||
|  |   return new Promise((resolve) => { | ||||||
|  |     console.log(formatFormValues(values)); | ||||||
|  |     resolve(formatFormValues(values)); | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const inputCommonStyles: Partial<InputProps & SelectProps> = { | ||||||
|  | }; | ||||||
| @ -1,4 +1 @@ | |||||||
| export type { OrderFormValues, OrderFormProps } from './types'; |  | ||||||
| export { OrderForm } from './order-form'; | export { OrderForm } from './order-form'; | ||||||
| export { carBodySelectOptions } from './car-body'; |  | ||||||
| export { carColorSelectOptions } from './car-color'; |  | ||||||
| @ -1,4 +1,4 @@ | |||||||
| import React, { ForwardedRef, forwardRef, memo, useEffect, useState } from 'react'; | import React, { forwardRef, memo, useEffect, useState } from 'react'; | ||||||
| import { | import { | ||||||
|   Input, |   Input, | ||||||
|   Box, |   Box, | ||||||
| @ -24,8 +24,12 @@ import { | |||||||
| } from './helper'; | } from './helper'; | ||||||
| import { LocationInputProps } from './types'; | import { LocationInputProps } from './types'; | ||||||
| 
 | 
 | ||||||
| export const BaseLocationInput = withYMaps( | export const LocationInput = memo( | ||||||
|   ({ ymaps, value = '', onChange, inputRef, ...props }: LocationInputProps & { inputRef: ForwardedRef<HTMLInputElement> }) => { |   withYMaps( | ||||||
|  |     forwardRef<HTMLInputElement, LocationInputProps>(function LocationInput( | ||||||
|  |       { ymaps, value, onChange, ...props }, | ||||||
|  |       ref, | ||||||
|  |     ) { | ||||||
|       const [inputValue, setInputValue] = useState<string>(''); |       const [inputValue, setInputValue] = useState<string>(''); | ||||||
| 
 | 
 | ||||||
|       useEffect(() => { |       useEffect(() => { | ||||||
| @ -38,13 +42,6 @@ export const BaseLocationInput = withYMaps( | |||||||
| 
 | 
 | ||||||
|       const onInputChange: InputProps['onChange'] = async (e) => { |       const onInputChange: InputProps['onChange'] = async (e) => { | ||||||
|         const newInputValue = e.target.value; |         const newInputValue = e.target.value; | ||||||
| 
 |  | ||||||
|       if ( |  | ||||||
|         isValidLocation(newInputValue) && |  | ||||||
|         (await isRealLocation(ymaps, newInputValue)) |  | ||||||
|       ) { |  | ||||||
|         onChange(newInputValue); |  | ||||||
|       } else { |  | ||||||
|         setInputValue(newInputValue); |         setInputValue(newInputValue); | ||||||
| 
 | 
 | ||||||
|         if (newInputValue.trim().length > 3) { |         if (newInputValue.trim().length > 3) { | ||||||
| @ -60,7 +57,6 @@ export const BaseLocationInput = withYMaps( | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         setIsSuggestionsPanelOpen(suggestions.length > 1); |         setIsSuggestionsPanelOpen(suggestions.length > 1); | ||||||
|       } |  | ||||||
|       }; |       }; | ||||||
| 
 | 
 | ||||||
|       const onFocus: InputProps['onFocus'] = () => { |       const onFocus: InputProps['onFocus'] = () => { | ||||||
| @ -105,9 +101,9 @@ export const BaseLocationInput = withYMaps( | |||||||
|             <PopoverAnchor> |             <PopoverAnchor> | ||||||
|               <Input |               <Input | ||||||
|                 {...props} |                 {...props} | ||||||
|               ref={inputRef} |                 ref={ref} | ||||||
|                 onBlur={onBlur} |                 onBlur={onBlur} | ||||||
|               value={inputValue || value} |                 value={inputValue} | ||||||
|                 onChange={onInputChange} |                 onChange={onInputChange} | ||||||
|                 onFocus={onFocus} |                 onFocus={onFocus} | ||||||
|                 placeholder={t('placeholder')} |                 placeholder={t('placeholder')} | ||||||
| @ -138,16 +134,11 @@ export const BaseLocationInput = withYMaps( | |||||||
|           </Popover> |           </Popover> | ||||||
|         </Box> |         </Box> | ||||||
|       ); |       ); | ||||||
|   }, |     }), | ||||||
|     true, |     true, | ||||||
|     ['suggest', 'geocode'], |     ['suggest', 'geocode'], | ||||||
|  |   ), | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| export const LocationInput = memo(forwardRef<HTMLInputElement, LocationInputProps>( |  | ||||||
|   function LocationInput(props, ref) { |  | ||||||
|     return <BaseLocationInput {...props} inputRef={ref} />; |  | ||||||
|   }, |  | ||||||
| )); |  | ||||||
| 
 |  | ||||||
| // todo: i18n
 | // todo: i18n
 | ||||||
| // todo: replace console.error with toast
 | // todo: replace console.error with toast
 | ||||||
|  | |||||||
| @ -31,22 +31,8 @@ export const MapComponent: FC<{ | |||||||
|         } |         } | ||||||
|       }, [selectedLocation]); |       }, [selectedLocation]); | ||||||
| 
 | 
 | ||||||
|       const [windowWidth, setWindowWidth] = useState(window.innerWidth); |  | ||||||
|       useEffect(() => { |  | ||||||
|         const handleResize = () => { |  | ||||||
|           setWindowWidth(window.innerWidth); |  | ||||||
|         }; |  | ||||||
|      |  | ||||||
|         window.addEventListener('resize', handleResize); |  | ||||||
|      |  | ||||||
|         return () => { |  | ||||||
|           window.removeEventListener('resize', handleResize); |  | ||||||
|         }; |  | ||||||
|       }, []); |  | ||||||
| 
 |  | ||||||
|       return ( |       return ( | ||||||
|         <Map |         <Map | ||||||
|           key={windowWidth} |  | ||||||
|           state={{ |           state={{ | ||||||
|             center: mapCenter, |             center: mapCenter, | ||||||
|             zoom: |             zoom: | ||||||
|  | |||||||
| @ -1,25 +1,20 @@ | |||||||
| import React, { useEffect } from 'react'; | import React, { FC } from 'react'; | ||||||
| import { useForm } from 'react-hook-form'; | import { useForm } from 'react-hook-form'; | ||||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||||
| import { Box, Flex, FormControl, FormLabel, VStack } from '@chakra-ui/react'; | import { Box, Flex, FormControl, FormLabel, VStack } from '@chakra-ui/react'; | ||||||
| 
 | 
 | ||||||
| import { CarBodySelect } from './car-body'; | import { CarBodySelect } from './car-body'; | ||||||
|  | import { CarColorInput } from './car-color'; | ||||||
| import { CarNumberInput } from './car-number'; | import { CarNumberInput } from './car-number'; | ||||||
| import { FormInputField, FormControllerField } from './field'; | import { FormInputField, FormControllerField } from './field'; | ||||||
| import { OrderFormProps, OrderFormValues } from './types'; | import { OrderFormValues } from './types'; | ||||||
| import { PhoneInput } from './phone'; | import { PhoneInput } from './phone'; | ||||||
| import { SubmitButton } from './submit'; | import { SubmitButton } from './submit'; | ||||||
| import { defaultValues, useGetValidationRules } from './helper'; | import { defaultValues, onSubmit, useGetValidationRules } from './helper'; | ||||||
| import { DateTimeInput, getMinDatetime, getMaxDatetime } from './date-time'; | import { DateTimeInput } from './date-time'; | ||||||
| import { | import { LocationInput, MapComponent, StringLocation, YMapsProvider } from './location'; | ||||||
|   LocationInput, |  | ||||||
|   MapComponent, |  | ||||||
|   StringLocation, |  | ||||||
|   YMapsProvider, |  | ||||||
| } from './location'; |  | ||||||
| import { CarColorSelect } from './car-color'; |  | ||||||
| 
 | 
 | ||||||
| export const OrderForm = ({ onSubmit, loading, ...props }: OrderFormProps) => { | export const OrderForm: FC = () => { | ||||||
|   const { |   const { | ||||||
|     handleSubmit, |     handleSubmit, | ||||||
|     control, |     control, | ||||||
| @ -40,21 +35,8 @@ export const OrderForm = ({ onSubmit, loading, ...props }: OrderFormProps) => { | |||||||
|     'carLocation', |     'carLocation', | ||||||
|   ]); |   ]); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |  | ||||||
|     if (!availableDatetimeBegin) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|     setValue('availableDatetimeEnd', getMinDatetime(availableDatetimeEnd, availableDatetimeBegin)); |  | ||||||
|   }, [availableDatetimeBegin]); |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (!availableDatetimeEnd) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|     setValue('availableDatetimeBegin', getMaxDatetime(availableDatetimeBegin, availableDatetimeEnd)); |  | ||||||
|   }, [availableDatetimeEnd]); |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <Box p={4} marginInline='auto' {...props}> |     <Box p={4} marginInline='auto'> | ||||||
|       <VStack |       <VStack | ||||||
|         as='form' |         as='form' | ||||||
|         noValidate |         noValidate | ||||||
| @ -85,7 +67,7 @@ export const OrderForm = ({ onSubmit, loading, ...props }: OrderFormProps) => { | |||||||
|           name='carColor' |           name='carColor' | ||||||
|           label={t('car-color-field.label')} |           label={t('car-color-field.label')} | ||||||
|           errors={errors} |           errors={errors} | ||||||
|           Input={CarColorSelect} |           Input={CarColorInput} | ||||||
|         /> |         /> | ||||||
|         <FormInputField |         <FormInputField | ||||||
|           control={control} |           control={control} | ||||||
| @ -141,7 +123,7 @@ export const OrderForm = ({ onSubmit, loading, ...props }: OrderFormProps) => { | |||||||
|             }} |             }} | ||||||
|           /> |           /> | ||||||
|         </YMapsProvider> |         </YMapsProvider> | ||||||
|         <SubmitButton isLoading={isSubmitting || loading} mt={4} /> |         <SubmitButton isLoading={isSubmitting} mt={4} /> | ||||||
|       </VStack> |       </VStack> | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
|  | |||||||
| @ -1,5 +1,3 @@ | |||||||
| import { SubmitHandler } from "react-hook-form"; |  | ||||||
| 
 |  | ||||||
| export type OrderFormValues = { | export type OrderFormValues = { | ||||||
|   phone: string; |   phone: string; | ||||||
|   carNumber: string; |   carNumber: string; | ||||||
| @ -9,8 +7,3 @@ export type OrderFormValues = { | |||||||
|   availableDatetimeBegin: string; |   availableDatetimeBegin: string; | ||||||
|   availableDatetimeEnd: string; |   availableDatetimeEnd: string; | ||||||
| }; | }; | ||||||
| 
 |  | ||||||
| export type OrderFormProps = { |  | ||||||
|   onSubmit: SubmitHandler<OrderFormValues>; |  | ||||||
|   loading: boolean; |  | ||||||
| }; |  | ||||||
| @ -1,102 +0,0 @@ | |||||||
| import React, { FC, memo, useRef } from 'react'; |  | ||||||
| import { Controller, useForm } from 'react-hook-form'; |  | ||||||
| import { |  | ||||||
|   Button, |  | ||||||
|   FormControl, |  | ||||||
|   FormErrorMessage, |  | ||||||
|   FormHelperText, |  | ||||||
|   FormLabel, |  | ||||||
|   HStack, |  | ||||||
|   Input, |  | ||||||
| } from '@chakra-ui/react'; |  | ||||||
| import { useTranslation } from 'react-i18next'; |  | ||||||
| 
 |  | ||||||
| import { landingApi } from '../../../__data__/service/landing.api'; |  | ||||||
| import { UploadCarImage } from '../../../models/api'; |  | ||||||
| 
 |  | ||||||
| import { useHandleUploadCarImageResponse } from './helper'; |  | ||||||
| 
 |  | ||||||
| type FormValues = { |  | ||||||
|   carImg: File & { |  | ||||||
|     fileName: string; |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| type CarImageFormProps = { |  | ||||||
|   orderId: UploadCarImage.Params['orderId']; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const CarImageForm: FC<CarImageFormProps> = memo(function CarImageForm({ |  | ||||||
|   orderId, |  | ||||||
| }) { |  | ||||||
|   const { |  | ||||||
|     handleSubmit, |  | ||||||
|     control, |  | ||||||
|     formState: { errors, isSubmitting }, |  | ||||||
|   } = useForm<FormValues>({ shouldFocusError: true }); |  | ||||||
| 
 |  | ||||||
|   const [uploadCarImage, uploadCarImageMutation] = |  | ||||||
|     landingApi.useUploadCarImageMutation(); |  | ||||||
|   useHandleUploadCarImageResponse(uploadCarImageMutation); |  | ||||||
| 
 |  | ||||||
|   const onSubmit = (formData: FormValues) => { |  | ||||||
|     const body = new FormData(); |  | ||||||
|     body.append('file', formData.carImg); |  | ||||||
|     uploadCarImage({ orderId, body }); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const fileInputRef = useRef(null); |  | ||||||
| 
 |  | ||||||
|   const { t } = useTranslation('~', { |  | ||||||
|     keyPrefix: 'dry-wash.order-view.upload-car-image', |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <form> |  | ||||||
|       <FormControl> |  | ||||||
|         <FormLabel htmlFor='carImg'>{t('field.label')}</FormLabel> |  | ||||||
|         <Controller |  | ||||||
|           control={control} |  | ||||||
|           name='carImg' |  | ||||||
|           render={({ field: { value, onChange, ...field } }) => { |  | ||||||
|             return ( |  | ||||||
|               <HStack gap={0}> |  | ||||||
|                 <Input |  | ||||||
|                   {...field} |  | ||||||
|                   ref={fileInputRef} |  | ||||||
|                   accept='image/png,image/jpeg' |  | ||||||
|                   value={value?.fileName} |  | ||||||
|                   onChange={(event) => { |  | ||||||
|                     onChange(event.target.files[0]); |  | ||||||
|                     handleSubmit(onSubmit)(); |  | ||||||
|                   }} |  | ||||||
|                   type='file' |  | ||||||
|                   hidden |  | ||||||
|                 /> |  | ||||||
|                 <Input |  | ||||||
|                   placeholder={t('file-input.placeholder')} |  | ||||||
|                   value={value?.name || ''} |  | ||||||
|                   readOnly |  | ||||||
|                   borderRightRadius={0} |  | ||||||
|                 /> |  | ||||||
|                 <Button |  | ||||||
|                   onClick={() => { |  | ||||||
|                     fileInputRef.current.click(); |  | ||||||
|                   }} |  | ||||||
|                   isLoading={isSubmitting || uploadCarImageMutation.isLoading} |  | ||||||
|                   colorScheme='primary' |  | ||||||
|                   paddingInline={8} |  | ||||||
|                   borderLeftRadius={0} |  | ||||||
|                 > |  | ||||||
|                   {t('file-input.button')} |  | ||||||
|                 </Button> |  | ||||||
|               </HStack> |  | ||||||
|             ); |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|         <FormErrorMessage>{errors.carImg?.message}</FormErrorMessage> |  | ||||||
|         <FormHelperText>{t('field.help')}</FormHelperText> |  | ||||||
|       </FormControl> |  | ||||||
|     </form> |  | ||||||
|   ); |  | ||||||
| }); |  | ||||||
| @ -1,35 +0,0 @@ | |||||||
| import { useEffect } from "react"; |  | ||||||
| import { useToast } from "@chakra-ui/react"; |  | ||||||
| import { useTranslation } from "react-i18next"; |  | ||||||
| 
 |  | ||||||
| import { isErrorMessage } from "../../../models/api"; |  | ||||||
| 
 |  | ||||||
| export const useHandleUploadCarImageResponse = (query: { |  | ||||||
|   isSuccess: boolean; |  | ||||||
|   isError: boolean; |  | ||||||
|   error?: unknown; |  | ||||||
| }) => { |  | ||||||
|   const toast = useToast(); |  | ||||||
|   const { t } = useTranslation('~', { |  | ||||||
|     keyPrefix: 'dry-wash.order-view.upload-car-image-query', |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (query.isError) { |  | ||||||
|       toast({ |  | ||||||
|         status: 'error', |  | ||||||
|         title: t('error.title'), |  | ||||||
|         description: isErrorMessage(query.error) ? query.error : undefined, |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|   }, [query.isError]); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (query.isSuccess) { |  | ||||||
|       toast({ |  | ||||||
|         status: 'success', |  | ||||||
|         title: t('success.title'), |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|   }, [query.isSuccess]); |  | ||||||
| }; |  | ||||||