diff --git a/src/index.tsx b/src/index.tsx index bc97292..0d12df4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,14 +1,14 @@ -import React from 'react' -import {createRoot} from 'react-dom/client' +import React from "react"; +import { createRoot } from "react-dom/client"; -import App from './App' -import './globals.css' +import App from "./App"; +import "./globals.css"; -const container = document.getElementById('root') as HTMLElement -const root = createRoot(container) +const container = document.getElementById("root") as HTMLElement; +const root = createRoot(container); root.render( - , -) + +); diff --git a/src/pages/route/create.tsx b/src/pages/route/create.tsx index 9e574df..6dca36a 100644 --- a/src/pages/route/create.tsx +++ b/src/pages/route/create.tsx @@ -130,20 +130,32 @@ export const RouteCreate = () => { required: "Это поле является обязательным", setValueAs: (value: string) => { try { - // Парсим строку в массив массивов - return JSON.parse(value); + // Разбиваем строку на строки и парсим каждую строку как пару координат + const lines = value.trim().split("\n"); + return lines.map((line) => { + const [lat, lon] = line + .trim() + .split(/[\s,]+/) + .map(Number); + if (isNaN(lat) || isNaN(lon)) { + throw new Error("Invalid coordinates"); + } + return [lat, lon]; + }); } catch { return []; } }, validate: (value: unknown) => { if (!Array.isArray(value)) return "Неверный формат"; + if (value.length === 0) + return "Введите хотя бы одну пару координат"; if ( !value.every( (point: unknown) => Array.isArray(point) && point.length === 2 ) ) { - return "Каждая точка должна быть массивом из двух координат"; + return "Каждая строка должна содержать две координаты"; } if ( !value.every((point: unknown[]) => @@ -159,14 +171,17 @@ export const RouteCreate = () => { }, })} error={!!(errors as any)?.path} - helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' + helperText={(errors as any)?.path?.message} margin="normal" fullWidth InputLabelProps={{ shrink: true }} type="text" label={"Координаты маршрута *"} name="path" - placeholder="[[1.1, 2.2], [2.1, 4.5]]" + placeholder="55.7558 37.6173 +55.7539 37.6208" + multiline + rows={4} /> { Number(value), })} error={!!(errors as any)?.governor_appeal} helperText={(errors as any)?.governor_appeal?.message} @@ -200,6 +216,7 @@ export const RouteCreate = () => { Number(value), })} error={!!(errors as any)?.scale_min} helperText={(errors as any)?.scale_min?.message} @@ -214,6 +231,7 @@ export const RouteCreate = () => { Number(value), })} error={!!(errors as any)?.scale_max} helperText={(errors as any)?.scale_max?.message} @@ -228,6 +246,7 @@ export const RouteCreate = () => { Number(value), })} error={!!(errors as any)?.rotate} helperText={(errors as any)?.rotate?.message} @@ -242,6 +261,7 @@ export const RouteCreate = () => { Number(value), })} error={!!(errors as any)?.center_latitude} helperText={(errors as any)?.center_latitude?.message} @@ -256,6 +276,7 @@ export const RouteCreate = () => { Number(value), })} error={!!(errors as any)?.center_longitude} helperText={(errors as any)?.center_longitude?.message} diff --git a/src/pages/route/edit.tsx b/src/pages/route/edit.tsx index 2582ecd..bdc841f 100644 --- a/src/pages/route/edit.tsx +++ b/src/pages/route/edit.tsx @@ -1,83 +1,132 @@ -import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material' -import {Edit, useAutocomplete} from '@refinedev/mui' -import {useForm} from '@refinedev/react-hook-form' -import {Controller} from 'react-hook-form' -import {useParams} from 'react-router' -import {LinkedItems} from '../../components/LinkedItems' -import {StationItem, VehicleItem, stationFields, vehicleFields} from './types' +import { + Autocomplete, + Box, + TextField, + FormControlLabel, + Checkbox, + Typography, +} from "@mui/material"; +import { Edit, useAutocomplete } from "@refinedev/mui"; +import { useForm } from "@refinedev/react-hook-form"; +import { Controller } from "react-hook-form"; +import { useParams } from "react-router"; +import { LinkedItems } from "../../components/LinkedItems"; +import { + StationItem, + VehicleItem, + stationFields, + vehicleFields, +} from "./types"; export const RouteEdit = () => { const { saveButtonProps, register, control, - formState: {errors}, - } = useForm({}) + formState: { errors }, + } = useForm({}); - const {id: routeId} = useParams<{id: string}>() + const { id: routeId } = useParams<{ id: string }>(); - const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({ - resource: 'carrier', + const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({ + resource: "carrier", onSearch: (value) => [ { - field: 'short_name', - operator: 'contains', + field: "short_name", + operator: "contains", value, }, ], - }) + }); return ( - + ( + render={({ field }) => ( option.id === field.value) || null} + value={ + carrierAutocompleteProps.options.find( + (option) => option.id === field.value + ) || null + } onChange={(_, value) => { - field.onChange(value?.id || '') + field.onChange(value?.id || ""); }} getOptionLabel={(item) => { - return item ? item.short_name : '' + return item ? item.short_name : ""; }} isOptionEqualToValue={(option, value) => { - return option.id === value?.id + return option.id === value?.id; }} - filterOptions={(options, {inputValue}) => { - return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase())) + filterOptions={(options, { inputValue }) => { + return options.filter((option) => + option.short_name + .toLowerCase() + .includes(inputValue.toLowerCase()) + ); }} - renderInput={(params) => } + renderInput={(params) => ( + + )} /> )} /> String(value), })} error={!!(errors as any)?.route_number} helperText={(errors as any)?.route_number?.message} margin="normal" fullWidth - InputLabelProps={{shrink: true}} + InputLabelProps={{ shrink: true }} type="text" - label={'Номер маршрута'} + label={"Номер маршрута"} name="route_number" /> field.onChange(e.target.checked)} />} />} + render={({ field }: { field: any }) => ( + field.onChange(e.target.checked)} + /> + } + /> + )} /> - + (Прямой / Обратный) @@ -86,38 +135,68 @@ export const RouteEdit = () => { control={control} defaultValue={[]} rules={{ - required: 'Это поле является обязательным', + required: "Это поле является обязательным", validate: (value: unknown) => { - if (!Array.isArray(value)) return 'Неверный формат' - if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) { - return 'Каждая точка должна быть массивом из двух координат' + if (!Array.isArray(value)) return "Неверный формат"; + if (value.length === 0) + return "Введите хотя бы одну пару координат"; + if ( + !value.every( + (point: unknown) => Array.isArray(point) && point.length === 2 + ) + ) { + return "Каждая строка должна содержать две координаты"; } - if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) { - return 'Координаты должны быть числами' + if ( + !value.every((point: unknown[]) => + point.every( + (coord: unknown) => + !isNaN(Number(coord)) && typeof coord === "number" + ) + ) + ) { + return "Координаты должны быть числами"; } - return true + return true; }, }} - render={({field, fieldState: {error}}) => ( + render={({ field, fieldState: { error } }) => ( point.join(" ")).join("\n") + : "" + } onChange={(e) => { try { - const parsed = JSON.parse(e.target.value) - field.onChange(parsed) + const lines = e.target.value.trim().split("\n"); + const parsed = lines.map((line) => { + const [lat, lon] = line + .trim() + .split(/[\s,]+/) + .map(Number); + if (isNaN(lat) || isNaN(lon)) { + throw new Error("Invalid coordinates"); + } + return [lat, lon]; + }); + field.onChange(parsed); } catch { - field.onChange([]) + field.onChange([]); } }} error={!!error} - helperText={error?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' + helperText={error?.message} margin="normal" fullWidth - InputLabelProps={{shrink: true}} + InputLabelProps={{ shrink: true }} type="text" - label={'Координаты маршрута'} - placeholder="[[1.1, 2.2], [2.1, 4.5]]" + label={"Координаты маршрута *"} + placeholder="55.7558 37.6173 +55.7539 37.6208" + multiline + rows={4} sx={{ marginBottom: 2, }} @@ -126,111 +205,130 @@ export const RouteEdit = () => { /> Number(value), })} error={!!(errors as any)?.scale_min} helperText={(errors as any)?.scale_min?.message} margin="normal" fullWidth - InputLabelProps={{shrink: true}} + InputLabelProps={{ shrink: true }} type="number" - label={'Масштаб (мин)'} + label={"Масштаб (мин)"} name="scale_min" /> Number(value), })} error={!!(errors as any)?.scale_max} helperText={(errors as any)?.scale_max?.message} margin="normal" fullWidth - InputLabelProps={{shrink: true}} + InputLabelProps={{ shrink: true }} type="number" - label={'Масштаб (макс)'} + label={"Масштаб (макс)"} name="scale_max" /> Number(value), })} error={!!(errors as any)?.rotate} helperText={(errors as any)?.rotate?.message} margin="normal" fullWidth - InputLabelProps={{shrink: true}} + InputLabelProps={{ shrink: true }} type="number" - label={'Поворот'} + label={"Поворот"} name="rotate" /> Number(value), })} error={!!(errors as any)?.center_latitude} helperText={(errors as any)?.center_latitude?.message} margin="normal" fullWidth - InputLabelProps={{shrink: true}} + InputLabelProps={{ shrink: true }} type="number" - label={'Центр. широта'} + label={"Центр. широта"} name="center_latitude" /> Number(value), })} error={!!(errors as any)?.center_longitude} helperText={(errors as any)?.center_longitude?.message} margin="normal" fullWidth - InputLabelProps={{shrink: true}} + InputLabelProps={{ shrink: true }} type="number" - label={'Центр. долгота'} + label={"Центр. долгота"} name="center_longitude" /> {routeId && ( <> - type="edit" parentId={routeId} parentResource="route" childResource="station" fields={stationFields} title="станции" /> + + type="edit" + parentId={routeId} + parentResource="route" + childResource="station" + fields={stationFields} + title="станции" + /> - type="edit" parentId={routeId} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" /> + + type="edit" + parentId={routeId} + parentResource="route" + childResource="vehicle" + fields={vehicleFields} + title="транспортные средства" + /> )} - ) -} + ); +}; diff --git a/src/preview /assets/.gitkeep b/src/preview /assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/preview /assets/icons/company-logo.svg b/src/preview /assets/icons/company-logo.svg new file mode 100644 index 0000000..488da6c --- /dev/null +++ b/src/preview /assets/icons/company-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/preview /assets/images/loader.gif b/src/preview /assets/images/loader.gif new file mode 100644 index 0000000..0d951ad Binary files /dev/null and b/src/preview /assets/images/loader.gif differ diff --git a/src/preview /components/MapWidgetContainer/MapWidgetContainer.tsx b/src/preview /components/MapWidgetContainer/MapWidgetContainer.tsx new file mode 100644 index 0000000..41c08fd --- /dev/null +++ b/src/preview /components/MapWidgetContainer/MapWidgetContainer.tsx @@ -0,0 +1,49 @@ +import { MapWidget, useMapWidgetContext } from '@mt/components'; +import { useGetMapData } from './useGetMapData'; +import { EventQueryData, useEventQuery, useLoading } from '@mt/utils'; +import { useEffect } from 'react'; +// TODO: resolve circular deps +import { Coordinates } from '@mt/common-types'; + +export const MapWidgetContainer = () => { + const { isLoading } = useLoading(); + const { data, refetch } = useGetMapData(); + const { currentPosition, setCurrentPosition, onMapDataFetched } = useMapWidgetContext(); + + const { data: events = [], isSuccess } = useEventQuery('/widgets/route-map/events', [ + 'REFRESH_DATA', + 'UPDATE_CURRENT_POINT_ON_TRACK', + ]); + + useEffect(() => { + if (!data) { + return; + } + + onMapDataFetched(data); + }, [data]); + + useEffect(() => { + if (!isSuccess || !events.length) { + return; + } + + if (events.some((e) => e['@type'] === 'REFRESH_DATA')) { + refetch(); + + return; + } + + const { currentPointOnTrack } = events.at(-1) as EventQueryData<{ + currentPointOnTrack: Coordinates; + }>; + + setCurrentPosition(currentPointOnTrack); + }, [events, isSuccess, refetch]); + + if (isLoading || !data || !currentPosition) { + return null; + } + + return ; +}; diff --git a/src/preview /components/MapWidgetContainer/index.ts b/src/preview /components/MapWidgetContainer/index.ts new file mode 100644 index 0000000..68177a4 --- /dev/null +++ b/src/preview /components/MapWidgetContainer/index.ts @@ -0,0 +1 @@ +export * from './MapWidgetContainer'; diff --git a/src/preview /components/MapWidgetContainer/mapStationsFromApi.ts b/src/preview /components/MapWidgetContainer/mapStationsFromApi.ts new file mode 100644 index 0000000..81155c1 --- /dev/null +++ b/src/preview /components/MapWidgetContainer/mapStationsFromApi.ts @@ -0,0 +1,19 @@ +import { Track } from '@mt/common-types'; +import { StationOnMap } from '@mt/components'; + +export function mapStationsFromApi(stations: StationOnMap[], track: Track): StationOnMap[] { + return stations.map((station) => { + const { pointOnMap } = station; + const trackIndex = track.findIndex( + (trackPoint) => pointOnMap.lat === trackPoint.lat && pointOnMap.lon === trackPoint.lon + ); + + return { + ...station, + pointOnMap: { + ...pointOnMap, + trackIndex, + }, + }; + }); +} diff --git a/src/preview /components/MapWidgetContainer/useGetMapData.ts b/src/preview /components/MapWidgetContainer/useGetMapData.ts new file mode 100644 index 0000000..aaefee4 --- /dev/null +++ b/src/preview /components/MapWidgetContainer/useGetMapData.ts @@ -0,0 +1,23 @@ +import { UseQueryResult, useQuery } from 'react-query'; +import { MapData } from '@mt/components'; +import { mapStationsFromApi } from './mapStationsFromApi'; + +export function useGetMapData(): UseQueryResult { + return useQuery( + 'getMapData', + async () => { + const { stationsOnMap, trackPoints, ...rest } = await fetch( + 'https://localhost:8443/widgets/route-map/data' + ).then((res) => res.json()); + + return { + trackPoints, + stationsOnMap: mapStationsFromApi(stationsOnMap, trackPoints), + ...rest, + }; + }, + { + refetchOnWindowFocus: false, + } + ); +} diff --git a/src/preview /components/RouteInfoWidgetContainer/RouteInfoWidgetContainer.tsx b/src/preview /components/RouteInfoWidgetContainer/RouteInfoWidgetContainer.tsx new file mode 100644 index 0000000..61b52db --- /dev/null +++ b/src/preview /components/RouteInfoWidgetContainer/RouteInfoWidgetContainer.tsx @@ -0,0 +1,9 @@ +import { RouteInfoWidget } from '@mt/components'; +import { useGetRouteInfo } from './useGetRouteInfo'; +import { HTMLAttributes } from 'react'; + +export const RouteInfoWidgetContainer = (props: HTMLAttributes) => { + const { data } = useGetRouteInfo(); + + return ; +}; diff --git a/src/preview /components/RouteInfoWidgetContainer/useGetRouteInfo.ts b/src/preview /components/RouteInfoWidgetContainer/useGetRouteInfo.ts new file mode 100644 index 0000000..d642dbd --- /dev/null +++ b/src/preview /components/RouteInfoWidgetContainer/useGetRouteInfo.ts @@ -0,0 +1,26 @@ +import { useQuery } from 'react-query'; +import { useEffect } from 'react'; +import { useEventQuery } from '@mt/utils'; +import { RouteInfoData } from '@mt/components'; + +export const useGetRouteInfo = () => { + const { data, isSuccess } = useEventQuery( + // 'getRouteInfoEvents', + '/widgets/route-info/events', + ['REFRESH_DATA'] + ); + + const routeInfoQuery = useQuery( + 'getRouteInfo', + async () => + await fetch('https://localhost:8443/widgets/route-info/data').then((res) => res.json()) + ); + + useEffect(() => { + if (isSuccess && data.length) { + routeInfoQuery.refetch(); + } + }, [data, isSuccess]); + + return routeInfoQuery; +}; diff --git a/src/preview /components/WeatherWidget/WeatherWidgetContainer.tsx b/src/preview /components/WeatherWidget/WeatherWidgetContainer.tsx new file mode 100644 index 0000000..03a870e --- /dev/null +++ b/src/preview /components/WeatherWidget/WeatherWidgetContainer.tsx @@ -0,0 +1,9 @@ +import { WeatherWidget } from '@mt/components'; +import { HTMLAttributes } from 'react'; +import { useWeatherData } from './useWeatherData'; + +export function WeatherWidgetContainer(props: HTMLAttributes) { + const { data } = useWeatherData(); + + return ; +} diff --git a/src/preview /components/WeatherWidget/useWeatherData.ts b/src/preview /components/WeatherWidget/useWeatherData.ts new file mode 100644 index 0000000..9d9e2a0 --- /dev/null +++ b/src/preview /components/WeatherWidget/useWeatherData.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; +import { useLongPollingQuery } from '@mt/utils'; +import { WEATHER_DEFAULTS, WeatherWidgetData } from '@mt/components'; + +export const useWeatherData = () => { + const [data, setData] = useState(WEATHER_DEFAULTS); + + const { isSuccess, data: weatherData } = useLongPollingQuery( + 'getWeatherData', + async () => { + const response = await fetch('https://localhost:8443/widgets/general-info/data'); + + return response.json(); + }, + { + pollingInterval: 3000, // client-side delayed long-polling + maxRetries: Number.MAX_SAFE_INTEGER, + } + ); + + useEffect(() => { + if (isSuccess && weatherData) { + const { weatherInfo, forecasts } = weatherData; + + setData({ + weatherInfo, + forecasts, + }); + } + }, [isSuccess, weatherData]); + + return { data }; +}; diff --git a/src/preview /components/all-attractions-dropdown/AllAttractionsDropdown.css b/src/preview /components/all-attractions-dropdown/AllAttractionsDropdown.css new file mode 100644 index 0000000..d9e0acb --- /dev/null +++ b/src/preview /components/all-attractions-dropdown/AllAttractionsDropdown.css @@ -0,0 +1,160 @@ +.dropdown { + position: relative; +} + +.dropdown-button { + position: relative; + display: flex; + width: 100%; + padding: 18px 0; + justify-content: center; + color: white; + font-weight: 700; + font-size: 18px; + line-height: 21px; + border: none; + background: linear-gradient( + 113.51deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #806c59; + border-radius: 10px 10px 0 0; + margin-top: 33px; + cursor: pointer; + align-items: center; +} + +.dropdown-icon { + height: 15px; + width: 21px; + left: 40px; + position: absolute; +} + +.dropdown-icon--opened { + transform: rotate(180deg); +} + +.dropdown-button:hover { + background: #806c59; +} + +.dropdown-content { + overflow-y: auto; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 675px; + padding: 0; + background: linear-gradient( + 113.51deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #806c59; + box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); + border-radius: 10px 10px 0 0; + z-index: 100; +} + +.dropdown-content-header { + display: flex; + align-items: center; + width: 100%; + padding: 15px 0; + font-weight: 700; + font-size: 18px; + line-height: 21px; + text-align: center; + color: #ffffff; + border-bottom: 1px solid #ccc; +} + +.dropdown-content-list { + /*52px - height of the header*/ + height: calc(675px - 52px); + padding-right: 35px; +} + +.alphabet-list { + position: fixed; + right: 35px; + bottom: 0; + padding: 0; + list-style: none; + + display: flex; + flex-direction: column; + justify-content: space-between; + /*52px - height of the header*/ + /*28px - top&bottom paddings from .attraction-list*/ + height: calc(675px - 52px - 28px); +} + +.alphabet-list li { + text-align: center; + font-weight: 400; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + flex-grow: 1; + width: 35px; + color: #ffffff; + cursor: pointer; +} + +.alphabet-list li:first-child { + padding-top: 0; +} + +.attraction-list { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-auto-rows: 150px; + gap: 24px 0; + padding: 14px 0; + margin: 0; +} + +.attraction-list li { + display: flex; + align-items: center; + flex-direction: column; +} + +.attraction-list li:hover { + cursor: pointer; +} + +.attraction-list li .dropdown-list-item__image-wrapper { + background: #ffffff; + border-radius: 50%; + width: 96px; + height: 96px; + display: flex; + align-items: center; + justify-content: center; +} + +.attraction-list li .dropdown-list-item__image-wrapper img { + max-width: 60px; +} + +.attraction-list li span { + width: 150px; + max-height: 40px; + text-overflow: ellipsis; + overflow: hidden; + margin-top: 8px; + + font-weight: 600; + font-size: 16px; + line-height: 19px; + text-align: center; + letter-spacing: 0.02em; + + color: #ffffff; +} diff --git a/src/preview /components/all-attractions-dropdown/all-attractions-dropdown.tsx b/src/preview /components/all-attractions-dropdown/all-attractions-dropdown.tsx new file mode 100644 index 0000000..91f1428 --- /dev/null +++ b/src/preview /components/all-attractions-dropdown/all-attractions-dropdown.tsx @@ -0,0 +1,127 @@ +import { useServerLocalization } from "@mt/i18n"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import "./AllAttractionsDropdown.css"; +import { FormattedMessage } from "react-intl"; + +import { useGetAttractionList } from "./useGetAttractionList"; +import { TouchScrollWrapper } from "@mt/lib"; + +interface AllAttractionsDropdownProps { + isIdleMode: boolean; + handleAttractionClick: (id: string) => void; +} + +const AllAttractionsDropdown = React.memo( + ({ isIdleMode, handleAttractionClick }: AllAttractionsDropdownProps) => { + const { data: attractions = [] } = useGetAttractionList(); + + const [isOpen, setIsOpen] = useState(false); + const attractionsListRef = useRef(null); + const localizeText = useServerLocalization(); + const letters = attractions + .sort((a, b) => + localizeText(a.name) + .toLowerCase() + .localeCompare(localizeText(b.name).toLowerCase()) + ) + .reduce((acc: string[], cur) => { + const firstLetter = localizeText(cur.name).charAt(0).toUpperCase(); + + if (!acc.includes(firstLetter)) { + return acc.concat(firstLetter); + } + + return acc; + }, []); + + const handleLetterClick = useCallback( + (letter: string) => { + const attractionByFirstLetter = attractions.find((it) => + localizeText(it.name).startsWith(letter) + ); + + if (attractionByFirstLetter) { + const item = attractionsListRef.current?.querySelector( + `#attraction-${attractionByFirstLetter.id}` + ); + + item?.scrollIntoView({ + behavior: "smooth", + block: "end", + inline: "nearest", + }); + } + }, + [attractions, localizeText] + ); + + useEffect(() => { + if (isIdleMode) setIsOpen(false); + }, [isIdleMode]); + + return ( +
+ + + {isOpen && ( +
+
setIsOpen(!isOpen)} + > + + +
+ +
+
+ + +
    + {attractions.map((attraction) => ( +
  • { + setIsOpen(!isOpen); + handleAttractionClick(attraction.id); + }} + > +
    + {localizeText(attraction.name)} +
    + {localizeText(attraction.name)} +
  • + ))} +
+
+ +
    + {letters.map((letter) => ( +
  • handleLetterClick(letter)}> + {letter} +
  • + ))} +
+
+ )} +
+ ); + } +); + +export default AllAttractionsDropdown; diff --git a/src/preview /components/all-attractions-dropdown/useGetAttractionList.ts b/src/preview /components/all-attractions-dropdown/useGetAttractionList.ts new file mode 100644 index 0000000..06064d0 --- /dev/null +++ b/src/preview /components/all-attractions-dropdown/useGetAttractionList.ts @@ -0,0 +1,36 @@ +import { useQuery, UseQueryResult } from 'react-query'; +import { LocalizedString } from '@mt/i18n'; +import { useEventQuery } from '@mt/utils'; +import { useEffect } from 'react'; + +interface Attraction { + id: string; + name: LocalizedString; + iconUrl: string; +} + +export function useGetAttractionList(): UseQueryResult { + const { data, isSuccess } = useEventQuery('/widgets/attraction-with-details-list/events', [ + 'REFRESH_DATA', + ]); + + const attractionListQuery = useQuery( + ['getAttractionList'], + async () => + await fetch('https://localhost:8443/widgets/attraction-with-details-list/data') + .then((res) => res.json()) + .then(({ touristAttractions }) => touristAttractions), + { + refetchOnWindowFocus: false, + refetchOnMount: false, + } + ); + + useEffect(() => { + if (isSuccess && data.length) { + attractionListQuery.refetch(); + } + }, [data, isSuccess]); + + return attractionListQuery; +} diff --git a/src/preview /components/attractions-widget/AttractionWidgetContainer.tsx b/src/preview /components/attractions-widget/AttractionWidgetContainer.tsx new file mode 100644 index 0000000..6d42076 --- /dev/null +++ b/src/preview /components/attractions-widget/AttractionWidgetContainer.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { AttractionWidget } from '@mt/components'; +import AllAttractionsDropdown from '../all-attractions-dropdown/all-attractions-dropdown'; + +import './AttractionsWidgetContainer.css'; +import { useLightboxContext } from '@mt/components'; +// TODO: resolve circular deps +import { ArticleBase, PhotoSphereLightboxData } from '@mt/common-types'; +import { Article } from '@front/types'; + +const POLL_INTERVAL_MS = 5000; +const IDLE_TIME_MS = 2 * 60 * 1000; // Idle time in milliseconds before restarting polling (2 minutes) +// const IDLE_TIME_MS = 15 * 1000; // debug + +export function AttractionWidgetContainer() { + const [touristArticles, setTouristArticles] = useState(null); + const [error, setError] = useState(null); + const [isPolling, setIsPolling] = useState(true); + const [isIdleMode, setIdleMode] = useState(true); + const idleTimeoutRef = useRef(null); + const containerRef = useRef(null); + const pollTimeoutRef = useRef(null); + const abortControllerRef = useRef(null); + + const { closeLightbox } = useLightboxContext(); + + const startPolling = useCallback(async () => { + closeLightbox(); + setIsPolling(true); + + abortControllerRef.current = new AbortController(); + const poll = async (attempt = 1) => { + try { + const response = await fetch('https://localhost:8443/widgets/attraction-details/events', { + signal: abortControllerRef.current?.signal, + }).then((resp) => resp.json()); + + // if we receive empty array - preserve previous attraction on the screen + if (response?.[0]) { + const [{ touristAttractionInfoPages }] = response; + + const articles: ArticleBase[] = touristAttractionInfoPages.map( + ({ title: name, ...rest }: Article) => ({ name, ...rest }) + ); + + setTouristArticles(articles); + } + + setError(null); + + if (isPolling) { + // Schedule the next poll + pollTimeoutRef.current = setTimeout(poll, POLL_INTERVAL_MS); + } + } catch (error: unknown) { + if ((error as TypeError).name === 'AbortError') { + console.log('Request was cancelled'); + } else { + setError(error as TypeError); + + if (isPolling && attempt < 3) { + // Schedule the next poll + pollTimeoutRef.current = setTimeout(() => poll(attempt + 1), POLL_INTERVAL_MS); + } + } + } + }; + + await poll(); + + return () => { + abortControllerRef.current?.abort(); + }; + }, [isPolling]); + + const stopPolling = () => { + setIsPolling(false); + abortControllerRef.current?.abort(); + + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + } + }; + + const handleUserAction = (e: Event) => { + const widgetContainer = containerRef.current; + + if (!widgetContainer || !e.target) return; + if (!widgetContainer.contains(e.target as Node)) return; + + e.stopPropagation(); + resetIdleMode(); + }; + + const handleAttractionSelect = useCallback(async (id: string) => { + const { touristAttractionInfoPages } = await fetch( + 'https://localhost:8443/widgets/attraction-details/data-by-params?' + + new URLSearchParams({ touristAttractionId: id }) + ).then((resp) => resp.json()); + + resetIdleMode(); + + const articles: ArticleBase[] = touristAttractionInfoPages.map( + ({ title: name, ...rest }: Article) => ({ name, ...rest }) + ); + + setTouristArticles(articles); + // https://tracker.yandex.ru/TS-32 + document.querySelector('.widget-text.active')!.scrollTop = 0; + }, []); + + const resetIdleMode = () => { + abortControllerRef.current?.abort(); // cancel ongoing requests + idleTimeoutRef.current && clearTimeout(idleTimeoutRef.current); // reset idle timeout + setIdleMode(false); + + idleTimeoutRef.current = setTimeout(() => setIdleMode(true), IDLE_TIME_MS); + }; + + useEffect(() => { + isIdleMode ? startPolling() : stopPolling(); + + return stopPolling; + }, [isIdleMode, startPolling]); + + // Attach event listeners to stop polling on user actions + useEffect(() => { + window.addEventListener('click', handleUserAction); + window.addEventListener('scroll', handleUserAction); + + return () => { + window.removeEventListener('click', handleUserAction); + window.removeEventListener('scroll', handleUserAction); + + if (idleTimeoutRef.current) { + clearTimeout(idleTimeoutRef.current); + } + }; + }, [containerRef]); + + return error ? ( +

Error: {error.message}

+ ) : ( + touristArticles && ( +
+ + + +
+ ) + ); +} diff --git a/src/preview /components/attractions-widget/AttractionsWidgetContainer.css b/src/preview /components/attractions-widget/AttractionsWidgetContainer.css new file mode 100644 index 0000000..5d03a0a --- /dev/null +++ b/src/preview /components/attractions-widget/AttractionsWidgetContainer.css @@ -0,0 +1,4 @@ +.attractions-widget-container { + padding: 32px 32px 0 0; + height: 100%; +} diff --git a/src/preview /components/dashboard/Dashboard.tsx b/src/preview /components/dashboard/Dashboard.tsx new file mode 100644 index 0000000..36cea3c --- /dev/null +++ b/src/preview /components/dashboard/Dashboard.tsx @@ -0,0 +1,70 @@ +import styled from "@emotion/styled"; +import { AttractionWidgetContainer } from "../attractions-widget/AttractionWidgetContainer"; +import { WeatherWidgetContainer } from "../WeatherWidget/WeatherWidgetContainer"; +import { OperativeInfoWidget } from "../operative-info-widget/operative-info-widget"; +import { NavWidgetContainer } from "../nav-widget/nav-widget-container"; +import { MapWidgetContainer } from "../MapWidgetContainer"; +import { RouteInfoWidgetContainer } from "../RouteInfoWidgetContainer/RouteInfoWidgetContainer"; + +const StyledDashboard = styled.div` + background-color: #000; + + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + + display: flex; + + .nav-widget--opened + .container { + margin-left: 290px; + } + + .container { + position: relative; + width: 100%; + height: 100%; + + margin-left: 0; + + transition: margin-left ease-in-out 0.3s; + + .left-top-wrapper { + position: absolute; + .route-number { + margin: 32px; + } + + .weather-widget { + margin: 32px; + } + } + } + + .right-sidebar { + flex-shrink: 0; + } +`; + +export function Dashboard() { + return ( + + + +
+
+ + +
+ + + + +
+ +
+ +
+
+ ); +} diff --git a/src/preview /components/main/MainScreen.tsx b/src/preview /components/main/MainScreen.tsx new file mode 100644 index 0000000..48c433e --- /dev/null +++ b/src/preview /components/main/MainScreen.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { Lightbox } from '@mt/components'; +import { LoadingScreen, useLoading } from '@mt/utils'; +import { useBackendStatus } from './useBackendStatus'; +import { Dashboard } from '../dashboard/Dashboard'; +import { useSplashScreenIsDisplayed } from './useSplashScreenStatus'; + +export function MainScreen() { + const { isLoading, hideLoadingScreen, showLoadingScreen } = useLoading(); + const { backendStatus } = useBackendStatus(); + const { isSplashScreenDisplayed } = useSplashScreenIsDisplayed(); + + useEffect(() => { + if (backendStatus === 'UP' && !isSplashScreenDisplayed) { + hideLoadingScreen(); + } else if (backendStatus === 'DOWN' || isSplashScreenDisplayed) { + showLoadingScreen(); + } + }, [ + backendStatus, + isSplashScreenDisplayed, + hideLoadingScreen, + showLoadingScreen, + ]); + + return isLoading ? ( + + ) : ( + <> + + + + + ); +} diff --git a/src/preview /components/main/useBackendStatus.ts b/src/preview /components/main/useBackendStatus.ts new file mode 100644 index 0000000..ad2a9b3 --- /dev/null +++ b/src/preview /components/main/useBackendStatus.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react'; +import { useLoading } from '@mt/utils'; + +type BE_STATUS = 'UP' | 'DOWN'; +interface BackendHealthResponse { + status: BE_STATUS; +} + +export const useBackendStatus = () => { + const [backendStatus, setBackendStatus] = useState('DOWN'); + const timeoutRef = useRef(null); + const { isLoading } = useLoading(); + + useEffect(() => { + const retry = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => checkStatus(), 1000); + }; + + const checkStatus = async () => { + await fetch('https://localhost:8443/_app/actuator/health') + .then((response) => { + if (!response.ok && response.status === 404) { + retry(); + } else { + return response.json(); + } + }) + .then((data: BackendHealthResponse) => { + if (data.status === 'UP') { + setBackendStatus('UP'); + } else { + setBackendStatus('DOWN'); + retry(); + } + }) + .catch((error) => { + setBackendStatus('DOWN'); + retry(); + }); + }; + + if (isLoading) { + checkStatus(); + } + }, [isLoading]); + + return { backendStatus }; +}; diff --git a/src/preview /components/main/useSplashScreenStatus.ts b/src/preview /components/main/useSplashScreenStatus.ts new file mode 100644 index 0000000..633eccc --- /dev/null +++ b/src/preview /components/main/useSplashScreenStatus.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { EventQueryData, useEventQuery } from '@mt/utils'; + +export const useSplashScreenIsDisplayed = () => { + const [isSplashScreenDisplayed, setIsSplashScreenDisplayed] = + useState(); + + const { data: events, isSuccess } = useEventQuery( + '/widgets/splash-screen/events', + ['UPDATE_DISPLAYED_FLAG'], + { maxRetries: Number.MAX_SAFE_INTEGER } + ); + + useEffect(() => { + if (!isSuccess || !events.length) { + return; + } + + const { isDisplayed } = events.at(-1) as EventQueryData<{ + isDisplayed: boolean; + }>; + + setIsSplashScreenDisplayed(isDisplayed); + }, [isSuccess, events]); + + return { isSplashScreenDisplayed }; +}; diff --git a/src/preview /components/nav-widget/components/AccordionListTab/AccordionListTab.tsx b/src/preview /components/nav-widget/components/AccordionListTab/AccordionListTab.tsx new file mode 100644 index 0000000..409dfb6 --- /dev/null +++ b/src/preview /components/nav-widget/components/AccordionListTab/AccordionListTab.tsx @@ -0,0 +1,108 @@ +import cn from 'classnames'; +import { HTMLAttributes, useCallback, useId, useMemo, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { TouchScrollWrapper, TransportIcon } from '@mt/components'; +import { uuid } from '@mt/common-types'; +import { useServerLocalization } from '@mt/i18n'; + +import { Attraction, Station } from '../../nav-widget.interface'; +import { NavAccordion, NavAccordionSummary, NavAccordionDetails } from '../../nav-widget.styles'; +import { getDistanceFormatter } from '../get-distance-formatter'; + +const simulateClick = (id: uuid) => { + document.getElementById(`accordSummary_${id}`)?.click(); +}; + +export type NestedItems = Array< + Attraction & Omit & Partial> +>; +export interface AccordionItem + extends Omit { + nestedItems: NestedItems; +} + +interface Props extends HTMLAttributes { + titleId: string; + isOpened: boolean; + items: AccordionItem[]; + onExpandChange?: (isExpanded: boolean, itemId: uuid) => void; + onNestedItemClick?: (itemId: uuid) => void; +} + +export const AccordionListTab = (props: Props) => { + const { titleId, isOpened, items, onExpandChange, onNestedItemClick, ...args } = props; + const localizeText = useServerLocalization(); + const formatDistance = getDistanceFormatter(localizeText); + const [expandedItem, setExpandedItem] = useState(null); + const scrollToExpanded = useCallback((id: uuid) => { + const expandedEl = document.getElementById(`accordWrapper_${id}`); + + expandedEl?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + }, []); + + const tabClasses = useMemo( + () => + cn('g-flex-column__item-fixed g-flex-column tab-container', { + 'tab-container--opened': isOpened, + }), + [isOpened] + ); + + const isExpanded = useCallback((id: uuid) => id === expandedItem, [expandedItem]); + + const handleChange = (isExpanded: boolean, itemId: uuid) => { + setExpandedItem(isExpanded ? itemId : null); + onExpandChange?.(isExpanded, itemId); + }; + + return ( +
+
+ +
+ + + {items.map(({ id, name, nestedItems }) => ( +
+
simulateClick(id)} /> + + isExpanded(id) && scrollToExpanded(id)} + disabled={!nestedItems.length} + expanded={isExpanded(id)} + onChange={(_, expanded) => handleChange(expanded, id)} + > + +
{localizeText(name)}
+
+ + {nestedItems.length && ( + + {nestedItems.map(({ id: nestedId, name, type, distance }, index) => ( +
onNestedItemClick?.(nestedId)} + > + {type && ( + + )} + +
{localizeText(name)}
+ +
{formatDistance(distance)}
+
+ ))} +
+ )} +
+
+ ))} + +
+ ); +}; diff --git a/src/preview /components/nav-widget/components/AttractionCard/AttractionCard.module.css b/src/preview /components/nav-widget/components/AttractionCard/AttractionCard.module.css new file mode 100644 index 0000000..d49f0c2 --- /dev/null +++ b/src/preview /components/nav-widget/components/AttractionCard/AttractionCard.module.css @@ -0,0 +1,5 @@ +.root { + position: absolute; + left: 312px; + bottom: 130px; +} diff --git a/src/preview /components/nav-widget/components/AttractionCard/AttractionCard.tsx b/src/preview /components/nav-widget/components/AttractionCard/AttractionCard.tsx new file mode 100644 index 0000000..845e005 --- /dev/null +++ b/src/preview /components/nav-widget/components/AttractionCard/AttractionCard.tsx @@ -0,0 +1,21 @@ +import { HTMLAttributes } from 'react'; + +import { uuid } from '@mt/common-types'; +import { AttractionShortPreview } from '@mt/components'; +import { useGetAttractionDetails } from '../../hooks/useGetAttractionDetails'; + +import styles from './AttractionCard.module.css'; + +interface AttractionCardProps { + attractionId: uuid; +} + +export function AttractionCard({ attractionId }: AttractionCardProps) { + const { isSuccess, data: attractionDetails } = useGetAttractionDetails(attractionId); + + if (!(isSuccess && attractionDetails)) { + return null; + } + + return ; +} diff --git a/src/preview /components/nav-widget/components/HomeTab/HomeTab.tsx b/src/preview /components/nav-widget/components/HomeTab/HomeTab.tsx new file mode 100644 index 0000000..89b553c --- /dev/null +++ b/src/preview /components/nav-widget/components/HomeTab/HomeTab.tsx @@ -0,0 +1,41 @@ +import { ButtonBase } from '@mui/material'; +import { FormattedMessage } from 'react-intl'; +import { GerbIcon } from '../../../../icons'; +import { HTMLAttributes } from 'react'; +import { NavTabs } from '../../nav-widget'; + +interface Props extends HTMLAttributes { + onOpenTab: (tabName: NavTabs) => void; +} + +export const HomeTab = ({ onOpenTab, ...props }: Props) => { + return ( +
+
+ +
+ +
+ onOpenTab('attractionsTab')}> + + + + onOpenTab('stationsTab')}> + + +
+ +
+ company-logo + +

+ }} /> +

+ +

+ +

+
+
+ ); +}; diff --git a/src/preview /components/nav-widget/components/get-distance-formatter.ts b/src/preview /components/nav-widget/components/get-distance-formatter.ts new file mode 100644 index 0000000..755d695 --- /dev/null +++ b/src/preview /components/nav-widget/components/get-distance-formatter.ts @@ -0,0 +1,29 @@ +import type { LocalizedString, LocalizeTextFn } from '@mt/i18n'; + +const meterLabel: LocalizedString = { + ru: 'м', + en: 'm', + zh: '米', +}; + +const kilometerLabel: LocalizedString = { + ru: 'км', + en: 'km', + zh: '公里', +}; + +export function getDistanceFormatter( + localizeText: LocalizeTextFn +): (distance: number | undefined) => string { + return (distance) => { + if (!distance) { + return ''; + } + + if (distance < 1000) { + return `${distance} ${localizeText(meterLabel)}`; + } + + return `${(distance / 1000).toFixed(2)} ${localizeText(kilometerLabel)}`; + }; +} diff --git a/src/preview /components/nav-widget/components/index.ts b/src/preview /components/nav-widget/components/index.ts new file mode 100644 index 0000000..de0f640 --- /dev/null +++ b/src/preview /components/nav-widget/components/index.ts @@ -0,0 +1,3 @@ +export * from './HomeTab/HomeTab'; +export * from './AttractionCard/AttractionCard'; +export * from './AccordionListTab/AccordionListTab'; diff --git a/src/preview /components/nav-widget/hooks/index.ts b/src/preview /components/nav-widget/hooks/index.ts new file mode 100644 index 0000000..447ca8d --- /dev/null +++ b/src/preview /components/nav-widget/hooks/index.ts @@ -0,0 +1 @@ +export * from './useGetAttractions'; diff --git a/src/preview /components/nav-widget/hooks/useGetAttractionDetails.ts b/src/preview /components/nav-widget/hooks/useGetAttractionDetails.ts new file mode 100644 index 0000000..8c7c711 --- /dev/null +++ b/src/preview /components/nav-widget/hooks/useGetAttractionDetails.ts @@ -0,0 +1,24 @@ +import { UseQueryResult, useQuery } from 'react-query'; +import { uuid } from '@mt/common-types'; +import { AttractionDetailsBE } from '../nav-widget.interface'; +import { AttractionShortPreviewProps } from '@mt/components'; + +export function useGetAttractionDetails( + touristAttractionId: uuid +): UseQueryResult { + return useQuery(['getAttractionDetails', touristAttractionId], async () => { + const { + touristAttractionAddress: subtitle, + touristAttractionDescription: content, + touristAttractionName: title, + touristAttractionImageUrl: img, + }: AttractionDetailsBE = await fetch( + 'https://localhost:8443/widgets/attraction-info/data-by-params?' + + new URLSearchParams({ + touristAttractionId: touristAttractionId as string, + }) + ).then((res) => res.json()); + + return { img, title, subtitle, content }; + }); +} diff --git a/src/preview /components/nav-widget/hooks/useGetAttractions.ts b/src/preview /components/nav-widget/hooks/useGetAttractions.ts new file mode 100644 index 0000000..99e0d33 --- /dev/null +++ b/src/preview /components/nav-widget/hooks/useGetAttractions.ts @@ -0,0 +1,28 @@ +import { UseQueryResult, useQuery } from 'react-query'; +import { Attraction } from '../nav-widget.interface'; +import { useEventQuery } from '@mt/utils'; +import { useEffect } from 'react'; + +export function useGetAttractions(): UseQueryResult { + const { data, isSuccess } = useEventQuery( + // 'getAttractionsEvents', + '/widgets/attraction-list/events', + ['REFRESH_DATA'] + ); + + const attractionQuery = useQuery( + 'getAttractions', + async () => + await fetch('https://localhost:8443/widgets/attraction-list/data') + .then((res) => res.json()) + .then(({ touristAttractions }) => touristAttractions) + ); + + useEffect(() => { + if (isSuccess && data.length) { + attractionQuery.refetch(); + } + }, [data, isSuccess]); + + return attractionQuery; +} diff --git a/src/preview /components/nav-widget/hooks/useGetStations.ts b/src/preview /components/nav-widget/hooks/useGetStations.ts new file mode 100644 index 0000000..b7eabeb --- /dev/null +++ b/src/preview /components/nav-widget/hooks/useGetStations.ts @@ -0,0 +1,28 @@ +import { UseQueryResult, useQuery } from 'react-query'; +import { Station } from '../nav-widget.interface'; +import { useEventQuery } from '@mt/utils'; +import { useEffect } from 'react'; + +export function useGetStations(): UseQueryResult { + const { data = [], isSuccess } = useEventQuery( + // 'getStationsEvents', + '/widgets/station-list/events', + ['REFRESH_DATA'] + ); + + const stationQuery = useQuery('getStations', async () => { + const { stations } = await fetch('https://localhost:8443/widgets/station-list/data').then( + (res) => res.json() + ); + + return stations; + }); + + useEffect(() => { + if (isSuccess && data.length) { + stationQuery.refetch(); + } + }, [data, isSuccess]); + + return stationQuery; +} diff --git a/src/preview /components/nav-widget/nav-widget-container.tsx b/src/preview /components/nav-widget/nav-widget-container.tsx new file mode 100644 index 0000000..a1d71f5 --- /dev/null +++ b/src/preview /components/nav-widget/nav-widget-container.tsx @@ -0,0 +1,15 @@ +import { HTMLAttributes } from 'react'; +import { NavWidget } from './nav-widget'; +import { useGetStations } from './hooks/useGetStations'; +import { useGetAttractions } from './hooks'; + +export function NavWidgetContainer(props: HTMLAttributes) { + const { data: stations } = useGetStations(); + const { data: attractions } = useGetAttractions(); + + if (!stations || !attractions) { + return null; + } + + return ; +} diff --git a/src/preview /components/nav-widget/nav-widget.interface.ts b/src/preview /components/nav-widget/nav-widget.interface.ts new file mode 100644 index 0000000..50c978e --- /dev/null +++ b/src/preview /components/nav-widget/nav-widget.interface.ts @@ -0,0 +1,24 @@ +import { uuid, TransportType } from '@mt/common-types'; +import { LocalizedString } from '@mt/i18n'; + +export interface Station { + id: uuid; + name: LocalizedString; + type: TransportType; + distance?: number; + nearbyTouristAttractions: Attraction[]; +} + +export interface Attraction { + id: uuid; + name: LocalizedString; + distance?: number; + nearbyTransportStops: Station[]; +} + +export interface AttractionDetailsBE { + touristAttractionImageUrl: string; + touristAttractionName: LocalizedString; + touristAttractionAddress: LocalizedString; + touristAttractionDescription: LocalizedString; +} diff --git a/src/preview /components/nav-widget/nav-widget.styles.tsx b/src/preview /components/nav-widget/nav-widget.styles.tsx new file mode 100644 index 0000000..ab13ac8 --- /dev/null +++ b/src/preview /components/nav-widget/nav-widget.styles.tsx @@ -0,0 +1,169 @@ +import styled from '@emotion/styled'; +import { AccordionDetails, AccordionSummary, accordionSummaryClasses } from '@mui/material'; +import MuiAccordion, { accordionClasses } from '@mui/material/Accordion'; + +export const NavAccordion = styled(MuiAccordion)` + box-shadow: none; + background-color: transparent; + color: #ffffff; + + &:before { + content: ''; + display: none; + } + + &.${accordionClasses.expanded} { + margin: 0; + } + &.${accordionClasses.disabled} { + background-color: transparent; + } +`; + +export const NavAccordionSummary = styled(AccordionSummary)` + pointer-events: none; + border-bottom: 1px solid #a6a6a6; + padding: 8px 16px; + line-height: 24px; + margin-top: 8px; + + .${accordionSummaryClasses.content} { + margin: 0; + width: 100%; + } + &.${accordionSummaryClasses.expanded} { + min-height: 0; + .${accordionSummaryClasses.content} { + margin: 0; + } + } + + &.${accordionSummaryClasses.disabled} { + opacity: 1; + } +`; + +export const NavAccordionDetails = styled(AccordionDetails)` + padding: 12px 16px; + background: linear-gradient(90.36deg, #4e351f 0.27%, #59422d 47.89%, #65503c 99.65%); +`; + +export const StyledNavWidget = styled.div` + height: 100%; + transition: all ease-in-out 0.3s; + + &:has(.tab-container--opened) { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), + rgba(128, 108, 89, 0.4); + } + + .tab-button { + display: block; + padding: 8px 16px; + background: #ffffff; + color: #000; + margin: 12px 30px; + width: calc(100% - 60px); + border-radius: 10px; + } + + .slogan { + font-style: italic; + font-weight: 400; + font-size: 16px; + line-height: 150%; + margin-top: 35px; + } + + .hashtag { + margin-top: 180px; + margin-bottom: 32px; + } + + .tab-container { + transform: translateY(100%); + height: calc(100% - 250px); + width: 100%; + transition: all ease-in-out 0.3s; + position: absolute; + bottom: 0; + + background: linear-gradient( + 113.51deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #806c59; + box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); + border-radius: 10px 10px 0 0; + + &.tab-container--opened { + transform: translateY(0); + filter: drop-shadow(0px -6px 13px rgba(0, 0, 0, 0.25)); + } + } + + .tab-title { + text-align: center; + font-weight: 700; + font-size: 16px; + line-height: 24px; + border-bottom: 1px solid #ffffff; + padding-top: 13px; + padding-bottom: 9px; + background: linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #806c59; + position: relative; + &:before { + content: ''; + width: 98px; + height: 4px; + border-radius: 0 0 4px 4px; + background: #a6a6a6; + opacity: 0.35; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + } + } + + .tab-list__item { + position: relative; + } + .tab-list__item-expander { + position: absolute; + top: -8px; + left: 0; + width: 100%; + height: 55px; + z-index: 1; + cursor: pointer; + } + .tab-list__item-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + display: block; + } + .tab-list__item-card { + padding: 12px 0; + } + + .stop__icon { + margin-right: 8px; + width: 18px; + } + + .distance { + font-size: 12px; + color: #d9d9d9; + margin-left: 5px; + line-height: 185%; + } +`; diff --git a/src/preview /components/nav-widget/nav-widget.tsx b/src/preview /components/nav-widget/nav-widget.tsx new file mode 100644 index 0000000..aaf8840 --- /dev/null +++ b/src/preview /components/nav-widget/nav-widget.tsx @@ -0,0 +1,105 @@ +import cn from 'classnames'; +import { HTMLAttributes, ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import { LocalizationContext, useServerLocalization } from '@mt/i18n'; +import { Order, uuid } from '@mt/common-types'; + +import { AttractionCard, AccordionListTab, NestedItems } from './components'; +import { Drawer, Icons } from '@mt/components'; + +import { Attraction, Station } from './nav-widget.interface'; +import { HomeTab } from './components'; +import { StyledNavWidget } from './nav-widget.styles'; + +export type NavTabs = 'stationsTab' | 'attractionsTab'; + +export interface NavWidgetProps extends HTMLAttributes { + stations: Station[]; + attractions: Attraction[]; +} +export function NavWidget({ stations, attractions }: NavWidgetProps) { + const { setLocale, locale } = useContext(LocalizationContext); + const localizeText = useServerLocalization(); + const [isOpen, setIsOpen] = useState(false); + const [openedTab, setOpenedTab] = useState(null); + const [attractionId, setAttractionId] = useState(null); + const [attractionOrder, setAttractionOrder] = useState('asc'); + + const sortAttractionsBtn: ReactNode = useMemo(() => { + if (openedTab === 'attractionsTab') { + return ( +
setAttractionOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'))} + > + +
+ ); + } + + return <>; + }, [openedTab, setAttractionOrder, attractionOrder]); + + useEffect(() => { + if (!isOpen) { + setOpenedTab(null); + } + + if (!isOpen || !openedTab) { + setAttractionId(null); + } + }, [isOpen, openedTab]); + + const mappedStations = useMemo(() => { + return (stations ?? []).map(({ nearbyTouristAttractions, ...station }) => ({ + ...station, + nestedItems: nearbyTouristAttractions as NestedItems, + })); + }, [stations]); + + const mappedAttractions = useMemo(() => { + return (attractions ?? []) + .map(({ nearbyTransportStops, ...attraction }) => ({ + ...attraction, + nestedItems: nearbyTransportStops as NestedItems, + })) + .sort( + (a, b) => + localizeText(a.name).toLowerCase().localeCompare(localizeText(b.name).toLowerCase()) * + (attractionOrder === 'asc' ? 1 : -1) + ); + }, [attractions, attractionOrder, locale]); + + const handleExpandChange = (isExpanded: boolean, id: uuid) => + setAttractionId(isExpanded ? id : null); + + return ( + setOpenedTab(null)} + onLocaleChange={setLocale} + actions={sortAttractionsBtn} + > + + + + + + + + + {attractionId && } + + ); +} diff --git a/src/preview /components/operative-info-widget/OperativeInfoWidget.css b/src/preview /components/operative-info-widget/OperativeInfoWidget.css new file mode 100644 index 0000000..3c51f5d --- /dev/null +++ b/src/preview /components/operative-info-widget/OperativeInfoWidget.css @@ -0,0 +1,76 @@ +.operative-info-widget { + position: absolute; + bottom: 12px; + right: 32px; +} + +.info-btn { + position: absolute; + bottom: 0; + right: 0; + width: 48px; + height: 48px; + border: 0; + padding: 0; + background: none; + cursor: pointer; +} + +.popup { + position: absolute; + right: 48px; /*btn size*/ + bottom: 0; + + min-width: 371px; + padding: 16px; + margin-right: 16px; + border-radius: 10px; + + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), + rgba(179, 165, 152, 0.4); + box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12); + + font-weight: 700; + font-size: 18px; + line-height: 150%; + color: #ffffff; +} + +.popup-title { + margin-bottom: 4px; +} + +.transfers-list, +.alerts-list { + margin: 0; + padding: 0; +} + +.alerts-list { + margin-bottom: 20px; +} + +.transfer-list-item, +.alert-list-item { + display: flex; + align-items: center; + gap: 9px; + list-style: none; + padding: 6px 12px; +} + +.alert-name { + display: flex; + align-items: center; +} + +.transfer-type-icon, +.transfer-type-icon svg, +.alert-icon { + width: 23px; + height: 23px; +} + +.alert-icon { + margin-right: 6px; +} diff --git a/src/preview /components/operative-info-widget/alert.interface.ts b/src/preview /components/operative-info-widget/alert.interface.ts new file mode 100644 index 0000000..dbb391f --- /dev/null +++ b/src/preview /components/operative-info-widget/alert.interface.ts @@ -0,0 +1,15 @@ +import { LocalizedString } from '@mt/i18n'; + +export interface AlertMessage { + key: string; + bgColor: string; + fontSize: number; + iconUrl: string; + order: number; + text: LocalizedString; +} + +export interface AlertEvent { + ['@type']: 'UPDATE_AVAILABLE_ALERTS'; + msgs: AlertMessage[]; +} diff --git a/src/preview /components/operative-info-widget/operative-info-widget.tsx b/src/preview /components/operative-info-widget/operative-info-widget.tsx new file mode 100644 index 0000000..d55bec7 --- /dev/null +++ b/src/preview /components/operative-info-widget/operative-info-widget.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from 'react'; +import { Icons, TransportIcon } from '@mt/components'; +import './OperativeInfoWidget.css'; +import { useOperativeInfo } from './useOperativeInfo'; +import { FormattedMessage } from 'react-intl'; +import { useServerLocalization } from '@mt/i18n'; +import { AlertMessage } from './alert.interface'; +import { TransferItem } from './transfer.interface'; + +export function OperativeInfoWidget() { + const [isOpen, setIsOpen] = useState(false); + const { transfers, alerts } = useOperativeInfo(); + + useEffect(() => { + setIsOpen(transfers.length > 0 || alerts.length > 0); + }, [transfers, alerts]); + + return ( +
+ + + {isOpen && ( +
+ + +
+ )} +
+ ); +} + +function AlertsList({ alerts }: { alerts: AlertMessage[] }) { + const localizeText = useServerLocalization(); + + return alerts.length ? ( +
    + {alerts.map(({ key, bgColor, fontSize, iconUrl, text }) => ( +
  • + + {iconUrl && } + {localizeText(text)} + +
  • + ))} +
+ ) : null; +} + +function TransfersList({ transfers }: { transfers: TransferItem[] }) { + return transfers.length ? ( + <> +
+ +
+ +
    + {transfers.map(({ type, label }) => ( +
  • + + + {label} +
  • + ))} +
+ + ) : null; +} diff --git a/src/preview /components/operative-info-widget/transfer.interface.ts b/src/preview /components/operative-info-widget/transfer.interface.ts new file mode 100644 index 0000000..2f4196b --- /dev/null +++ b/src/preview /components/operative-info-widget/transfer.interface.ts @@ -0,0 +1,19 @@ +import { Transfer } from '@front/types'; +import { TransportType } from '@mt/common-types'; + +export interface PublicRoute { + type: TransportType; + // Public route number string (ex.: '43') + number: string; +} + +export interface TransferEvent { + ['@type']: 'UPDATE_AVAILABLE_TRANSFERS'; + publicRoutes: PublicRoute[]; + stations: Transfer[]; +} + +export interface TransferItem { + type: TransportType; + label: string; +} diff --git a/src/preview /components/operative-info-widget/useOperativeInfo.ts b/src/preview /components/operative-info-widget/useOperativeInfo.ts new file mode 100644 index 0000000..502fb8e --- /dev/null +++ b/src/preview /components/operative-info-widget/useOperativeInfo.ts @@ -0,0 +1,83 @@ +import { Dispatch, useEffect, useState } from 'react'; +import { EventQueryData, useEventQuery } from '@mt/utils'; +import { TransportType } from '@mt/common-types'; +import { LocalizeTextFn, useServerLocalization } from '@mt/i18n'; +import { TransferEvent, PublicRoute, TransferItem } from './transfer.interface'; +import { AlertEvent, AlertMessage } from './alert.interface'; + +export const useOperativeInfo = () => { + const localizeText = useServerLocalization(); + const [transfers, setTransfers] = useState([]); + const [alerts, setAlerts] = useState([]); + + const { data: events, isSuccess } = useEventQuery( + '/widgets/operative-info/events', + ['UPDATE_AVAILABLE_TRANSFERS', 'UPDATE_AVAILABLE_ALERTS'] + ); + + useEffect(() => { + if (isSuccess && events.length) { + // prettier-ignore + const transferEvent = events.filter((e) => e['@type'] === 'UPDATE_AVAILABLE_TRANSFERS').at(-1) as EventQueryData; + // prettier-ignore + const alertEvent = events.filter((e) => e['@type'] === 'UPDATE_AVAILABLE_ALERTS').at(-1) as EventQueryData; + + updateTransfers(transferEvent, setTransfers, localizeText); + updateAlerts(alertEvent, setAlerts); + } + }, [events, isSuccess]); + + return { transfers, alerts }; +}; + +function updateTransfers( + transferEvent: EventQueryData, + setTransfers: Dispatch, + localizeText: LocalizeTextFn +) { + if (transferEvent) { + const { publicRoutes, stations } = transferEvent as TransferEvent; + + const newTransfers: TransferItem[] = [ + ...mapPublicRoutesToTransfers(publicRoutes), + ...stations.map(({ type, name }) => ({ + type, + label: localizeText(name), + })), + ]; + + setTransfers(newTransfers); + } +} + +function updateAlerts(alertEvent: EventQueryData, setAlerts: Dispatch) { + if (alertEvent) { + const { msgs } = alertEvent as AlertEvent; + + const newAlerts: AlertMessage[] = [...msgs] + .sort((a, b) => a.order - a.order) + .map((msg) => ({ + ...msg, + key: crypto.randomUUID(), + })); + + setAlerts(newAlerts); + } +} + +function mapPublicRoutesToTransfers(routes: PublicRoute[]): TransferItem[] { + const routeMap = routes.reduce((acc: Partial>, item) => { + const { type, number } = item; + + acc[type] = [...(acc[type] ?? []), number]; + + return acc; + }, {}); + + return Object.entries(routeMap).flatMap(([type, numbers]) => { + return { + type, + label: numbers.join(', '), + } as TransferItem; + }); +} diff --git a/src/preview /components/weather-widget/weather.interface.ts b/src/preview /components/weather-widget/weather.interface.ts new file mode 100644 index 0000000..d6301b6 --- /dev/null +++ b/src/preview /components/weather-widget/weather.interface.ts @@ -0,0 +1,29 @@ +export type WeatherTypes = + | 'CLOUDY' + | 'PARTLYCLOUDY' + | 'RAINY' + | 'SNOW' + | 'SNOWY' + | 'SUNNY' + | 'THUNDER'; + +export interface WeatherDayShortProps { + condition: WeatherTypes | null; + temperature: number | null; +} +export type WeatherDayProps = WeatherDayShortProps & { + humidity: number | null; + windSpeed: number | null; +}; +export interface WeatherForecastsProps { + weatherInfo: WeatherDayShortProps; +} + +export interface WeatherWidgetData { + forecasts: WeatherForecastsProps[]; + weatherInfo: WeatherDayProps; +} + +export type WeatherDayRow = WeatherDayShortProps & { + weekday: number; +}; diff --git a/src/preview /i18n/LocaleSwitcher/LocaleSwitcher.css b/src/preview /i18n/LocaleSwitcher/LocaleSwitcher.css new file mode 100644 index 0000000..84685cc --- /dev/null +++ b/src/preview /i18n/LocaleSwitcher/LocaleSwitcher.css @@ -0,0 +1,37 @@ +.locale-switcher { + position: relative; + display: inline-block; +} + +.locale-switcher__button { + width: 48px; + height: 48px; + display: block; + cursor: pointer; + background: transparent; + border: none; + padding: 0; +} + +.locale-switcher__options { + display: flex; + gap: 10px; +} + +.locale-switcher__option { + width: 48px; + height: 48px; + cursor: pointer; + background: #ffffff; + border: none; + border-radius: 50%; + text-transform: uppercase; + font-weight: bold; + padding: 0; + font-size: 22px; +} + +.locale-switcher__option.selected { + background-color: #cccccc; + color: #ffffff; +} diff --git a/src/preview /i18n/LocaleSwitcher/LocaleSwitcher.tsx b/src/preview /i18n/LocaleSwitcher/LocaleSwitcher.tsx new file mode 100644 index 0000000..200c613 --- /dev/null +++ b/src/preview /i18n/LocaleSwitcher/LocaleSwitcher.tsx @@ -0,0 +1,45 @@ +import React, { useCallback, useState } from 'react'; +// TODO: resolve circular deps (probably we should move icons to a separate lib) +import { Icons } from '@mt/components'; +import { Locale, localesMap } from '../i18n.interface'; +import './LocaleSwitcher.css'; + +interface LocaleSwitcherProps { + onLocaleChange: (locale: Locale) => void; +} + +export const LocaleSwitcher = ({ onLocaleChange }: LocaleSwitcherProps) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedLocale, setSelectedLocale] = useState('ru'); + + const handleLocaleChange = useCallback( + (locale: Locale) => { + setSelectedLocale(locale); + setIsOpen(false); + onLocaleChange(locale); + }, + [isOpen] + ); + + return ( +
+ {!isOpen ? ( + + ) : ( +
+ {Object.entries(localesMap).map(([label, locale]) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/preview /i18n/en.json b/src/preview /i18n/en.json new file mode 100644 index 0000000..6ea598b --- /dev/null +++ b/src/preview /i18n/en.json @@ -0,0 +1,10 @@ +{ + "support-of-the-government-of-spb": "With the support of the Government of St. Petersburg", + "attractions": "Attractions", + "stops": "Stops", + "available-transfers": "Available transfers:", + "hashtag": "#UsAlongTheWay", + "slogan": "Preserving history,{br}moving into the future.", + "loading": "Loading...", + "close": "Close" +} diff --git a/src/preview /i18n/i18n.interface.ts b/src/preview /i18n/i18n.interface.ts new file mode 100644 index 0000000..be02898 --- /dev/null +++ b/src/preview /i18n/i18n.interface.ts @@ -0,0 +1,10 @@ +// and don't forget to add new locale here as well +export type Locale = 'en' | 'ru' | 'zh'; + +export const localesMap: Record = { + ru: 'ru', + 中文: 'zh', + en: 'en', +}; + +export type LocalizedString = Record; diff --git a/src/preview /i18n/index.ts b/src/preview /i18n/index.ts new file mode 100644 index 0000000..a77478e --- /dev/null +++ b/src/preview /i18n/index.ts @@ -0,0 +1,5 @@ +export * from './i18n.interface'; +export * from './language-loader'; +export * from './useServerLocalization'; +export * from './localization-context'; +export * from './LocaleSwitcher/LocaleSwitcher'; diff --git a/src/preview /i18n/language-loader.ts b/src/preview /i18n/language-loader.ts new file mode 100644 index 0000000..976d962 --- /dev/null +++ b/src/preview /i18n/language-loader.ts @@ -0,0 +1,15 @@ +import en from './en.json'; +import ru from './ru.json'; +import zh from './zh.json'; +import { Locale } from './i18n.interface'; + +const languages = { + en, + ru, + zh, + // Add more language imports here +}; +export function getLanguage(locale: Locale): Record { + // Return the language object for the specified locale + return languages[locale] || languages.ru; // Default to Russian if the locale is not found +} diff --git a/src/preview /i18n/localization-context.tsx b/src/preview /i18n/localization-context.tsx new file mode 100644 index 0000000..e579aaa --- /dev/null +++ b/src/preview /i18n/localization-context.tsx @@ -0,0 +1,25 @@ +import React, { createContext, ReactNode, useState } from 'react'; +import { IntlProvider } from 'react-intl'; +import { Locale } from './i18n.interface'; +import { getLanguage } from './language-loader'; + +export const LocalizationContext = createContext<{ + locale: Locale; + setLocale: (locale: Locale) => void; +}>({ + locale: 'ru', + // eslint-disable-next-line @typescript-eslint/no-empty-function + setLocale: (locale: Locale) => {}, +}); + +export const LocalizationProvider = ({ children }: { children: ReactNode }) => { + const [locale, setLocale] = useState('ru'); + + return ( + + + {children} + + + ); +}; diff --git a/src/preview /i18n/ru.json b/src/preview /i18n/ru.json new file mode 100644 index 0000000..c41ac43 --- /dev/null +++ b/src/preview /i18n/ru.json @@ -0,0 +1,10 @@ +{ + "support-of-the-government-of-spb": "При поддержке Правительства Санкт-Петербурга", + "attractions": "Достопримечательности", + "stops": "Остановки", + "available-transfers": "Доступны пересадки:", + "hashtag": "#ВсемПоПути", + "slogan": "Сохраняя историю,{br}движемся в будущее.", + "loading": "Загрузка...", + "close": "Закрыть" +} diff --git a/src/preview /i18n/useServerLocalization.ts b/src/preview /i18n/useServerLocalization.ts new file mode 100644 index 0000000..b50a4db --- /dev/null +++ b/src/preview /i18n/useServerLocalization.ts @@ -0,0 +1,19 @@ +import { useContext } from 'react'; +import { LocalizationContext } from './localization-context'; +import { Locale, LocalizedString } from './i18n.interface'; + +export type LocalizeTextFn = (content: LocalizedString | undefined) => string; + +export function useServerLocalization() { + const { locale } = useContext(LocalizationContext); + + const localizeText: LocalizeTextFn = (content) => { + if (content && typeof content === 'object' && locale in content) { + return content[locale as Locale]; + } else { + return ''; + } + }; + + return localizeText; +} diff --git a/src/preview /i18n/zh.json b/src/preview /i18n/zh.json new file mode 100644 index 0000000..cda9202 --- /dev/null +++ b/src/preview /i18n/zh.json @@ -0,0 +1,10 @@ +{ + "support-of-the-government-of-spb": "在聖彼得堡政府的支持下", + "attractions": "景點", + "stops": "停止", + "available-transfers": "可用的轉移:", + "hashtag": "#我们在路上", + "slogan": "保存历史、{br}迈向未来。", + "loading": "載入中...", + "close": "關閉" +} diff --git a/src/preview /lib/AttractionShortPreview/AttractionShortPreview.css b/src/preview /lib/AttractionShortPreview/AttractionShortPreview.css new file mode 100644 index 0000000..7b68896 --- /dev/null +++ b/src/preview /lib/AttractionShortPreview/AttractionShortPreview.css @@ -0,0 +1,39 @@ +.attraction-card { + height: 415px; + width: 315px; + background: linear-gradient( + 113.51deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #806c59; + border-radius: 10px; + + overflow: hidden; + color: #ffffff; +} + +.attraction-card__content { + padding: 12px; +} + +.attraction-card__title { + margin: 8px 0 0 0; +} + +.attraction-card__text { + margin: 30px 0 0 0; + white-space: pre-wrap; +} + +.attraction-card__subtitle { + margin: 8px 0 0 0; + font-weight: 400; +} + +.attraction-card__image { + min-width: 100%; + max-height: 50%; + padding: 2px; + border-radius: 10px 10px 0 0; +} diff --git a/src/preview /lib/AttractionShortPreview/AttractionShortPreview.tsx b/src/preview /lib/AttractionShortPreview/AttractionShortPreview.tsx new file mode 100644 index 0000000..e2726fe --- /dev/null +++ b/src/preview /lib/AttractionShortPreview/AttractionShortPreview.tsx @@ -0,0 +1,43 @@ +import './AttractionShortPreview.css'; + +import { LocalizedString, useServerLocalization } from '@mt/i18n'; +import classNames from 'classnames'; +import { TouchScrollWrapper } from '../TouchScrollWrapper/TouchScrollWrapper'; +import { HTMLAttributes } from 'react'; + +export interface AttractionShortPreviewProps extends Omit, 'title'> { + img: string; + title: LocalizedString; + subtitle: LocalizedString; + content: LocalizedString; +} + +export function AttractionShortPreview({ + img, + title, + subtitle, + content, + className, + ...props +}: AttractionShortPreviewProps) { + const localizeText = useServerLocalization(); + + return ( +
+ {img && {localizeText(title)}} + + +
+

{localizeText(title)}

+ +
{localizeText(subtitle)}
+ +

+

+
+
+ ); +} diff --git a/src/preview /lib/AttractionWidget/AttractionWidget.css b/src/preview /lib/AttractionWidget/AttractionWidget.css new file mode 100644 index 0000000..3ae3c4e --- /dev/null +++ b/src/preview /lib/AttractionWidget/AttractionWidget.css @@ -0,0 +1,126 @@ +.widget-container { + width: 545px; + height: var(--attraction-widget-container-height, 100%); + max-height: calc(100% - 90px); + color: #ffffff; + background: #806c59; + border: 2px solid #806c59; + border-radius: 10px; +} + +.widget-content { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + width: 100%; + height: 100%; + overflow: hidden; +} + +.widget-slide { + position: relative; + display: none; + top: 0; + left: 0; + flex-direction: column; + justify-content: flex-start; + align-items: center; + width: 100%; +} + +.widget-slide.active, +.widget-slide.preview { + display: flex; + flex: 1; + overflow: auto; +} + +.widget-media { + width: 100%; + height: auto; + max-height: 644px; +} + +.view-container { + border-radius: 8px 8px 0 0; +} + +.widget-header { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), + rgba(179, 165, 152, 0.4); + box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12); + width: 100%; + padding: 9px 16px; + font-weight: 700; + font-size: 24px; + line-height: 120%; +} + +.widget-text { + width: 100%; + align-self: self-start; + padding: 16px; + font-weight: 400; + font-size: 18px; + line-height: 150%; /* or 27px */ + opacity: 0; + transition: opacity 0.5s ease-in-out; + user-select: none; + word-break: break-word; + white-space: pre-wrap; +} + +.widget-text p { + margin: 0; +} + +.widget-text.preview { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-weight: 700; + font-size: 48px; + line-height: 120%; + text-align: center; +} + +.widget-text.active { + opacity: 1; +} + +.widget-titles { + display: flex; + height: 50px; + justify-content: space-evenly; + align-items: center; + width: 100%; + margin: 5px 0 0 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), + rgba(179, 165, 152, 0.4); + box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12); + border-radius: 0 0 10px 10px; + padding: 12px 0; +} + +.widget-title { + font-weight: 400; + font-size: 18px; + line-height: 21px; + cursor: pointer; + user-select: none; + width: 100px; + text-align: center; +} + +.widget-title.active { + font-weight: bold; + text-decoration: underline; + text-underline-offset: 5px; +} + +.widget-title.preview { + display: none; +} diff --git a/src/preview /lib/AttractionWidget/AttractionWidget.tsx b/src/preview /lib/AttractionWidget/AttractionWidget.tsx new file mode 100644 index 0000000..e349404 --- /dev/null +++ b/src/preview /lib/AttractionWidget/AttractionWidget.tsx @@ -0,0 +1,111 @@ +import React, { HTMLAttributes, useEffect } from 'react'; +import { useServerLocalization } from '@mt/i18n'; +import cn from 'classnames'; +import { useSwipeable } from 'react-swipeable'; +import { ArticleBase } from '@mt/common-types'; +import './AttractionWidget.css'; +import { usePrevious } from '@mt/utils'; +import { AttractionMedia } from './media/AttractionMedia'; +import { useStore } from 'react-admin'; +import { TouchScrollWrapper } from '../TouchScrollWrapper/TouchScrollWrapper'; + +export interface AttractionsWidgetProps extends HTMLAttributes { + articles: ArticleBase[]; + isIdleMode: boolean; + isPreviewOnly?: boolean; +} + +export const ATTRACTION_WIDGET_TABINDEX_STORE_KEY = 'attractions.widget.tabindex'; + +export function AttractionWidget({ + articles, + isIdleMode, + isPreviewOnly = false, + ...props +}: AttractionsWidgetProps) { + const [activeIndex, setActiveIndex] = useStore(ATTRACTION_WIDGET_TABINDEX_STORE_KEY, 0); + const prevArticles = usePrevious(articles) || []; + const localizeText = useServerLocalization(); + + const swipeHandlers = useSwipeable({ + onSwipedLeft: ({ event }) => { + event.preventDefault(); + setActiveIndex((activeIndex) => (activeIndex + 1) % articles.length); + }, + onSwipedRight: ({ event }) => { + event.preventDefault(); + setActiveIndex((activeIndex) => (activeIndex - 1 + articles.length) % articles.length); + }, + swipeDuration: 500, + preventScrollOnSwipe: true, + trackMouse: true, + }); + + const handleClick = (index: number) => { + setActiveIndex(index); + document.querySelector('.widget-text.active')!.scrollTop = 0; + }; + + useEffect(() => setActiveIndex(activeIndex), [activeIndex]); + + useEffect(() => { + if ( + !isPreviewOnly && + (isIdleMode || JSON.stringify(prevArticles) !== JSON.stringify(articles)) + ) { + setActiveIndex(0); + } + + // admin specific case: during edit we removed active article + if (prevArticles?.length > articles?.length) { + setActiveIndex(0); + } + }, [isPreviewOnly, isIdleMode, articles]); + + return ( +
+
+ {articles?.map((article, index) => ( +
handleClick(index)} + > +
+ +
+ + {index !== 0 &&
{localizeText(articles[0].text)}
} + + +
+ +
+ ))} + +
+ {articles?.map((article, index) => ( +
handleClick(index)} + > + {localizeText(article.name)} +
+ ))} +
+
+
+ ); +} diff --git a/src/preview /lib/AttractionWidget/media/AttractionMedia.css b/src/preview /lib/AttractionWidget/media/AttractionMedia.css new file mode 100644 index 0000000..34774e4 --- /dev/null +++ b/src/preview /lib/AttractionWidget/media/AttractionMedia.css @@ -0,0 +1,52 @@ +.widget-image, +.widget-video, +.widget-3d-model { + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + display: block; + border-radius: 8px 8px 0 0; +} + +.widget-3d-model { + height: 350px; +} + +.widget-media__wrapper { + position: relative; + /*TODO: it worth to investigate it further... quite weird behavior of */ + box-sizing: content-box !important; +} + +.fullscreen-photo-sphere-btn, +.fullscreen-3d-btn { + width: 20px; + height: 20px; + position: absolute; + right: 10px; + bottom: 10px; + cursor: pointer; + z-index: 100; + opacity: 0.7; +} + +.media-with-watermark { + position: relative; +} + +.watermark { + position: absolute; + top: 10px; + left: 10px; + width: 50px; + height: auto; +} + +.psv-autorotate-button { + display: block !important; +} + +.psv-menu-button { + display: none !important; +} diff --git a/src/preview /lib/AttractionWidget/media/AttractionMedia.tsx b/src/preview /lib/AttractionWidget/media/AttractionMedia.tsx new file mode 100644 index 0000000..9edc63e --- /dev/null +++ b/src/preview /lib/AttractionWidget/media/AttractionMedia.tsx @@ -0,0 +1,34 @@ +import { Media } from '@mt/common-types'; +import { ImageMedia } from './ImageMedia'; +import { VideoMedia } from './VideoMedia'; +import { PhotoSphereMedia } from './PhotoSphereMedia'; +import { Object3DMedia } from './Object3DMedia'; +import { memo } from 'react'; + +export const AttractionMedia = memo( + ({ media }: { media: Media }) => { + const { type, url, watermarkUrl } = media; + + if (!url) return null; + + switch (type) { + case 'IMAGE': + return ; + case 'VIDEO': + return ; + case 'PHOTO_SPHERE': + return ; + case 'OBJECT_3D': + return ; + default: + return null; + } + }, + ({ media }, { media: newMedia }) => { + return ( + media.url === newMedia.url && + media.watermarkUrl === newMedia.watermarkUrl && + media.type === newMedia.type + ); + } +); diff --git a/src/preview /lib/AttractionWidget/media/ImageMedia.tsx b/src/preview /lib/AttractionWidget/media/ImageMedia.tsx new file mode 100644 index 0000000..94f4934 --- /dev/null +++ b/src/preview /lib/AttractionWidget/media/ImageMedia.tsx @@ -0,0 +1,25 @@ +import cn from 'classnames'; +import React from 'react'; + +import './AttractionMedia.css'; + +interface ImageMediaProps { + url: string; + alt: string; + watermarkUrl?: string; +} + +export const ImageMedia = ({ url, alt, watermarkUrl }: ImageMediaProps) => ( + <> + {alt} + {watermarkUrl && ( + Watermark + )} + +); diff --git a/src/preview /lib/AttractionWidget/media/Object3DMedia.tsx b/src/preview /lib/AttractionWidget/media/Object3DMedia.tsx new file mode 100644 index 0000000..ac5e6c5 --- /dev/null +++ b/src/preview /lib/AttractionWidget/media/Object3DMedia.tsx @@ -0,0 +1,50 @@ +import cn from 'classnames'; +import React, { useEffect, useState } from 'react'; + +import './AttractionMedia.css'; +import ModelViewer from '../../model-viewer/ModelViewer'; +import { Icons, useLightboxContext } from '@mt/components'; +import { Object3DLightboxData } from '@mt/common-types'; + +interface Object3DMediaProps { + url: string; + watermarkUrl?: string; +} + +export const Object3DMedia = ({ url, watermarkUrl }: Object3DMediaProps) => { + // prettier-ignore + const { setData, openLightbox } = useLightboxContext(); + const [autoRotate, setAutoRotate] = useState(true); + + const handle3DFullscreenOpen = () => { + setAutoRotate(false); + setData({ + type: 'OBJECT_3D', + modelUrl: url, + watermarkUrl, + }); + openLightbox(); + }; + + useEffect(() => { + setAutoRotate(true); + }, [url]); + + return ( +
+
+ + {watermarkUrl && Watermark} +
+ + handle3DFullscreenOpen()} + /> +
+ ); +}; diff --git a/src/preview /lib/AttractionWidget/media/PhotoSphereMedia.tsx b/src/preview /lib/AttractionWidget/media/PhotoSphereMedia.tsx new file mode 100644 index 0000000..5edd16f --- /dev/null +++ b/src/preview /lib/AttractionWidget/media/PhotoSphereMedia.tsx @@ -0,0 +1,57 @@ +import cn from 'classnames'; +import React, { useRef } from 'react'; +import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer'; + +import { PhotoSphereLightboxData } from '@mt/common-types'; + +import './AttractionMedia.css'; +import { useLightboxContext } from '../../lightbox'; +import { Icons } from '@mt/components'; + +interface PhotoSphereMediaProps { + url: string; + watermarkUrl?: string; +} + +export const PhotoSphereMedia = ({ url, watermarkUrl }: PhotoSphereMediaProps) => { + // prettier-ignore + const { setData, openLightbox } = useLightboxContext(); + // react-photo-sphere-viewer doesn't have exported types, so here's a bit of a hardcoded piece + const photoSphereRef = useRef<{ stopAutoRotate: () => void }>(); + + const handlePhotoSphereFullscreenOpen = () => { + photoSphereRef.current?.stopAutoRotate(); + setData({ + type: 'PHOTO_SPHERE', + imageUrl: url, + watermarkUrl, + }); + openLightbox(); + }; + + return ( +
+ + {watermarkUrl && Watermark} + {/* the following is a workaround to open lightbox-like preview in the middle of the screen instead of the real fullscreen */} + handlePhotoSphereFullscreenOpen()} + /> +
+ ); +}; diff --git a/src/preview /lib/AttractionWidget/media/VideoMedia.tsx b/src/preview /lib/AttractionWidget/media/VideoMedia.tsx new file mode 100644 index 0000000..c0d6fd5 --- /dev/null +++ b/src/preview /lib/AttractionWidget/media/VideoMedia.tsx @@ -0,0 +1,26 @@ +import cn from 'classnames'; +import React from 'react'; + +import './AttractionMedia.css'; + +interface VideoMediaProps { + url: string; + watermarkUrl?: string; +} + +export const VideoMedia = ({ url, watermarkUrl }: VideoMediaProps) => ( + <> +