├── .nvmrc ├── .npmrc ├── src ├── react-app-env.d.ts ├── components │ ├── Content │ │ ├── styles.module.css │ │ └── index.tsx │ ├── EventsList │ │ ├── constants.ts │ │ ├── Skeleton │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ └── style.module.css │ ├── RubricTitle │ │ ├── Skeleton │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── styles.module.css │ │ └── index.tsx │ ├── EventCardMain │ │ ├── Skeleton │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── style.module.css │ │ └── index.tsx │ ├── Checkbox │ │ ├── styles.module.css │ │ └── index.tsx │ ├── TextInput │ │ ├── styles.module.css │ │ └── index.tsx │ ├── MenuGeoLabel │ │ ├── down.svg │ │ ├── styles.module.css │ │ └── index.tsx │ ├── SelectionList │ │ ├── Skeleton │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── style.module.css │ │ └── index.tsx │ ├── ClearButton │ │ ├── styles.module.css │ │ └── index.tsx │ ├── DateFilter │ │ ├── Button │ │ │ ├── down.svg │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── Skeleton │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── styles.module.css │ │ └── index.tsx │ ├── GalleryModal │ │ ├── back.svg │ │ └── styles.module.css │ ├── YaMapModal │ │ ├── back.svg │ │ ├── loader.svg │ │ ├── Map.tsx │ │ ├── index.tsx │ │ └── styles.module.css │ ├── BackwardButton │ │ ├── backward.svg │ │ ├── styles.module.css │ │ └── index.tsx │ ├── YaMapWidget │ │ ├── styles.module.css │ │ └── index.tsx │ ├── ActionButton │ │ ├── styles.module.css │ │ └── index.tsx │ ├── EventCardVertical │ │ ├── Skeleton │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── style.module.css │ │ └── index.tsx │ ├── CollapsedText │ │ ├── styles.module.css │ │ └── index.tsx │ ├── EventCard │ │ ├── Skeleton │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── style.module.css │ │ └── index.tsx │ ├── StackNavigator │ │ ├── context.ts │ │ ├── hooks.ts │ │ ├── Screen │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ └── index.tsx │ ├── PageHeader │ │ ├── assets │ │ │ ├── search.svg │ │ │ └── menu-burger.svg │ │ ├── styles.module.css │ │ └── index.tsx │ ├── PriceLabel │ │ ├── styles.module.css │ │ └── index.tsx │ ├── AuthBox │ │ ├── styles.module.css │ │ ├── avatar.svg │ │ └── index.tsx │ ├── EventsSlider │ │ ├── style.module.css │ │ └── index.tsx │ ├── LoginButton │ │ ├── styles.module.css │ │ ├── index.tsx │ │ └── avatar.svg │ ├── Skeletons │ │ ├── styles.module.css │ │ └── index.tsx │ ├── Image │ │ ├── styles.module.css │ │ └── index.tsx │ ├── MenuNavigation │ │ ├── style.module.css │ │ └── index.tsx │ ├── TicketPrice │ │ └── index.tsx │ ├── LazyBlock │ │ └── index.tsx │ ├── LazyRender │ │ └── index.tsx │ ├── Gallery │ │ ├── styles.module.css │ │ └── index.tsx │ ├── CityModal │ │ └── styles.module.css │ ├── MenuModal │ │ ├── style.module.css │ │ └── index.tsx │ ├── CitySelect │ │ └── styles.module.css │ └── CityChange │ │ └── index.tsx ├── lib │ ├── api │ │ ├── fragments │ │ │ ├── metro.ts │ │ │ ├── paging.ts │ │ │ ├── tag-preview.ts │ │ │ ├── city-preview.ts │ │ │ ├── coordinates.ts │ │ │ ├── image-size.ts │ │ │ ├── schedule-preview.ts │ │ │ ├── event-main-image.ts │ │ │ ├── city.ts │ │ │ ├── ticket.ts │ │ │ ├── gallery-image.ts │ │ │ ├── event-schedule-info.ts │ │ │ ├── actual-events.ts │ │ │ ├── actual-event.ts │ │ │ ├── place-preview.ts │ │ │ ├── event-preview.ts │ │ │ ├── event.ts │ │ │ └── suggest.ts │ │ └── types.ts │ ├── date.ts │ ├── is-ios.ts │ ├── price.ts │ ├── checkout │ │ └── types │ │ │ ├── index.ts │ │ │ ├── checkout-options.ts │ │ │ ├── checkout-request.ts │ │ │ └── checkout-state.ts │ ├── maps.ts │ ├── js-api │ │ ├── push.ts │ │ ├── metrika.ts │ │ ├── utils.ts │ │ └── autofill.ts │ ├── error.ts │ ├── lazy.ts │ ├── url-builder.ts │ ├── metrika │ │ ├── install.js │ │ ├── event.ts │ │ ├── index.ts │ │ ├── ecommerce.ts │ │ └── types.ts │ ├── geolocation.ts │ ├── autofill.ts │ ├── global.d.ts │ ├── oauth.ts │ ├── payment.ts │ └── request.ts ├── assets │ ├── login-avatar.png │ └── login-avatar-2x.png ├── screens │ ├── EventScreen │ │ ├── components │ │ │ ├── ContentBlock │ │ │ │ ├── styles.module.css │ │ │ │ └── index.tsx │ │ │ ├── Title │ │ │ │ ├── styles.module.css │ │ │ │ └── index.tsx │ │ │ ├── VenueInfo │ │ │ │ ├── styles.module.css │ │ │ │ └── index.tsx │ │ │ ├── EventsFeed │ │ │ │ ├── Skeleton │ │ │ │ │ ├── styles.module.css │ │ │ │ │ └── index.tsx │ │ │ │ ├── styles.module.css │ │ │ │ └── index.tsx │ │ │ ├── RecommendedEvents │ │ │ │ ├── Skeleton │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── Component.tsx │ │ │ ├── CheckoutModal │ │ │ │ ├── components │ │ │ │ │ ├── CheckoutTextInput │ │ │ │ │ │ ├── styles.module.css │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── CheckoutEventInfo │ │ │ │ │ │ ├── styles.module.css │ │ │ │ │ │ └── index.tsx │ │ │ │ └── styles.module.css │ │ │ ├── Cover │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ └── TicketButton │ │ │ │ └── styles.module.css │ │ ├── styles.module.css │ │ ├── index.tsx │ │ └── Skeleton │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ ├── SearchScreen │ │ ├── Input │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── SearchResult │ │ │ ├── Skeleton │ │ │ │ ├── styles.module.css │ │ │ │ └── index.tsx │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── styles.module.css │ │ ├── PopularResult │ │ │ ├── Skeleton │ │ │ │ ├── styles.module.css │ │ │ │ └── index.tsx │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ └── index.tsx │ ├── MainScreen │ │ ├── Skeleton │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── Title │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ └── index.tsx │ ├── SelectionScreen │ │ ├── Skeleton │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ └── index.tsx │ ├── OrdersScreen │ │ ├── styles.module.css │ │ ├── Skeleton │ │ │ └── index.tsx │ │ ├── Order │ │ │ ├── Skeleton │ │ │ │ ├── styles.module.css │ │ │ │ └── index.tsx │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── Component.tsx │ │ └── index.tsx │ └── RubricScreen │ │ └── index.tsx ├── redux │ ├── actions.ts │ ├── slices │ │ ├── date-filter.ts │ │ ├── menu.ts │ │ ├── city-list.ts │ │ └── autofill.ts │ └── index.ts ├── hooks │ ├── usePrevious.ts │ ├── useMetrikaHit.ts │ ├── useThrottleLoading.ts │ ├── useVisibleOnce.ts │ ├── useWatchAuth.ts │ ├── usePaginatedList.ts │ ├── useInfiniteScroll.ts │ ├── useVisible.ts │ └── useScrollEffect.ts ├── index.css ├── PolyfillApp.tsx └── index.tsx ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── index.html └── api │ └── event │ ├── event-concert-id5.json │ ├── event-concert-id10.json │ └── event-concert-id6.json ├── docs ├── architecture.png └── Architecture.md ├── .editorconfig ├── AUTHORS ├── .gitignore ├── tsconfig.json ├── README.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.13.0 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=http://registry.npmjs.org 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/components/Content/styles.module.css: -------------------------------------------------------------------------------- 1 | .content { 2 | margin: 16px; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/EventsList/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_ITEMS_GAP = 18; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandex/miniapp-example/master/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandex/miniapp-example/master/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandex/miniapp-example/master/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /src/components/EventsList/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .events { 2 | display: grid; 3 | } 4 | -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandex/miniapp-example/master/docs/architecture.png -------------------------------------------------------------------------------- /src/lib/api/fragments/metro.ts: -------------------------------------------------------------------------------- 1 | export type Metro = { 2 | name: string; 3 | colors: string[]; 4 | }; 5 | -------------------------------------------------------------------------------- /src/lib/api/fragments/paging.ts: -------------------------------------------------------------------------------- 1 | export type Paging = { 2 | offset: number; 3 | total: number; 4 | }; 5 | -------------------------------------------------------------------------------- /src/lib/api/fragments/tag-preview.ts: -------------------------------------------------------------------------------- 1 | export type Tag = { 2 | name: string; 3 | code: string; 4 | }; 5 | -------------------------------------------------------------------------------- /src/assets/login-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandex/miniapp-example/master/src/assets/login-avatar.png -------------------------------------------------------------------------------- /src/screens/EventScreen/components/ContentBlock/styles.module.css: -------------------------------------------------------------------------------- 1 | .block { 2 | padding-bottom: 24px; 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/login-avatar-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandex/miniapp-example/master/src/assets/login-avatar-2x.png -------------------------------------------------------------------------------- /src/lib/api/fragments/city-preview.ts: -------------------------------------------------------------------------------- 1 | export type CityPreview = { 2 | name: string; 3 | geoid: number; 4 | }; 5 | -------------------------------------------------------------------------------- /src/lib/api/fragments/coordinates.ts: -------------------------------------------------------------------------------- 1 | export type Coordinates = { 2 | longitude: number; 3 | latitude: number; 4 | }; 5 | -------------------------------------------------------------------------------- /src/redux/actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@reduxjs/toolkit'; 2 | 3 | export const cleanup = createAction('cleanup'); 4 | -------------------------------------------------------------------------------- /src/components/RubricTitle/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | width: 60%; 3 | height: 20px; 4 | margin: 3px 0 21px; 5 | } 6 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/Input/styles.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | position: relative; 3 | 4 | width: 100%; 5 | height: 56px; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/api/fragments/image-size.ts: -------------------------------------------------------------------------------- 1 | export type MediaImageSize = { 2 | url: string; 3 | width: number; 4 | height: number; 5 | }; 6 | -------------------------------------------------------------------------------- /src/components/EventCardMain/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | width: 100%; 3 | height: 144px; 4 | 5 | border-radius: 8px; 6 | } 7 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/SearchResult/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | width: 40%; 3 | height: 20px; 4 | margin: 27px 0 19px; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Checkbox/styles.module.css: -------------------------------------------------------------------------------- 1 | .checkbox { 2 | margin-right: 10px; 3 | } 4 | 5 | .label { 6 | display: flex; 7 | align-content: center; 8 | } 9 | -------------------------------------------------------------------------------- /src/screens/MainScreen/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .content { 2 | margin: 0 16px; 3 | } 4 | 5 | .title { 6 | width: 60%; 7 | height: 32px; 8 | margin: 20px 0 16px; 9 | } 10 | -------------------------------------------------------------------------------- /src/screens/SelectionScreen/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .content { 2 | margin: 16px; 3 | } 4 | 5 | .title { 6 | width: 60%; 7 | height: 20px; 8 | margin: 0 0 18px; 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/lib/api/fragments/schedule-preview.ts: -------------------------------------------------------------------------------- 1 | export type SchedulePreview = { 2 | text: string | null; 3 | singleDate: { 4 | day: number; 5 | month: number; 6 | } | null; 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/api/fragments/event-main-image.ts: -------------------------------------------------------------------------------- 1 | import { MediaImageSize } from './image-size'; 2 | 3 | export type TouchPrimaryImage = { 4 | bgColor: string | null; 5 | touchPrimary: MediaImageSize; 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/date.ts: -------------------------------------------------------------------------------- 1 | import { format, parse } from 'date-fns'; 2 | 3 | export const dateToString = (date: Date) => format(date, 'YYYY-MM-DD').toString(); 4 | export const parseDate = (date: string) => parse(date); 5 | -------------------------------------------------------------------------------- /src/components/TextInput/styles.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | padding: 0; 3 | 4 | font-size: 15px; 5 | 6 | border-width: 0 10px; 7 | border-color: transparent; 8 | outline: none; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/MenuGeoLabel/down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/RubricTitle/styles.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | margin: 0 0 18px; 3 | 4 | font-size: 20px; 5 | font-weight: bold; 6 | line-height: 26px; 7 | 8 | opacity: .9; 9 | color: #202020; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/SelectionList/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .block { 2 | display: grid; 3 | grid-gap: 8px; 4 | } 5 | 6 | .item { 7 | width: 100%; 8 | padding-top: 50%; 9 | 10 | border-radius: 8px; 11 | } 12 | -------------------------------------------------------------------------------- /src/screens/MainScreen/Title/styles.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | margin: 20px 0 16px; 3 | 4 | font-size: 32px; 5 | font-weight: bold; 6 | line-height: 40px; 7 | 8 | opacity: .9; 9 | color: #202020; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ClearButton/styles.module.css: -------------------------------------------------------------------------------- 1 | .clear { 2 | padding: 0; 3 | 4 | font-family: 'Helvetica Neue', Arial, sans-serif; 5 | 6 | border: none; 7 | outline: none; 8 | background-color: transparent; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/DateFilter/Button/down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/Title/styles.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | margin-top: 0; 3 | margin-bottom: 16px; 4 | 5 | font-size: 20px; 6 | font-weight: bold; 7 | line-height: 26px; 8 | 9 | color: #1a1a1a; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/GalleryModal/back.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/YaMapModal/back.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/BackwardButton/backward.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/is-ios.ts: -------------------------------------------------------------------------------- 1 | export const isIOS = (): boolean => 2 | (/iPad|iPhone|iPod/.test(navigator.platform) || 3 | /iPad|iPhone|iPod/.test(navigator.userAgent) || 4 | (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) && 5 | !window.MSStream; 6 | -------------------------------------------------------------------------------- /src/screens/MainScreen/Title/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | const Title: React.FC = ({ children }) => { 6 | return

{children}

; 7 | }; 8 | 9 | export default Title; 10 | -------------------------------------------------------------------------------- /src/lib/api/fragments/city.ts: -------------------------------------------------------------------------------- 1 | export type MenuTag = { 2 | name: string; 3 | code: string; 4 | }; 5 | 6 | export type City = { 7 | name: string; 8 | geoid: number; 9 | longitude: number; 10 | latitude: number; 11 | eventsMenu: MenuTag[]; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/RubricTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | const RubricTitle: React.FC = ({ children }) => { 6 | return

{children}

; 7 | }; 8 | 9 | export default RubricTitle; 10 | -------------------------------------------------------------------------------- /src/components/EventsList/style.module.css: -------------------------------------------------------------------------------- 1 | .list { 2 | display: grid; 3 | grid-template-columns: 1fr; 4 | } 5 | 6 | .wrapper { 7 | display: flex; 8 | justify-content: center; 9 | 10 | margin-top: 16px; 11 | } 12 | 13 | .button { 14 | width: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/Title/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | const Title: React.FC = ({ children }) => { 6 | return

{children}

; 7 | }; 8 | 9 | export default Title; 10 | -------------------------------------------------------------------------------- /src/lib/api/fragments/ticket.ts: -------------------------------------------------------------------------------- 1 | export type Currency = 'rub' | 'usd'; 2 | 3 | export type Price = { 4 | currency: Currency; 5 | min: number | null; 6 | max: number | null; 7 | }; 8 | 9 | export type Ticket = { 10 | id: string | null; 11 | price: Price | null; 12 | }; 13 | -------------------------------------------------------------------------------- /src/lib/price.ts: -------------------------------------------------------------------------------- 1 | import { Currency } from './api/fragments/ticket'; 2 | 3 | const currencySymbol: { [key in Currency]: string } = { 4 | rub: '₽', 5 | usd: '$', 6 | }; 7 | 8 | export function getCurrencySymbol(currency: Currency) { 9 | return currencySymbol[currency]; 10 | } 11 | -------------------------------------------------------------------------------- /src/screens/OrdersScreen/styles.module.css: -------------------------------------------------------------------------------- 1 | .content { 2 | padding: 0 16px; 3 | } 4 | 5 | .empty { 6 | position: absolute; 7 | top: 56px; 8 | right: 0; 9 | bottom: 0; 10 | left: 0; 11 | 12 | display: flex; 13 | } 14 | 15 | .text { 16 | margin: auto; 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/api/fragments/gallery-image.ts: -------------------------------------------------------------------------------- 1 | import { MediaImageSize } from './image-size'; 2 | 3 | export type GalleryImage = { 4 | bgColor: string | null; 5 | thumbnail: MediaImageSize; 6 | thumbnail2x: MediaImageSize; 7 | large: MediaImageSize; 8 | large2x: MediaImageSize; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/YaMapWidget/styles.module.css: -------------------------------------------------------------------------------- 1 | .map { 2 | position: relative; 3 | 4 | width: 100vw; 5 | height: 88px; 6 | margin-bottom: 16px; 7 | margin-left: -12px; 8 | 9 | background-color: #fef33a; 10 | background-position: 50% 50%; 11 | background-size: cover; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/MenuGeoLabel/styles.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | padding: 0 0 8px; 3 | } 4 | 5 | .select { 6 | display: inline-block; 7 | 8 | padding-right: 21px; 9 | 10 | font-size: 16px; 11 | line-height: 24px; 12 | 13 | background: #fff url('./down.svg') 100% 50% no-repeat; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | export function usePrevious(value: T) { 4 | const previousValue = useRef(); 5 | 6 | useEffect(() => { 7 | previousValue.current = value; 8 | }, [value]); 9 | 10 | return previousValue.current; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/DateFilter/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .block { 2 | display: flex; 3 | overflow: hidden; 4 | 5 | padding: 23px 14px 4px; 6 | } 7 | 8 | .filter { 9 | flex: none; 10 | 11 | width: 89px; 12 | height: 40px; 13 | margin-right: 8px; 14 | 15 | border-radius: 20px; 16 | } 17 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | line-height: 1.15; 3 | } 4 | 5 | body { 6 | overflow: hidden; 7 | 8 | margin: 0; 9 | 10 | font-family: 'Helvetica Neue', Arial, sans-serif; 11 | 12 | background: #fff; 13 | -webkit-font-smoothing: antialiased; 14 | } 15 | 16 | h2 { 17 | margin: 0; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/api/fragments/event-schedule-info.ts: -------------------------------------------------------------------------------- 1 | import { PlacePreview } from './place-preview'; 2 | import { SchedulePreview } from './schedule-preview'; 3 | 4 | export type EventScheduleInfo = { 5 | oneOfPlaces: PlacePreview | null; 6 | placePreview: string | null; 7 | preview: SchedulePreview | null; 8 | }; 9 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/VenueInfo/styles.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | padding-bottom: 6px; 3 | 4 | font-size: 16px; 5 | font-weight: bold; 6 | line-height: 22px; 7 | } 8 | 9 | .address { 10 | font-size: 12px; 11 | line-height: 16px; 12 | 13 | color: rgba(0, 0, 0, .5); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/RubricTitle/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Text } from '../../../components/Skeletons'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | const RubricTitleSkeleton: React.FC = () => ; 8 | 9 | export default RubricTitleSkeleton; 10 | -------------------------------------------------------------------------------- /src/lib/api/fragments/actual-events.ts: -------------------------------------------------------------------------------- 1 | import { EventData } from './event'; 2 | import { Paging } from './paging'; 3 | import { ActualEvent } from './actual-event'; 4 | 5 | export type ActualEvents = { 6 | items: Array; 7 | paging: Paging; 8 | prefetchItems?: Array; 9 | }; 10 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/SearchResult/styles.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | margin: 24px 0 16px; 3 | 4 | font-size: 20px; 5 | font-weight: bold; 6 | line-height: 26px; 7 | } 8 | 9 | .notFound { 10 | margin: 20vh 0; 11 | 12 | font-size: 6vw; 13 | text-align: center; 14 | 15 | color: #999; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/EventCardMain/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Rect } from '../../Skeletons'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | const EventCardMainSkeleton: React.FC = () => { 8 | return ; 9 | }; 10 | 11 | export default EventCardMainSkeleton; 12 | -------------------------------------------------------------------------------- /src/components/ActionButton/styles.module.css: -------------------------------------------------------------------------------- 1 | .action { 2 | font-family: 'Helvetica Neue', Arial, sans-serif; 3 | 4 | border: none; 5 | border-radius: 100px; 6 | outline: none; 7 | background: #ffdb4d; 8 | } 9 | 10 | .invalid, 11 | .action[disabled] { 12 | color: rgb(128, 128, 128); 13 | background: rgb(242, 242, 242); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/EventCardVertical/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | width: 208px; 3 | } 4 | 5 | .image { 6 | height: 144px; 7 | 8 | border-radius: 12px; 9 | } 10 | 11 | .title { 12 | height: 16px; 13 | margin-top: 12px; 14 | } 15 | 16 | .description { 17 | width: 65%; 18 | height: 12px; 19 | margin-top: 6px; 20 | } 21 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The following authors have created the source code of "MiniApp Example" published and distributed by YANDEX LLC as the owner: 2 | 3 | Maxim Zemskov 4 | Aleksandr Bulanov 5 | Kirill Medvedev 6 | Alexander Vakhitov 7 | Alexander Belev 8 | -------------------------------------------------------------------------------- /src/components/BackwardButton/styles.module.css: -------------------------------------------------------------------------------- 1 | .backward-icon { 2 | display: inline-block; 3 | 4 | width: 16px; 5 | height: 14px; 6 | mask-image: url('backward.svg'); 7 | } 8 | 9 | .icon-white { 10 | background-color: #fff; 11 | } 12 | 13 | .icon-black { 14 | background-color: #444; 15 | } 16 | 17 | .backward-button { 18 | height: 15px; 19 | } 20 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/ContentBlock/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | const ContentBlock: React.FC<{ className?: string }> = ({ children, className }) => { 6 | return
{children}
; 7 | }; 8 | 9 | export default ContentBlock; 10 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/styles.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | align-items: center; 4 | 5 | box-sizing: border-box; 6 | height: 56px; 7 | padding: 18px 16px; 8 | 9 | box-shadow: 0 0 14px 0 rgba(0, 0, 0, .25); 10 | } 11 | 12 | .content { 13 | overflow-x: hidden; 14 | overflow-y: auto; 15 | 16 | padding: 0 18px 16px; 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/checkout/types/index.ts: -------------------------------------------------------------------------------- 1 | import { YandexCheckoutRequest } from './checkout-request'; 2 | 3 | declare global { 4 | interface Window { 5 | YandexCheckoutRequest?: YandexCheckoutRequest; 6 | } 7 | } 8 | 9 | export * from './checkout-request'; 10 | export * from './checkout-details'; 11 | export * from './checkout-options'; 12 | export * from './checkout-state'; 13 | -------------------------------------------------------------------------------- /src/screens/OrdersScreen/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import OrderSkeleton from '../Order/Skeleton'; 4 | 5 | import EventsListSkeleton from '../../../components/EventsList/Skeleton'; 6 | 7 | const OrdersScreenSkeleton: React.FC = () => { 8 | return ; 9 | }; 10 | 11 | export default OrdersScreenSkeleton; 12 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/EventsFeed/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .feed { 2 | margin-bottom: 24px; 3 | } 4 | 5 | .title { 6 | width: 80%; 7 | max-width: 275px; 8 | height: 20px; 9 | margin-bottom: 16px; 10 | } 11 | 12 | .events { 13 | display: flex; 14 | overflow: hidden; 15 | } 16 | 17 | .item { 18 | flex: none; 19 | 20 | margin-right: 12px; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Content/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | export type ContentProps = { 6 | className?: string; 7 | }; 8 | 9 | const Content: React.FC = props => { 10 | return
{props.children}
; 11 | }; 12 | 13 | export default Content; 14 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/PopularResult/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | width: 60%; 3 | height: 18px; 4 | margin: 28px 0 12px; 5 | } 6 | 7 | .event { 8 | height: 14px; 9 | margin: 26px 0; 10 | } 11 | 12 | .event:nth-child(1n) { 13 | width: 80%; 14 | } 15 | 16 | .event:nth-child(2n) { 17 | width: 50%; 18 | } 19 | 20 | .event:nth-child(3n) { 21 | width: 60%; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/CollapsedText/styles.module.css: -------------------------------------------------------------------------------- 1 | .short { 2 | display: -webkit-box; 3 | -webkit-box-orient: vertical; 4 | overflow: hidden; 5 | } 6 | 7 | .hidden { 8 | position: absolute; 9 | 10 | display: none; 11 | 12 | pointer-events: none; 13 | } 14 | 15 | .button { 16 | height: 28px; 17 | 18 | font-size: 13px; 19 | line-height: 28px; 20 | 21 | color: #04b; 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/api/fragments/actual-event.ts: -------------------------------------------------------------------------------- 1 | import { EventPreview } from './event-preview'; 2 | 3 | export type ActualEvent = { 4 | eventPreview: EventPreview; 5 | scheduleInfo: { 6 | placesTotal: number | null; 7 | preview: { 8 | singleDate: { 9 | day: string; 10 | month: string; 11 | } | null; 12 | } | null; 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/EventCard/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | display: flex; 3 | } 4 | 5 | .image { 6 | width: 152px; 7 | height: 100px; 8 | margin-right: 18px; 9 | 10 | border-radius: 8px; 11 | } 12 | 13 | .right { 14 | flex: 1; 15 | } 16 | 17 | .title { 18 | height: 16px; 19 | margin-bottom: 12px; 20 | } 21 | 22 | .description { 23 | width: 80%; 24 | height: 12px; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/YaMapModal/loader.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/StackNavigator/context.ts: -------------------------------------------------------------------------------- 1 | import { createRef, createContext, RefObject } from 'react'; 2 | 3 | type StackNavigatorContextType = { 4 | onBackward: () => void; 5 | isVisible: boolean; 6 | ref: RefObject; 7 | }; 8 | 9 | export const StackNavigatorContext = createContext({ 10 | onBackward: () => {}, 11 | isVisible: false, 12 | ref: createRef(), 13 | }); 14 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/EventsFeed/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | } 4 | 5 | .button { 6 | position: absolute; 7 | top: 0; 8 | right: 0; 9 | 10 | display: block; 11 | 12 | padding: 6px 14px; 13 | 14 | font-size: 12px; 15 | line-height: 16px; 16 | text-decoration: none; 17 | 18 | color: #fff; 19 | border-radius: 20px; 20 | background: #202020; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/PageHeader/assets/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/api/fragments/place-preview.ts: -------------------------------------------------------------------------------- 1 | import { CityPreview } from './city-preview'; 2 | import { Coordinates } from './coordinates'; 3 | import { Metro } from './metro'; 4 | 5 | export type PlacePreview = { 6 | id: string; 7 | title: string | null; 8 | address: string | null; 9 | type: { 10 | name: string; 11 | }; 12 | city: CityPreview | null; 13 | metro: Metro[]; 14 | coordinates: Coordinates | null; 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/DateFilter/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | overflow-x: scroll; 4 | -webkit-overflow-scrolling: touch; 5 | 6 | margin-bottom: -26px; /* Отрицательный отступ и большой паддинг снизу чтобы не обрезалась тень из-за overflow */ 7 | padding: 23px 14px 30px; 8 | overscroll-behavior-x: contain; 9 | scrollbar-width: none; 10 | } 11 | 12 | .container::-webkit-scrollbar { 13 | display: none; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/EventCardVertical/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Rect, Text } from '../../Skeletons'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | export const EventCardVerticalSkeleton: React.FC = () => ( 8 |
9 | 10 | 11 | 12 |
13 | ); 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # editors 26 | .idea 27 | .vscode 28 | -------------------------------------------------------------------------------- /src/components/PriceLabel/styles.module.css: -------------------------------------------------------------------------------- 1 | .price { 2 | position: absolute; 3 | z-index: 2; 4 | 5 | height: 28px; 6 | padding: 0 10px; 7 | 8 | font-size: 12px; 9 | font-weight: bold; 10 | line-height: 28px; 11 | 12 | color: #fff; 13 | border-radius: 20px; 14 | background: #202020; 15 | } 16 | 17 | .left { 18 | bottom: 10px; 19 | left: 10px; 20 | } 21 | 22 | .right { 23 | right: 14px; 24 | bottom: 14px; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/PageHeader/assets/menu-burger.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/AuthBox/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: inline-flex; 3 | } 4 | 5 | .image { 6 | display: block; 7 | overflow: hidden; 8 | 9 | width: 32px; 10 | height: 32px; 11 | 12 | border-radius: 50%; 13 | } 14 | 15 | .image img { 16 | border-radius: 50%; 17 | background-color: #fff; 18 | } 19 | 20 | .username { 21 | margin-left: 9px; 22 | 23 | font-size: 16px; 24 | font-weight: bold; 25 | line-height: 32px; 26 | } 27 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/PopularResult/styles.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | margin: 26px 0 0; 3 | 4 | font-size: 16px; 5 | font-weight: bold; 6 | line-height: 24px; 7 | 8 | color: #1a1a1a; 9 | } 10 | 11 | .events { 12 | margin: 10px 0 0; 13 | padding: 0; 14 | 15 | list-style: none; 16 | } 17 | 18 | .event { 19 | font-size: 14px; 20 | line-height: 40px; 21 | } 22 | 23 | .link { 24 | text-decoration: none; 25 | 26 | color: #000; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/TextInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | type Props = JSX.IntrinsicElements['input']; 6 | const TextInput = forwardRef(({ className, type, ...props }, ref) => { 7 | const cls = [styles.input, className].filter(Boolean).join(' '); 8 | 9 | return ; 10 | }); 11 | 12 | export default TextInput; 13 | -------------------------------------------------------------------------------- /src/lib/maps.ts: -------------------------------------------------------------------------------- 1 | type StaticMapUrlOptions = { 2 | size: [number, number]; 3 | z: number; 4 | ll?: [number, number]; 5 | pt?: [number, number, 'pm2rdl']; 6 | }; 7 | 8 | export function getStaticMapUrl(options: StaticMapUrlOptions) { 9 | const params = new URLSearchParams({ l: 'map' }); 10 | 11 | for (const [key, value] of Object.entries(options)) { 12 | params.set(key, String(value)); 13 | } 14 | 15 | return `//static-maps.yandex.ru/1.x/?${params}`; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/DateFilter/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Rect } from '../../Skeletons'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | const DateFilterSkeleton: React.FC = () => ( 8 |
9 | {Array(2) 10 | .fill(0) 11 | .map((_, index) => ( 12 | 13 | ))} 14 |
15 | ); 16 | 17 | export default DateFilterSkeleton; 18 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/RecommendedEvents/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { EventsFeedSkeleton } from '../../EventsFeed/Skeleton'; 4 | 5 | const RecommendedEventsSkeleton: React.FC = () => { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default RecommendedEventsSkeleton; 17 | -------------------------------------------------------------------------------- /src/components/DateFilter/Button/styles.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | height: 40px; 3 | margin-right: 8px; 4 | padding: 0 20px; 5 | 6 | font-size: 14px; 7 | font-weight: 500; 8 | line-height: 40px; 9 | white-space: nowrap; 10 | 11 | border-radius: 20px; 12 | box-shadow: 0 5px 20px rgba(193, 193, 193, .4); 13 | } 14 | 15 | .button.active { 16 | color: #f00; 17 | } 18 | 19 | .button.tip { 20 | padding-right: 36px; 21 | 22 | background: url('./down.svg') 62px 19px no-repeat; 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/useMetrikaHit.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useScreenVisible } from '../components/StackNavigator'; 4 | import { hit } from '../lib/metrika'; 5 | import { YMetrikaVisitParams } from '../lib/metrika/types'; 6 | 7 | export function useMetrikaHit(params?: YMetrikaVisitParams) { 8 | const isVisible = useScreenVisible(); 9 | 10 | useEffect(() => { 11 | if (isVisible) { 12 | hit(window.location.href, { params }); 13 | } 14 | }, [isVisible, params]); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/ClearButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | export type Props = { 6 | className?: string; 7 | onClick: () => void; 8 | }; 9 | const ClearButton: React.FC = ({ className, children, onClick }) => { 10 | const cls = [styles.clear, className].filter(Boolean).join(' '); 11 | return ( 12 | 15 | ); 16 | }; 17 | 18 | export default ClearButton; 19 | -------------------------------------------------------------------------------- /src/components/EventsSlider/style.module.css: -------------------------------------------------------------------------------- 1 | .list { 2 | display: flex; 3 | overflow-x: scroll; 4 | -webkit-overflow-scrolling: touch; 5 | flex-direction: row; 6 | 7 | scroll-snap-type: x mandatory; 8 | overscroll-behavior-x: contain; 9 | scrollbar-width: none; 10 | } 11 | 12 | .list::-webkit-scrollbar { 13 | display: none; 14 | } 15 | 16 | .item { 17 | flex: none; 18 | 19 | margin-left: 12px; 20 | scroll-snap-align: center; 21 | } 22 | 23 | .item:first-child { 24 | margin-left: 0; 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/js-api/push.ts: -------------------------------------------------------------------------------- 1 | import { callJsApi } from './utils'; 2 | 3 | export type YandexPushTokenForTransaction = { 4 | jwtToken: string; 5 | pushToken: string; 6 | }; 7 | 8 | export function getPushTokenForTransaction(paymentToken: string): Promise { 9 | return callJsApi({ 10 | name: 'window.yandex.app.push.getPushTokenForTransaction', 11 | args: [paymentToken], 12 | scope: window?.yandex?.app?.push, 13 | method: 'getPushTokenForTransaction', 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/checkout/types/checkout-options.ts: -------------------------------------------------------------------------------- 1 | export type YandexCheckoutOptions = { 2 | // Показывается рядом с суммой заказа. 3 | // По умолчанию: домен страницы, на которой открыт чекаут. 4 | shopName?: string; 5 | 6 | // Показывается рядом названием магазина. Рекомендуемый размер 48x48. 7 | // По умолчанию: favicon страницы, на которой открыт чекаут. 8 | shopIcon?: string; 9 | 10 | // Используется в случаях, если на одном домене работает несколько разных магазинов. 11 | // По умолчанию: `/` 12 | baseUrl?: string; 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/LoginButton/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: inline-flex; 3 | } 4 | 5 | .container::before { 6 | display: block; 7 | 8 | width: 24px; 9 | height: 24px; 10 | 11 | content: ''; 12 | 13 | background: url('avatar.svg') center center no-repeat; 14 | background-size: contain; 15 | } 16 | 17 | .button { 18 | margin-left: 9px; 19 | padding: 0; 20 | 21 | font-size: 16px; 22 | font-weight: bold; 23 | line-height: 24px; 24 | 25 | border: none; 26 | outline: none; 27 | background: none; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/PriceLabel/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Ticket } from '../../lib/api/fragments/ticket'; 4 | import TicketPrice from '../TicketPrice'; 5 | 6 | import styles from './styles.module.css'; 7 | 8 | type Props = { 9 | ticket: Ticket; 10 | position?: 'left' | 'right'; 11 | }; 12 | 13 | const PriceLabel: React.FC = props => ( 14 |
15 | 16 |
17 | ); 18 | 19 | export default PriceLabel; 20 | -------------------------------------------------------------------------------- /src/screens/OrdersScreen/Order/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | display: flex; 3 | justify-content: space-between; 4 | 5 | box-sizing: border-box; 6 | height: 92px; 7 | padding: 16px; 8 | } 9 | 10 | .image { 11 | flex: 0 0 48px; 12 | 13 | height: 60px; 14 | 15 | border-radius: 8px; 16 | } 17 | 18 | .content { 19 | width: 100%; 20 | margin: 0 16px; 21 | } 22 | 23 | .right { 24 | flex: 0 0 75px; 25 | } 26 | 27 | .short { 28 | width: 60%; 29 | margin: 4px 0; 30 | } 31 | 32 | .right .short { 33 | margin-left: auto; 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | export type Props = JSX.IntrinsicElements['input'] & { 6 | label?: string; 7 | }; 8 | const Checkbox: React.FC = ({ className, label, ...props }) => { 9 | const cls = [styles.checkbox, className].filter(Boolean).join(' '); 10 | 11 | return ( 12 | 16 | ); 17 | }; 18 | 19 | export default Checkbox; 20 | -------------------------------------------------------------------------------- /src/components/EventCard/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Rect, Text } from '../../Skeletons'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | const EventCardSkeleton: React.FC = () => { 8 | return ( 9 |
10 | 11 |
12 | 13 | 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default EventCardSkeleton; 20 | -------------------------------------------------------------------------------- /src/components/YaMapModal/Map.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Map as YMap, Placemark, YMaps } from 'react-yandex-maps'; 3 | 4 | type Props = { 5 | point: [number, number]; 6 | }; 7 | 8 | const Map: React.FC = ({ point }) => { 9 | const mapData = useMemo(() => ({ center: point, zoom: 15 }), [point]); 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Map; 21 | -------------------------------------------------------------------------------- /src/lib/error.ts: -------------------------------------------------------------------------------- 1 | export enum AppErrorCode { 2 | OauthAccessDenied = 'oauth_access_denied', 3 | OauthUnauthorizedClient = 'oauth_unauthorized_client', 4 | JsApiDenied = 'js_api_denied', 5 | JsApiCancelled = 'js_api_cancelled', 6 | JsApiAlreadyShown = 'js_api_already_shown', 7 | JsApiMethodNotAvailable = 'js_api_method_not_available', 8 | } 9 | 10 | export class AppError extends Error { 11 | public code: AppErrorCode; 12 | 13 | constructor(code: AppErrorCode, message: string) { 14 | super(message); 15 | 16 | this.code = code; 17 | this.name = 'AppError'; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/SelectionList/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Rect } from '../../Skeletons'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | const SelectionListSkeleton: React.FC<{ className?: string }> = ({ className }) => { 8 | return ( 9 |
10 | {Array(5) 11 | .fill(0) 12 | .map((_, index) => ( 13 | 14 | ))} 15 |
16 | ); 17 | }; 18 | 19 | export default SelectionListSkeleton; 20 | -------------------------------------------------------------------------------- /src/components/StackNavigator/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react'; 2 | 3 | import { StackNavigatorContext } from './context'; 4 | import { BackwardProps } from './StackNavigator'; 5 | 6 | export function useBackward(params?: BackwardProps) { 7 | const onBackward = useContext(StackNavigatorContext).onBackward; 8 | 9 | return useMemo(() => onBackward.bind(null, params), [onBackward, params]); 10 | } 11 | 12 | export function useScreenVisible() { 13 | return useContext(StackNavigatorContext).isVisible; 14 | } 15 | 16 | export function useScreenRef() { 17 | return useContext(StackNavigatorContext).ref; 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react", 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Skeletons/styles.module.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --skeleton-color: #eee; 3 | --skeleton-animation: loading 1.5s ease-in-out infinite; 4 | } 5 | 6 | .rect { 7 | background-color: var(--skeleton-color); 8 | 9 | animation: var(--skeleton-animation); 10 | } 11 | 12 | .text { 13 | height: 12px; 14 | 15 | border-radius: 26px; 16 | background-color: var(--skeleton-color); 17 | 18 | animation: var(--skeleton-animation); 19 | } 20 | 21 | @keyframes loading { 22 | 0% { 23 | opacity: 1; 24 | } 25 | 26 | 50% { 27 | opacity: .4; 28 | } 29 | 30 | 100% { 31 | opacity: 1; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/LoginButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import { useDispatch } from 'react-redux'; 4 | 5 | import { login } from '../../redux/slices/user'; 6 | 7 | import styles from './styles.module.css'; 8 | 9 | const LoginButton: React.FC = () => { 10 | const dispatch = useDispatch(); 11 | 12 | const onLoginClick = useCallback(() => { 13 | dispatch(login()); 14 | }, [dispatch]); 15 | 16 | return ( 17 |
18 | 19 |
20 | ); 21 | }; 22 | 23 | export default LoginButton; 24 | -------------------------------------------------------------------------------- /src/lib/js-api/metrika.ts: -------------------------------------------------------------------------------- 1 | import { AppErrorCode } from '../error'; 2 | import { callJsApi } from './utils'; 3 | 4 | export async function reportGoalReached(goal: string, params: object): Promise { 5 | try { 6 | await callJsApi({ 7 | name: 'window.yandex.app.reportGoalReached', 8 | args: [goal, params], 9 | scope: window?.yandex?.app, 10 | method: 'reportGoalReached', 11 | }); 12 | } catch (err) { 13 | const { code } = err; 14 | 15 | if (code === AppErrorCode.JsApiMethodNotAvailable) { 16 | return; 17 | } 18 | 19 | console.error(err); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Image/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | } 4 | 5 | .image { 6 | width: 100%; 7 | height: 100%; 8 | 9 | color: #fff; 10 | object-fit: cover; 11 | } 12 | 13 | .stub { 14 | position: absolute; 15 | top: 0; 16 | right: 0; 17 | bottom: 0; 18 | left: 0; 19 | 20 | display: block; 21 | 22 | content: ''; 23 | pointer-events: none; 24 | 25 | opacity: 1; 26 | } 27 | 28 | .stub-hidden { 29 | opacity: 0; 30 | 31 | animation: fadeIn .4s ease forwards; 32 | } 33 | 34 | @keyframes fadeIn { 35 | from { 36 | opacity: 1; 37 | } 38 | 39 | to { 40 | opacity: 0; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/MenuNavigation/style.module.css: -------------------------------------------------------------------------------- 1 | .list { 2 | margin: 0; 3 | padding: 0; 4 | 5 | list-style: none; 6 | 7 | font-size: 16px; 8 | } 9 | 10 | .item { 11 | display: flex; 12 | justify-content: stretch; 13 | align-items: stretch; 14 | 15 | height: 55px; 16 | 17 | line-height: 55px; 18 | 19 | border-top: 1px solid #efefef; 20 | } 21 | 22 | .item:first-child { 23 | border-top: none; 24 | } 25 | 26 | .orders-text { 27 | font-weight: 500; 28 | } 29 | 30 | .link { 31 | display: block; 32 | 33 | width: 100%; 34 | 35 | text-decoration: none; 36 | 37 | color: rgba(0, 0, 0, .8); 38 | } 39 | 40 | .active-item { 41 | color: #000; 42 | } 43 | -------------------------------------------------------------------------------- /src/PolyfillApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import App from './App'; 4 | 5 | const polyfillPromises: Promise[] = []; 6 | 7 | if (!('IntersectionObserver' in window)) { 8 | // @ts-ignore 9 | polyfillPromises.push(import('intersection-observer')); 10 | } 11 | 12 | const PolyfillApp: React.FC = () => { 13 | const [canRender, setCanRender] = useState(Boolean(!polyfillPromises.length)); 14 | 15 | useEffect(() => { 16 | if (polyfillPromises.length) { 17 | Promise.all(polyfillPromises).then(() => setCanRender(true)); 18 | } 19 | }, []); 20 | 21 | return <>{canRender && }; 22 | }; 23 | 24 | export default PolyfillApp; 25 | -------------------------------------------------------------------------------- /src/components/EventsList/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentType } from 'react'; 2 | 3 | import { DEFAULT_ITEMS_GAP } from '../constants'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | type Props = { 8 | count: number; 9 | card: ComponentType; 10 | itemsGap?: number; 11 | }; 12 | 13 | const EventsListSkeleton: React.FC = ({ itemsGap = DEFAULT_ITEMS_GAP, count, card: Card }) => ( 14 |
15 | {Array(count) 16 | .fill(0) 17 | .map((_, index) => ( 18 | 19 | ))} 20 |
21 | ); 22 | 23 | export default EventsListSkeleton; 24 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/PopularResult/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Text } from '../../../../components/Skeletons'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | type Props = { 8 | showTitle?: boolean; 9 | }; 10 | 11 | const PopularResultSkeleton: React.FC = ({ showTitle }) => ( 12 | <> 13 | {showTitle && } 14 | 15 |
16 | {Array(15) 17 | .fill(0) 18 | .map((_, index) => ( 19 | 20 | ))} 21 |
22 | 23 | ); 24 | 25 | export default PopularResultSkeleton; 26 | -------------------------------------------------------------------------------- /src/components/DateFilter/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | type Props = { 6 | onClick: () => void; 7 | isActive?: boolean; 8 | tip?: boolean; 9 | }; 10 | 11 | const DateFilterButton: React.FC = props => { 12 | const classNames = [styles.button]; 13 | 14 | if (props.isActive) { 15 | classNames.push(styles.active); 16 | } 17 | 18 | if (props.tip) { 19 | classNames.push(styles.tip); 20 | } 21 | 22 | return ( 23 |
24 | {props.children} 25 |
26 | ); 27 | }; 28 | 29 | export default DateFilterButton; 30 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/CheckoutModal/components/CheckoutTextInput/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: block; 3 | } 4 | 5 | .label { 6 | font-size: 13px; 7 | line-height: 16px; 8 | 9 | color: rgba(0, 0, 0, .4); 10 | } 11 | 12 | .input { 13 | display: block; 14 | 15 | width: 100%; 16 | height: 34px; 17 | margin: -3px 0 0; 18 | 19 | font-size: 16px; 20 | font-weight: normal; 21 | font-style: normal; 22 | line-height: 34px; 23 | 24 | color: rgba(0, 0, 0, .8); 25 | border: none; 26 | border-bottom: 1px solid rgb(207, 207, 207); 27 | background: none; 28 | } 29 | 30 | .input:-webkit-autofill { 31 | box-shadow: 0 0 0 1000px #fff inset; 32 | } 33 | -------------------------------------------------------------------------------- /src/hooks/useThrottleLoading.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useThrottleLoading(isLoading: boolean, time: number) { 4 | const [isThrottleLoading, setThrottleLoading] = useState(false); 5 | 6 | // Задерживаем отрисовку на некоторое время после начала загрузки данных. 7 | // Тем самым исключим моргание между заглушкой и рендером основного компонента 8 | useEffect(() => { 9 | if (isLoading) { 10 | const newTimerId = window.setTimeout(() => setThrottleLoading(true), time); 11 | 12 | return () => clearTimeout(newTimerId); 13 | } 14 | 15 | setThrottleLoading(false); 16 | }, [isLoading, time]); 17 | 18 | return isThrottleLoading; 19 | } 20 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { HashRouter } from 'react-router-dom'; 5 | import { PersistGate } from 'redux-persist/integration/react'; 6 | 7 | import { initMetrika } from './lib/metrika'; 8 | import store, { persistor } from './redux'; 9 | import App from './PolyfillApp'; 10 | 11 | import './index.css'; 12 | 13 | initMetrika(); 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | , 23 | document.getElementById('root') 24 | ); 25 | -------------------------------------------------------------------------------- /src/screens/EventScreen/styles.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | position: relative; 3 | z-index: 2; 4 | 5 | margin-top: -16px; 6 | padding: 4px 12px 0; 7 | 8 | border-radius: 16px 16px 0 0; 9 | background-color: #fff; 10 | } 11 | 12 | .city { 13 | margin-top: 12px; 14 | padding-bottom: 4px; 15 | 16 | font-size: 12px; 17 | line-height: 16px; 18 | 19 | color: rgba(0, 0, 0, .5); 20 | } 21 | 22 | .schedule { 23 | padding-bottom: 16px; 24 | 25 | font-size: 16px; 26 | font-weight: bold; 27 | line-height: 22px; 28 | } 29 | 30 | .description { 31 | font-size: 14px; 32 | line-height: 20px; 33 | white-space: pre-wrap; 34 | } 35 | 36 | .placeholder { 37 | height: 80px; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/TicketPrice/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Ticket } from '../../lib/api/fragments/ticket'; 4 | import { getCurrencySymbol } from '../../lib/price'; 5 | 6 | type Props = { 7 | ticket: Ticket; 8 | exact?: boolean; 9 | }; 10 | 11 | const space = '\u00A0'; 12 | 13 | const TicketPrice: React.FC = ({ ticket, exact }) => { 14 | if (!ticket.price || !ticket.price.min) { 15 | return null; 16 | } 17 | 18 | const text = [ 19 | exact ? '' : `от${space}`, 20 | (ticket.price.min || 0) / 100, 21 | space, 22 | getCurrencySymbol(ticket.price.currency), 23 | ].filter(Boolean).join(space); 24 | 25 | return <>{text}; 26 | }; 27 | 28 | export default TicketPrice; 29 | -------------------------------------------------------------------------------- /src/lib/lazy.ts: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | 3 | type LazyDeferred = typeof lazy; 4 | 5 | // Минимальное время перед импортом, чтобы дать отрисоваться скелетону 6 | const LAZY_DEFFERED_SLEEP = 50; 7 | // Минимальное время импорта компонента 8 | const LAZY_DEFFERED_DELAY = 500; 9 | 10 | export const lazyDeferred: LazyDeferred = importCallback => { 11 | type LazyImportType = ReturnType; 12 | 13 | return lazy(() => 14 | Promise.all([ 15 | new Promise(resolve => setTimeout(() => resolve(importCallback()), LAZY_DEFFERED_SLEEP)), 16 | new Promise(resolve => setTimeout(resolve, LAZY_DEFFERED_DELAY)), 17 | ]).then(([module]) => module) 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/RecommendedEvents/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | import { lazyDeferred } from '../../../../lib/lazy'; 4 | 5 | import LazyBlock from '../../../../components/LazyBlock'; 6 | import RecommendedEventsSkeleton from './Skeleton'; 7 | 8 | const Component = lazyDeferred(() => 9 | import( 10 | /* webpackChunkName: "recommended-events" */ 11 | './Component' 12 | ) 13 | ); 14 | 15 | const RecommendedEventsLazyBlock: React.FC = () => { 16 | return ( 17 | }> 18 | } /> 19 | 20 | ); 21 | }; 22 | 23 | export default memo(RecommendedEventsLazyBlock); 24 | -------------------------------------------------------------------------------- /src/screens/SelectionScreen/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Text } from '../../../components/Skeletons'; 4 | import DateFilterSkeleton from '../../../components/DateFilter/Skeleton'; 5 | import EventsListSkeleton from '../../../components/EventsList/Skeleton'; 6 | import EventCardSkeleton from '../../../components/EventCard/Skeleton'; 7 | 8 | import styles from './styles.module.css'; 9 | 10 | const SelectionScreenSkeleton: React.FC = () => ( 11 |
12 | 13 |
14 | 15 | 16 |
17 |
18 | ); 19 | 20 | export default SelectionScreenSkeleton; 21 | -------------------------------------------------------------------------------- /src/lib/js-api/utils.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppErrorCode } from '../error'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | type YandexJsApiObject = Record | undefined; 5 | 6 | export function callJsApi>({ 7 | name, 8 | args, 9 | scope, 10 | method, 11 | }: { 12 | name: string; 13 | args: Parameters[K]>>; 14 | scope: T; 15 | method: K, 16 | }): ReturnType[K]>> { 17 | const func = scope ? scope![method] : null; 18 | 19 | if (!func) { 20 | throw new AppError(AppErrorCode.JsApiMethodNotAvailable, `${name} is not available in this browser version.`); 21 | } 22 | 23 | return func.apply(scope, args); 24 | } 25 | -------------------------------------------------------------------------------- /src/redux/slices/date-filter.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | import { dateToString } from '../../lib/date'; 4 | 5 | export type DateFilter = { 6 | date: string; 7 | period: number; 8 | }; 9 | 10 | const initialState: DateFilter = { 11 | date: dateToString(new Date()).toString(), 12 | period: 1, 13 | }; 14 | 15 | const dateFilter = createSlice({ 16 | name: 'date-filter', 17 | initialState, 18 | reducers: { 19 | setDate(state, action: PayloadAction<{ date: string; period?: number }>) { 20 | state.date = action.payload.date; 21 | state.period = action.payload.period || 1; 22 | }, 23 | }, 24 | }); 25 | 26 | export const { setDate } = dateFilter.actions; 27 | 28 | export default dateFilter.reducer; 29 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/SearchResult/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Text } from '../../../../components/Skeletons'; 4 | import EventsListSkeleton from '../../../../components/EventsList/Skeleton'; 5 | import EventCardSkeleton from '../../../../components/EventCard/Skeleton'; 6 | 7 | import styles from './styles.module.css'; 8 | 9 | const SearchResultSkeleton: React.FC = () => ( 10 | <> 11 | {Array(3) 12 | .fill(0) 13 | .map((_, index) => ( 14 |
15 | 16 | 17 | 18 |
19 | ))} 20 | 21 | ); 22 | 23 | export default SearchResultSkeleton; 24 | -------------------------------------------------------------------------------- /src/lib/url-builder.ts: -------------------------------------------------------------------------------- 1 | type QueryParams = Record; 2 | 3 | export function getMainPageUrl() { 4 | return '/'; 5 | } 6 | 7 | export function getEventUrl(id: string) { 8 | return `/event/${id}`; 9 | } 10 | 11 | export function getEventGalleryUrl(id: string, query: QueryParams) { 12 | const params = query ? `?${new URLSearchParams(query)}` : ''; 13 | 14 | return `/event/${id}/gallery${params}`; 15 | } 16 | 17 | export function getRubricUrl(code: string) { 18 | return `/events/${encodeURIComponent(code)}`; 19 | } 20 | 21 | export function getSelectionUrl(code: string) { 22 | return `/selection/${encodeURIComponent(code)}`; 23 | } 24 | 25 | export function getSearchUrl() { 26 | return '/search'; 27 | } 28 | 29 | export function getOrdersUrl() { 30 | return '/orders'; 31 | } 32 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/CheckoutModal/components/CheckoutEventInfo/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | 5 | font-size: 16px; 6 | line-height: 20px; 7 | } 8 | 9 | .title { 10 | flex: 1; 11 | 12 | margin: 0 12px; 13 | 14 | color: rgba(0, 0, 0, .8); 15 | } 16 | 17 | .price { 18 | flex: none; 19 | 20 | font-size: 16px; 21 | line-height: 20px; 22 | 23 | color: rgba(0, 0, 0, .4); 24 | } 25 | 26 | .image, 27 | .preview { 28 | display: block; 29 | flex: none; 30 | 31 | width: 48px; 32 | height: 48px; 33 | 34 | border-radius: 6px; 35 | } 36 | 37 | .image { 38 | overflow: hidden; 39 | 40 | border-radius: 6px; 41 | object-fit: cover; 42 | } 43 | 44 | .preview { 45 | background: #eaeaea; 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/api/fragments/event-preview.ts: -------------------------------------------------------------------------------- 1 | import { Ticket } from './ticket'; 2 | import { Tag } from './tag-preview'; 3 | import { MediaImageSize } from './image-size'; 4 | 5 | export type EventPreview = { 6 | id: string; 7 | title: string; 8 | argument: string | null; 9 | type: Tag; 10 | tags: Tag[]; 11 | contentRating: string | null; 12 | tickets: Ticket[] | null; 13 | userRating: { 14 | overall: { 15 | count: number; 16 | value: number; 17 | }; 18 | } | null; 19 | image: { 20 | bgColor: string | null; 21 | eventListCard: MediaImageSize; 22 | eventListCard2x: MediaImageSize; 23 | actualListCard: MediaImageSize; 24 | actualListCard2x: MediaImageSize; 25 | selectionCard: MediaImageSize; 26 | } | null; 27 | }; 28 | -------------------------------------------------------------------------------- /src/lib/metrika/install.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | (function() { 3 | 'use strict'; 4 | 5 | var isBeaconAvailable = Boolean( 6 | window.yandex && 7 | window.yandex.navigator && 8 | window.yandex.navigator.sendPersistentBeacon 9 | ); 10 | var scriptName = isBeaconAvailable ? 'tag_turboapp.js' : 'tag.js'; 11 | 12 | window.ym = function() { 13 | (window.ym.a = window.ym.a || []).push(arguments); 14 | } 15 | window.ym.l = Number(new Date()); 16 | 17 | var metrikaScript = document.createElement('script'); 18 | metrikaScript.src = 'https://mc.yandex.ru/metrika/' + scriptName; 19 | metrikaScript.async = true; 20 | 21 | var firstScript = document.getElementsByTagName('script')[0]; 22 | firstScript.parentNode.insertBefore(metrikaScript, firstScript); 23 | })(); 24 | -------------------------------------------------------------------------------- /src/components/LoginButton/avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/AuthBox/avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/LazyBlock/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, Suspense, ReactElement, ReactNode } from 'react'; 2 | 3 | import LazyRender from '../LazyRender'; 4 | 5 | type Props = { 6 | children: ReactNode; 7 | fetchData?: () => void; 8 | skeleton?: ReactElement; 9 | }; 10 | 11 | const LazyBlock: React.FC = ({ children, skeleton, fetchData }) => { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | const Inner: React.FC = ({ children, skeleton, fetchData }) => { 20 | useEffect(() => { 21 | fetchData && fetchData(); 22 | }, [fetchData]); 23 | 24 | return {children}; 25 | }; 26 | 27 | export default LazyBlock; 28 | -------------------------------------------------------------------------------- /src/hooks/useVisibleOnce.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useState } from 'react'; 2 | 3 | export function useVisibleOnce(ref: RefObject, options?: IntersectionObserverInit) { 4 | // Пока не навешан обработчик на элемент, мы точно не можем сказать видим он или нет, 5 | // поэтому начальное значение undefined 6 | const [isVisible, setVisible] = useState(); 7 | 8 | useEffect(() => { 9 | if (!ref.current || isVisible) { 10 | return; 11 | } 12 | 13 | const observer = new IntersectionObserver(([entry]) => { 14 | setVisible(entry.isIntersecting); 15 | }, options); 16 | 17 | observer.observe(ref.current); 18 | 19 | return () => { 20 | observer.disconnect(); 21 | }; 22 | }, [ref, options, isVisible]); 23 | 24 | return isVisible; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/LazyRender/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, ReactElement, useState, useEffect } from 'react'; 2 | 3 | import { useVisibleOnce } from '../../hooks/useVisibleOnce'; 4 | 5 | type Props = { 6 | children: ReactElement; 7 | skeleton?: ReactElement; 8 | intersectionOptions?: IntersectionObserverInit; 9 | }; 10 | 11 | const LazyRender: React.FC = ({ children, skeleton, intersectionOptions }) => { 12 | const ref = useRef(null); 13 | const canRender = useVisibleOnce(ref, intersectionOptions); 14 | const [needSkeleton, showSkeleton] = useState(false); 15 | 16 | useEffect(() => { 17 | if (!canRender) { 18 | showSkeleton(true); 19 | } 20 | }, [canRender]); 21 | 22 | return
{canRender ? children : needSkeleton && skeleton}
; 23 | }; 24 | 25 | export default LazyRender; 26 | -------------------------------------------------------------------------------- /src/screens/OrdersScreen/Order/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Rect, Text } from '../../../../components/Skeletons'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | const OrderSkeleton: React.FC = () => { 8 | return ( 9 |
10 | 11 |
12 | 13 | 14 | 15 |
16 |
17 | 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default OrderSkeleton; 25 | -------------------------------------------------------------------------------- /src/components/Skeletons/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | type RectProps = { 6 | className?: string; 7 | width?: number | string; 8 | height?: number | string; 9 | borderRadius?: number; 10 | }; 11 | 12 | export const Rect: React.FC = ({ className, width, height }) => { 13 | return
; 14 | }; 15 | 16 | type TextProps = { 17 | className?: string; 18 | width?: number | string; 19 | size?: number; 20 | }; 21 | 22 | export const Text: React.FC = ({ className, width, size }) => { 23 | return
; 24 | }; 25 | 26 | function mix(...classNames: Array) { 27 | return classNames.filter(Boolean).join(' '); 28 | } 29 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/EventsFeed/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Text } from '../../../../../components/Skeletons'; 4 | import { EventCardVerticalSkeleton } from '../../../../../components/EventCardVertical/Skeleton'; 5 | 6 | import styles from './styles.module.css'; 7 | 8 | export const EventsFeedSkeleton: React.FC = () => ( 9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 |
23 | ); 24 | -------------------------------------------------------------------------------- /src/components/BackwardButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Button from '../ClearButton'; 4 | import { useBackward } from '../StackNavigator/hooks'; 5 | 6 | import styles from './styles.module.css'; 7 | 8 | type Props = { 9 | fill?: 'white' | 'black'; 10 | className?: string; 11 | }; 12 | 13 | const BackwardIcon: React.FC = ({ fill = 'white' }) => { 14 | const classNames = [styles['backward-icon'], styles[`icon-${fill}`]].join(' '); 15 | 16 | return ; 17 | }; 18 | 19 | const Backward: React.FC = props => { 20 | const onClick = useBackward(); 21 | 22 | return ( 23 | 26 | ); 27 | }; 28 | 29 | export default Backward; 30 | -------------------------------------------------------------------------------- /src/components/ActionButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ButtonHTMLAttributes, forwardRef, ReactNode } from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | 5 | export type Props = { 6 | type?: ButtonHTMLAttributes['type']; 7 | className?: string; 8 | disabled?: boolean; 9 | formInvalid?: boolean; 10 | onClick?: () => void; 11 | children: ReactNode; 12 | }; 13 | const ActionButton = forwardRef( 14 | ({ type, className, disabled, formInvalid, children, onClick }, ref) => { 15 | const cls = [styles.action, className, formInvalid && styles.invalid].filter(Boolean).join(' '); 16 | return ( 17 | 20 | ); 21 | } 22 | ); 23 | 24 | export default ActionButton; 25 | -------------------------------------------------------------------------------- /src/components/EventsSlider/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ActualEvent } from '../../lib/api/fragments/actual-event'; 4 | 5 | import styles from './style.module.css'; 6 | 7 | export type Props = { 8 | items: Array; 9 | component: React.FC<{ 10 | event: ActualEvent; 11 | onClick?: () => void; 12 | className?: string; 13 | }>; 14 | }; 15 | 16 | const EventsSlider: React.FC = ({ items, component: Card }) => { 17 | return ( 18 |
19 | {items.map(actualEvent => { 20 | if (!actualEvent) { 21 | return null; 22 | } 23 | 24 | return ; 25 | })} 26 |
27 | ); 28 | }; 29 | 30 | export default EventsSlider; 31 | -------------------------------------------------------------------------------- /src/hooks/useWatchAuth.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import { checkUser } from '../redux/slices/user'; 5 | 6 | export function useWatchAuth() { 7 | const dispatch = useDispatch(); 8 | 9 | // Проверяем авторизацию на старте приложения 10 | useEffect(() => { 11 | dispatch(checkUser()); 12 | }, [dispatch]); 13 | 14 | // При переключении владок браузера пользователь мог сменить логин, поэтому проверяем авторизацию 15 | useEffect(() => { 16 | const onVisibilityChange = () => { 17 | if (document.visibilityState === 'visible') { 18 | dispatch(checkUser()); 19 | } 20 | }; 21 | 22 | document.addEventListener('visibilitychange', onVisibilityChange); 23 | 24 | return () => document.removeEventListener('visibilitychange', onVisibilityChange); 25 | }, [dispatch]); 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/usePaginatedList.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from 'react'; 2 | 3 | type PaginatedListOptions = { 4 | items: T[]; 5 | hasMore: boolean; 6 | loadMore: () => void; 7 | pageSize: number; 8 | }; 9 | 10 | export function usePaginatedList(options: PaginatedListOptions): [T[], boolean, () => void] { 11 | const { items, hasMore, loadMore, pageSize } = options; 12 | 13 | const [limit, setLimit] = useState(pageSize); 14 | const events = useMemo(() => items.slice(0, limit), [items, limit]); 15 | 16 | const wrappedLoadMore = useCallback(() => { 17 | if (items.length > limit) { 18 | setLimit(limit + pageSize); 19 | } else { 20 | loadMore(); 21 | } 22 | }, [loadMore, limit, pageSize, items]); 23 | 24 | const wrappedHasMore = hasMore || limit < items.length; 25 | 26 | return [events, wrappedHasMore, wrappedLoadMore]; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/MenuGeoLabel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { RootState } from '../../redux'; 5 | 6 | import styles from './styles.module.css'; 7 | 8 | const MenuGeoLabel: React.FC<{ 9 | setCityModalVisibleCallback: (visible: boolean) => void; 10 | }> = ({ setCityModalVisibleCallback }) => { 11 | const currentCity = useSelector((state: RootState) => state.city.currentCity); 12 | const onCitySelectLabelClickHandle = useCallback(() => { 13 | setCityModalVisibleCallback(true); 14 | }, [setCityModalVisibleCallback]); 15 | 16 | return ( 17 |
18 |
19 | {currentCity ? currentCity.name : 'Выбрать город'} 20 |
21 |
22 | ); 23 | }; 24 | 25 | export default MenuGeoLabel; 26 | -------------------------------------------------------------------------------- /src/lib/api/fragments/event.ts: -------------------------------------------------------------------------------- 1 | import { EventScheduleInfo } from './event-schedule-info'; 2 | import { GalleryImage } from './gallery-image'; 3 | import { TouchPrimaryImage } from './event-main-image'; 4 | import { Ticket } from './ticket'; 5 | 6 | export type Event = { 7 | id: string; 8 | title: string; 9 | argument: string | null; 10 | contentRating: string | null; 11 | description: string | null; 12 | tags: { 13 | name: string; 14 | code: string; 15 | }[]; 16 | image: TouchPrimaryImage | null; 17 | images: Array | null; 18 | type: { 19 | name: string; 20 | }; 21 | userRating: { 22 | overall: { 23 | count: number; 24 | value: number; 25 | }; 26 | } | null; 27 | tickets: Array | null; 28 | }; 29 | 30 | export type EventData = { 31 | event: Event; 32 | scheduleInfo: EventScheduleInfo; 33 | }; 34 | -------------------------------------------------------------------------------- /src/hooks/useInfiniteScroll.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useMemo } from 'react'; 2 | 3 | import { useVisible } from './useVisible'; 4 | 5 | type InfiniteScrollOptions = { 6 | ref: RefObject; 7 | isLoading: boolean; 8 | hasMore: boolean; 9 | loadMore: () => void; 10 | threshold?: number; 11 | }; 12 | 13 | export function useInfiniteScroll({ threshold, ref, isLoading, loadMore, hasMore }: InfiniteScrollOptions) { 14 | const options = useMemo(() => { 15 | return { 16 | rootMargin: `0px 0px ${threshold || 0}px 0px`, 17 | }; 18 | }, [threshold]); 19 | 20 | const needLoadMore = useVisible(ref, options) || false; 21 | 22 | useEffect(() => { 23 | if (!hasMore) return; 24 | if (isLoading) return; 25 | 26 | if (needLoadMore) { 27 | loadMore(); 28 | } 29 | }, [needLoadMore, loadMore, hasMore, isLoading]); 30 | } 31 | -------------------------------------------------------------------------------- /src/screens/MainScreen/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Text } from '../../../components/Skeletons'; 4 | import DateFilterSkeleton from '../../../components/DateFilter/Skeleton'; 5 | import EventsListSkeleton from '../../../components/EventsList/Skeleton'; 6 | import EventCardMainSkeleton from '../../../components/EventCardMain/Skeleton'; 7 | import SelectionListSkeleton from '../../../components/SelectionList/Skeleton'; 8 | 9 | import styles from './styles.module.css'; 10 | 11 | const MainScreenSkeleton: React.FC = () => ( 12 |
13 | 14 |
15 | 16 | 17 | 18 | 19 |
20 |
21 | ); 22 | 23 | export default MainScreenSkeleton; 24 | -------------------------------------------------------------------------------- /src/components/StackNavigator/Screen/styles.module.css: -------------------------------------------------------------------------------- 1 | .screen { 2 | position: fixed; 3 | z-index: 1; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | 9 | overflow: scroll; 10 | -webkit-overflow-scrolling: touch; 11 | 12 | background: #fff; 13 | 14 | transition-timing-function: cubic-bezier(.25, .1, .25, 1); 15 | transition-duration: 300ms; 16 | transition-property: transform, filter; 17 | } 18 | 19 | .hidden { 20 | display: none; 21 | } 22 | 23 | .screenEnter { 24 | transform: translateX(100vw); 25 | } 26 | 27 | .screenEnterActive { 28 | pointer-events: none; 29 | 30 | transform: translateX(0); 31 | } 32 | 33 | .screenExit { 34 | transform: translateX(0); 35 | } 36 | 37 | .screenExitActive { 38 | pointer-events: none; 39 | 40 | transition-duration: 250ms; 41 | transform: translateX(100vw); 42 | } 43 | 44 | .offset { 45 | pointer-events: none; 46 | 47 | filter: brightness(.5); 48 | 49 | transform: translateX(-25vw); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/Gallery/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | scroll-snap-type: x mandatory; 4 | 5 | overflow-x: scroll; 6 | overscroll-behavior-x: contain; 7 | -webkit-overflow-scrolling: touch; 8 | scrollbar-width: none; 9 | } 10 | 11 | .container::-webkit-scrollbar { 12 | display: none; 13 | } 14 | 15 | .thumbnail { 16 | flex: none; 17 | 18 | width: 208px; 19 | margin-left: 12px; 20 | 21 | scroll-snap-align: center; 22 | } 23 | 24 | .thumbnail:first-child { 25 | margin-left: 0; 26 | } 27 | 28 | .thumbnail:last-child { 29 | padding-right: 17px; 30 | } 31 | 32 | .image { 33 | position: relative; 34 | 35 | overflow: hidden; 36 | 37 | width: 208px; 38 | height: 144px; 39 | 40 | border-radius: 12px; 41 | } 42 | 43 | .image::after { 44 | position: absolute; 45 | top: 0; 46 | right: 0; 47 | bottom: 0; 48 | left: 0; 49 | 50 | display: block; 51 | 52 | content: ''; 53 | 54 | background-color: rgba(0, 0, 0, .1); 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/api/fragments/suggest.ts: -------------------------------------------------------------------------------- 1 | import { MediaImageSize } from './image-size'; 2 | import { Currency } from './ticket'; 3 | 4 | export type Document = { 5 | object: { 6 | objectType: 'SearchObjectEvent'; 7 | id: string; 8 | title: string; 9 | argument: string | null; 10 | minPrice: { 11 | currency: Currency; 12 | value: number; 13 | } | null; 14 | type: { 15 | code: string; 16 | name: string; 17 | }; 18 | image: { 19 | bgColor: string | null; 20 | eventListCard: MediaImageSize; 21 | eventListCard2x: MediaImageSize; 22 | actualListCard: MediaImageSize; 23 | actualListCard2x: MediaImageSize; 24 | selectionCard: MediaImageSize; 25 | } | null; 26 | }; 27 | }; 28 | 29 | export type Groups = { 30 | code: string; 31 | title: string | null; 32 | documents: Document[]; 33 | }; 34 | 35 | export type Suggest = { 36 | groups: Groups[]; 37 | }; 38 | -------------------------------------------------------------------------------- /src/screens/EventScreen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, Suspense } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import { lazyDeferred } from '../../lib/lazy'; 5 | import { loadEvent } from '../../redux/slices/event'; 6 | import { loadRecommendedEvents } from '../../redux/slices/recommended-events'; 7 | 8 | import EventSkeleton from './Skeleton'; 9 | 10 | const Component = lazyDeferred(() => 11 | import( 12 | /* webpackChunkName: "event-screen" */ 13 | './Component' 14 | ) 15 | ); 16 | 17 | const EventScreen: React.FC<{ id: string }> = ({ id }) => { 18 | const dispatch = useDispatch(); 19 | 20 | useEffect(() => { 21 | dispatch(loadEvent(id)); 22 | dispatch(loadRecommendedEvents()); 23 | }, [dispatch, id]); 24 | 25 | return ( 26 | <> 27 | }> 28 | } /> 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default EventScreen; 35 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/VenueInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import YaMapWidget from '../../../../components/YaMapWidget'; 4 | import { ScheduleInfo } from '../../../../redux/slices/event'; 5 | 6 | import styles from './styles.module.css'; 7 | 8 | type Props = { 9 | schedule: Partial; 10 | }; 11 | 12 | const VenueInfo: React.FC = ({ schedule }) => { 13 | const { oneOfPlaces, placePreview } = schedule; 14 | 15 | if (!oneOfPlaces || !oneOfPlaces.coordinates) { 16 | return
{placePreview}
; 17 | } 18 | 19 | return ( 20 | <> 21 | 26 |
{oneOfPlaces.title}
27 |
{oneOfPlaces.address}
28 | 29 | ); 30 | }; 31 | 32 | export default VenueInfo; 33 | -------------------------------------------------------------------------------- /src/screens/OrdersScreen/Order/styles.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | 6 | box-sizing: border-box; 7 | min-height: 92px; 8 | padding: 16px 0; 9 | } 10 | 11 | .logo-container { 12 | display: flex; 13 | flex: 0 0 48px; 14 | } 15 | 16 | .logo { 17 | overflow: hidden; 18 | 19 | height: 60px; 20 | margin: auto; 21 | 22 | border-radius: 8px; 23 | } 24 | 25 | .content { 26 | display: flex; 27 | flex-grow: 1; 28 | } 29 | 30 | .info { 31 | flex-grow: 1; 32 | 33 | margin: 0 16px; 34 | } 35 | 36 | .status-container { 37 | flex-shrink: 0; 38 | 39 | text-align: right; 40 | } 41 | 42 | .title, 43 | .cost { 44 | font-size: 16px; 45 | font-weight: 500; 46 | line-height: 20px; 47 | } 48 | 49 | .schedule, 50 | .status { 51 | margin-top: 2px; 52 | 53 | font-size: 14px; 54 | line-height: 18px; 55 | 56 | color: rgba(0, 0, 0, .4); 57 | } 58 | 59 | .info p, 60 | .status-container p { 61 | margin: 0; 62 | } 63 | -------------------------------------------------------------------------------- /src/redux/slices/menu.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { persistReducer } from 'redux-persist'; 3 | 4 | import { getPersistConfig } from '../helpers/persist'; 5 | 6 | import { MenuTag } from '../../lib/api/fragments/city'; 7 | 8 | export type Menu = { 9 | visible: boolean; 10 | items: Array; 11 | activeItem: MenuTag['code']; 12 | }; 13 | 14 | const initialState: Menu = { 15 | visible: false, 16 | items: [], 17 | activeItem: '', 18 | }; 19 | 20 | const menu = createSlice({ 21 | name: 'menu', 22 | initialState, 23 | reducers: { 24 | setItems(state, action: PayloadAction) { 25 | state.items = action.payload; 26 | }, 27 | setVisible(state, action: PayloadAction) { 28 | state.visible = action.payload; 29 | }, 30 | }, 31 | }); 32 | 33 | export const { setItems, setVisible } = menu.actions; 34 | 35 | const persistConfig = getPersistConfig('menu'); 36 | export default persistReducer(persistConfig, menu.reducer); 37 | -------------------------------------------------------------------------------- /src/lib/js-api/autofill.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppErrorCode } from '../error'; 2 | import { callJsApi } from './utils'; 3 | 4 | export enum YandexProfileField { 5 | Name = 'name', 6 | Email = 'email', 7 | Phone = 'phone', 8 | Address = 'address', 9 | } 10 | 11 | export type YandexProfileData = { 12 | firstName: string; 13 | lastName: string; 14 | middleName: string; 15 | phoneNumber: string; 16 | email: string; 17 | streetAddress: string; 18 | }; 19 | 20 | export async function getProfileData(profileFields: Array): Promise { 21 | try { 22 | return await callJsApi({ 23 | name: 'window.yandex.autofill.getProfileData', 24 | args: [profileFields], 25 | scope: window?.yandex?.autofill, 26 | method: 'getProfileData', 27 | }); 28 | } catch (err) { 29 | const { code } = err; 30 | 31 | if (code === 'denied') { 32 | throw new AppError(AppErrorCode.JsApiDenied, 'Autofill denied.'); 33 | } 34 | 35 | throw err; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/geolocation.ts: -------------------------------------------------------------------------------- 1 | const GEOLOCATIONS_OPTIONS = { 2 | timeout: 10000, 3 | maximumAge: 60 * 60 * 1000, 4 | }; 5 | 6 | export async function getCurrentPosition(): Promise { 7 | return new Promise((resolve, reject) => { 8 | const resolveCallback = (position: Position) => resolve(position.coords); 9 | 10 | window.navigator.geolocation.getCurrentPosition(resolveCallback, reject, GEOLOCATIONS_OPTIONS); 11 | }); 12 | } 13 | 14 | function deg2rad(deg: number) { 15 | return deg * (Math.PI / 180); 16 | } 17 | 18 | // http://en.wikipedia.org/wiki/Haversine_formula 19 | export function getDistanceBetweenPoints(lat1: number, lon1: number, lat2: number, lon2: number): number { 20 | const R = 6371; // Радиус земли в КМ 21 | 22 | const tmp = 23 | Math.sin(deg2rad(lat2 - lat1) / 2) * Math.sin(deg2rad(lat2 - lat1) / 2) + 24 | Math.cos(deg2rad(lat1)) * 25 | Math.cos(deg2rad(lat2)) * 26 | Math.sin(deg2rad(lon2 - lon1) / 2) * 27 | Math.sin(deg2rad(lon2 - lon1) / 2); 28 | 29 | return 2 * R * Math.asin(Math.sqrt(tmp)); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/EventCardVertical/style.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | width: 208px; 3 | 4 | text-decoration: none; 5 | } 6 | 7 | .title { 8 | display: -webkit-box; 9 | -webkit-box-orient: vertical; 10 | -webkit-line-clamp: 2; 11 | overflow: hidden; 12 | 13 | margin-top: 12px; 14 | 15 | font-size: 16px; 16 | font-weight: bold; 17 | line-height: 22px; 18 | word-break: break-word; 19 | 20 | color: #000; 21 | } 22 | 23 | .annotation { 24 | margin-top: 6px; 25 | 26 | font-size: 12px; 27 | line-height: 16px; 28 | 29 | color: rgba(0, 0, 0, .5); 30 | } 31 | 32 | .wrapper { 33 | position: relative; 34 | 35 | overflow: hidden; 36 | 37 | border-radius: 12px; 38 | } 39 | 40 | .overlay { 41 | position: absolute; 42 | top: 0; 43 | right: 0; 44 | bottom: 0; 45 | left: 0; 46 | 47 | background: rgba(0, 0, 0, .2); 48 | } 49 | 50 | .image, 51 | .preview { 52 | display: block; 53 | 54 | width: 208px; 55 | height: 144px; 56 | } 57 | 58 | .image { 59 | object-fit: cover; 60 | } 61 | 62 | .preview { 63 | background: #eaeaea; 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/autofill.ts: -------------------------------------------------------------------------------- 1 | import { getProfileData as jsApiGetProfileData, YandexProfileData, YandexProfileField } from './js-api/autofill'; 2 | 3 | const AUTOFILL_SDK = 'https://yastatic.net/s3/passport-sdk/autofill/v1/sdk-latest.js'; 4 | 5 | const autofillReadyPromise = new Promise((resolve, reject) => { 6 | if (window.yandex?.autofill?.getProfileData) { 7 | return resolve(); 8 | } 9 | 10 | // Загружаем скрипт для автоматического заполнения полей 11 | const script = document.createElement('script'); 12 | const firstScript = document.getElementsByTagName('script')[0]; 13 | 14 | script.src = AUTOFILL_SDK; 15 | script.async = true; 16 | script.onload = () => resolve(); 17 | script.onerror = () => reject(); 18 | 19 | firstScript.parentNode?.insertBefore(script, firstScript); 20 | }); 21 | 22 | export async function getProfileData(): Promise { 23 | await autofillReadyPromise; 24 | 25 | return jsApiGetProfileData([ 26 | YandexProfileField.Name, 27 | YandexProfileField.Phone, 28 | YandexProfileField.Email, 29 | YandexProfileField.Address, 30 | ]); 31 | } 32 | -------------------------------------------------------------------------------- /src/screens/OrdersScreen/Component.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { useMetrikaHit } from '../../hooks/useMetrikaHit'; 5 | 6 | import { 7 | ordersLoadingSelector, 8 | ordersSelector 9 | } from '../../redux/slices/order'; 10 | 11 | import Order from './Order'; 12 | 13 | import styles from './styles.module.css'; 14 | 15 | type Props = { 16 | skeleton: ReactElement 17 | }; 18 | 19 | const Component: React.FC = ({ skeleton }) => { 20 | const orders = useSelector(ordersSelector); 21 | const isLoading = useSelector(ordersLoadingSelector); 22 | 23 | useMetrikaHit(); 24 | 25 | if (isLoading) { 26 | return skeleton; 27 | } 28 | 29 | return ( 30 |
31 | {orders.length > 0 ? orders.map(order => ) : ( 32 |
33 |

Вы пока не покупали билеты

34 |
35 | )} 36 |
37 | ); 38 | }; 39 | 40 | export default Component; 41 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useCallback, useEffect, useState, useRef } from 'react'; 2 | 3 | import TextInput from '../../../components/TextInput'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | const SuggestInput: React.FC<{ onChange: (value: string) => void }> = props => { 8 | const inputRef = useRef(null); 9 | const [value, setValue] = useState(''); 10 | const onChange = useCallback( 11 | (e: ChangeEvent) => { 12 | const { value } = e.target; 13 | 14 | props.onChange(value); 15 | setValue(value); 16 | }, 17 | [setValue, props] 18 | ); 19 | 20 | useEffect(() => { 21 | if (inputRef.current) { 22 | inputRef.current.focus(); 23 | } 24 | }, [inputRef]); 25 | 26 | return ( 27 | 34 | ); 35 | }; 36 | 37 | export default SuggestInput; 38 | -------------------------------------------------------------------------------- /src/components/StackNavigator/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RouteProps, useLocation } from 'react-router-dom'; 3 | 4 | import { isIOS } from '../../lib/is-ios'; 5 | import StackNavigator from './StackNavigator'; 6 | 7 | export { useBackward, useScreenRef, useScreenVisible } from './hooks'; 8 | 9 | export type Params = Record; 10 | export type Location = ReturnType; 11 | 12 | export type ScreenProps = { 13 | params: Params; 14 | location: Location; 15 | }; 16 | 17 | export type ScreenComponent = React.FC; 18 | 19 | export type ScreenConfig = RouteProps & { 20 | component: ScreenComponent; 21 | }; 22 | 23 | export type Screens = ScreenConfig[]; 24 | export type Options = { 25 | maxDepth?: number; 26 | transitions?: boolean; 27 | }; 28 | 29 | export function createStackNavigator(screens: Screens, options: Options = {}) { 30 | if (isIOS() && 'scrollRestoration' in window.history) { 31 | window.history.scrollRestoration = 'manual'; 32 | } 33 | 34 | return () => ; 35 | } 36 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/Cover/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Event } from '../../../../redux/slices/event'; 4 | 5 | import Image from '../../../../components/Image'; 6 | 7 | import styles from './styles.module.css'; 8 | 9 | type Props = { 10 | event: Partial; 11 | imageRef: React.Ref; 12 | }; 13 | 14 | const Cover: React.FC = ({ event, imageRef }) => { 15 | const { title, argument, image, tags, contentRating } = event; 16 | 17 | const tagLine = [...(tags || []).map(tag => tag.name), contentRating].filter(Boolean).join(' • '); 18 | 19 | return ( 20 |
21 | {image && } 22 |
23 |
24 |

{title}

25 |
{argument}
26 |
{tagLine}
27 |
28 |
29 | ); 30 | }; 31 | 32 | export default Cover; 33 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/RecommendedEvents/Component.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { recommendedEventsSelector, recommendedEventsUISelector } from '../../../../redux/slices/recommended-events'; 5 | 6 | import EventsFeed from '../EventsFeed'; 7 | 8 | type Props = { 9 | skeleton: ReactElement; 10 | }; 11 | 12 | const Component: React.FC = ({ skeleton }) => { 13 | const recommendedEvents = useSelector(recommendedEventsSelector) || {}; 14 | const { isLoading } = useSelector(recommendedEventsUISelector) || {}; 15 | 16 | if (isLoading) { 17 | return skeleton; 18 | } 19 | 20 | return ( 21 | <> 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Component; 31 | -------------------------------------------------------------------------------- /src/components/CityModal/styles.module.css: -------------------------------------------------------------------------------- 1 | .city-modal { 2 | position: fixed; 3 | z-index: 200; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | 9 | pointer-events: none; 10 | } 11 | 12 | .city-modal::before { 13 | position: absolute; 14 | top: 0; 15 | right: 0; 16 | bottom: 0; 17 | left: 0; 18 | 19 | content: ''; 20 | 21 | opacity: 0; 22 | background: #000; 23 | will-change: opacity; 24 | } 25 | 26 | .city-modal-visible_yes::before { 27 | opacity: .5; 28 | 29 | animation-name: fadeIn; 30 | animation-duration: .1s; 31 | animation-timing-function: ease-out; 32 | } 33 | 34 | .city-modal-visible_yes { 35 | pointer-events: all; 36 | } 37 | 38 | .city-modal-visible_no::before { 39 | opacity: 0; 40 | 41 | animation-name: fadeOut; 42 | animation-duration: .1s; 43 | animation-timing-function: ease-out; 44 | } 45 | 46 | @keyframes fadeIn { 47 | from { 48 | opacity: 0; 49 | } 50 | 51 | to { 52 | opacity: .5; 53 | } 54 | } 55 | 56 | @keyframes fadeOut { 57 | from { 58 | opacity: .5; 59 | } 60 | 61 | to { 62 | opacity: 0; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/components/SelectionList/style.module.css: -------------------------------------------------------------------------------- 1 | .block { 2 | display: grid; 3 | grid-gap: 8px; 4 | } 5 | 6 | .item { 7 | position: relative; 8 | 9 | display: block; 10 | overflow: hidden; 11 | 12 | width: 100%; 13 | padding-top: 50%; 14 | 15 | text-decoration: none; 16 | 17 | border-radius: 8px; 18 | outline: none; 19 | } 20 | 21 | .item::before { 22 | position: absolute; 23 | z-index: 2; 24 | top: 0; 25 | right: 0; 26 | bottom: 0; 27 | left: 0; 28 | 29 | content: ''; 30 | 31 | background: rgba(0, 0, 0, .25); 32 | } 33 | 34 | .image { 35 | position: absolute; 36 | z-index: 1; 37 | top: 0; 38 | right: 0; 39 | bottom: 0; 40 | left: 0; 41 | 42 | max-width: 100%; 43 | } 44 | 45 | .info { 46 | position: absolute; 47 | z-index: 3; 48 | right: 0; 49 | bottom: 0; 50 | left: 0; 51 | 52 | padding: 16px; 53 | } 54 | 55 | .title { 56 | font-size: 20px; 57 | font-weight: bold; 58 | line-height: 26px; 59 | 60 | color: #fff; 61 | } 62 | 63 | .counter { 64 | margin-top: 4px; 65 | 66 | font-size: 14px; 67 | line-height: 20px; 68 | 69 | color: rgba(255, 255, 255, .8); 70 | } 71 | -------------------------------------------------------------------------------- /src/screens/RubricScreen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, Suspense } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { lazyDeferred } from '../../lib/lazy'; 5 | import { RootState } from '../../redux'; 6 | import { loadRubricEvents } from '../../redux/slices/rubric-events'; 7 | 8 | import PageHeader from '../../components/PageHeader'; 9 | import SelectionScreenSkeleton from '../SelectionScreen/Skeleton'; 10 | 11 | const Component = lazyDeferred(() => 12 | import( 13 | /* webpackChunkName: "rubric-screen" */ 14 | './Component' 15 | ) 16 | ); 17 | 18 | const RubricScreen: React.FC<{ code: string }> = ({ code }) => { 19 | const dispatch = useDispatch(); 20 | const date = useSelector((state: RootState) => state.dateFilter); 21 | 22 | useEffect(() => { 23 | dispatch(loadRubricEvents(code, date)); 24 | }, [dispatch, code, date]); 25 | 26 | return ( 27 | <> 28 | 29 | }> 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default RubricScreen; 37 | -------------------------------------------------------------------------------- /src/screens/SelectionScreen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, Suspense } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { lazyDeferred } from '../../lib/lazy'; 5 | import { RootState } from '../../redux'; 6 | import { loadSelectionEvents } from '../../redux/slices/selection-events'; 7 | 8 | import PageHeader from '../../components/PageHeader'; 9 | 10 | import SelectionScreenSkeleton from './Skeleton'; 11 | 12 | const Component = lazyDeferred(() => 13 | import( 14 | /* webpackChunkName: "selection-screen" */ 15 | './Component' 16 | ) 17 | ); 18 | 19 | const SelectionScreen: React.FC<{ code: string }> = ({ code }) => { 20 | const dispatch = useDispatch(); 21 | const date = useSelector((state: RootState) => state.dateFilter); 22 | 23 | useEffect(() => { 24 | dispatch(loadSelectionEvents(code, date)); 25 | }, [dispatch, code, date]); 26 | 27 | return ( 28 | <> 29 | 30 | }> 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default SelectionScreen; 38 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | MiniApp Example 22 | 23 | 24 | 25 |
26 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/hooks/useVisible.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useLayoutEffect, useState, useRef } from 'react'; 2 | 3 | export function useVisible(ref: RefObject, options?: IntersectionObserverInit) { 4 | // Пока не навешан обработчик на элемент, мы точно не можем сказать видим он или нет, 5 | // поэтому начальное значение undefined 6 | const [isVisible, setVisible] = useState(); 7 | const optionsRef = useRef(options); 8 | 9 | useLayoutEffect(() => { 10 | if (!ref.current) { 11 | return; 12 | } 13 | 14 | if (options !== optionsRef.current && process.env.NODE_ENV === 'development') { 15 | optionsRef.current = options; 16 | 17 | console.warn( 18 | '[useVisible] Изменились опции для IntersectionObserver. Это может приводить к существенному падению производительности при скролле.' 19 | ); 20 | } 21 | 22 | const observer = new IntersectionObserver(([entry]) => { 23 | setVisible(entry.isIntersecting); 24 | }, options); 25 | 26 | observer.observe(ref.current); 27 | 28 | return () => { 29 | observer.disconnect(); 30 | }; 31 | }, [ref, options]); 32 | 33 | return isVisible; 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/metrika/event.ts: -------------------------------------------------------------------------------- 1 | import { reportGoalReached } from '../js-api/metrika'; 2 | import { CreateOrderResponse } from '../api/types'; 3 | import { reachGoal, MetrikaGoals, MetrikaOrderParams } from '.'; 4 | 5 | function buildMetrikaOrderParams(response: CreateOrderResponse): MetrikaOrderParams { 6 | return { 7 | order_id: response.id, 8 | price: response.cost, 9 | price_without_discount: response.cost, 10 | }; 11 | } 12 | 13 | export async function logOrderInitiated(response: CreateOrderResponse): Promise { 14 | const metrikaParams = buildMetrikaOrderParams(response); 15 | 16 | reachGoal(MetrikaGoals.OrderInitiated, metrikaParams); 17 | await reportGoalReached(MetrikaGoals.OrderInitiated, metrikaParams); 18 | } 19 | 20 | export async function logOrderCompleted(response: CreateOrderResponse): Promise { 21 | const metrikaParams = buildMetrikaOrderParams(response); 22 | 23 | reachGoal(MetrikaGoals.OrderCompleted, metrikaParams); 24 | await reportGoalReached(MetrikaGoals.OrderCompleted, metrikaParams); 25 | } 26 | 27 | export function logOrderError(response: CreateOrderResponse): void { 28 | const metrikaParams = buildMetrikaOrderParams(response); 29 | 30 | reachGoal(MetrikaGoals.OrderError, metrikaParams); 31 | } 32 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/CheckoutModal/components/CheckoutEventInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Ticket } from '../../../../../../lib/api/fragments/ticket'; 4 | import { Event } from '../../../../../../redux/slices/event'; 5 | 6 | import Image from '../../../../../../components/Image'; 7 | import TicketPrice from '../../../../../../components/TicketPrice'; 8 | 9 | import styles from './styles.module.css'; 10 | 11 | type Props = { 12 | className: string; 13 | event: Event; 14 | ticket: Ticket; 15 | }; 16 | 17 | const CheckoutEventInfo: React.FC = ({ className, event, ticket }) => { 18 | const { title, image } = event; 19 | 20 | return ( 21 |
22 | {image && image.touchPrimary ? ( 23 | {title} 24 | ) : ( 25 |
26 | )} 27 |
{title}
28 |
29 | 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default CheckoutEventInfo; 36 | -------------------------------------------------------------------------------- /src/components/EventCard/style.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | display: flex; 3 | 4 | text-decoration: none; 5 | } 6 | 7 | .title { 8 | display: -webkit-box; 9 | -webkit-box-orient: vertical; 10 | -webkit-line-clamp: 3; 11 | overflow: hidden; 12 | 13 | margin-top: 5px; 14 | 15 | font-size: 16px; 16 | font-weight: bold; 17 | line-height: 22px; 18 | word-break: break-word; 19 | 20 | color: #000; 21 | } 22 | 23 | .annotation { 24 | display: -webkit-box; 25 | -webkit-box-orient: vertical; 26 | -webkit-line-clamp: 3; 27 | overflow: hidden; 28 | 29 | font-size: 12px; 30 | line-height: 16px; 31 | 32 | color: rgba(0, 0, 0, .5); 33 | } 34 | 35 | .image-wrapper { 36 | position: relative; 37 | 38 | overflow: hidden; 39 | flex: none; 40 | 41 | width: 152px; 42 | height: 100px; 43 | margin-right: 18px; 44 | 45 | border-radius: 8px; 46 | } 47 | 48 | .image-overlay { 49 | position: absolute; 50 | top: 0; 51 | right: 0; 52 | bottom: 0; 53 | left: 0; 54 | 55 | background: rgba(0, 0, 0, .2); 56 | } 57 | 58 | .image, 59 | .preview { 60 | display: block; 61 | 62 | width: 152px; 63 | height: 100px; 64 | } 65 | 66 | .image { 67 | object-fit: cover; 68 | } 69 | 70 | .preview { 71 | background: #eaeaea; 72 | } 73 | -------------------------------------------------------------------------------- /src/hooks/useScrollEffect.ts: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | import { throttle } from 'throttle-debounce'; 3 | 4 | const SCROLL_THROTTLE_INTERVAL = 50; 5 | 6 | export const useScrollEffect = ( 7 | scrollableRef: React.MutableRefObject, 8 | predicateRef: React.MutableRefObject, 9 | predicate: (predicateRef: React.MutableRefObject) => boolean 10 | ) => { 11 | const [value, setValue] = useState(() => predicate(predicateRef)); 12 | const updateValue = useCallback( 13 | throttle(SCROLL_THROTTLE_INTERVAL, () => { 14 | setValue(predicate(predicateRef)); 15 | }), 16 | [setValue, scrollableRef, predicateRef, predicate] 17 | ); 18 | 19 | useEffect(() => { 20 | const { current } = scrollableRef; 21 | 22 | current?.addEventListener('scroll', updateValue); 23 | 24 | return () => { 25 | current?.removeEventListener('scroll', updateValue); 26 | }; 27 | }, [scrollableRef, predicateRef, updateValue]); 28 | 29 | useEffect(() => { 30 | updateValue(); 31 | }, [updateValue, scrollableRef, predicateRef]); 32 | 33 | return value; 34 | }; 35 | -------------------------------------------------------------------------------- /src/screens/MainScreen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, Suspense } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { lazyDeferred } from '../../lib/lazy'; 5 | import { RootState } from '../../redux'; 6 | import { loadActualEvents } from '../../redux/slices/actual-events'; 7 | import { loadSelections } from '../../redux/slices/selections'; 8 | 9 | import PageHeader from '../../components/PageHeader'; 10 | 11 | import MainScreenSkeleton from './Skeleton'; 12 | 13 | const Component = lazyDeferred(() => 14 | import( 15 | /* webpackChunkName: "main-screen" */ 16 | './Component' 17 | ) 18 | ); 19 | 20 | const MainScreen: React.FC = () => { 21 | const dispatch = useDispatch(); 22 | const date = useSelector((state: RootState) => state.dateFilter); 23 | const { geoid } = useSelector((state: RootState) => state.city.currentCity); 24 | 25 | useEffect(() => { 26 | dispatch(loadActualEvents(date, geoid)); 27 | dispatch(loadSelections(date, geoid)); 28 | }, [dispatch, date, geoid]); 29 | 30 | return ( 31 | <> 32 | 33 | }> 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default MainScreen; 41 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/TicketButton/styles.module.css: -------------------------------------------------------------------------------- 1 | .static { 2 | margin-top: 12px; 3 | 4 | text-align: center; 5 | } 6 | 7 | .sticky { 8 | position: fixed; 9 | z-index: 50; 10 | right: 0; 11 | bottom: 0; 12 | left: 0; 13 | 14 | padding: 16px 12px; 15 | 16 | background-color: #fff; 17 | box-shadow: 0 0 14px 0 #00000040; 18 | 19 | will-change: transform; 20 | 21 | /* Делаем поправку на тень */ 22 | transform: translateY(120%); 23 | } 24 | 25 | .sticky-visible_yes { 26 | transform: translateY(0%); 27 | animation-name: slideUp; 28 | animation-duration: .4s; 29 | animation-timing-function: ease; 30 | } 31 | 32 | .sticky-visible_no { 33 | transform: translateY(120%); 34 | animation-name: slideDown; 35 | animation-duration: .4s; 36 | animation-timing-function: ease; 37 | } 38 | 39 | @keyframes slideUp { 40 | from { 41 | transform: translateY(120%); 42 | } 43 | 44 | to { 45 | transform: translateY(0%); 46 | } 47 | } 48 | 49 | @keyframes slideDown { 50 | from { 51 | transform: translateY(0%); 52 | } 53 | 54 | to { 55 | transform: translateY(120%); 56 | } 57 | } 58 | 59 | .button { 60 | width: 100%; 61 | height: 48px; 62 | 63 | font-size: 16px; 64 | font-weight: bold; 65 | line-height: 24px; 66 | } 67 | -------------------------------------------------------------------------------- /src/lib/metrika/index.ts: -------------------------------------------------------------------------------- 1 | import { reportGoalReached as jsApiReportGoalReached } from '../js-api/metrika'; 2 | import { YMetrikaInitParams, YMetrikaVisitParams } from './types'; 3 | 4 | const METRIKA_COUNTER_ID = 62405404; 5 | 6 | export const initialOptions: YMetrikaInitParams = { 7 | defer: true, 8 | clickmap: true, 9 | trackLinks: true, 10 | trackHash: true, 11 | accurateTrackBounce: true, 12 | ecommerce: 'dataLayer' 13 | }; 14 | 15 | export enum MetrikaGoals { 16 | OrderInitiated = 'order_initiated', 17 | OrderCompleted = 'order_completed', 18 | OrderError = 'order_error', 19 | } 20 | 21 | export type MetrikaOrderParams = { 22 | price: number; 23 | order_id: number; 24 | price_without_discount: number; 25 | } 26 | 27 | export function initMetrika() { 28 | window.dataLayer = window.dataLayer ?? []; 29 | window.ym?.(METRIKA_COUNTER_ID, 'init', initialOptions); 30 | } 31 | 32 | export function hit(url: string, options?: { params?: YMetrikaVisitParams }) { 33 | window.ym?.(METRIKA_COUNTER_ID, 'hit', url, options); 34 | } 35 | 36 | export function reachGoal(name: MetrikaGoals, params: MetrikaOrderParams) { 37 | window.ym?.(METRIKA_COUNTER_ID, 'reachGoal', name, params); 38 | } 39 | 40 | export function reportGoalReached(goal: MetrikaGoals, params: MetrikaOrderParams) { 41 | jsApiReportGoalReached(goal, params); 42 | } 43 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/CheckoutModal/components/CheckoutTextInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import TextInput from '../../../../../../components/TextInput'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | type Props = { 8 | className: string; 9 | label: string; 10 | type?: string; 11 | name?: string; 12 | value: string; 13 | onChange: (value: string) => void; 14 | onFocus?: () => void; 15 | required?: boolean; 16 | }; 17 | 18 | const CheckoutTextInput: React.FC = props => { 19 | const { className, label, type, name, value, onChange, onFocus, required } = props; 20 | const onInputChange = useCallback( 21 | (e: React.ChangeEvent) => { 22 | onChange(e.target.value); 23 | }, 24 | [onChange] 25 | ); 26 | 27 | return ( 28 | 40 | ); 41 | }; 42 | 43 | export default CheckoutTextInput; 44 | -------------------------------------------------------------------------------- /src/components/StackNavigator/Screen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useRef } from 'react'; 2 | 3 | import { StackNavigatorContext } from '../context'; 4 | import { Location, Params, ScreenComponent } from '../index'; 5 | 6 | import { isIOS } from '../../../lib/is-ios'; 7 | import styles from './styles.module.css'; 8 | 9 | export type ScreenType = { 10 | id: string; 11 | params: Params; 12 | component: ScreenComponent; 13 | location: Location; 14 | }; 15 | 16 | type Props = { 17 | isVisible: boolean; 18 | onBackward: () => void; 19 | screen: ScreenType; 20 | transitions?: boolean; 21 | }; 22 | 23 | const Screen: React.FC = ({ isVisible, transitions, screen, onBackward }) => { 24 | const className = isIOS() ? '' : [styles.screen, !isVisible && !transitions && styles.hidden].filter(Boolean).join(' '); 25 | const ref = useRef(null); 26 | 27 | const context = useMemo(() => { 28 | return { 29 | onBackward, 30 | isVisible, 31 | ref, 32 | }; 33 | }, [onBackward, isVisible, ref]); 34 | 35 | return ( 36 | 37 |
38 | 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default Screen; 45 | -------------------------------------------------------------------------------- /src/components/MenuModal/style.module.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | z-index: 100; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | } 9 | 10 | .modal::before { 11 | position: absolute; 12 | top: 0; 13 | right: 0; 14 | bottom: 0; 15 | left: 0; 16 | 17 | content: ''; 18 | 19 | opacity: 0; 20 | background: #000; 21 | will-change: opacity; 22 | 23 | transition: opacity 100ms ease-out; 24 | } 25 | 26 | .content { 27 | position: absolute; 28 | top: 0; 29 | bottom: 0; 30 | 31 | display: flex; 32 | flex-direction: column; 33 | 34 | box-sizing: border-box; 35 | width: 290px; 36 | padding: 18px 16px 0; 37 | 38 | background: #fff; 39 | 40 | transition: transform 200ms ease-out; 41 | transform: translate(-290px); 42 | 43 | will-change: transform; 44 | overscroll-behavior-y: contain; 45 | } 46 | 47 | .hidden { 48 | pointer-events: none; 49 | } 50 | 51 | .visible::before { 52 | opacity: .5; 53 | } 54 | 55 | .visible .content { 56 | transform: translate(0); 57 | } 58 | 59 | .body { 60 | overflow-x: hidden; 61 | overflow-y: scroll; 62 | flex-grow: 1; 63 | 64 | padding-bottom: 18px; 65 | } 66 | 67 | .profile { 68 | margin-bottom: 20px; 69 | } 70 | 71 | .psuid { 72 | overflow: hidden; 73 | 74 | text-overflow: ellipsis; 75 | } 76 | 77 | .logout { 78 | height: 55px; 79 | 80 | line-height: 55px; 81 | 82 | border-top: 1px solid #efefef; 83 | } 84 | -------------------------------------------------------------------------------- /src/screens/OrdersScreen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { lazyDeferred } from '../../lib/lazy'; 5 | import { loadEvent } from '../../redux/slices/event'; 6 | import { fetchOrders, orderEventIdsSelector } from '../../redux/slices/order'; 7 | 8 | import PageHeader from '../../components/PageHeader'; 9 | 10 | import OrdersScreenSkeleton from './Skeleton'; 11 | 12 | const Component = lazyDeferred(() => 13 | import( 14 | /* webpackChunkName: "orders-screen" */ 15 | './Component' 16 | ) 17 | ); 18 | 19 | const OrdersScreen: React.FC = () => { 20 | const dispatch = useDispatch(); 21 | const eventIds = useSelector(orderEventIdsSelector); 22 | 23 | const skeleton = ; 24 | 25 | useEffect(() => { 26 | dispatch(fetchOrders()); 27 | }, [dispatch]); 28 | 29 | useEffect(() => { 30 | eventIds.forEach(id => { 31 | dispatch(loadEvent(id)); 32 | }); 33 | }, [eventIds, dispatch]); 34 | 35 | return ( 36 | <> 37 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default OrdersScreen; 51 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/EventsFeed/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import { getRubricUrl } from '../../../../lib/url-builder'; 5 | import { RecommendedEvents } from '../../../../redux/slices/recommended-events'; 6 | 7 | import EventsSlider from '../../../../components/EventsSlider'; 8 | import EventCardVertical from '../../../../components/EventCardVertical'; 9 | 10 | import ContentBlock from '../ContentBlock'; 11 | import Title from '../Title'; 12 | 13 | import styles from './styles.module.css'; 14 | 15 | type Props = { 16 | title: string; 17 | events: RecommendedEvents['concert']; 18 | tagCode?: string; 19 | }; 20 | 21 | const EventsFeed: React.FC = ({ title, events, tagCode }) => { 22 | if (!events) { 23 | return null; 24 | } 25 | 26 | if (events.items.length === 0) { 27 | return null; 28 | } 29 | 30 | return ( 31 | 32 | {title} 33 | {tagCode && events.paging.total > 5 ? : null} 34 | 35 | 36 | ); 37 | }; 38 | 39 | const MoreButton: React.FC<{ tagCode: string }> = props => { 40 | return ( 41 | 42 | Все 43 | 44 | ); 45 | }; 46 | 47 | export default EventsFeed; 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Интеграция сервиса с Платформой 2 | 3 | Миниапп представляет собой оптимизированное веб-приложение, которое имеет доступ к возможностям платформы, умеет работать оффлайн и по ощущениям максимально приближено к работе обычных мобильных приложений. 4 | 5 | [Demo](https://yandex.github.io/miniapp-example/) 6 | 7 | ## Оплата 8 | В приложении поддержана оплата билетов, для этого запускается отдельный [бэкенд](https://github.com/yandex/miniapp-example-backend). 9 | 10 | ## Технология реализации миниаппа 11 | 12 | Миниапп написан по технологии SPA (Single Page Application) — это веб-приложение, размещенное на одной веб-странице, которая для обеспечения работы загружает весь необходимый код вместе с загрузкой самой страницы. Основное преимущество этой технологии — обновление контента страницы без перезагрузки страницы. 13 | 14 | ## Манифест 15 | 16 | Манифест — это Web App Manifest с дополнительными секциями для Платформы. 17 | 18 | - [Описание полей и примеры](docs/Manifest.md) 19 | - Документация: https://www.w3.org/TR/appmanifest/ 20 | 21 | Более подробно об [архитектуре](docs/Architecture.md). 22 | 23 | ## Разработка и запуск 24 | ### Команды 25 | 26 | - `npm start` - запуск приложения в режиме разработки; 27 | - `npm run build` - сборка production версии приложения; 28 | - `npm run deploy` - развертывание приложения на gh-pages. 29 | 30 | ### Разработка 31 | 32 | - Документация по [React](https://reactjs.org/) 33 | - Документация по [Create React App](https://facebook.github.io/create-react-app/docs/getting-started/) 34 | -------------------------------------------------------------------------------- /src/components/Image/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | 3 | import { MediaImageSize } from '../../lib/api/fragments/image-size'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | const emptyImage = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs='; 8 | 9 | export type ImageProps = { 10 | className?: string; 11 | src?: MediaImageSize | null; 12 | src2x?: MediaImageSize | null; 13 | bgColor?: string | null; 14 | alt?: string; 15 | }; 16 | 17 | const Image: React.FC = props => { 18 | const color = props.bgColor || '#555'; 19 | const [isLoaded, setLoaded] = useState(false); 20 | 21 | const onLoad = useCallback(() => { 22 | if (props.src) { 23 | setLoaded(true); 24 | } 25 | }, [setLoaded, props.src]); 26 | const stubClassNames = [styles.stub, isLoaded && styles['stub-hidden']].filter(Boolean).join(' '); 27 | const containerClassNames = [props.className, styles.container].filter(Boolean).join(' '); 28 | 29 | return ( 30 |
31 | {props.alt} 38 |
39 |
40 | ); 41 | }; 42 | 43 | export default Image; 44 | -------------------------------------------------------------------------------- /src/redux/slices/city-list.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { persistReducer } from 'redux-persist'; 3 | 4 | import { fetchCityList } from '../../lib/api'; 5 | import { getPersistConfig } from '../helpers/persist'; 6 | 7 | import { City } from '../../lib/api/fragments/city'; 8 | import { AppThunk } from '../index'; 9 | 10 | export type CityListState = { 11 | isLoading: boolean; 12 | items: City[]; 13 | }; 14 | 15 | const initialState: CityListState = { 16 | isLoading: false, 17 | items: [], 18 | }; 19 | 20 | const cityList = createSlice({ 21 | name: 'city-list', 22 | initialState, 23 | reducers: { 24 | setLoading(state, action: PayloadAction) { 25 | state.isLoading = action.payload; 26 | }, 27 | setCityListResult(state, action: PayloadAction) { 28 | state.items = action.payload; 29 | }, 30 | }, 31 | }); 32 | 33 | export const { setLoading, setCityListResult } = cityList.actions; 34 | 35 | export const loadCityList = (): AppThunk => async dispatch => { 36 | dispatch(setLoading(true)); 37 | 38 | try { 39 | const { cities } = await fetchCityList(); 40 | dispatch(setCityListResult(cities)); 41 | } catch (err) { 42 | console.error(err); 43 | } 44 | 45 | dispatch(setLoading(false)); 46 | }; 47 | 48 | const persistConfig = getPersistConfig('city-list', { 49 | blacklist: ['isLoading'], 50 | }); 51 | export default persistReducer(persistConfig, cityList.reducer); 52 | -------------------------------------------------------------------------------- /src/components/SelectionList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import { getSelectionUrl } from '../../lib/url-builder'; 5 | import { Selection } from '../../redux/slices/selections'; 6 | import Image from '../Image'; 7 | 8 | import styles from './style.module.css'; 9 | 10 | export type Props = { 11 | items: Array; 12 | }; 13 | 14 | const SelectionList: React.FC = ({ items }) => { 15 | return ( 16 |
17 | {items.map(selection => { 18 | if (!selection) { 19 | return null; 20 | } 21 | 22 | return ( 23 | 24 | {selection.title} 30 |
31 |
{selection.title}
32 |
{selection.count} событий
33 |
34 | 35 | ); 36 | })} 37 |
38 | ); 39 | }; 40 | 41 | export default SelectionList; 42 | -------------------------------------------------------------------------------- /src/lib/metrika/ecommerce.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '../api/fragments/event'; 2 | 3 | import { CurrencyCode } from './types'; 4 | 5 | function getProductFromEvent(event: Event) { 6 | const price = event.tickets?.[0]?.price?.min; 7 | 8 | return { 9 | id: event.id, 10 | name: event.title, 11 | category: event.type.name, 12 | price: price ? price / 100 : undefined 13 | }; 14 | } 15 | 16 | function getEventCurrency(event: Event) { 17 | return event.tickets?.[0]?.price?.currency.toUpperCase() as CurrencyCode | undefined; 18 | } 19 | 20 | export function logProductView(event: Event) { 21 | window.dataLayer.push({ 22 | ecommerce: { 23 | currencyCode: getEventCurrency(event), 24 | detail: { 25 | products: [getProductFromEvent(event)] 26 | } 27 | } 28 | }); 29 | } 30 | 31 | export function logProductAdd(event: Event) { 32 | window.dataLayer.push({ 33 | ecommerce: { 34 | currencyCode: getEventCurrency(event), 35 | add: { 36 | products: [getProductFromEvent(event)] 37 | } 38 | } 39 | }); 40 | } 41 | 42 | export function logProductPurchase(event: Event, orderId: number) { 43 | window.dataLayer.push({ 44 | ecommerce: { 45 | currencyCode: getEventCurrency(event), 46 | purchase: { 47 | actionField: { 48 | id: orderId.toString(), 49 | }, 50 | products: [getProductFromEvent(event)] 51 | } 52 | } 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/SearchResult/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { RootState } from '../../../redux'; 5 | 6 | import EventsList from '../../../components/EventsList'; 7 | import EventCard from '../../../components/EventCard'; 8 | import { useThrottleLoading } from '../../../hooks/useThrottleLoading'; 9 | 10 | import SearchResultSkeleton from './Skeleton'; 11 | import styles from './styles.module.css'; 12 | 13 | type Props = { 14 | onItemClick: () => void; 15 | }; 16 | 17 | const SKELETON_THROTTLE_MS = 250; 18 | 19 | const SearchResult: React.FC = ({ onItemClick }) => { 20 | const { groups } = useSelector((state: RootState) => state.search.data.results); 21 | const { isLoading } = useSelector((state: RootState) => state.search.ui.results); 22 | 23 | const isThrottleLoading = useThrottleLoading(isLoading, SKELETON_THROTTLE_MS); 24 | 25 | if (isThrottleLoading) { 26 | return ; 27 | } 28 | 29 | if (groups.length === 0 && !isLoading && !isThrottleLoading) { 30 | return
Ничего не найдено
; 31 | } 32 | 33 | return ( 34 | <> 35 | {groups.map(({ code, title, events }) => ( 36 |
37 |

{title}

38 | 39 |
40 | ))} 41 | 42 | ); 43 | }; 44 | 45 | export default SearchResult; 46 | -------------------------------------------------------------------------------- /docs/Architecture.md: -------------------------------------------------------------------------------- 1 | # Архитектура миниаппа 2 | 3 | 4 | 5 | User action — действие пользователя, которое инициирует загрузку приложения. Вызывает навигейт на URL миниаппа. 6 | 7 | Процесс загрузки начинается с запроса #1 в NativeCache за шаблоном приложения (AppTemplate). Приложение представляет из себя набор экранов, состоящих из компонентов (UI Blocks), которые могут быть отрисованы в двух состояниях: 8 | 9 | - скелет внутренних компонентов (например, пока данные для отрисовки еще запрашиваются); 10 | - компоненты с данными. 11 | 12 | Весь JS-код, который реализует бизнес-логику, логику работы блоков и т.д., делится на модули (JS Blocks) и грузится лениво, по требованию. 13 | 14 | ## Offline 15 | 16 | Данные (AppData) хранятся отдельно от приложения в IndexedDB.
17 | Политика кэширования данных целиком на стороне приложения. 18 | 19 | ## SplashScreen 20 | 21 | Время до первого сontentfull-кадра у нас не может быть меньше времени готовности нативного webView, а это далеко не нулевое время. Поэтому мы должны максимально быстро показать пользователю хоть что-нибудь. Для этого нам в всегда нужен шаблон, который должен быть максимально легким: HTML+CSS и никакого JS. Этот шаблон встроен в index.html и скрывается, когда отработал клиентский рендер стартового экрана. 22 | 23 | ## NativeCache 24 | 25 | Политика кеширования: 26 | 27 | - Для оптимизации скорости загрузки страниц платформа будет кэшировать всю статику (CSS, JS, HTML); 28 | - Обновление бандла со статикой будет происходить в версионированном виде в фоновом режиме, не влияя на текущий сеанс работы с сервисом. 29 | -------------------------------------------------------------------------------- /src/components/YaMapWidget/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | 3 | import { Coordinates } from '../../lib/api/fragments/coordinates'; 4 | import { Metro } from '../../lib/api/fragments/metro'; 5 | 6 | import { getStaticMapUrl } from '../../lib/maps'; 7 | 8 | import YaMapModal from '../YaMapModal'; 9 | import Image from '../Image'; 10 | 11 | import styles from './styles.module.css'; 12 | 13 | type Props = { 14 | coordinates: Coordinates; 15 | metro: Metro[] | null; 16 | address: string | null; 17 | }; 18 | 19 | const YaMapWidget: React.FC = props => { 20 | const staticMapImage = { 21 | url: getStaticMapUrl({ 22 | size: [650, 176], 23 | z: 16, 24 | pt: [props.coordinates.longitude, props.coordinates.latitude, 'pm2rdl'], 25 | }), 26 | width: 650, 27 | height: 176, 28 | }; 29 | 30 | const [isModalOpen, setModalOpen] = useState(false); 31 | 32 | const onMapClick = useCallback(() => { 33 | setModalOpen(true); 34 | 35 | document.body.style.overflow = 'hidden'; 36 | }, []); 37 | 38 | const onBackClick = useCallback(() => { 39 | setModalOpen(false); 40 | 41 | document.body.style.overflow = 'auto'; 42 | }, []); 43 | 44 | return ( 45 | <> 46 |
47 | 48 |
49 | {isModalOpen && } 50 | 51 | ); 52 | }; 53 | 54 | export default YaMapWidget; 55 | -------------------------------------------------------------------------------- /src/lib/checkout/types/checkout-request.ts: -------------------------------------------------------------------------------- 1 | import { YandexCheckoutDetails, YandexCheckoutDetailsUpdate } from './checkout-details'; 2 | import { YandexCheckoutOptions } from './checkout-options'; 3 | import { YandexCheckoutState } from './checkout-state'; 4 | 5 | export type YandexCheckoutEvent = 6 | | 'restoreState' 7 | | 'cityChange' 8 | | 'shippingOptionChange' 9 | | 'shippingAddressChange' 10 | | 'pickupOptionChange' 11 | | 'datetimeOptionChange' 12 | | 'promoCodeChange' 13 | | 'paymentOptionChange' 14 | | 'paymentStart' 15 | | 'paymentError'; 16 | 17 | export type YandexCheckoutRequestUpdateEvent = { 18 | // Тип события, для которого вызван обработчик 19 | type: YandexCheckoutEvent; 20 | 21 | // Состояние формы на момент наступления события 22 | checkoutState: YandexCheckoutState; 23 | 24 | // Метод для обновления формы заказа 25 | updateWith(details: null | YandexCheckoutDetailsUpdate | Promise): void; 26 | }; 27 | 28 | export interface YandexCheckoutRequest { 29 | // Открытие диалога с формой заказа 30 | show(): Promise; 31 | 32 | // Добавление/удаление обработчиков событий 33 | addEventListener( 34 | event: YandexCheckoutEvent, 35 | listener: (event: YandexCheckoutRequestUpdateEvent) => void 36 | ): void; 37 | removeEventListener( 38 | event: YandexCheckoutEvent, 39 | listener: (event: YandexCheckoutRequestUpdateEvent) => void 40 | ): void; 41 | 42 | new (details: YandexCheckoutDetails, options?: YandexCheckoutOptions): YandexCheckoutRequest; 43 | } 44 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/PopularResult/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { useSelector } from 'react-redux'; 4 | 5 | import { getEventUrl } from '../../../lib/url-builder'; 6 | import { RootState } from '../../../redux'; 7 | 8 | import PopularResultSkeleton from './Skeleton'; 9 | 10 | import styles from './styles.module.css'; 11 | 12 | type Props = { 13 | onLinkClick: () => void; 14 | }; 15 | const Index: React.FC = ({ onLinkClick }) => { 16 | const { events } = useSelector((state: RootState) => state.search.data.popularEvents); 17 | const { isLoading } = useSelector((state: RootState) => state.search.ui.popularEvents); 18 | 19 | return ( 20 |
21 |
Популярные запросы
22 | {isLoading && } 23 |
    24 | {events.map((actualEvent, i) => { 25 | if (!actualEvent) { 26 | return null; 27 | } 28 | 29 | const { id, title } = actualEvent.eventPreview; 30 | 31 | return ( 32 |
  • 33 | 34 | {title} 35 | 36 |
  • 37 | ); 38 | })} 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default Index; 45 | -------------------------------------------------------------------------------- /src/components/GalleryModal/styles.module.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | z-index: 100; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | 9 | color: #fff; 10 | background: #000; 11 | } 12 | 13 | .carousel { 14 | display: flex; 15 | overflow-x: scroll; 16 | -webkit-overflow-scrolling: touch; 17 | align-items: center; 18 | 19 | height: 100%; 20 | scroll-snap-type: x mandatory; 21 | overscroll-behavior-x: contain; 22 | scrollbar-width: none; 23 | } 24 | 25 | .carousel::-webkit-scrollbar { 26 | display: none; 27 | } 28 | 29 | .image { 30 | display: flex; 31 | flex: none; 32 | justify-content: center; 33 | align-items: center; 34 | 35 | width: 100vw; 36 | height: 100%; 37 | margin-left: 20px; 38 | scroll-snap-align: center; 39 | } 40 | 41 | .image img { 42 | max-width: 100%; 43 | margin: 0 auto; 44 | } 45 | 46 | .image:first-child { 47 | margin-left: 0; 48 | } 49 | 50 | .close { 51 | position: fixed; 52 | z-index: 2; 53 | top: 0; 54 | left: 0; 55 | 56 | width: calc(16px + 20px * 2); 57 | height: calc(14px + 22px * 2); 58 | padding: 20px; 59 | 60 | font-size: 0; 61 | 62 | color: #fff; 63 | border: none; 64 | outline: none; 65 | background: url('./back.svg') center center no-repeat; 66 | background-size: 16px 14px; 67 | } 68 | 69 | .counter { 70 | position: fixed; 71 | z-index: 1; 72 | top: 0; 73 | right: 0; 74 | left: 0; 75 | 76 | font-size: 14px; 77 | line-height: 56px; 78 | text-align: center; 79 | 80 | background: rgba(0, 0, 0, .2); 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/metrika/types.ts: -------------------------------------------------------------------------------- 1 | export type YMetrikaVisitParams = object | object[]; 2 | 3 | export type YMetrikaInitParams = { 4 | defer?: boolean; 5 | clickmap?: boolean; 6 | trackLinks?: boolean; 7 | accurateTrackBounce?: boolean; 8 | webvisor?: boolean; 9 | trackHash?: boolean; 10 | params?: YMetrikaVisitParams; 11 | ecommerce?: string; 12 | }; 13 | 14 | export type Product = { 15 | id: string; 16 | name?: string; 17 | brand?: string; 18 | category?: string; 19 | coupon?: string; 20 | position?: number; 21 | price?: number; 22 | quantity?: number; 23 | variant?: string; 24 | } 25 | 26 | export type Action = { 27 | products: Product[]; 28 | } 29 | 30 | export type ActionField = { 31 | id: string; 32 | coupon?: string; 33 | goal_id?: number; 34 | revenue?: number; 35 | } 36 | 37 | export type Purchase = Action & { 38 | actionField: ActionField; 39 | } 40 | 41 | export type CurrencyCode = 'RUB' | 'USD' 42 | 43 | type EcommerceValueBase = { 44 | currencyCode?: CurrencyCode; 45 | } 46 | 47 | export type EcommerceValueAdd = EcommerceValueBase & { 48 | add: Action; 49 | } 50 | 51 | export type EcommerceValueRemove = EcommerceValueBase & { 52 | remove: Action; 53 | } 54 | 55 | export type EcommerceValueDetail = EcommerceValueBase & { 56 | detail: Action; 57 | } 58 | 59 | export type EcommerceValuePurchase = EcommerceValueBase & { 60 | purchase: Purchase; 61 | }; 62 | 63 | export type EcommerceItem = { 64 | ecommerce: EcommerceValueAdd 65 | | EcommerceValueRemove 66 | | EcommerceValueDetail 67 | | EcommerceValuePurchase; 68 | } 69 | -------------------------------------------------------------------------------- /src/screens/EventScreen/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Rect, Text } from '../../../components/Skeletons'; 4 | import RecommendedEventsSkeleton from '../components/RecommendedEvents/Skeleton'; 5 | 6 | import styles from './styles.module.css'; 7 | 8 | const EventSkeleton: React.FC = () => ( 9 |
10 |
11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 |
27 | 28 | 29 | 30 |
31 |
32 | 33 |
34 | {Array(5) 35 | .fill(0) 36 | .map((_, index) => ( 37 | 38 | ))} 39 |
40 | 41 |
42 | 43 |
44 |
45 | ); 46 | export default EventSkeleton; 47 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/Cover/styles.module.css: -------------------------------------------------------------------------------- 1 | .cover { 2 | position: relative; 3 | z-index: 1; 4 | 5 | height: 100vw; 6 | margin-top: -56px; 7 | 8 | background-position: 50% 50%; 9 | background-size: cover; 10 | } 11 | 12 | .image { 13 | position: absolute; 14 | z-index: 1; 15 | top: 0; 16 | 17 | width: 100%; 18 | height: 100%; 19 | } 20 | 21 | .fade { 22 | position: absolute; 23 | z-index: 3; 24 | top: 0; 25 | 26 | width: 100%; 27 | height: 100%; 28 | 29 | opacity: .8; 30 | /* stylelint-disable */ 31 | background: linear-gradient(180deg, #000, rgba(0, 0, 0, .254) 66.23%, rgba(0, 0, 0, .116) 79.15%, rgba(0, 0, 0, .056) 89.65%, rgba(0, 0, 0, .01)); 32 | /* stylelint-enable */ 33 | mix-blend-mode: normal; 34 | } 35 | 36 | .fade::after { 37 | position: absolute; 38 | z-index: 2; 39 | top: 0; 40 | left: 0; 41 | 42 | display: block; 43 | 44 | width: 100%; 45 | height: 100%; 46 | 47 | content: ''; 48 | 49 | opacity: .3; 50 | background: #221826; 51 | } 52 | 53 | .overview { 54 | position: absolute; 55 | z-index: 4; 56 | bottom: 0; 57 | 58 | padding: 0 12px 32px; 59 | 60 | color: #fff; 61 | } 62 | 63 | .title { 64 | margin-top: 0; 65 | margin-bottom: 4px; 66 | 67 | font-size: 24px; 68 | font-weight: bold; 69 | line-height: 32px; 70 | letter-spacing: -.5px; 71 | } 72 | 73 | .argument { 74 | margin-bottom: 4px; 75 | 76 | font-size: 16px; 77 | line-height: 22px; 78 | } 79 | 80 | .tags { 81 | font-size: 14px; 82 | line-height: 24px; 83 | white-space: pre-wrap; 84 | 85 | opacity: .6; 86 | } 87 | -------------------------------------------------------------------------------- /src/redux/slices/autofill.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | import { UserInfo } from '../../lib/api/types'; 4 | import { AppErrorCode } from '../../lib/error'; 5 | import { getProfileData } from '../../lib/autofill'; 6 | import { AppThunk, RootState } from '..'; 7 | 8 | type AutofillState = { 9 | user?: UserInfo; 10 | }; 11 | 12 | const initialState: AutofillState = {}; 13 | 14 | const slice = createSlice({ 15 | name: 'autofill', 16 | initialState, 17 | reducers: { 18 | setAutofilledUser(state, action: PayloadAction) { 19 | state.user = action.payload; 20 | }, 21 | }, 22 | }); 23 | 24 | const { setAutofilledUser } = slice.actions; 25 | 26 | export const isAutofilledSelector = (state: RootState) => Boolean(state.autofill.user); 27 | export const autofilledUserSelector = (state: RootState) => state.autofill.user; 28 | 29 | export const autofill = (): AppThunk => async dispatch => { 30 | try { 31 | const { 32 | email, 33 | lastName, 34 | firstName, 35 | middleName, 36 | phoneNumber: phone, 37 | streetAddress: address, 38 | } = await getProfileData(); 39 | 40 | const name = [lastName, firstName, middleName].filter(Boolean).join(' '); 41 | 42 | dispatch(setAutofilledUser({ name, phone, email, address })); 43 | } catch (err) { 44 | const { code } = err; 45 | 46 | if ([AppErrorCode.JsApiDenied, AppErrorCode.JsApiMethodNotAvailable].includes(code)) { 47 | return console.warn(err); 48 | } 49 | 50 | console.error(err); 51 | } 52 | }; 53 | 54 | export default slice.reducer; 55 | -------------------------------------------------------------------------------- /src/components/CitySelect/styles.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | position: relative; 3 | 4 | width: 100%; 5 | height: 56px; 6 | 7 | border-left-width: 0; 8 | } 9 | 10 | .wrapper { 11 | position: absolute; 12 | top: 0; 13 | bottom: 0; 14 | 15 | overflow-x: hidden; 16 | overflow-y: scroll; 17 | 18 | box-sizing: border-box; 19 | width: 100%; 20 | padding: 10px 16px; 21 | 22 | background: #fff; 23 | 24 | /* 25 | Лишние 40 пкс для того, чтобы нельзя было увидеть попап при скролле экрана 26 | с одновременным скрытием нижней полоски браузера 27 | */ 28 | transform: translate(0, calc(100vh + 40px)); 29 | 30 | will-change: transform; 31 | overscroll-behavior-y: contain; 32 | } 33 | 34 | .wrapper-visible_yes { 35 | transform: translate(0, 0); 36 | animation-name: slideUp; 37 | animation-duration: .2s; 38 | animation-timing-function: ease-out; 39 | } 40 | 41 | .wrapper-visible_no { 42 | transform: translate(0, calc(100vh + 40px)); 43 | animation-name: slideDown; 44 | animation-duration: .2s; 45 | animation-timing-function: ease-out; 46 | } 47 | 48 | .city-list { 49 | margin: 0; 50 | padding: 0; 51 | 52 | list-style: none; 53 | } 54 | 55 | .city-item { 56 | padding: 10px 0; 57 | 58 | color: #444; 59 | border-top: 1px solid #e3e3e3; 60 | } 61 | 62 | @keyframes slideUp { 63 | from { 64 | transform: translate(0, calc(100vh + 40px)); 65 | } 66 | 67 | to { 68 | transform: translate(0, 0); 69 | } 70 | } 71 | 72 | @keyframes slideDown { 73 | from { 74 | transform: translate(0, 0); 75 | } 76 | 77 | to { 78 | transform: translate(0, calc(100vh + 40px)); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/EventCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import { getEventUrl } from '../../lib/url-builder'; 5 | import { ActualEvent } from '../../lib/api/fragments/actual-event'; 6 | 7 | import Image from '../Image'; 8 | import PriceLabel from '../PriceLabel'; 9 | 10 | import styles from './style.module.css'; 11 | 12 | export type Props = { 13 | event: ActualEvent; 14 | onClick?: () => void; 15 | }; 16 | 17 | const EventCard: React.FC = props => { 18 | const { id, title, argument, image, tickets } = props.event.eventPreview; 19 | const ticket = tickets && tickets[0]; 20 | 21 | return ( 22 | 23 |
24 | {image && image.eventListCard ? ( 25 | {title} 32 | ) : ( 33 |
34 | )} 35 | {ticket && } 36 | {image &&
} 37 |
38 |
39 |
{title}
40 |
{argument}
41 |
42 | 43 | ); 44 | }; 45 | 46 | export default memo(EventCard); 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miniapp-example", 3 | "version": "1.31.0", 4 | "private": true, 5 | "homepage": "https://yandex.github.io/miniapp-example/", 6 | "scripts": { 7 | "start": "react-scripts start", 8 | "build": "react-scripts build && node ./tools/generate-yandex-manifest.js", 9 | "deploy": "gh-pages -d build", 10 | "predeploy": "PUBLIC_URL=https://yandex.github.io/miniapp-example REACT_APP_API_HOST=/miniapp-example REACT_APP_PAYMENT_API_HOST=https://miniapp-api.tap.yandex.net npm run build" 11 | }, 12 | "browserslist": { 13 | "production": [ 14 | "last 2 versions" 15 | ], 16 | "development": [ 17 | "last 2 versions" 18 | ] 19 | }, 20 | "devDependencies": { 21 | "@piotr-cz/redux-persist-idb-storage": "1.0.2", 22 | "@reduxjs/toolkit": "1.5.0", 23 | "@types/react": "16.8.25", 24 | "@types/react-dom": "16.8.5", 25 | "@types/react-redux": "7.1.5", 26 | "@types/react-router-dom": "5.1.2", 27 | "@types/react-transition-group": "4.2.3", 28 | "@types/redux-thunk": "2.1.0", 29 | "@types/throttle-debounce": "2.1.0", 30 | "date-fns": "1.30.1", 31 | "gh-pages": "2.1.1", 32 | "intersection-observer": "0.7.0", 33 | "react": "16.8.4", 34 | "react-dom": "16.8.4", 35 | "react-redux": "7.1.3", 36 | "react-router-dom": "5.1.2", 37 | "react-scripts": "3.4.0", 38 | "react-transition-group": "4.3.0", 39 | "react-use": "13.12.2", 40 | "react-yandex-maps": "4.2.0", 41 | "redux-persist": "6.0.0", 42 | "redux-thunk": "2.3.0", 43 | "throttle-debounce": "2.1.0", 44 | "typescript": "3.7.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/EventCardMain/style.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | display: flex; 3 | 4 | text-decoration: none; 5 | } 6 | 7 | .title { 8 | display: -webkit-box; 9 | -webkit-box-orient: vertical; 10 | -webkit-line-clamp: 3; 11 | overflow: hidden; 12 | 13 | font-size: 16px; 14 | font-weight: bold; 15 | line-height: 22px; 16 | word-break: break-word; 17 | 18 | color: #fff; 19 | } 20 | 21 | .annotation { 22 | font-size: 12px; 23 | line-height: 16px; 24 | 25 | color: rgba(255, 255, 255, .5); 26 | } 27 | 28 | .image-wrapper { 29 | position: relative; 30 | 31 | overflow: hidden; 32 | flex-shrink: 0; 33 | 34 | width: 60%; 35 | height: 144px; 36 | 37 | border-radius: 0 8px 8px 0; 38 | } 39 | 40 | .info-block { 41 | position: relative; 42 | 43 | flex-grow: 1; 44 | 45 | border-radius: 8px 0 0 8px; 46 | } 47 | 48 | .description { 49 | position: absolute; 50 | right: 12px; 51 | bottom: 13px; 52 | left: 12px; 53 | } 54 | 55 | .image-overlay { 56 | position: absolute; 57 | top: 0; 58 | right: 0; 59 | bottom: 0; 60 | left: 0; 61 | 62 | background: rgba(0, 0, 0, .2); 63 | } 64 | 65 | .image, 66 | .preview { 67 | width: 100%; 68 | height: 144px; 69 | } 70 | 71 | .image { 72 | object-fit: cover; 73 | } 74 | 75 | .preview { 76 | background: #eaeaea; 77 | } 78 | 79 | .date { 80 | position: absolute; 81 | top: 13px; 82 | left: 11px; 83 | 84 | display: flex; 85 | 86 | font-size: 12px; 87 | 88 | color: #fff; 89 | } 90 | 91 | .day { 92 | padding-right: 3px; 93 | 94 | font-size: 32px; 95 | } 96 | 97 | .month { 98 | line-height: 16px; 99 | } 100 | -------------------------------------------------------------------------------- /src/components/EventCardVertical/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import { getEventUrl } from '../../lib/url-builder'; 5 | import { ActualEvent } from '../../lib/api/fragments/actual-event'; 6 | 7 | import Image from '../Image'; 8 | import PriceLabel from '../PriceLabel'; 9 | 10 | import styles from './style.module.css'; 11 | 12 | export type Props = { 13 | event: ActualEvent; 14 | onClick?: () => void; 15 | className?: string; 16 | }; 17 | 18 | const EventCardVertical: React.FC = props => { 19 | const { id, title, argument, image, tickets } = props.event.eventPreview; 20 | const ticket = tickets && tickets[0]; 21 | 22 | return ( 23 | 28 |
29 | {image && image.eventListCard ? ( 30 | {title} 37 | ) : ( 38 |
39 | )} 40 | {ticket && } 41 | {image &&
} 42 |
43 |
{title}
44 |
{argument}
45 | 46 | ); 47 | }; 48 | 49 | export default memo(EventCardVertical); 50 | -------------------------------------------------------------------------------- /src/lib/global.d.ts: -------------------------------------------------------------------------------- 1 | import { YandexTransactionPushToken } from './js-api/push'; 2 | import { YandexProfileData, YandexProfileField } from './js-api/autofill'; 3 | import { YandexAuthInfo, YandexAuthPSUIDInfo, YandexAuthScope } from './js-api/auth'; 4 | import { EcommerceItem, YMetrikaInitParams, YMetrikaVisitParams } from './metrika/types'; 5 | 6 | declare global { 7 | interface Window { 8 | ym(counterId: number, action: 'init', params?: YMetrikaInitParams): void; 9 | ym(counterId: number, action: 'hit', url: string, options?: { params?: YMetrikaVisitParams }): void; 10 | ym( 11 | counterId: number, 12 | action: 'reachGoal', 13 | target: string, 14 | params?: YMetrikaVisitParams, 15 | cb?: () => void, 16 | ctx?: object 17 | ): void; 18 | dataLayer: Array; 19 | yandex?: { 20 | app?: { 21 | auth?: { 22 | identify?: (clientId: string) => Promise; 23 | authorize?: (clientId: string, scopes?: Array) => Promise; 24 | updateUserInfo?: (authToken: string) => Promise; 25 | getCurrentUserId?: (clientId: string) => Promise; 26 | }; 27 | push?: { 28 | getPushTokenForTransaction?: (paymentToken: string) => Promise; 29 | }; 30 | reportGoalReached?: (goal: string, data: object) => Promise; 31 | }; 32 | autofill?: { 33 | getProfileData?: (profileFields: Array) => Promise; 34 | }; 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/screens/EventScreen/components/CheckoutModal/styles.module.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | z-index: 200; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | } 9 | 10 | .modal::before { 11 | position: absolute; 12 | top: 0; 13 | right: 0; 14 | bottom: 0; 15 | left: 0; 16 | 17 | content: ''; 18 | 19 | opacity: 0; 20 | background: #000; 21 | will-change: opacity; 22 | 23 | transition: opacity 100ms ease-out; 24 | } 25 | 26 | .content { 27 | --top-left-offset: 4px; 28 | 29 | position: absolute; 30 | right: 4px; 31 | bottom: var(--top-left-offset); 32 | left: 4px; 33 | 34 | overflow-y: scroll; 35 | 36 | box-sizing: border-box; 37 | max-height: calc(100vh - var(--top-left-offset) * 2); 38 | padding: 20px 16px 16px; 39 | 40 | border-radius: 24px; 41 | background: #fff; 42 | 43 | transition: transform 200ms ease-out; 44 | transform: translate(0, 100%); 45 | will-change: transform; 46 | overscroll-behavior-x: contain; 47 | scrollbar-width: none; 48 | } 49 | 50 | .content::-webkit-scrollbar { 51 | display: none; 52 | } 53 | 54 | .visible::before { 55 | opacity: .5; 56 | } 57 | 58 | .visible .content { 59 | transform: translate(0, 0); 60 | } 61 | 62 | .hidden { 63 | pointer-events: none; 64 | } 65 | 66 | .title { 67 | margin-bottom: 16px; 68 | 69 | font-size: 20px; 70 | font-weight: bold; 71 | line-height: 26px; 72 | } 73 | 74 | .event { 75 | margin-bottom: 20px; 76 | } 77 | 78 | .input { 79 | margin-top: 9px; 80 | } 81 | 82 | .total { 83 | margin-top: 32px; 84 | } 85 | 86 | .button { 87 | width: 100%; 88 | height: 44px; 89 | margin-top: 16px; 90 | 91 | font-size: 14px; 92 | line-height: 44px; 93 | 94 | color: rgba(0, 0, 0, .8); 95 | border-radius: 8px; 96 | } 97 | -------------------------------------------------------------------------------- /src/components/CollapsedText/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState, useCallback } from 'react'; 2 | 3 | import ClearButton from '../ClearButton'; 4 | 5 | import styles from './styles.module.css'; 6 | 7 | export type ContentProps = { 8 | className?: string; 9 | lines: number; 10 | fullTextLabel: string; 11 | }; 12 | 13 | const CollapsedText: React.FC = props => { 14 | const shortRef = useRef(null); 15 | const fullRef = useRef(null); 16 | const [visible, setVisible] = useState(false); 17 | 18 | useEffect(() => { 19 | if (!shortRef.current || !fullRef.current) { 20 | return; 21 | } 22 | 23 | const shortRect = shortRef.current.getBoundingClientRect(); 24 | const fullRect = fullRef.current.getBoundingClientRect(); 25 | 26 | if (shortRect.height === fullRect.height) { 27 | setVisible(true); 28 | } 29 | }, [shortRef, fullRef, props.lines]); 30 | 31 | const onClick = useCallback(() => setVisible(true), [setVisible]); 32 | 33 | return ( 34 | <> 35 | {!visible && ( 36 |
41 | {props.children} 42 |
43 | )} 44 |
45 | {props.children} 46 |
47 | {!visible && ( 48 | 49 | {props.fullTextLabel} 50 | 51 | )} 52 | 53 | ); 54 | }; 55 | 56 | export default CollapsedText; 57 | -------------------------------------------------------------------------------- /src/components/AuthBox/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { MediaImageSize } from '../../lib/api/fragments/image-size'; 6 | import { userSelector, isAuthenticatedSelector, isAuthorizedSelector, authorize } from '../../redux/slices/user'; 7 | 8 | import Image from '../Image'; 9 | import LoginButton from '../LoginButton'; 10 | 11 | import styles from './styles.module.css'; 12 | 13 | const getAvatarImage = (avatarId: string): MediaImageSize => ({ 14 | url: `https://avatars.yandex.net/get-yapic/${avatarId}/islands-middle`, 15 | width: 42, 16 | height: 42, 17 | }); 18 | const getAvatarImage2x = (avatarId: string): MediaImageSize => ({ 19 | url: `https://avatars.yandex.net/get-yapic/${avatarId}/islands-retina-middle`, 20 | width: 84, 21 | height: 84, 22 | }); 23 | 24 | const AuthBox: React.FC = () => { 25 | const dispatch = useDispatch(); 26 | 27 | const currentUser = useSelector(userSelector); 28 | const isAuthenticated = useSelector(isAuthenticatedSelector); 29 | const isAuthorized = useSelector(isAuthorizedSelector); 30 | 31 | const onClick = useCallback(() => { 32 | if (!isAuthorized) { 33 | dispatch(authorize()); 34 | } 35 | }, [dispatch, isAuthorized]); 36 | 37 | if (!isAuthenticated) { 38 | return ; 39 | } 40 | 41 | const name = currentUser.display_name || 'Профиль'; 42 | const avatarId = currentUser.avatar_id || 'default'; 43 | 44 | return ( 45 |
46 | {name} 52 |
{name}
53 |
54 | ); 55 | }; 56 | 57 | export default AuthBox; 58 | -------------------------------------------------------------------------------- /src/components/PageHeader/styles.module.css: -------------------------------------------------------------------------------- 1 | .page-header { 2 | position: sticky; 3 | z-index: 50; 4 | top: 0; 5 | right: 0; 6 | left: 0; 7 | 8 | display: flex; 9 | align-items: center; 10 | 11 | height: 56px; 12 | 13 | background-color: #fff; 14 | box-shadow: 0 0 6px 3px rgba(0, 0, 0, .15); 15 | 16 | will-change: background-color; 17 | 18 | transition: background-color .3s ease-in-out; 19 | } 20 | 21 | .page-header::after { 22 | flex-grow: 1; 23 | order: 3; 24 | 25 | content: ''; 26 | } 27 | 28 | .page-header_clear { 29 | background-color: transparent; 30 | box-shadow: none; 31 | } 32 | 33 | .menu-button, 34 | .backward-button { 35 | display: flex; 36 | justify-content: center; 37 | align-items: center; 38 | order: 1; 39 | 40 | width: 48px; 41 | height: 56px; 42 | } 43 | 44 | .burger-menu-icon { 45 | display: block; 46 | 47 | width: 20px; 48 | height: 20px; 49 | 50 | background-image: url('assets/menu-burger.svg'); 51 | } 52 | 53 | .logo { 54 | display: block; 55 | order: 2; 56 | 57 | height: 56px; 58 | 59 | font-weight: bold; 60 | line-height: 56px; 61 | white-space: nowrap; 62 | text-decoration: none; 63 | 64 | color: #202020; 65 | } 66 | 67 | .text { 68 | order: 2; 69 | 70 | font-size: 20px; 71 | font-weight: 500; 72 | 73 | color: rgba(0, 0, 0, .8); 74 | } 75 | 76 | .suggest-button { 77 | display: flex; 78 | align-items: center; 79 | order: 4; 80 | 81 | width: 56px; 82 | height: 56px; 83 | } 84 | 85 | .suggest-icon { 86 | display: block; 87 | 88 | width: 24px; 89 | height: 24px; 90 | margin: auto; 91 | mask-image: url('assets/search.svg'); 92 | } 93 | 94 | .icon-white { 95 | background-color: #fff; 96 | } 97 | 98 | .icon-black { 99 | background-color: #444; 100 | } 101 | -------------------------------------------------------------------------------- /src/components/Gallery/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState, MouseEvent, useMemo } from 'react'; 2 | 3 | import { GalleryImage } from '../../lib/api/fragments/gallery-image'; 4 | 5 | import GalleryModal from '../GalleryModal'; 6 | import Image from '../Image'; 7 | 8 | import styles from './styles.module.css'; 9 | 10 | type Props = { 11 | items: Array; 12 | }; 13 | 14 | const Gallery: React.FC = props => { 15 | const [selectedIndex, setSelectedIndex] = useState(-1); 16 | 17 | const images = useMemo(() => { 18 | return props.items.filter(Boolean); 19 | }, [props.items]) as GalleryImage[]; 20 | 21 | const onItemClick = useCallback((e: MouseEvent) => { 22 | const el = e.currentTarget; 23 | const index = Array.prototype.indexOf.call(el.parentElement!.children, el); 24 | setSelectedIndex(index); 25 | }, []); 26 | 27 | const onBackClick = useCallback(() => { 28 | setSelectedIndex(-1); 29 | }, []); 30 | 31 | return ( 32 | <> 33 |
34 | {images.map((image, i) => { 35 | return ( 36 |
37 | {`Фотография 44 |
45 | ); 46 | })} 47 |
48 | {selectedIndex !== -1 && ( 49 | 50 | )} 51 | 52 | ); 53 | }; 54 | 55 | export default Gallery; 56 | -------------------------------------------------------------------------------- /src/components/YaMapModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, MouseEvent, useMemo, lazy } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | import { Coordinates } from '../../lib/api/fragments/coordinates'; 5 | import { Metro } from '../../lib/api/fragments/metro'; 6 | 7 | import styles from './styles.module.css'; 8 | 9 | const Map = lazy(() => 10 | import( 11 | /* webpackChunkName: "ya-map-modal" */ 12 | './Map' 13 | ) 14 | ); 15 | 16 | type Props = { 17 | onBackClick: (e: MouseEvent) => void; 18 | coordinates: Coordinates; 19 | address: string | null; 20 | metro: Metro[] | null; 21 | }; 22 | 23 | const MetroStation: React.FC = props => { 24 | return ( 25 |
26 | {props.colors.map(color => { 27 | return
; 28 | })} 29 | {props.name} 30 |
31 | ); 32 | }; 33 | 34 | const YaMapModal: React.FC = props => { 35 | const { coordinates, metro, address } = props; 36 | const place = useMemo<[number, number]>(() => [coordinates.longitude, coordinates.latitude], [coordinates]); 37 | 38 | return createPortal( 39 |
40 |
41 | 44 |
45 | 46 | 47 | 48 | {(metro || address) && ( 49 |
50 | {metro && metro.map(station => )} 51 | {address} 52 |
53 | )} 54 |
, 55 | document.body 56 | ); 57 | }; 58 | 59 | export default YaMapModal; 60 | -------------------------------------------------------------------------------- /src/screens/SearchScreen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useState } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { throttle } from 'throttle-debounce'; 4 | 5 | import { loadPopularEvents, loadSearchResults, resetResults } from '../../redux/slices/search'; 6 | 7 | import BackwardButton from '../../components/BackwardButton'; 8 | import { useMetrikaHit } from '../../hooks/useMetrikaHit'; 9 | 10 | import SuggestInput from './Input'; 11 | import SearchResult from './SearchResult'; 12 | import PopularResult from './PopularResult'; 13 | 14 | import styles from './styles.module.css'; 15 | 16 | const INPUT_THROTTLE = 500; 17 | 18 | const SearchScreen: React.FC = () => { 19 | const [hasQuery, setHasQuery] = useState(false); 20 | const dispatch = useDispatch(); 21 | 22 | useEffect(() => { 23 | dispatch(loadPopularEvents()); 24 | }, [dispatch]); 25 | 26 | useMetrikaHit(); 27 | 28 | const onInputChange = useMemo(() => { 29 | return throttle(INPUT_THROTTLE, (text: string) => { 30 | text = text.trim(); 31 | 32 | setHasQuery(Boolean(text)); 33 | 34 | if (!text) { 35 | dispatch(resetResults()); 36 | return; 37 | } 38 | 39 | dispatch(loadSearchResults(text)); 40 | }); 41 | }, [dispatch]); 42 | 43 | const onSearchClose = useCallback(() => { 44 | dispatch(resetResults()); 45 | }, [dispatch]); 46 | 47 | return ( 48 | <> 49 |
50 | 51 | 52 |
53 |
54 | {hasQuery ? ( 55 | 56 | ) : ( 57 | 58 | )} 59 |
60 | 61 | ); 62 | }; 63 | 64 | export default SearchScreen; 65 | -------------------------------------------------------------------------------- /src/components/CityChange/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { RootState } from '../../redux'; 5 | 6 | import Checkbox from '../Checkbox'; 7 | import ActionButton from '../ActionButton'; 8 | import { City } from '../CityModal'; 9 | 10 | import styles from './styles.module.css'; 11 | 12 | type CityChangeProps = { 13 | visible: boolean; 14 | wasClosed: boolean; 15 | useGeolocation: boolean; 16 | openCityModal: () => void; 17 | onSaveClick: () => void; 18 | onGeolocationChanged: (event: ChangeEvent) => void; 19 | selectedCity: City; 20 | }; 21 | 22 | const CityChange: React.FC = ({ 23 | visible, 24 | openCityModal, 25 | wasClosed, 26 | onSaveClick, 27 | useGeolocation, 28 | onGeolocationChanged, 29 | selectedCity, 30 | }) => { 31 | const isFetchingLocation = useSelector((state: RootState) => state.city.isFetchingLocation); 32 | 33 | const className = [ 34 | styles.wrapper, 35 | visible || wasClosed ? styles[`wrapper-visible_${visible ? 'yes' : 'no'}`] : '', 36 | isFetchingLocation && styles.wrapper_loading, 37 | ].join(' '); 38 | 39 | return ( 40 |
41 |
Выбрать город
42 |
43 | 44 |
45 |
46 |
Город
47 |
48 | {selectedCity.name} 49 |
50 |
51 | 52 | Сохранить 53 | 54 |
55 | ); 56 | }; 57 | 58 | export default CityChange; 59 | -------------------------------------------------------------------------------- /src/screens/EventScreen/Skeleton/styles.module.css: -------------------------------------------------------------------------------- 1 | .image { 2 | height: 100vw; 3 | 4 | background: #eee; 5 | } 6 | 7 | .button-wrapper { 8 | box-sizing: border-box; 9 | width: 100vw; 10 | margin-top: -16px; 11 | padding: 16px 12px; 12 | 13 | border-radius: 16px 16px 0 0; 14 | background-color: #fff; 15 | } 16 | 17 | .button { 18 | height: 48px; 19 | 20 | border-radius: 26px; 21 | } 22 | 23 | .date-1 { 24 | width: 128px; 25 | height: 10px; 26 | margin: 11px 12px 0; 27 | } 28 | 29 | .date-2 { 30 | width: 257px; 31 | height: 17px; 32 | margin: 10px 12px 20px; 33 | } 34 | 35 | .map { 36 | height: 95px; 37 | } 38 | 39 | .place-1 { 40 | width: 199px; 41 | height: 17px; 42 | margin: 11px 12px 0; 43 | } 44 | 45 | .place-2 { 46 | width: 128px; 47 | height: 10px; 48 | margin: 10px 12px 20px; 49 | } 50 | 51 | .gallery { 52 | overflow: hidden; 53 | 54 | padding: 0 12px 26px; 55 | } 56 | 57 | .gallery-header { 58 | width: 139px; 59 | height: 20px; 60 | margin-bottom: 20px; 61 | } 62 | 63 | .gallery-content { 64 | display: flex; 65 | } 66 | 67 | .gallery-card { 68 | flex: none; 69 | 70 | width: 208px; 71 | height: 144px; 72 | margin-right: 12px; 73 | 74 | border-radius: 12px; 75 | } 76 | 77 | .description { 78 | display: flex; 79 | overflow: hidden; 80 | flex-direction: column; 81 | justify-content: space-between; 82 | 83 | box-sizing: border-box; 84 | height: 122px; 85 | padding: 0 12px 33px; 86 | } 87 | 88 | .description-line-1, 89 | .description-line-2 { 90 | width: 294px; 91 | height: 10px; 92 | } 93 | 94 | .description-line-3 { 95 | width: 248px; 96 | height: 10px; 97 | } 98 | 99 | .description-line-4 { 100 | width: 324px; 101 | height: 10px; 102 | } 103 | 104 | .description-line-5 { 105 | width: 210px; 106 | height: 10px; 107 | } 108 | 109 | .recommended { 110 | padding: 0 12px 26px; 111 | } 112 | -------------------------------------------------------------------------------- /src/lib/oauth.ts: -------------------------------------------------------------------------------- 1 | import { CLIENT_ID } from './js-api/auth'; 2 | import { AppError, AppErrorCode } from './error'; 3 | 4 | enum OauthErrorCodes { 5 | AccessDenied = 'access_denied', 6 | UnauthorizedClient = 'unauthorized_client', 7 | } 8 | 9 | const parsedOauthToken = (function() { 10 | const params = new URLSearchParams(window.location.hash.slice(1)); 11 | 12 | const token = params.get('access_token'); 13 | const error = params.get('error'); 14 | const errorDescription = params.get('error_description'); 15 | 16 | // Нужно очистить hash от данных OAuth, т.к. роутинг страниц завязан на нём 17 | if (token || error) { 18 | const url = new URL(window.location.href); 19 | 20 | url.hash = ''; 21 | 22 | window.history.replaceState(window.history.state, document.title, url.toString()); 23 | } 24 | 25 | return { token, error, errorDescription }; 26 | })(); 27 | 28 | export function getOauthToken(options: { withError?: boolean } = {}): string | null { 29 | const { withError } = options; 30 | const { token, error, errorDescription } = parsedOauthToken; 31 | 32 | if (error && withError) { 33 | const errorReason = errorDescription ? decodeURIComponent(errorDescription) : 'unknown reason'; 34 | 35 | switch (error) { 36 | case OauthErrorCodes.AccessDenied: 37 | throw new AppError(AppErrorCode.OauthAccessDenied, `OAuth access denied: ${errorReason}`); 38 | case OauthErrorCodes.UnauthorizedClient: 39 | throw new AppError(AppErrorCode.OauthUnauthorizedClient, `OAuth unauthorized client: ${errorReason}`); 40 | } 41 | } 42 | 43 | return token; 44 | } 45 | 46 | export function redirectToOauthAuthorize(): void { 47 | const search = new URLSearchParams(); 48 | 49 | search.append('client_id', CLIENT_ID); 50 | search.append('redirect_uri', `${window.location.origin}${window.location.pathname}`); 51 | search.append('response_type', 'token'); 52 | 53 | window.location.href = `https://oauth.yandex.ru/authorize?${search.toString()}`; 54 | } 55 | -------------------------------------------------------------------------------- /src/components/DateFilter/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useCallback, useMemo, useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { isTomorrow, startOfToday, startOfTomorrow } from 'date-fns'; 4 | 5 | import { RootState } from '../../redux'; 6 | import { setDate } from '../../redux/slices/date-filter'; 7 | import { parseDate, dateToString } from '../../lib/date'; 8 | 9 | import DateFilterButton from './Button'; 10 | import styles from './styles.module.css'; 11 | 12 | export enum Presets { 13 | Today = 'Today', 14 | Tomorrow = 'Tomorrow', 15 | } 16 | 17 | const DateFilter: React.FC = () => { 18 | const dispatch = useDispatch(); 19 | const { date } = useSelector((state: RootState) => state.dateFilter); 20 | const parsedDate = useMemo(() => parseDate(date), [date]); 21 | const [preset, setPreset] = useState(getPresetByDate(parsedDate)); 22 | 23 | const setTodayPreset = useCallback(() => { 24 | setPreset(Presets.Today); 25 | 26 | dispatch( 27 | setDate({ 28 | date: dateToString(startOfToday()), 29 | period: 1, 30 | }) 31 | ); 32 | }, [dispatch]); 33 | 34 | const setTomorrowPreset = useCallback(() => { 35 | setPreset(Presets.Tomorrow); 36 | 37 | dispatch( 38 | setDate({ 39 | date: dateToString(startOfTomorrow()), 40 | period: 1, 41 | }) 42 | ); 43 | }, [dispatch]); 44 | 45 | return ( 46 |
47 | 48 | Сегодня 49 | 50 | 51 | Завтра 52 | 53 |
54 | ); 55 | }; 56 | 57 | function getPresetByDate(date: Date) { 58 | if (isTomorrow(date)) { 59 | return Presets.Tomorrow; 60 | } 61 | 62 | return Presets.Today; 63 | } 64 | 65 | export default memo(DateFilter); 66 | -------------------------------------------------------------------------------- /src/components/YaMapModal/styles.module.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | z-index: 100; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | 9 | color: #000; 10 | background-color: #fafafa; 11 | background-image: url('loader.svg'); 12 | background-repeat: no-repeat; 13 | background-position: 50% calc(50% - 70px); 14 | background-size: 50px; 15 | } 16 | 17 | .close { 18 | position: fixed; 19 | z-index: 1; 20 | top: 13px; 21 | left: 16px; 22 | 23 | width: 50px; 24 | height: 50px; 25 | 26 | border-radius: 25px; 27 | background-color: #fff; 28 | box-shadow: 0 4px 16px rgba(0, 0, 0, .1); 29 | } 30 | 31 | .close-button { 32 | width: calc(16px + 17px * 2); 33 | height: calc(14px + 18px * 2); 34 | padding: 18px 17px; 35 | 36 | font-size: 0; 37 | 38 | color: #fff; 39 | border: none; 40 | outline: none; 41 | background: url('./back.svg') center center no-repeat; 42 | background-size: 16px 14px; 43 | } 44 | 45 | .metro-containter { 46 | position: absolute; 47 | right: 0; 48 | bottom: 0; 49 | left: 0; 50 | 51 | padding: 12px 16px 28px; 52 | 53 | font-size: 15px; 54 | line-height: 18px; 55 | 56 | background-color: #fff; 57 | } 58 | 59 | .metro-containter::after { 60 | position: absolute; 61 | top: -12px; 62 | right: 0; 63 | left: 0; 64 | 65 | height: 12px; 66 | 67 | content: ''; 68 | 69 | border-radius: 12px 12px 0 0; 70 | background-color: #fff; 71 | } 72 | 73 | .metro-containter::before { 74 | position: absolute; 75 | top: -24px; 76 | right: 0; 77 | left: 0; 78 | 79 | height: 24px; 80 | 81 | content: ''; 82 | 83 | background: linear-gradient(360deg, rgba(0, 0, 0, .1) 0%, rgba(0, 0, 0, 0) 106.25%); 84 | } 85 | 86 | .station { 87 | padding-bottom: 7px; 88 | } 89 | 90 | .metro-point { 91 | display: inline-block; 92 | 93 | width: 8px; 94 | height: 8px; 95 | margin-right: 5px; 96 | margin-bottom: 2px; 97 | 98 | border-radius: 50%; 99 | } 100 | -------------------------------------------------------------------------------- /src/lib/payment.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppErrorCode } from './error'; 2 | import { UserInfo, CreateOrderResponse } from './api/types'; 3 | 4 | interface YandexPaymentMethodData extends PaymentMethodData { 5 | supportedMethods: 'yandex', 6 | data: { 7 | userEmail?: string; 8 | paymentToken: string; 9 | }; 10 | } 11 | 12 | export async function processNativePayment(orderInfo: CreateOrderResponse, userInfo: UserInfo) { 13 | const { paymentToken, cost, id } = orderInfo; 14 | const { email: userEmail } = userInfo; 15 | 16 | const yandexMethodData: YandexPaymentMethodData = { 17 | supportedMethods: 'yandex', 18 | data: { 19 | userEmail, 20 | paymentToken, 21 | } 22 | }; 23 | 24 | const details: PaymentDetailsInit = { 25 | id: id.toString(), 26 | total: { 27 | label: 'Покупка билета', 28 | amount: { 29 | value: cost.toString(), 30 | currency: 'RUB', 31 | } 32 | } 33 | }; 34 | 35 | if (!window.PaymentRequest) { 36 | throw new AppError( 37 | AppErrorCode.JsApiMethodNotAvailable, 38 | 'window.PaymentRequest is not available in this browser version.' 39 | ); 40 | } 41 | 42 | try { 43 | const request = new PaymentRequest([yandexMethodData], details); 44 | const canMakePayment = await request.canMakePayment(); 45 | 46 | if (!canMakePayment) { 47 | throw new AppError(AppErrorCode.JsApiMethodNotAvailable, 'request.canMakePayment() returns false.'); 48 | } 49 | 50 | const response = await request.show(); 51 | await response.complete('success'); 52 | } catch (err) { 53 | const { message } = err; 54 | 55 | if (message.includes('[NOT_STARTED]')) { 56 | throw new AppError(AppErrorCode.JsApiCancelled, 'window.PaymentRequest canceled.'); 57 | } 58 | 59 | if (message.includes('Already called show')) { 60 | throw new AppError(AppErrorCode.JsApiAlreadyShown, 'window.PaymentRequest already shown.'); 61 | } 62 | 63 | throw err; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /public/api/event/event-concert-id5.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": { 3 | "id": "event-concert-id5", 4 | "title": "Хор Московской консерватории", 5 | "argument": null, 6 | "contentRating": "18+", 7 | "description": " Камерный хор, созданный по инициативе профессора А. С. Соколова в декабре 1994 года дирижёром Борисом Тевлиным.", 8 | "tags": [ 9 | { 10 | "name": "Концерт", 11 | "code": "concert" 12 | } 13 | ], 14 | "image": null, 15 | "images": [], 16 | "type": { 17 | "name": "Концерт" 18 | }, 19 | "userRating": null, 20 | "tickets": [ 21 | { 22 | "id": "id", 23 | "price": { 24 | "currency": "rub", 25 | "min": 10000, 26 | "max": 10000 27 | } 28 | } 29 | ] 30 | }, 31 | "scheduleInfo": { 32 | "oneOfPlaces": { 33 | "id": "id", 34 | "title": "Консерватория им. Чайковского", 35 | "address": "ул. Большая Никитская, 13/6", 36 | "type": { 37 | "name": "Концертный зал" 38 | }, 39 | "city": { 40 | "name": "Москва", 41 | "geoid": 213 42 | }, 43 | "metro": [ 44 | { 45 | "name": "Арбатская", 46 | "colors": ["#003399"] 47 | }, 48 | { 49 | "name": "Библиотека им. Ленина", 50 | "colors": ["#cc0000"] 51 | }, 52 | { 53 | "name": "Охотный Ряд", 54 | "colors": ["#cc0000"] 55 | } 56 | ], 57 | "coordinates": { 58 | "longitude": 37.60585545962319, 59 | "latitude": 55.75631872698533 60 | } 61 | }, 62 | "placePreview": "Консерватория им. Чайковского", 63 | "preview": { 64 | "text": "25 и 27 декабря", 65 | "singleDate": null 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/checkout/types/checkout-state.ts: -------------------------------------------------------------------------------- 1 | export type YandexCheckoutDatetimeOptionState = { 2 | id: string; 3 | timeOption?: { 4 | id: string; 5 | }; 6 | }; 7 | 8 | export type YandexCheckoutCityState = { 9 | city: string; 10 | region?: string; // Область/округ/регион 11 | country?: string; 12 | } 13 | 14 | export type YandexCheckoutOrderShippingAddressState = { 15 | rawAddress: string; // Ввод пользователя 16 | district?: string; // Район города 17 | street?: string; 18 | house?: string; 19 | porch?: string; 20 | intercom?: string; 21 | apartment?: string; 22 | floor?: string; 23 | postalCode?: string; 24 | comment?: string; 25 | }; 26 | 27 | export type YandexCheckoutOrderState = { 28 | // Уникальный идентфикатор заказа. 29 | id: string; 30 | 31 | // Город, если заполнен пользователем 32 | city?: YandexCheckoutCityState, 33 | 34 | // Идентификатор способа доставки, если выбран 35 | shippingOption?: { 36 | id: string; 37 | }; 38 | 39 | // Адрес доставки, если заполнен пользователем 40 | shippingAddress?: YandexCheckoutOrderShippingAddressState; 41 | 42 | // Идентификатор пункта самовывоза, если выбран 43 | pickupOption?: { 44 | id: string; 45 | }; 46 | 47 | // Идентификатор даты и времени доставки, если выбраны 48 | datetimeOption?: YandexCheckoutDatetimeOptionState; 49 | }; 50 | 51 | export type YandexCheckoutPayerDetailsState = Record; 52 | 53 | export type YandexCheckoutState = { 54 | // Коментарий к заказу 55 | comment?: string; 56 | 57 | // Значение поля с промокодом 58 | promoCode?: string; 59 | 60 | // Контакты покупателя 61 | // Note: поле доступны только после перехода к оплате (событие `paymentStart`) 62 | payerDetails?: YandexCheckoutPayerDetailsState; 63 | 64 | orders: Array; 65 | 66 | // Выбранный способ оплаты, если выбран 67 | paymentOption?: { 68 | // Выбранный тип оплаты. Реквизиты банковской карты не доступны. 69 | type: string; 70 | 71 | // Дополнительные данные для выбранного способа оплаты 72 | data?: object; 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /src/redux/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, Action, getDefaultMiddleware, combineReducers } from '@reduxjs/toolkit'; 2 | import { ThunkAction } from 'redux-thunk'; 3 | import { persistStore } from 'redux-persist'; 4 | import * as PERSIST_CONTSTANS from 'redux-persist/lib/constants'; 5 | 6 | import eventReducer from './slices/event'; 7 | import autofillReducer from './slices/autofill'; 8 | import actualEventsReducer from './slices/actual-events'; 9 | import rubricEventsReducer from './slices/rubric-events'; 10 | import selectionsReducer from './slices/selections'; 11 | import selectionEventsReducer from './slices/selection-events'; 12 | import cityReducer from './slices/city'; 13 | import cityListReducer from './slices/city-list'; 14 | import searchReducer from './slices/search'; 15 | import menuReducer from './slices/menu'; 16 | import dateFilterReducer from './slices/date-filter'; 17 | import recommendedReducer from './slices/recommended-events'; 18 | import userReducer from './slices/user'; 19 | import orderReducer from './slices/order'; 20 | 21 | const PERSIST_ACTIONS = [ 22 | PERSIST_CONTSTANS.FLUSH, 23 | PERSIST_CONTSTANS.REHYDRATE, 24 | PERSIST_CONTSTANS.PAUSE, 25 | PERSIST_CONTSTANS.PERSIST, 26 | PERSIST_CONTSTANS.PURGE, 27 | PERSIST_CONTSTANS.REGISTER, 28 | ]; 29 | const rootReducer = combineReducers({ 30 | event: eventReducer, 31 | autofill: autofillReducer, 32 | actualEvents: actualEventsReducer, 33 | rubricEvents: rubricEventsReducer, 34 | selections: selectionsReducer, 35 | selectionEvents: selectionEventsReducer, 36 | city: cityReducer, 37 | cityList: cityListReducer, 38 | search: searchReducer, 39 | menu: menuReducer, 40 | dateFilter: dateFilterReducer, 41 | recommendedEvents: recommendedReducer, 42 | user: userReducer, 43 | order: orderReducer, 44 | }); 45 | const store = configureStore({ 46 | reducer: rootReducer, 47 | middleware: getDefaultMiddleware({ 48 | serializableCheck: { 49 | ignoredActions: PERSIST_ACTIONS, 50 | }, 51 | }), 52 | }); 53 | 54 | export type RootState = ReturnType; 55 | export type AppThunk = ThunkAction>; 56 | 57 | export const persistor = persistStore(store); 58 | 59 | export default store; 60 | -------------------------------------------------------------------------------- /public/api/event/event-concert-id10.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": { 3 | "id": "event-concert-id10", 4 | "title": "Класс доцента А.В. Анчевской (скрипка)", 5 | "argument": null, 6 | "contentRating": "18+", 7 | "description": null, 8 | "tags": [ 9 | { 10 | "name": "Концерт", 11 | "code": "concert" 12 | }, 13 | { 14 | "name": "Классическая музыка", 15 | "code": "classical_music" 16 | } 17 | ], 18 | "image": null, 19 | "images": [], 20 | "type": { 21 | "name": "Концерт" 22 | }, 23 | "userRating": null, 24 | "tickets": [ 25 | { 26 | "id": "id", 27 | "price": { 28 | "currency": "rub", 29 | "min": 10000, 30 | "max": 10000 31 | } 32 | } 33 | ] 34 | }, 35 | "scheduleInfo": { 36 | "oneOfPlaces": { 37 | "id": "id", 38 | "title": "Консерватория им. Чайковского", 39 | "address": "ул. Большая Никитская, 13/6", 40 | "type": { 41 | "name": "Концертный зал" 42 | }, 43 | "city": { 44 | "name": "Москва", 45 | "geoid": 213 46 | }, 47 | "metro": [ 48 | { 49 | "name": "Арбатская", 50 | "colors": ["#003399"] 51 | }, 52 | { 53 | "name": "Библиотека им. Ленина", 54 | "colors": ["#cc0000"] 55 | }, 56 | { 57 | "name": "Охотный Ряд", 58 | "colors": ["#cc0000"] 59 | } 60 | ], 61 | "coordinates": { 62 | "longitude": 37.60585545962319, 63 | "latitude": 55.75631872698533 64 | } 65 | }, 66 | "placePreview": "Консерватория им. Чайковского", 67 | "preview": { 68 | "text": "Сегодня 25 декабря, 18:30", 69 | "singleDate": { 70 | "day": "25", 71 | "month": "декабря" 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /public/api/event/event-concert-id6.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": { 3 | "id": "event-concert-id6", 4 | "title": "V Фестиваль классической музыки памяти Наума Штаркмана", 5 | "argument": null, 6 | "contentRating": "18+", 7 | "description": "Штаркман широко признан как интерпретатор романтического и постромантического репертуара — прежде всего, произведений Шумана, Шопена, Листа, Чайковского и Рахманинова.", 8 | "tags": [ 9 | { 10 | "name": "Концерт", 11 | "code": "concert" 12 | }, 13 | { 14 | "name": "Классическая музыка", 15 | "code": "classical_music" 16 | } 17 | ], 18 | "image": null, 19 | "images": [], 20 | "type": { 21 | "name": "Концерт" 22 | }, 23 | "userRating": null, 24 | "tickets": [ 25 | { 26 | "id": "id", 27 | "price": { 28 | "currency": "rub", 29 | "min": 30000, 30 | "max": 50000 31 | } 32 | } 33 | ] 34 | }, 35 | "scheduleInfo": { 36 | "oneOfPlaces": { 37 | "id": "id", 38 | "title": "Башмет Центр", 39 | "address": "ул. Большая Полянка д. 45", 40 | "type": { 41 | "name": "Дом культуры" 42 | }, 43 | "city": { 44 | "name": "Москва", 45 | "geoid": 213 46 | }, 47 | "metro": [ 48 | { 49 | "name": "Полянка", 50 | "colors": ["#a2a5b4"] 51 | }, 52 | { 53 | "name": "Добрынинская", 54 | "colors": ["#7f0000"] 55 | }, 56 | { 57 | "name": "Октябрьская", 58 | "colors": ["#ff7f00"] 59 | } 60 | ], 61 | "coordinates": { 62 | "longitude": 37.6199299, 63 | "latitude": 55.73321 64 | } 65 | }, 66 | "placePreview": "Башмет Центр", 67 | "preview": { 68 | "text": "Декабрь", 69 | "singleDate": null 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/components/EventCardMain/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import { getEventUrl } from '../../lib/url-builder'; 5 | import { ActualEvent } from '../../lib/api/fragments/actual-event'; 6 | 7 | import Image from '../Image'; 8 | import PriceLabel from '../PriceLabel'; 9 | 10 | import styles from './style.module.css'; 11 | 12 | export type Props = { 13 | event: ActualEvent; 14 | }; 15 | 16 | const EventCardMain: React.FC = props => { 17 | const { preview } = props.event.scheduleInfo; 18 | const { id, title, type, image, tickets } = props.event.eventPreview; 19 | const ticket = tickets && tickets[0]; 20 | 21 | const bgColor = (image && image.bgColor) || '#555'; 22 | const singleDate = preview && preview.singleDate; 23 | 24 | return ( 25 | 26 |
27 | {singleDate && ( 28 |
29 |
{singleDate.day}
30 |
{singleDate.month}
31 |
32 | )} 33 | 34 |
35 |
{type.name}
36 |
{title}
37 |
38 |
39 |
40 | {image && image.actualListCard ? ( 41 | {title} 48 | ) : ( 49 |
50 | )} 51 | {ticket && } 52 | {image &&
} 53 |
54 | 55 | ); 56 | }; 57 | 58 | export default memo(EventCardMain); 59 | -------------------------------------------------------------------------------- /src/lib/request.ts: -------------------------------------------------------------------------------- 1 | type QueryParams = { 2 | [key: string]: string | number | string[] | undefined; 3 | }; 4 | 5 | type RetryOptions = { 6 | retryCount: number; 7 | retryDelay: number; 8 | }; 9 | 10 | const defaultRetryOptions: RetryOptions = { 11 | retryCount: 2, 12 | retryDelay: 2000, 13 | }; 14 | 15 | function queryParams(params?: QueryParams) { 16 | if (!params) { 17 | return ''; 18 | } 19 | 20 | const result = new URLSearchParams(); 21 | 22 | Object.entries(params).forEach(([key, value]) => { 23 | if (typeof value === 'undefined') { 24 | return; 25 | } 26 | 27 | if (Array.isArray(value)) { 28 | return value.forEach(valueItem => result.append(key, valueItem)); 29 | } 30 | 31 | result.append(key, value.toString()); 32 | }); 33 | 34 | return result.toString(); 35 | } 36 | 37 | async function fetchRetry(url: string, init?: RequestInit, retryOptions: RetryOptions = defaultRetryOptions) { 38 | const { retryCount, retryDelay } = retryOptions; 39 | 40 | for (let attempt = 0; attempt <= Math.max(retryCount, 0); attempt++) { 41 | try { 42 | return await fetch(url, init); 43 | } catch (err) { 44 | if (attempt === retryCount) { 45 | throw err; 46 | } 47 | 48 | if (retryDelay) { 49 | await new Promise(resolve => setTimeout(resolve, retryDelay)); 50 | } 51 | } 52 | } 53 | 54 | return Promise.reject('Unable to fetch. Check retryOptions values'); 55 | } 56 | 57 | async function request(url: string, options?: RequestInit): Promise { 58 | const response = await fetchRetry(url, options); 59 | 60 | if (response.ok) { 61 | return response.json(); 62 | } 63 | 64 | return Promise.reject(response); 65 | } 66 | 67 | export async function get( 68 | host: string, 69 | path: string, 70 | query?: QueryParams, 71 | options?: RequestInit 72 | ): Promise { 73 | return request(`${host}${path}${queryParams(query) ? `?${queryParams(query)}` : ''}`, options); 74 | } 75 | 76 | export async function post(host: string, path: string, options: RequestInit): Promise { 77 | return request(`${host}${path}`, { 78 | ...options, 79 | method: 'POST', 80 | headers: { 81 | ...options.headers, 82 | 'Content-Type': 'application/json', 83 | }, 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/MenuModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent, useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import MenuList from '../MenuNavigation'; 5 | import AuthBox from '../AuthBox'; 6 | import MenuGeoLabel from '../MenuGeoLabel'; 7 | 8 | import { RootState } from '../../redux'; 9 | import { setVisible as setMenuVisible } from '../../redux/slices/menu'; 10 | import { psuidSelector, isAuthenticatedSelector, logout } from '../../redux/slices/user'; 11 | 12 | import styles from './style.module.css'; 13 | 14 | const MenuModal: React.FC<{ 15 | setCityModalVisibleCallback: (visible: boolean) => void; 16 | }> = ({ setCityModalVisibleCallback }) => { 17 | const dispatch = useDispatch(); 18 | 19 | const { visible, items } = useSelector((state: RootState) => state.menu); 20 | const isAuthenticated = useSelector(isAuthenticatedSelector); 21 | const psuid = useSelector(psuidSelector); 22 | 23 | const onClose = useCallback(() => { 24 | setCityModalVisibleCallback(false); 25 | dispatch(setMenuVisible(false)); 26 | }, [dispatch, setCityModalVisibleCallback]); 27 | 28 | const onModalContentClick = useCallback((e: MouseEvent) => { 29 | e.stopPropagation(); 30 | }, []); 31 | 32 | const onLogoutClick = useCallback(() => { 33 | dispatch(logout()); 34 | }, [dispatch]); 35 | 36 | const className = [styles.modal, visible ? styles.visible : styles.hidden].join(' '); 37 | 38 | return ( 39 |
40 |
41 |
42 |
43 | 44 |
45 | 46 | 47 | 48 |
49 | 50 | {isAuthenticated && ( 51 |
52 | {psuid &&

PSUID: {psuid}

} 53 |
54 | Выйти 55 |
56 |
57 | )} 58 |
59 |
60 | ); 61 | }; 62 | 63 | export default MenuModal; 64 | -------------------------------------------------------------------------------- /src/components/MenuNavigation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | import { useSelector } from 'react-redux'; 4 | 5 | import { getOrdersUrl, getRubricUrl } from '../../lib/url-builder'; 6 | 7 | import { MenuTag } from '../../lib/api/fragments/city'; 8 | 9 | import { isAuthenticatedSelector } from '../../redux/slices/user'; 10 | import { ordersSelector } from '../../redux/slices/order'; 11 | 12 | import styles from './style.module.css'; 13 | 14 | type MenuItemProps = { 15 | text: string; 16 | to: string; 17 | onItemClick: () => void; 18 | }; 19 | const MenuItem: React.FC = props => { 20 | const { text, to, onItemClick } = props; 21 | return ( 22 |
  • 23 | 30 | {text} 31 | 32 |
  • 33 | ); 34 | }; 35 | 36 | export type MenuListProps = { 37 | tags: MenuTag[]; 38 | onItemClick: () => void; 39 | }; 40 | const MenuList: React.FC = props => { 41 | const isAuthenticated = useSelector(isAuthenticatedSelector); 42 | const orders = useSelector(ordersSelector); 43 | 44 | return ( 45 |
      46 | {isAuthenticated && ( 47 |
    • 48 | 54 | Мои заказы 55 | 56 | {orders.length > 0 && {orders.length}} 57 |
    • 58 | )} 59 | 64 | {props.tags.map(tag => ( 65 | 71 | ))} 72 |
    73 | ); 74 | }; 75 | 76 | export default MenuList; 77 | -------------------------------------------------------------------------------- /src/screens/OrdersScreen/Order/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { OrderResponse, PaymentStatus } from '../../../lib/api/types'; 5 | import { getCurrencySymbol } from '../../../lib/price'; 6 | 7 | import { EventPage, eventPageSelector } from '../../../redux/slices/event'; 8 | 9 | import Image from '../../../components/Image'; 10 | 11 | import styles from './styles.module.css'; 12 | 13 | const StatusMap = { 14 | [PaymentStatus.New]: 'Не оплачен', 15 | [PaymentStatus.InModeration]: 'Идёт оплата', 16 | [PaymentStatus.Held]: 'Идёт оплата', 17 | [PaymentStatus.InProgress]: 'Идёт оплата', 18 | [PaymentStatus.ModerationNegative]: 'Отклонён', 19 | [PaymentStatus.InCancel]: 'Отменён', 20 | [PaymentStatus.Canceled]: 'Отменён', 21 | [PaymentStatus.Rejected]: 'Отклонён', 22 | [PaymentStatus.Paid]: 'Оплачен', 23 | }; 24 | 25 | type Props = { 26 | order: OrderResponse 27 | } 28 | 29 | const Order: React.FC = ({ order }) => { 30 | const eventPage: Partial = useSelector(eventPageSelector(order.event.id)); 31 | const { event, schedule } = eventPage; 32 | const ticket = event?.tickets?.[0]; 33 | const image = event?.images?.[0]; 34 | const currency = ticket?.price?.currency ?? 'rub'; 35 | 36 | const status = order.apiResponseStatus === 'fail' ? 'Ошибка' : StatusMap[order.status]; 37 | 38 | return ( 39 |
    40 |
    41 | 46 |
    47 |
    48 |
    49 |

    {order.event.title}

    50 |
    51 |

    Сегодня в 20:00

    52 |

    {schedule?.oneOfPlaces?.title}

    53 |
    54 |
    55 |
    56 |

    {order.cost} {getCurrencySymbol(currency)}

    57 |

    {status}

    58 |
    59 |
    60 |
    61 | ); 62 | }; 63 | 64 | export default Order; 65 | -------------------------------------------------------------------------------- /src/lib/api/types.ts: -------------------------------------------------------------------------------- 1 | import { ActualEvents } from './fragments/actual-events'; 2 | import { EventData } from './fragments/event'; 3 | import { ActualEvent } from './fragments/actual-event'; 4 | import { City } from './fragments/city'; 5 | import { Suggest } from './fragments/suggest'; 6 | 7 | export type RubricEventsResponse = { 8 | title: string; 9 | events: ActualEvents; 10 | }; 11 | 12 | export type ActualEventsResponse = { 13 | events: ActualEvents; 14 | }; 15 | 16 | export type EventResponse = EventData; 17 | 18 | export type SelectionsResponse = { 19 | selections: Array<{ 20 | code: string; 21 | title: string; 22 | count: number; 23 | events: Array; 24 | } | null>; 25 | }; 26 | 27 | export type SelectionEventsResponse = { 28 | title: string; 29 | events: ActualEvents; 30 | }; 31 | 32 | export type CityInfoResponse = { 33 | cityInfo: City; 34 | }; 35 | 36 | export type CityListResponse = { 37 | cities: City[]; 38 | }; 39 | 40 | export type SuggestResponse = { 41 | suggest: Suggest; 42 | }; 43 | 44 | enum Places { 45 | Top = 'top', 46 | Cinema = 'cinema', 47 | Concert = 'concert', 48 | Theatre = 'theatre', 49 | } 50 | 51 | export type RecommendedEventsResponse = { 52 | [Places.Top]?: ActualEvents; 53 | [Places.Cinema]?: ActualEvents; 54 | [Places.Concert]?: ActualEvents; 55 | [Places.Theatre]?: ActualEvents; 56 | }; 57 | 58 | export type UserInfo = { 59 | name: string; 60 | email: string; 61 | phone: string; 62 | address: string; 63 | }; 64 | 65 | export type CreateOrderResponse = { 66 | paymentToken: string; 67 | id: number; 68 | cost: number; 69 | }; 70 | 71 | export enum PaymentStatus { 72 | New = 'new', 73 | InModeration = 'in_moderation', 74 | Held = 'held', 75 | InProgress = 'in_progress', 76 | ModerationNegative = 'moderation_negative', 77 | InCancel = 'in_cancel', 78 | Canceled = 'canceled', 79 | Rejected = 'rejected', 80 | Paid = 'paid', 81 | } 82 | 83 | export type OrderResponse = { 84 | id: number; 85 | status: PaymentStatus; 86 | apiResponseStatus: 'success' | 'fail'; 87 | cost: number; 88 | event: { 89 | id: string; 90 | title: string; 91 | } 92 | } 93 | 94 | export type UserInfoResponse = { 95 | uid: string; 96 | login: string; 97 | name?: string; 98 | email?: string; 99 | psuid?: string; 100 | avatar_id?: string; 101 | display_name?: string; 102 | } 103 | -------------------------------------------------------------------------------- /src/components/PageHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useCallback } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { useHistory, Link } from 'react-router-dom'; 4 | 5 | import { setVisible as setMenuVisible } from '../../redux/slices/menu'; 6 | import { getMainPageUrl, getSearchUrl } from '../../lib/url-builder'; 7 | 8 | import BackwardButton from '../BackwardButton'; 9 | import ClearButton from '../ClearButton'; 10 | 11 | import styles from './styles.module.css'; 12 | 13 | const Logo: React.FC = () => ( 14 | 15 | MiniApp Example 16 | 17 | ); 18 | 19 | type IconProps = { 20 | fill?: 'white' | 'black'; 21 | }; 22 | 23 | const SuggestIcon: React.FC = ({ fill = 'black' }) => { 24 | const className = [styles['suggest-icon'], styles[`icon-${fill}`]].join(' '); 25 | 26 | return ; 27 | }; 28 | 29 | function setMods(className: string, mods?: string) { 30 | if (!mods) { 31 | return styles[className]; 32 | } 33 | 34 | return `${styles[className]} ${styles[`${className}_${mods}`]}`; 35 | } 36 | 37 | export type Props = { 38 | hasMenu?: boolean; 39 | hasLogo?: boolean; 40 | backward?: 'white' | 'black' | ''; 41 | mods?: 'clear' | ''; 42 | text?: string; 43 | }; 44 | 45 | const PageHeader: React.FC = props => { 46 | const { hasMenu, hasLogo, backward, mods, text } = props; 47 | 48 | const history = useHistory(); 49 | const dispatch = useDispatch(); 50 | 51 | const onSearchClick = useCallback(() => { 52 | history.push(getSearchUrl()); 53 | }, [history]); 54 | 55 | const onMenuClick = useCallback(() => { 56 | dispatch(setMenuVisible(true)); 57 | }, [dispatch]); 58 | 59 | return ( 60 |
    61 | {Boolean(backward) && } 62 | {Boolean(hasMenu) && ( 63 | 64 | 65 | 66 | )} 67 | {hasLogo && } 68 | {text && {text}} 69 | 70 | 71 | 72 |
    73 | ); 74 | }; 75 | 76 | export default memo(PageHeader); 77 | --------------------------------------------------------------------------------