feat/landing #30
							
								
								
									
										17
									
								
								locales/en.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								locales/en.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | |||||||
|  | { | ||||||
|  |   "dry-wash.landing.benefits-section.description": "Discover the benefits of our car dry cleaning services", | ||||||
|  |   "dry-wash.landing.benefits-section.heading": "Advantages of an eco-friendly car wash", | ||||||
|  |   "dry-wash.landing.benefits-section.list.0": "Environmentally friendly products", | ||||||
|  |   "dry-wash.landing.benefits-section.list.1": "Fast and efficient service", | ||||||
|  |   "dry-wash.landing.benefits-section.list.2": "Convenient mobile access", | ||||||
|  |   "dry-wash.landing.benefits-section.list.3": "Reliable and trustworthy", | ||||||
|  |   "dry-wash.landing.footer.copyright": "\u00A9 {{currentYear}} DryMaster. All rights reserved", | ||||||
|  |   "dry-wash.landing.footer.links.faq": "FAQ", | ||||||
|  |   "dry-wash.landing.footer.links.privacy-policy": "Privacy policy", | ||||||
|  |   "dry-wash.landing.footer.links.service-terms": "Terms of service", | ||||||
|  |   "dry-wash.landing.hero-section.description": "Experience the ultimate convenience of a dry car wash, designed for a cleaner planet.", | ||||||
|  |   "dry-wash.landing.hero-section.headline": "Revitalize Your Ride with Eco-Friendly Care!", | ||||||
|  |   "dry-wash.landing.make-order-button": "Make order", | ||||||
|  |   "dry-wash.landing.site-logo": "The logo of the \"Dry Master\" company", | ||||||
|  |   "dry-wash.landing.social-proof-section.heading": "We are being chosen" | ||||||
|  | } | ||||||
| @ -29,6 +29,21 @@ | |||||||
|   "dry-wash.arm.master.sideBar.title": " Сухой мастер", |   "dry-wash.arm.master.sideBar.title": " Сухой мастер", | ||||||
|   "dry-wash.arm.master.sideBar.title.master": "Мастера", |   "dry-wash.arm.master.sideBar.title.master": "Мастера", | ||||||
|   "dry-wash.arm.master.sideBar.title.orders": "Заказы", |   "dry-wash.arm.master.sideBar.title.orders": "Заказы", | ||||||
|  |   "dry-wash.landing.benefits-section.description": "Откройте для себя преимущества наших услуг по химчистке автомобилей", | ||||||
|  |   "dry-wash.landing.benefits-section.heading": "Преимущества экологичной автомойки", | ||||||
|  |   "dry-wash.landing.benefits-section.list.0": "Экологически безопасные продукты", | ||||||
|  |   "dry-wash.landing.benefits-section.list.1": "Быстрое и эффективное обслуживание", | ||||||
|  |   "dry-wash.landing.benefits-section.list.2": "Удобный мобильный доступ", | ||||||
|  |   "dry-wash.landing.benefits-section.list.3": "Надежный и заслуживающий доверия", | ||||||
|  |   "dry-wash.landing.footer.copyright": "\u00A9 {{currentYear}} DryMaster. Все права защищены", | ||||||
|  |   "dry-wash.landing.footer.links.faq": "FAQ", | ||||||
|  |   "dry-wash.landing.footer.links.privacy-policy": "Политика конфиденциальности", | ||||||
|  |   "dry-wash.landing.footer.links.service-terms": "Условия обслуживания", | ||||||
|  |   "dry-wash.landing.hero-section.description": "Ощутите максимальное удобство сухой мойки автомобилей, созданной для того, чтобы планета стала чище", | ||||||
|  |   "dry-wash.landing.hero-section.headline": "Оживите свою поездку с помощью экологически чистого ухода!", | ||||||
|  |   "dry-wash.landing.make-order-button": "Сделать заказ", | ||||||
|  |   "dry-wash.landing.site-logo": "Логотип компании \u00ABDry Master\u00BB", | ||||||
|  |   "dry-wash.landing.social-proof-section.heading": "Нас выбирают", | ||||||
|   "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": " Вернуться на главную", | ||||||
|  | |||||||
							
								
								
									
										549
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										549
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -17,7 +17,7 @@ | |||||||
|     "author": "", |     "author": "", | ||||||
|     "license": "ISC", |     "license": "ISC", | ||||||
|     "dependencies": { |     "dependencies": { | ||||||
|         "@brojs/cli": "^1.6.1", |         "@brojs/cli": "^1.6.3", | ||||||
|         "@chakra-ui/icons": "^2.2.4", |         "@chakra-ui/icons": "^2.2.4", | ||||||
|         "@chakra-ui/react": "^2.4.2", |         "@chakra-ui/react": "^2.4.2", | ||||||
|         "@emotion/react": "^11.4.1", |         "@emotion/react": "^11.4.1", | ||||||
| @ -30,6 +30,7 @@ | |||||||
|         "i18next": "^23.16.4", |         "i18next": "^23.16.4", | ||||||
|         "react": "^18.3.1", |         "react": "^18.3.1", | ||||||
|         "react-dom": "^18.3.1", |         "react-dom": "^18.3.1", | ||||||
|  |         "react-i18next": "^15.1.1", | ||||||
|         "react-icons": "^5.3.0", |         "react-icons": "^5.3.0", | ||||||
|         "react-router-dom": "^6.27.0" |         "react-router-dom": "^6.27.0" | ||||||
|     }, |     }, | ||||||
|  | |||||||
							
								
								
									
										24
									
								
								src/__data__/urls.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/__data__/urls.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | import { generatePath } from "react-router-dom"; | ||||||
|  | import { getNavigationValue } from "@brojs/cli"; | ||||||
|  | import { Order } from "../models"; | ||||||
|  | 
 | ||||||
|  | export const URLs = { | ||||||
|  |   landing: { | ||||||
|  |     url: getNavigationValue("dry-wash.main"), | ||||||
|  |     getUrl() { | ||||||
|  |       return this.url; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   orderForm: { | ||||||
|  |     url: getNavigationValue("dry-wash.create"), | ||||||
|  |     getUrl() { | ||||||
|  |       return this.url; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   orderView: { | ||||||
|  |     url: getNavigationValue("dry-wash.view.order"), | ||||||
|  |     getUrl(orderId: Order.Id) { | ||||||
|  |       return generatePath(this.url, { orderId }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }; | ||||||
| @ -1,4 +1,5 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
| import { | import { | ||||||
|   MdEco, |   MdEco, | ||||||
|   MdMiscellaneousServices, |   MdMiscellaneousServices, | ||||||
| @ -10,33 +11,39 @@ import { CtaButton, PageSection } from '../'; | |||||||
| import { ListItem } from './ListItem'; | import { ListItem } from './ListItem'; | ||||||
| 
 | 
 | ||||||
| export const BenefitsSection: FC = () => { | export const BenefitsSection: FC = () => { | ||||||
|   return ( |   const { t } = useTranslation('~', { | ||||||
|     <PageSection> |     keyPrefix: 'dry-wash.landing.benefits-section', | ||||||
|       <VStack w='full' spacing={2}> |   }); | ||||||
|         <Heading as='h2'>Преимущества экологичной автомойки</Heading> | 
 | ||||||
|         <Text> |   const listData = [ | ||||||
|           Откройте для себя преимущества наших услуг по химчистке автомобилей |  | ||||||
|         </Text> |  | ||||||
|       </VStack> |  | ||||||
|       <List display='flex' flexDirection='column' spacing={3}> |  | ||||||
|         {[ |  | ||||||
|     { |     { | ||||||
|       Icon: MdEco, |       Icon: MdEco, | ||||||
|             children: 'Экологически безопасные продукты', |       children: t('list.0'), | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       Icon: MdMiscellaneousServices, |       Icon: MdMiscellaneousServices, | ||||||
|             children: 'Быстрое и эффективное обслуживание', |       children: t('list.1'), | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       Icon: MdPlace, |       Icon: MdPlace, | ||||||
|             children: 'Удобный мобильный доступ', |       children: t('list.2'), | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       Icon: MdHandshake, |       Icon: MdHandshake, | ||||||
|             children: 'Надежный и заслуживающий доверия', |       children: t('list.3'), | ||||||
|     }, |     }, | ||||||
|         ].map((props, i) => ( |   ]; | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <PageSection> | ||||||
|  |       <VStack w='full' spacing={2}> | ||||||
|  |         <Heading as='h2'>{t('heading')}</Heading> | ||||||
|  |         <Text> | ||||||
|  |           {t('description')} | ||||||
|  |         </Text> | ||||||
|  |       </VStack> | ||||||
|  |       <List display='flex' flexDirection='column' spacing={3}> | ||||||
|  |         {listData.map((props, i) => ( | ||||||
|           <ListItem key={i} {...props} /> |           <ListItem key={i} {...props} /> | ||||||
|         ))} |         ))} | ||||||
|       </List> |       </List> | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ import React, { FC, PropsWithChildren } from 'react'; | |||||||
| import { ListIcon, ListItem as ChakraListItem } from '@chakra-ui/react'; | import { ListIcon, ListItem as ChakraListItem } from '@chakra-ui/react'; | ||||||
| import { IconType } from 'react-icons'; | import { IconType } from 'react-icons'; | ||||||
| 
 | 
 | ||||||
| type ListItemProps = PropsWithChildren & { | export type ListItemProps = PropsWithChildren & { | ||||||
|   Icon: IconType; |   Icon: IconType; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1 +1 @@ | |||||||
| export { ListItem } from './ListItem'; | export { type ListItemProps, ListItem } from './ListItem'; | ||||||
| @ -1,11 +1,20 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
| import { Link as RouterLink } from 'react-router-dom'; | import { Link as RouterLink } from 'react-router-dom'; | ||||||
| import { ButtonProps, Button } from '@chakra-ui/react'; | import { ButtonProps, Button } from '@chakra-ui/react'; | ||||||
|  | import { URLs } from '../../../__data__/urls'; | ||||||
| 
 | 
 | ||||||
| export const CtaButton: FC<ButtonProps> = (props) => { | export const CtaButton: FC<ButtonProps> = (props) => { | ||||||
|  |   const { t } = useTranslation(); | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Button as={RouterLink} to='/dry-wash/order-form' colorScheme='primary' {...props}> |     <Button | ||||||
|       Сделать заказ |       as={RouterLink} | ||||||
|  |       to={URLs.orderForm.getUrl()} | ||||||
|  |       colorScheme='primary' | ||||||
|  |       {...props} | ||||||
|  |     > | ||||||
|  |       {t('dry-wash.landing.make-order-button')} | ||||||
|     </Button> |     </Button> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -1,8 +1,11 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
| import { Text } from '@chakra-ui/react'; | import { Text } from '@chakra-ui/react'; | ||||||
| 
 | 
 | ||||||
| const currentYear = new Date().getFullYear(); | const currentYear = new Date().getFullYear(); | ||||||
| 
 | 
 | ||||||
| export const Copyright: FC = () => { | export const Copyright: FC = () => { | ||||||
|   return <Text color='whiteAlpha.500'>© {currentYear} DryMaster. Все права защищены</Text>; |   const { t } = useTranslation(); | ||||||
|  | 
 | ||||||
|  |   return <Text color='whiteAlpha.500'>{t('dry-wash.landing.footer.copyright', { currentYear })}</Text>; | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -1,20 +1,27 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
| import { Link, List, ListItem } from '@chakra-ui/react'; | import { Link, List, ListItem } from '@chakra-ui/react'; | ||||||
| import { Link as RouterLink } from 'react-router-dom'; | import { Link as RouterLink } from 'react-router-dom'; | ||||||
| import { SiteLogo, PageSection } from '../'; | import { SiteLogo, PageSection } from '../'; | ||||||
| import { Copyright } from './Copyright'; | import { Copyright } from './Copyright'; | ||||||
| 
 | 
 | ||||||
| export const Footer: FC = () => { | export const Footer: FC = () => { | ||||||
|  |   const { t } = useTranslation('~', { | ||||||
|  |     keyPrefix: 'dry-wash.landing.footer.links', | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   const listData = [ | ||||||
|  |     { to: '#', label: t('privacy-policy') }, | ||||||
|  |     { to: '#', label: t('service-terms') }, | ||||||
|  |     { to: '#', label: t('faq')  }, | ||||||
|  |   ]; | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <PageSection as='footer' py={5} bg='gray.700' color='white'> |     <PageSection as='footer' py={5} bg='gray.700' color='white'> | ||||||
|       <SiteLogo /> |       <SiteLogo /> | ||||||
|       <Copyright /> |       <Copyright /> | ||||||
|       <List spacing={2}> |       <List spacing={2}> | ||||||
|         {[ |         {listData.map(({ to, label }, i) => ( | ||||||
|           { to: '#', label: 'Политика конфиденциальности' }, |  | ||||||
|           { to: '#', label: 'Условия обслуживания' }, |  | ||||||
|           { to: '#', label: 'FAQ' }, |  | ||||||
|         ].map(({ to, label }, i) => ( |  | ||||||
|           <ListItem key={i}> |           <ListItem key={i}> | ||||||
|             <Link as={RouterLink} to={to}> |             <Link as={RouterLink} to={to}> | ||||||
|               {label} |               {label} | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
| import { Box, Heading, Text, Center, VStack, BoxProps } from '@chakra-ui/react'; | import { Box, Heading, Text, Center, VStack, BoxProps } from '@chakra-ui/react'; | ||||||
| import { DemoVideoPosterImg } from '../../../assets/images'; | import { DemoVideoPosterImg } from '../../../assets/images'; | ||||||
| import { CtaButton, SiteLogo, PageSection } from '../'; | import { CtaButton, SiteLogo, PageSection } from '../'; | ||||||
| @ -6,6 +7,10 @@ import { CtaButton, SiteLogo, PageSection } from '../'; | |||||||
| type HeroSectionProps = Pick<BoxProps, 'flexShrink'>; | type HeroSectionProps = Pick<BoxProps, 'flexShrink'>; | ||||||
| 
 | 
 | ||||||
| export const HeroSection: FC<HeroSectionProps> = ({ flexShrink }) => { | export const HeroSection: FC<HeroSectionProps> = ({ flexShrink }) => { | ||||||
|  |   const { t } = useTranslation('~', { | ||||||
|  |     keyPrefix: 'dry-wash.landing.hero-section', | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Box flexShrink={flexShrink} as='header' pos='relative' zIndex={0}> |     <Box flexShrink={flexShrink} as='header' pos='relative' zIndex={0}> | ||||||
|       <Box |       <Box | ||||||
| @ -41,15 +46,14 @@ export const HeroSection: FC<HeroSectionProps> = ({ flexShrink }) => { | |||||||
|             color='white' |             color='white' | ||||||
|             __css={{ textWrap: 'balance' }} |             __css={{ textWrap: 'balance' }} | ||||||
|           > |           > | ||||||
|             Оживите свою поездку с помощью экологически чистого ухода! |             {t('headline')} | ||||||
|           </Heading> |           </Heading> | ||||||
|           <Text |           <Text | ||||||
|             textAlign='center' |             textAlign='center' | ||||||
|             __css={{ textWrap: 'balance' }} |             __css={{ textWrap: 'balance' }} | ||||||
|             color='white' |             color='white' | ||||||
|           > |           > | ||||||
|             Ощутите максимальное удобство сухой мойки автомобилей, созданной для |             {t('description')} | ||||||
|             того, чтобы планета стала чище |  | ||||||
|           </Text> |           </Text> | ||||||
|         </VStack> |         </VStack> | ||||||
|         <CtaButton size='lg' /> |         <CtaButton size='lg' /> | ||||||
|  | |||||||
| @ -1,10 +1,12 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
| import { Image } from '@chakra-ui/react'; | import { Image } from '@chakra-ui/react'; | ||||||
| import { LogoSvg } from '../../../assets/icons'; | import { LogoSvg } from '../../../assets/icons'; | ||||||
| 
 | 
 | ||||||
| export const SiteLogo: FC = () => { | export const SiteLogo: FC = () => { | ||||||
|   return <Image src={LogoSvg} alt='Логотип компании "Сухой мастер"' w={40} />; |   const { t } = useTranslation(); | ||||||
|  | 
 | ||||||
|  |   return <Image src={LogoSvg} alt={t('dry-wash.landing.site-logo')} w={40} />; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| // todo: add i18n for alt
 |  | ||||||
| // todo: replace Image by SVG React component
 | // todo: replace Image by SVG React component
 | ||||||
| @ -1,4 +1,5 @@ | |||||||
| import React, { FC, useEffect, useState } from 'react'; | import React, { FC, useEffect, useState } from 'react'; | ||||||
|  | import { useTranslation } from 'react-i18next'; | ||||||
| import { | import { | ||||||
|   Tab, |   Tab, | ||||||
|   TabList, |   TabList, | ||||||
| @ -7,13 +8,17 @@ import { | |||||||
|   Tabs, |   Tabs, | ||||||
|   Text, |   Text, | ||||||
| } from '@chakra-ui/react'; | } from '@chakra-ui/react'; | ||||||
| import { mockReviews } from '../../../../mocks/landing'; | import { mockReview } from '../../../../mocks/landing'; | ||||||
|  | import { Review } from '../../../../models'; | ||||||
| import { ReviewCard } from './ReviewCard'; | import { ReviewCard } from './ReviewCard'; | ||||||
| 
 | 
 | ||||||
| const reviewsCount = mockReviews.length; |  | ||||||
| const SLIDE_CHANGE_INTERVAL = 5000; | const SLIDE_CHANGE_INTERVAL = 5000; | ||||||
| 
 | 
 | ||||||
| export const ReviewsSlider: FC = () => { | export const ReviewsSlider: FC = () => { | ||||||
|  |   const { i18n } = useTranslation(); | ||||||
|  |   const mockReviews: Review.View[] = mockReview.getReviewsByLocale(i18n.language); | ||||||
|  |   const reviewsCount = mockReviews.length; | ||||||
|  | 
 | ||||||
|   const [activeTab, setActiveTab] = useState(0); |   const [activeTab, setActiveTab] = useState(0); | ||||||
|   const [isSlideShowStopped, setIsSlideShowStopped] = useState(false); |   const [isSlideShowStopped, setIsSlideShowStopped] = useState(false); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,15 +1,17 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
| import { | import { useTranslation } from 'react-i18next'; | ||||||
|   Heading, | import { Heading, HStack } from '@chakra-ui/react'; | ||||||
|   HStack, |  | ||||||
| } from '@chakra-ui/react'; |  | ||||||
| import { CtaButton, PageSection } from '../'; | import { CtaButton, PageSection } from '../'; | ||||||
| import { ReviewsSlider } from './ReviewsSlider'; | import { ReviewsSlider } from './ReviewsSlider'; | ||||||
| 
 | 
 | ||||||
| export const SocialProofSection: FC = () => { | export const SocialProofSection: FC = () => { | ||||||
|  |   const { t } = useTranslation('~', { | ||||||
|  |     keyPrefix: 'dry-wash.landing.social-proof-section', | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <PageSection> |     <PageSection> | ||||||
|       <Heading as='h2'>Нас выбирают</Heading> |       <Heading as='h2'>{t('heading')}</Heading> | ||||||
|       <ReviewsSlider /> |       <ReviewsSlider /> | ||||||
|       <HStack w='full' justify='flex-end'> |       <HStack w='full' justify='flex-end'> | ||||||
|         <CtaButton /> |         <CtaButton /> | ||||||
|  | |||||||
| @ -1,34 +1,2 @@ | |||||||
| type ReviewItem = { | export * as mockOrder from './order'; | ||||||
|   id: string; | export * as mockReview from './review'; | ||||||
|   firstname: string; |  | ||||||
|   lastname: string; |  | ||||||
|   picture: string; |  | ||||||
|   text: string; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const mockReviews: ReviewItem[] = [ |  | ||||||
|   { |  | ||||||
|     firstname: 'Анна', |  | ||||||
|     lastname: 'Смирнова', |  | ||||||
|     picture: 'https://img.freepik.com/free-photo/indoor-portrait-beautiful-freckled-woman-with-dark-curly-hair-wears-fashionable-striped-shirt-rejoices-day-off-isolated-white-wall-curly-satisfied-woman-stands-indoor-alone_273609-15765.jpg', |  | ||||||
|     text: "Недавно воспользовалась услугами сухой мойки автомобилей и осталась крайне удовлетворена. Процесс был проведён профессионально: сотрудники использовали качественные средства, которые не повредили лакокрасочное покрытие. Особенно впечатлила возможность мыть машину без воды, что не только экономит ресурсы, но и бережет окружающую среду. Рекомендую всем, кто заботится о своём автомобиле и экологии!" |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     firstname: 'Дмитрий', |  | ||||||
|     lastname: 'Петров', |  | ||||||
|     picture: 'https://img.freepik.com/free-photo/calm-handsome-curly-haired-boy-posing-isolated-light-grey-standing-still-looks-peaceful-wearing-casual-manner-youth-style-concept_176532-8831.jpg', |  | ||||||
|     text: "Как же я рад, что нашел эту сухую мойку! Моя машина сияет, как новенькая! 🌟 Сначала был скептически настроен, думал, как же без воды можно отмыть всё это? Но результат превзошёл все ожидания! Ветеринар мойки профессионально подошёл к делу, и она теперь выглядит потрясающе. Если вы хотите, чтобы ваш автомобиль всегда выглядел на 100%, обязательно попробуйте!" |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     firstname: 'Алексей', |  | ||||||
|     lastname: 'Сидоров', |  | ||||||
|     picture: 'https://img.freepik.com/free-photo/waist-up-portrait-handsome-serious-unshaven-male-keeps-hands-together-dressed-dark-blue-shirt-has-talk-with-interlocutor-stands-against-white-wall-self-confident-man-freelancer_273609-16320.jpg', |  | ||||||
|     text: "Сухая мойка автомобилей - интересное решение, которое я опробовал недавно. В целом остался доволен качеством работы. Однако, не все загрязнения удалось удалить с первого раза, но сотрудник предложил дополнительные услуги, что меня устроило. Плюс, большое внимание уделили защите поверхности, что тоже немаловажно. Думаю, в следующий раз снова воспользуюсь этой услугой." |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     firstname: 'Екатерина', |  | ||||||
|     lastname: 'Иванова', |  | ||||||
|     picture: 'https://img.freepik.com/free-photo/portrait-young-blonde-woman-with-plait-polka-dot-blouse_273609-10490.jpg', |  | ||||||
|     text: "К сожалению, мой опыт с сухой мойкой автомобилей оказался неудачным. Ожидала увидеть чистую машину после процедуры, но многие участки остались незаделанными. Кроме того, процедура заняла больше времени, чем мне обещали. Возможно, в этом конкретном центре что-то пошло не так, но я бы не стала повторно обращаться за этой услугой." |  | ||||||
|   }, |  | ||||||
| ].map((data, i) => ({ id: `review${i}`, ...data })); |  | ||||||
							
								
								
									
										7
									
								
								src/mocks/landing/order.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/mocks/landing/order.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | |||||||
|  | import { Order } from "../../models"; | ||||||
|  | 
 | ||||||
|  | export const orders: Order.View[] = [ | ||||||
|  |   { id: 'id1' }, | ||||||
|  |   { id: 'id2' }, | ||||||
|  |   { id: 'id3' }, | ||||||
|  | ]; | ||||||
							
								
								
									
										64
									
								
								src/mocks/landing/review.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/mocks/landing/review.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,64 @@ | |||||||
|  | import { LANGUAGES, Review } from "../../models"; | ||||||
|  | 
 | ||||||
|  | const addId = (items) => items.map((data, i) => ({ id: `review${i}`, ...data })); | ||||||
|  | 
 | ||||||
|  | const enReviews: Review.View[] = addId([ | ||||||
|  |   { | ||||||
|  |     firstname: 'Anna', | ||||||
|  |     lastname: 'Smirnova', | ||||||
|  |     picture: 'https://img.freepik.com/free-photo/indoor-portrait-beautiful-freckled-woman-with-dark-curly-hair-wears-fashionable-striped-shirt-rejoices-day-off-isolated-white-wall-curly-satisfied-woman-stands-indoor-alone_273609-15765.jpg', | ||||||
|  |     text: "I recently used the services of a dry car wash and was extremely satisfied. The process was carried out professionally: the staff used high-quality products that did not damage the paintwork. I was especially impressed by the ability to wash the car without water, which not only saves resources, but also protects the environment. I recommend it to everyone who cares about their car and the environment!" | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     firstname: 'Dmitry', | ||||||
|  |     lastname: 'Petrov', | ||||||
|  |     picture: 'https://img.freepik.com/free-photo/calm-handsome-curly-haired-boy-posing-isolated-light-grey-standing-still-looks-peaceful-wearing-casual-manner-youth-style-concept_176532-8831.jpg', | ||||||
|  |     text: "I'm so glad I found this dry sink! My car is shining like new! 🌟 At first I was skeptical, I thought, how can you wash all this without water? But the result exceeded all expectations! Moika's veterinarian took a professional approach to the case, and she now looks amazing. If you want your car to always look 100%, be sure to try it!" | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     firstname: 'Alexey', | ||||||
|  |     lastname: 'Sidorov', | ||||||
|  |     picture: 'https://img.freepik.com/free-photo/waist-up-portrait-handsome-serious-unshaven-male-keeps-hands-together-dressed-dark-blue-shirt-has-talk-with-interlocutor-stands-against-white-wall-self-confident-man-freelancer_273609-16320.jpg', | ||||||
|  |     text: "Dry car washing is an interesting solution that I tried out recently. Overall, I was satisfied with the quality of the work. However, not all the impurities were removed the first time, but the employee offered additional services, which suited me. Plus, a lot of attention was paid to surface protection, which is also important. I think I'll use this service again next time." | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     firstname: 'Ekaterina', | ||||||
|  |     lastname: 'Ivanova', | ||||||
|  |     picture: 'https://img.freepik.com/free-photo/portrait-young-blonde-woman-with-plait-polka-dot-blouse_273609-10490.jpg', | ||||||
|  |     text: "Unfortunately, my experience with dry car washing turned out to be unsuccessful. I expected to see a clean car after the procedure, but many areas remained undone. Besides, the procedure took longer than I was promised. Perhaps something went wrong in this particular center, but I would not apply for this service again." | ||||||
|  |   }, | ||||||
|  | ]); | ||||||
|  | 
 | ||||||
|  | const ruReviews: Review.View[] = addId([ | ||||||
|  |   { | ||||||
|  |     firstname: 'Анна', | ||||||
|  |     lastname: 'Смирнова', | ||||||
|  |     picture: 'https://img.freepik.com/free-photo/indoor-portrait-beautiful-freckled-woman-with-dark-curly-hair-wears-fashionable-striped-shirt-rejoices-day-off-isolated-white-wall-curly-satisfied-woman-stands-indoor-alone_273609-15765.jpg', | ||||||
|  |     text: "Недавно воспользовалась услугами сухой мойки автомобилей и осталась крайне удовлетворена. Процесс был проведён профессионально: сотрудники использовали качественные средства, которые не повредили лакокрасочное покрытие. Особенно впечатлила возможность мыть машину без воды, что не только экономит ресурсы, но и бережет окружающую среду. Рекомендую всем, кто заботится о своём автомобиле и экологии!" | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     firstname: 'Дмитрий', | ||||||
|  |     lastname: 'Петров', | ||||||
|  |     picture: 'https://img.freepik.com/free-photo/calm-handsome-curly-haired-boy-posing-isolated-light-grey-standing-still-looks-peaceful-wearing-casual-manner-youth-style-concept_176532-8831.jpg', | ||||||
|  |     text: "Как же я рад, что нашел эту сухую мойку! Моя машина сияет, как новенькая! 🌟 Сначала был скептически настроен, думал, как же без воды можно отмыть всё это? Но результат превзошёл все ожидания! Ветеринар мойки профессионально подошёл к делу, и она теперь выглядит потрясающе. Если вы хотите, чтобы ваш автомобиль всегда выглядел на 100%, обязательно попробуйте!" | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     firstname: 'Алексей', | ||||||
|  |     lastname: 'Сидоров', | ||||||
|  |     picture: 'https://img.freepik.com/free-photo/waist-up-portrait-handsome-serious-unshaven-male-keeps-hands-together-dressed-dark-blue-shirt-has-talk-with-interlocutor-stands-against-white-wall-self-confident-man-freelancer_273609-16320.jpg', | ||||||
|  |     text: "Сухая мойка автомобилей - интересное решение, которое я опробовал недавно. В целом остался доволен качеством работы. Однако, не все загрязнения удалось удалить с первого раза, но сотрудник предложил дополнительные услуги, что меня устроило. Плюс, большое внимание уделили защите поверхности, что тоже немаловажно. Думаю, в следующий раз снова воспользуюсь этой услугой." | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     firstname: 'Екатерина', | ||||||
|  |     lastname: 'Иванова', | ||||||
|  |     picture: 'https://img.freepik.com/free-photo/portrait-young-blonde-woman-with-plait-polka-dot-blouse_273609-10490.jpg', | ||||||
|  |     text: "К сожалению, мой опыт с сухой мойкой автомобилей оказался неудачным. Ожидала увидеть чистую машину после процедуры, но многие участки остались незаделанными. Кроме того, процедура заняла больше времени, чем мне обещали. Возможно, в этом конкретном центре что-то пошло не так, но я бы не стала повторно обращаться за этой услугой." | ||||||
|  |   }, | ||||||
|  | ]); | ||||||
|  | 
 | ||||||
|  | const reviewsMapByLocale: Record<LANGUAGES, Review.View[]> = { | ||||||
|  |   [LANGUAGES.en]: enReviews, | ||||||
|  |   [LANGUAGES.ru]: ruReviews | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const getReviewsByLocale = (locale: string) => reviewsMapByLocale[locale]; | ||||||
							
								
								
									
										4
									
								
								src/models/i18n.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/models/i18n.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | |||||||
|  | export const enum LANGUAGES { | ||||||
|  |   en = 'en', | ||||||
|  |   ru = 'ru' | ||||||
|  | } | ||||||
							
								
								
									
										31
									
								
								src/models/i18next.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/models/i18next.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | |||||||
|  | import defaultLocale from '../../locales/ru.json'; | ||||||
|  | 
 | ||||||
|  | type Split<S extends string, D extends string> = | ||||||
|  |   S extends `${infer A}${D}${infer B}` ? [A, ...Split<B, D>] : [S]; | ||||||
|  | 
 | ||||||
|  | type NestedObject<T extends string[]> = | ||||||
|  |   T extends [infer Head, ...infer Tail] ? | ||||||
|  |     Head extends string ? | ||||||
|  |       { [key in Head]: NestedObject<Tail extends string[] ? Tail : []> } : never : | ||||||
|  |     string; | ||||||
|  | 
 | ||||||
|  | // Основная утилита для обработки union type
 | ||||||
|  | type CreateTree<T> = | ||||||
|  |   UnionToIntersection<T extends infer U ? | ||||||
|  |     U extends string ? | ||||||
|  |       NestedObject<Split<U, '.'>> : never : never>; | ||||||
|  | 
 | ||||||
|  | // Утилита для объединения типов
 | ||||||
|  | type UnionToIntersection<U> = | ||||||
|  |   // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||||
|  |   (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; | ||||||
|  | 
 | ||||||
|  | type LanguageResource = CreateTree<keyof typeof defaultLocale>; | ||||||
|  | 
 | ||||||
|  | declare module "i18next" { | ||||||
|  |   interface CustomTypeOptions { | ||||||
|  |     resources: { | ||||||
|  |       '~': LanguageResource | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								src/models/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/models/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | export * from './i18n'; | ||||||
|  | export * as Order from './order'; | ||||||
|  | export * as Review from './review'; | ||||||
							
								
								
									
										5
									
								
								src/models/order.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/models/order.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | |||||||
|  | export type Id = string; | ||||||
|  | 
 | ||||||
|  | export type View = { | ||||||
|  |   id: Id; | ||||||
|  | }; | ||||||
							
								
								
									
										9
									
								
								src/models/review.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/models/review.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | |||||||
|  | export type Id = string; | ||||||
|  | 
 | ||||||
|  | export type View = { | ||||||
|  |   id: Id; | ||||||
|  |   firstname: string; | ||||||
|  |   lastname: string; | ||||||
|  |   picture: URL['href']; | ||||||
|  |   text: string; | ||||||
|  | }; | ||||||
| @ -1,5 +1,5 @@ | |||||||
| import React, { FC } from 'react'; | import React, { FC } from 'react'; | ||||||
| import { Box, Container, VStack } from '@chakra-ui/react'; | import { Container, VStack } from '@chakra-ui/react'; | ||||||
| import { | import { | ||||||
|   BenefitsSection, |   BenefitsSection, | ||||||
|   Footer, |   Footer, | ||||||
| @ -19,14 +19,12 @@ const Page: FC = () => { | |||||||
|         bg='white' |         bg='white' | ||||||
|         centerContent |         centerContent | ||||||
|       > |       > | ||||||
|         <VStack w='full' h='full' alignItems='stretch'> |         <VStack w='full' h='full' alignItems='stretch' flexGrow={1}> | ||||||
|           <HeroSection flexShrink={0} /> |           <HeroSection flexShrink={0} /> | ||||||
|           <Box flexGrow={1}> |           <VStack as='main' flexGrow={1}> | ||||||
|             <VStack as='main'> |  | ||||||
|             <BenefitsSection /> |             <BenefitsSection /> | ||||||
|             <SocialProofSection /> |             <SocialProofSection /> | ||||||
|           </VStack> |           </VStack> | ||||||
|           </Box> |  | ||||||
|           <Footer /> |           <Footer /> | ||||||
|         </VStack> |         </VStack> | ||||||
|       </Container> |       </Container> | ||||||
|  | |||||||
| @ -1,7 +1,20 @@ | |||||||
| import React from "react"; | import React from 'react'; | ||||||
|  | import { Link as RouterLink } from 'react-router-dom'; | ||||||
|  | import { Button } from '@chakra-ui/react'; | ||||||
|  | import { URLs } from '../../__data__/urls'; | ||||||
|  | import { mockOrder } from '../../mocks/landing'; | ||||||
| 
 | 
 | ||||||
| const Page = () => { | const Page = () => { | ||||||
|   return <h1>Order form </h1>; |   return ( | ||||||
|  |     <> | ||||||
|  |       <h1>Order form</h1> | ||||||
|  |       {mockOrder.orders.map(({ id }) => ( | ||||||
|  |         <Button key={id} as={RouterLink} to={URLs.orderView.getUrl(id)}> | ||||||
|  |           Посмотреть заказ {id} | ||||||
|  |         </Button> | ||||||
|  |       ))} | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default Page; | export default Page; | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ import React, { lazy, Suspense } from 'react'; | |||||||
| import { Routes, Route } from 'react-router-dom'; | import { Routes, Route } from 'react-router-dom'; | ||||||
| import { PageSpinner } from './components'; | import { PageSpinner } from './components'; | ||||||
| import Arm from './pages/arm'; | import Arm from './pages/arm'; | ||||||
|  | import { URLs } from './__data__/urls'; | ||||||
| import NotFound from './pages/notFound/notFound'; | import NotFound from './pages/notFound/notFound'; | ||||||
| 
 | 
 | ||||||
| const Landing = lazy(() => import('./pages/landing')); | const Landing = lazy(() => import('./pages/landing')); | ||||||
| @ -12,11 +13,9 @@ const Routers = () => { | |||||||
|   return ( |   return ( | ||||||
|     <Suspense fallback={<PageSpinner />}> |     <Suspense fallback={<PageSpinner />}> | ||||||
|       <Routes> |       <Routes> | ||||||
|         <Route path='/dry-wash'> |         <Route path={URLs.landing.url} element={<Landing />} /> | ||||||
|           <Route index element={<Landing />} /> |         <Route path={URLs.orderForm.url} element={<OrderForm />} /> | ||||||
|           <Route path='order-form' element={<OrderForm />} /> |         <Route path={URLs.orderView.url} element={<OrderView />} /> | ||||||
|           <Route path='order-view' element={<OrderView />} /> |  | ||||||
|         </Route> |  | ||||||
|         <Route path='/dry-wash/arm/*' element={<Arm />}></Route> |         <Route path='/dry-wash/arm/*' element={<Arm />}></Route> | ||||||
|         <Route path='*' element={<NotFound />} /> |         <Route path='*' element={<NotFound />} /> | ||||||
|       </Routes> |       </Routes> | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user