diff --git a/src/components/order-form/form/car-body/car-body-select.tsx b/src/components/order-form/form/car-body/car-body-select.tsx new file mode 100644 index 0000000..ca3f068 --- /dev/null +++ b/src/components/order-form/form/car-body/car-body-select.tsx @@ -0,0 +1,23 @@ +import React, { forwardRef } from 'react'; +import { Select, SelectProps } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; + +import { carBodySelectOptions } from './helper'; + +export const CarBodySelect = forwardRef( + function CarBodySelect(props, ref) { + const { t } = useTranslation('~', { + keyPrefix: 'dry-wash.order-create.car-body-select', + }); + + return ( + + ); + }, +); diff --git a/src/components/order-form/form/car-body/helper.ts b/src/components/order-form/form/car-body/helper.ts new file mode 100644 index 0000000..d906122 --- /dev/null +++ b/src/components/order-form/form/car-body/helper.ts @@ -0,0 +1,50 @@ +import { Car } from "../../../../models/landing"; + +import { CarBodySelectOption } from "./types"; + +export const carBodySelectOptions: CarBodySelectOption[] = [ + { + value: Car.BodyStyle.SEDAN, + labelTKey: 'sedan' + }, + { + value: Car.BodyStyle.HATCHBACK, + labelTKey: 'hatchback' + }, + { + value: Car.BodyStyle.CROSSOVER, + labelTKey: 'crossover' + }, + { + value: Car.BodyStyle.SUV, + labelTKey: 'suv' + }, + { + value: Car.BodyStyle.STATION_WAGON, + labelTKey: 'station-wagon' + }, + { + value: Car.BodyStyle.COUPE, + labelTKey: 'coupe' + }, + { + value: Car.BodyStyle.MINIVAN, + labelTKey: 'minivan' + }, + { + value: Car.BodyStyle.PICKUP, + labelTKey: 'pickup' + }, + { + value: Car.BodyStyle.LIFTBACK, + labelTKey: 'liftback' + }, + { + value: Car.BodyStyle.SPORTS_CAR, + labelTKey: 'sports-car' + }, + { + value: Car.BodyStyle.OTHER, + labelTKey: 'other' + }, +]; \ No newline at end of file diff --git a/src/components/order-form/form/car-body/index.ts b/src/components/order-form/form/car-body/index.ts new file mode 100644 index 0000000..0222204 --- /dev/null +++ b/src/components/order-form/form/car-body/index.ts @@ -0,0 +1 @@ +export { CarBodySelect } from './car-body-select'; \ No newline at end of file diff --git a/src/components/order-form/form/car-body/types.ts b/src/components/order-form/form/car-body/types.ts new file mode 100644 index 0000000..926719f --- /dev/null +++ b/src/components/order-form/form/car-body/types.ts @@ -0,0 +1,17 @@ +import { Car } from "../../../../models/landing"; + +export type CarBodySelectOption = { + value: Car.BodyStyle; + labelTKey: + 'sedan' | + 'hatchback' | + 'crossover' | + 'suv' | + 'station-wagon' | + 'coupe' | + 'minivan' | + 'pickup' | + 'liftback' | + 'sports-car' | + 'other'; +}; diff --git a/src/components/order-form/form/car-color/car-color-input.tsx b/src/components/order-form/form/car-color/car-color-input.tsx new file mode 100644 index 0000000..0bf94f3 --- /dev/null +++ b/src/components/order-form/form/car-color/car-color-input.tsx @@ -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( + function CarColorInput(props, ref) { + const listId = useId(); + + return ( + <> + + + {CAR_COLORS.map(({ code, name }) => ( + + ))} + + + ); + }, +); + +// todo: add option color visual indication \ No newline at end of file diff --git a/src/components/order-form/form/car-color/helper.ts b/src/components/order-form/form/car-color/helper.ts new file mode 100644 index 0000000..b080ed1 --- /dev/null +++ b/src/components/order-form/form/car-color/helper.ts @@ -0,0 +1,34 @@ +export const CAR_COLORS: Record<'name' | 'code', string>[] = [ + { + name: 'white', + code: '#ffffff' + }, + { + name: 'black', + code: '#000000' + }, + { + name: 'silver', + code: '#c0c0c0' + }, + { + name: 'gray', + code: '#808080' + }, + { + name: 'beige-brown', + code: '#796745' + }, + { + name: 'red', + code: '#b90000' + }, + { + name: 'blue', + code: '#003B62' + }, + { + name: 'green', + code: '#078d51' + }, +]; \ No newline at end of file diff --git a/src/components/order-form/form/car-color/index.ts b/src/components/order-form/form/car-color/index.ts new file mode 100644 index 0000000..4cc34e5 --- /dev/null +++ b/src/components/order-form/form/car-color/index.ts @@ -0,0 +1 @@ +export { CarColorInput } from './car-color-input'; \ No newline at end of file diff --git a/src/components/order-form/form/car-number/car-number-input.tsx b/src/components/order-form/form/car-number/car-number-input.tsx new file mode 100644 index 0000000..5d3daa1 --- /dev/null +++ b/src/components/order-form/form/car-number/car-number-input.tsx @@ -0,0 +1,22 @@ +import React, { forwardRef } from 'react'; +import { Input, InputProps } from '@chakra-ui/react'; + +import { handleInputChange } from './helper'; + +export const CarNumberInput = forwardRef( + function CarNumberInput({ onChange, ...props }, ref) { + return ( + { + const formattedValue = handleInputChange(e.target.value); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + onChange?.(formattedValue); + }} + maxLength={8} + /> + ); + }, +); diff --git a/src/components/order-form/form/car-number/helper.ts b/src/components/order-form/form/car-number/helper.ts new file mode 100644 index 0000000..2efb4dc --- /dev/null +++ b/src/components/order-form/form/car-number/helper.ts @@ -0,0 +1,33 @@ +const VALID_LETTER = 'а|в|е|к|м|н|о|р|с|т|у|х'; + +const invalidCharsRe = new RegExp(`[^(${VALID_LETTER})0-9]`, 'gi'); +const cleanValue = (value: string) => value.replace(invalidCharsRe, ''); + +const validCarNumberInputRe = new RegExp(`^([${VALID_LETTER}]{1}|$)((?:[0-9]|$)(?:[0-9]|$)(?:[0-9]|$))([${VALID_LETTER}]{1,2}|$)$`, 'gi'); +const isValidInput = (cleanedValue: string) => validCarNumberInputRe.test(cleanedValue); + +const formatAsCarNumber = (cleanedValue: string) => { + return cleanedValue.replace(validCarNumberInputRe, (_, p1, p2, p3) => [p1, p2, p3].join(' ')).toUpperCase(); +}; +const getWithoutLastChar = (value: string) => value.substring(0, value.length - 1); + +export const handleInputChange = (value: string | undefined | null) => { + const cleanedValue = cleanValue(value); + + if (!cleanedValue) { + return ''; + } + + if (isValidInput(cleanedValue)) { + return formatAsCarNumber(cleanedValue).trim(); + } + + return getWithoutLastChar(value).trim(); +}; + +const validCarNumberRe = new RegExp(`^[${VALID_LETTER}][0-9]{3}[${VALID_LETTER}]{2}$`, 'i'); + +export const isValidCarNumber = (value: string) => { + const cleanedValue = cleanValue(value); + return validCarNumberRe.test(cleanedValue); +}; \ No newline at end of file diff --git a/src/components/order-form/form/car-number/index.ts b/src/components/order-form/form/car-number/index.ts new file mode 100644 index 0000000..c02c531 --- /dev/null +++ b/src/components/order-form/form/car-number/index.ts @@ -0,0 +1,2 @@ +export { CarNumberInput } from './car-number-input'; +export { isValidCarNumber } from './helper'; \ No newline at end of file diff --git a/src/components/order-form/form/date-time/date-time.tsx b/src/components/order-form/form/date-time/date-time.tsx new file mode 100644 index 0000000..27db53c --- /dev/null +++ b/src/components/order-form/form/date-time/date-time.tsx @@ -0,0 +1,12 @@ +import React, { forwardRef } from 'react'; +import { Input, InputProps } from '@chakra-ui/react'; + +export type DateTimeInputProps = InputProps; + +export const DateTimeInput = forwardRef( + function DateTimeInput(props, ref) { + return ; + }, +); + +// todo: apply brand styles to popover \ No newline at end of file diff --git a/src/components/order-form/form/date-time/index.ts b/src/components/order-form/form/date-time/index.ts new file mode 100644 index 0000000..08f8dc6 --- /dev/null +++ b/src/components/order-form/form/date-time/index.ts @@ -0,0 +1 @@ +export { type DateTimeInputProps, DateTimeInput } from './date-time'; \ No newline at end of file diff --git a/src/components/order-form/form/field/form-controller-field.tsx b/src/components/order-form/form/field/form-controller-field.tsx new file mode 100644 index 0000000..941ec5d --- /dev/null +++ b/src/components/order-form/form/field/form-controller-field.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { FormControl, FormLabel, FormErrorMessage } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; + +import { FormControllerFieldProps } from './types'; + +export const FormControllerField = ({ + control, + name, + label, + isRequired, + rules, + errors, + Controller +}: FormControllerFieldProps) => { + const { t } = useTranslation('~', { + keyPrefix: 'dry-wash.order-create.form.field', + }); + const fieldErrors = errors[name]; + + return ( + + {label} + + {fieldErrors?.message} + + ); +}; \ No newline at end of file diff --git a/src/components/order-form/form/field/form-input-field.tsx b/src/components/order-form/form/field/form-input-field.tsx new file mode 100644 index 0000000..54bcb4a --- /dev/null +++ b/src/components/order-form/form/field/form-input-field.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { + FormControl, + FormLabel, + FormErrorMessage, + FormHelperText, +} from '@chakra-ui/react'; +import { Controller } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { FormInputFieldProps } from './types'; + +export const FormInputField = ({ + control, + name, + label, + isRequired, + rules, + errors, + Input, + inputProps, + help, +}: FormInputFieldProps) => { + const { t } = useTranslation('~', { + keyPrefix: 'dry-wash.order-create.form.field', + }); + const fieldErrors = errors[name]; + + return ( + + {label && {label}} + ( + + )} + /> + {fieldErrors?.message} + {help && {help}} + + ); +}; \ No newline at end of file diff --git a/src/components/order-form/form/field/index.ts b/src/components/order-form/form/field/index.ts new file mode 100644 index 0000000..62b6301 --- /dev/null +++ b/src/components/order-form/form/field/index.ts @@ -0,0 +1,3 @@ +export type { FormFieldProps } from './types'; +export { FormInputField } from './form-input-field'; +export { FormControllerField } from './form-controller-field'; \ No newline at end of file diff --git a/src/components/order-form/form/field/types.ts b/src/components/order-form/form/field/types.ts new file mode 100644 index 0000000..bfc6d9a --- /dev/null +++ b/src/components/order-form/form/field/types.ts @@ -0,0 +1,26 @@ +import { ReactElement, ReactNode } from 'react'; +import { ControllerProps, ControllerRenderProps, FieldErrors } from 'react-hook-form'; +import { FormLabelProps, FormControlProps, HelpTextProps, InputProps as ChakraInputProps, SelectProps as ChakraSelectProps } from '@chakra-ui/react'; + +import { OrderFormValues } from '../types'; + +export type FormFieldProps = { + name: ControllerProps['name'] & FormLabelProps['htmlFor']; + label?: FormLabelProps['children']; + errors: FieldErrors; + control: ControllerProps['control']; + isRequired?: FormControlProps['isRequired']; + rules?: ControllerProps['rules']; +}; + +type InputProps = Partial & ChakraInputProps & ChakraSelectProps>; + +export type FormInputFieldProps) => ReactNode> = FormFieldProps & { + Input: Input; + inputProps?: InputProps; + help?: HelpTextProps['children']; +}; + +export type FormControllerFieldProps = FormFieldProps & { + Controller: (props: Pick, 'control' | 'name' | 'rules'>) => ReactElement; +}; \ No newline at end of file diff --git a/src/components/order-form/form/helper.ts b/src/components/order-form/form/helper.ts new file mode 100644 index 0000000..d052752 --- /dev/null +++ b/src/components/order-form/form/helper.ts @@ -0,0 +1,67 @@ +import { useTranslation } from "react-i18next"; +import { InputProps, SelectProps } from "@chakra-ui/react"; + +import { Order } from "../../../models/landing"; + +import { FormFieldProps } from "./field"; +import { OrderFormValues } from "./types"; +import { isValidCarNumber } from "./car-number"; +import { isValidPhoneNumber } from "./phone"; + +export const defaultValues: Partial = { + phone: '', + carNumber: '', + carColor: '', + availableDatetimeBegin: '', + availableDatetimeEnd: '', +}; + +export const useGetValidationRules = () => { + const { t } = useTranslation('~', { + keyPrefix: 'dry-wash.order-create.form', + }); + + return { + isValidPhoneNumber: { + validate: (value: string) => isValidPhoneNumber(value) || t('phone-field.invalid') + }, + isValidCarNumber: { + validate: (value: string) => isValidCarNumber(value) || t('car-number-field.invalid') + }, + } satisfies Record; +}; + +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 = { +}; \ No newline at end of file diff --git a/src/components/order-form/form/index.ts b/src/components/order-form/form/index.ts new file mode 100644 index 0000000..c445fce --- /dev/null +++ b/src/components/order-form/form/index.ts @@ -0,0 +1 @@ +export { OrderForm } from './order-form'; \ No newline at end of file diff --git a/src/components/order-form/form/order-form.tsx b/src/components/order-form/form/order-form.tsx new file mode 100644 index 0000000..ebe890a --- /dev/null +++ b/src/components/order-form/form/order-form.tsx @@ -0,0 +1,130 @@ +import React, { FC } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { + Box, + Flex, + FormControl, + FormLabel, + Input, + VStack, +} from '@chakra-ui/react'; + +import { CarBodySelect } from './car-body'; +import { CarColorInput } from './car-color'; +import { CarNumberInput } from './car-number'; +import { FormInputField, FormControllerField } from './field'; +import { OrderFormValues } from './types'; +import { PhoneInput } from './phone'; +import { SubmitButton } from './submit'; +import { defaultValues, onSubmit, useGetValidationRules } from './helper'; +import { DateTimeInput } from './date-time'; + +export const OrderForm: FC = () => { + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + watch, + } = useForm({ defaultValues, shouldFocusError: true }); + + const { t } = useTranslation('~', { + keyPrefix: 'dry-wash.order-create.form', + }); + + const RULES = useGetValidationRules(); + + const [availableDatetimeBegin, availableDatetimeEnd] = watch([ + 'availableDatetimeBegin', + 'availableDatetimeEnd', + ]); + + return ( + + + + + + + + + {t('washing-datetime-field.label')} + + + + + + + + + + + + + ); +}; + +// todo: remove layout shift, when a validation message is displayed +// todo: select location using an interactive map +// todo: fix time range available values \ No newline at end of file diff --git a/src/components/order-form/form/phone/helper.ts b/src/components/order-form/form/phone/helper.ts new file mode 100644 index 0000000..07d5386 --- /dev/null +++ b/src/components/order-form/form/phone/helper.ts @@ -0,0 +1 @@ +export { isValidPhoneNumber } from "react-phone-number-input"; \ No newline at end of file diff --git a/src/components/order-form/form/phone/index.ts b/src/components/order-form/form/phone/index.ts new file mode 100644 index 0000000..a3dbab9 --- /dev/null +++ b/src/components/order-form/form/phone/index.ts @@ -0,0 +1,2 @@ +export { PhoneInput } from './phone-input'; +export { isValidPhoneNumber } from './helper'; \ No newline at end of file diff --git a/src/components/order-form/form/phone/phone-input.tsx b/src/components/order-form/form/phone/phone-input.tsx new file mode 100644 index 0000000..212ab66 --- /dev/null +++ b/src/components/order-form/form/phone/phone-input.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import ReactPhoneNumberInput from 'react-phone-number-input/react-hook-form-input'; +import { Input } from '@chakra-ui/react'; + +import 'react-phone-number-input/style.css'; + +import { PhoneInputType } from './types'; + +export const PhoneInput: PhoneInputType = (props) => { + return ( + + ); +}; diff --git a/src/components/order-form/form/phone/types.ts b/src/components/order-form/form/phone/types.ts new file mode 100644 index 0000000..d1916b3 --- /dev/null +++ b/src/components/order-form/form/phone/types.ts @@ -0,0 +1,7 @@ +import { FieldValues } from 'react-hook-form'; +import { DefaultInputComponentProps } from 'react-phone-number-input'; +import { DefaultFormValues, Props } from 'react-phone-number-input/react-hook-form'; + +type PhoneInputProps = Props; + +export type PhoneInputType = (props: PhoneInputProps) => JSX.Element; \ No newline at end of file diff --git a/src/components/order-form/form/submit/index.ts b/src/components/order-form/form/submit/index.ts new file mode 100644 index 0000000..ed2b5bd --- /dev/null +++ b/src/components/order-form/form/submit/index.ts @@ -0,0 +1 @@ +export { SubmitButton } from './submit-button'; \ No newline at end of file diff --git a/src/components/order-form/form/submit/submit-button.tsx b/src/components/order-form/form/submit/submit-button.tsx new file mode 100644 index 0000000..1a6ea90 --- /dev/null +++ b/src/components/order-form/form/submit/submit-button.tsx @@ -0,0 +1,15 @@ +import React, { FC } from 'react'; +import { Button, ButtonProps } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; + +export const SubmitButton: FC = (props) => { + const { t } = useTranslation('~', { + keyPrefix: 'dry-wash.order-create.form.submit-button', + }); + + return ( + + ); +}; diff --git a/src/components/order-form/form/types.ts b/src/components/order-form/form/types.ts new file mode 100644 index 0000000..9746d54 --- /dev/null +++ b/src/components/order-form/form/types.ts @@ -0,0 +1,9 @@ +export type OrderFormValues = { + phone: string; + carNumber: string; + carColor: string; + carBody: string; + carLocation: string; + availableDatetimeBegin: string; + availableDatetimeEnd: string; +}; \ No newline at end of file diff --git a/src/components/order-form/index.ts b/src/components/order-form/index.ts new file mode 100644 index 0000000..b8a36bf --- /dev/null +++ b/src/components/order-form/index.ts @@ -0,0 +1 @@ +export * from './form'; \ No newline at end of file