diff --git a/src/adapters/scene_direction.js b/src/adapters/scene_direction.js index c39b8f463..3863ef2df 100644 --- a/src/adapters/scene_direction.js +++ b/src/adapters/scene_direction.js @@ -240,9 +240,9 @@ export default class SceneDirection { }); } - refreshDirection(type, lngLat) { + refreshDirection(which, lngLat) { const newPoint = new LatLonPoi(lngLat); - fire('change_direction_point', type, '', newPoint); + fire('change_direction_point', which, newPoint); } reset() { diff --git a/src/panel/PanelManager.jsx b/src/panel/PanelManager.jsx index 0a96ebe53..28da6b006 100644 --- a/src/panel/PanelManager.jsx +++ b/src/panel/PanelManager.jsx @@ -4,7 +4,7 @@ import FavoritesPanel from './favorites/FavoritesPanel'; import PoiPanel from './poi/PoiPanel'; import ServicePanel from './service/ServicePanel'; import CategoryPanel from 'src/panel/category/CategoryPanel'; -import DirectionPanel from 'src/panel/direction/DirectionPanel'; +import Directions from 'src/panel/direction/Directions'; import Telemetry from 'src/libs/telemetry'; import { parseQueryString, buildQueryString } from 'src/libs/url_utils'; import { fire, listen, unListen } from 'src/libs/customEvents'; @@ -154,17 +154,13 @@ const PanelManager = ({ router }) => { }); if (directionConf.enabled) { - const isPublicTransportActive = - (directionConf.publicTransport && directionConf.publicTransport.enabled) || - parseQueryString(document.location.search)['pt'] === 'true'; - router.addRoute('Routes', '/routes(?:/?)(.*)', (routeParams, options) => { const params = parseQueryString(routeParams); - params.details = params.details === 'true'; + params.activeDetails = params.details === 'true'; params.activeRouteId = Number(params.selected) || 0; setPanelOptions({ - ActivePanel: DirectionPanel, - options: { ...params, ...options, isPublicTransportActive }, + ActivePanel: Directions, + options: { ...params, ...options }, panelSize: 'default', }); }); diff --git a/src/panel/direction/DesktopDirectionPanel.jsx b/src/panel/direction/DesktopDirectionPanel.jsx new file mode 100644 index 000000000..e918fc568 --- /dev/null +++ b/src/panel/direction/DesktopDirectionPanel.jsx @@ -0,0 +1,35 @@ +import React, { useContext } from 'react'; +import { Panel, Divider, ShareMenu } from 'src/components/ui'; +import { Button, IconShare } from '@qwant/qwant-ponents'; +import { DirectionContext } from './directionStore'; +import { useI18n } from 'src/hooks'; + +const DesktopDirectionPanel = ({ form, result, onClose, onShareClick }) => { + const { routes } = useContext(DirectionContext).state; + const { _ } = useI18n(); + + return ( + <Panel className="direction-panel" onClose={onClose} renderHeader={form}> + <div className="direction-autocomplete_suggestions" /> + {routes.length > 0 && ( + <ShareMenu url={window.location.toString()}> + {openMenu => ( + <Button + className="direction-panel-share-button u-ml-auto u-flex-shrink-0 u-mr-m" + variant="tertiary" + title={_('Share itinerary', 'direction')} + onClick={e => onShareClick(e, openMenu)} + > + <IconShare /> + {_('Share itinerary', 'direction')} + </Button> + )} + </ShareMenu> + )} + <Divider paddingTop={8} paddingBottom={0} /> + {result} + </Panel> + ); +}; + +export default DesktopDirectionPanel; diff --git a/src/panel/direction/DirectionForm.jsx b/src/panel/direction/DirectionForm.jsx index cf170b49e..6497af03a 100644 --- a/src/panel/direction/DirectionForm.jsx +++ b/src/panel/direction/DirectionForm.jsx @@ -1,29 +1,27 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState, useContext, useCallback } from 'react'; import PropTypes from 'prop-types'; import DirectionInput from './DirectionInput'; import VehicleSelector from './VehicleSelector'; import { Divider } from 'src/components/ui'; import { IconArrowUpDown } from 'src/components/ui/icons'; import { Button } from '@qwant/qwant-ponents'; +import * as address from 'src/libs/address'; +import { getInputValue } from 'src/libs/suggest'; +import { isNullOrEmpty } from 'src/libs/object'; import { useI18n, useDevice } from 'src/hooks'; +import { DirectionContext } from './directionStore'; -const DirectionForm = ({ - isLoading, - origin, - destination, - onChangeDirectionPoint, - onReversePoints, - vehicles, - onSelectVehicle, - activeVehicle, - isInitializing, - originInputText, - destinationInputText, -}) => { +const DirectionForm = ({ onReversePoints, onSelectVehicle, isInitializing }) => { const { _ } = useI18n(); const { isMobile } = useDevice(); const originRef = useRef(null); const destinationRef = useRef(null); + const { + state: { origin, destination, vehicles, vehicle, isLoading }, + setPoint, + } = useContext(DirectionContext); + const [originInputText, setOriginInputText] = useState(''); + const [destinationInputText, setDestinationInputText] = useState(''); useEffect(() => { if (isMobile || isInitializing) { @@ -45,6 +43,44 @@ const DirectionForm = ({ }, 0); }; + const onChangePoint = which => (value, point) => { + if (which === 'origin') { + setOriginInputText(value); + } else { + setDestinationInputText(value); + } + if (point || value === '') { + setPoint(which, point); + } + }; + + const setText = useCallback((which, point) => { + const setter = which === 'origin' ? setOriginInputText : setDestinationInputText; + + async function fetchAddress(poi) { + poi.address = await address.fetch(poi); + setter(getInputValue(poi)); + } + + if (point) { + if (point.type === 'geoloc') { + setter(point.name); + } else if (isNullOrEmpty(point.address)) { + fetchAddress(point); + } else { + setter(getInputValue(point)); + } + } + }, []); + + useEffect(() => { + setText('origin', origin); + }, [origin, setText]); + + useEffect(() => { + setText('destination', destination); + }, [destination, setText]); + return ( <div className="direction-form"> <form className="direction-fields" noValidate> @@ -55,7 +91,7 @@ const DirectionForm = ({ point={origin} otherPoint={destination} pointType="origin" - onChangePoint={(input, point) => onChangeDirectionPoint('origin', input, point)} + onChangePoint={onChangePoint('origin')} ref={originRef} withGeoloc={destination ? destination.type !== 'geoloc' : true} /> @@ -66,7 +102,7 @@ const DirectionForm = ({ point={destination} otherPoint={origin} pointType="destination" - onChangePoint={(input, point) => onChangeDirectionPoint('destination', input, point)} + onChangePoint={onChangePoint('destination')} ref={destinationRef} withGeoloc={origin ? origin.type !== 'geoloc' : true} /> @@ -85,7 +121,7 @@ const DirectionForm = ({ </form> <VehicleSelector vehicles={vehicles} - activeVehicle={activeVehicle} + activeVehicle={vehicle} onSelectVehicle={onSelectVehicle} /> </div> @@ -93,17 +129,9 @@ const DirectionForm = ({ }; DirectionForm.propTypes = { - isLoading: PropTypes.bool, - origin: PropTypes.object, - destination: PropTypes.object, - onChangeDirectionPoint: PropTypes.func.isRequired, onReversePoints: PropTypes.func.isRequired, - vehicles: PropTypes.array.isRequired, onSelectVehicle: PropTypes.func.isRequired, - activeVehicle: PropTypes.string.isRequired, isInitializing: PropTypes.bool, - originInputText: PropTypes.string, - destinationInputText: PropTypes.string, }; export default DirectionForm; diff --git a/src/panel/direction/DirectionMap.jsx b/src/panel/direction/DirectionMap.jsx new file mode 100644 index 000000000..854a7c603 --- /dev/null +++ b/src/panel/direction/DirectionMap.jsx @@ -0,0 +1,65 @@ +import { useContext, useEffect } from 'react'; +import { fire, listen, unListen } from 'src/libs/customEvents'; +import { DirectionContext } from './directionStore'; + +const DirectionMap = () => { + const { state, setPoint } = useContext(DirectionContext); + const { origin, destination, vehicle, routes, activeRouteId } = state; + + useEffect(() => { + if (origin) { + window.execOnMapLoaded(() => { + fire('set_origin', origin); + if (!destination) { + fire('fit_map', origin); + } + }); + } + + if (destination) { + window.execOnMapLoaded(() => { + fire('set_destination', destination); + if (!origin) { + fire('fit_map', destination); + } + }); + } + }, [origin, destination]); + + useEffect(() => { + const dragPointHandler = listen('change_direction_point', setPoint); + const setPointHandler = listen('set_direction_point', point => { + if (origin && destination) { + return; + } + // if one point is already filled, fill the other + setPoint({ type: origin ? 'destination' : 'origin', data: point }); + }); + + return () => { + unListen(dragPointHandler); + unListen(setPointHandler); + fire('clean_routes'); + fire('update_map_paddings'); + }; + }, [origin, destination, setPoint]); + + useEffect(() => { + window.execOnMapLoaded(() => { + fire('set_routes', { + routes, + vehicle, + activeRouteId, + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [routes /* Omit activeRouteId and vehicle to prevent costly redraws */]); + + useEffect(() => { + fire('set_main_route', { routeId: activeRouteId, fitView: true }); + }, [activeRouteId]); + + return null; +}; + +export default DirectionMap; diff --git a/src/panel/direction/DirectionPanel.jsx b/src/panel/direction/DirectionPanel.jsx index f09af0e1d..c242c7ef2 100644 --- a/src/panel/direction/DirectionPanel.jsx +++ b/src/panel/direction/DirectionPanel.jsx @@ -1,85 +1,64 @@ -/* globals _ */ -import React from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { Panel, Divider, ShareMenu } from 'src/components/ui'; -import { Button, IconShare } from '@qwant/qwant-ponents'; import MobileDirectionPanel from './MobileDirectionPanel'; +import DesktopDirectionPanel from './DesktopDirectionPanel'; import DirectionForm from './DirectionForm'; import RouteResult from './RouteResult'; -import DirectionApi, { modes } from 'src/adapters/direction_api'; +import DirectionMap from './DirectionMap'; import Telemetry from 'src/libs/telemetry'; import { toUrl as poiToUrl, fromUrl as poiFromUrl } from 'src/libs/pois'; import Error from 'src/adapters/error'; import Poi from 'src/adapters/poi/poi.js'; -import { fire, listen, unListen } from 'src/libs/customEvents'; -import * as address from 'src/libs/address'; import NavigatorGeolocalisationPoi from 'src/adapters/poi/specials/navigator_geolocalisation_poi'; -import { getInputValue } from 'src/libs/suggest'; import { geolocationPermissions, getGeolocationPermission } from 'src/libs/geolocation'; import { openPendingDirectionModal } from 'src/modals/GeolocationModal'; import { updateQueryString } from 'src/libs/url_utils'; -import { isNullOrEmpty } from 'src/libs/object'; -import { usePageTitle, useDevice } from 'src/hooks'; +import { useDevice } from 'src/hooks'; +import { DirectionContext } from './directionStore'; -class DirectionPanel extends React.Component { - static propTypes = { - origin: PropTypes.string, - destination: PropTypes.string, - poi: PropTypes.object, - mode: PropTypes.string, - isPublicTransportActive: PropTypes.bool, - activeRouteId: PropTypes.number, - details: PropTypes.bool, - isMobile: PropTypes.bool, - }; - - static defaultProps = { - activeRouteId: 0, - }; - - constructor(props) { - super(props); - - this.vehicles = [modes.DRIVING, modes.WALKING, modes.CYCLING]; - if (this.props.isPublicTransportActive) { - this.vehicles.splice(1, 0, modes.PUBLIC_TRANSPORT); - } - - const activeVehicle = this.vehicles.indexOf(props.mode) !== -1 ? props.mode : modes.DRIVING; +const DirectionPanel = ({ poi, urlValues }) => { + const { state, dispatch } = useContext(DirectionContext); + const { origin, destination, vehicle, activeRouteId, activeDetails } = state; + const { isMobile } = useDevice(); + const [isInitializing, setIsInitializing] = useState(true); - this.lastQueryId = 0; + useEffect(() => { + Telemetry.add(Telemetry.ITINERARY_OPEN); + initialize(); + autoGeoloc(); - this.state = { - vehicle: activeVehicle, - origin: null, - destination: (props.poi && Poi.deserialize(props.poi)) || null, - isLoading: false, - isDirty: true, // useful to track intermediary states, when API update call is not made yet - error: 0, - routes: [], - isInitializing: true, - originInputText: '', - destinationInputText: '', + return () => { + dispatch({ type: 'reset' }); }; + }, [dispatch, initialize, autoGeoloc]); - this.restorePoints(props); - } - - async componentDidMount() { - Telemetry.add(Telemetry.ITINERARY_OPEN); - document.body.classList.add('directions-open'); - this.dragPointHandler = listen('change_direction_point', this.changeDirectionPoint); - this.setPointHandler = listen('set_direction_point', this.setDirectionPoint); + // url side effect + useEffect(() => { + if (isInitializing) { + return; + } + const search = updateQueryString({ + mode: vehicle, + origin: origin ? poiToUrl(origin) : null, + destination: destination ? poiToUrl(destination) : null, + selected: activeRouteId, + details: activeDetails, + }); + const relativeUrl = 'routes/' + search; + // @TODO: not always replace=true + window.app.navigateTo(relativeUrl, window.history.state, { replace: true }); + }, [isInitializing, origin, destination, vehicle, activeRouteId, activeDetails]); + const autoGeoloc = useCallback(async () => { // on mobile, when no origin is specified, try auto-geoloc - if (this.props.isMobile && !this.state.origin && !this.props.origin) { + if (isMobile && !urlValues.origin) { const geolocationPermission = await getGeolocationPermission(); let modalAccepted = false; // on an empty form, if the user's position permission hasn't been asked yet, show modal if ( - !this.state.destination && - !this.props.destination && + !urlValues.destination && + !poi && geolocationPermission === geolocationPermissions.PROMPT ) { modalAccepted = await openPendingDirectionModal(); @@ -87,335 +66,154 @@ class DirectionPanel extends React.Component { // If the user's position can be requested, put it in the origin field if (geolocationPermission === geolocationPermissions.GRANTED || modalAccepted) { - const origin = new NavigatorGeolocalisationPoi(); + const position = new NavigatorGeolocalisationPoi(); try { - await origin.geolocate({ + await position.geolocate({ displayErrorModal: false, }); - this.setState({ origin, originInputText: origin.name }, this.update); + dispatch({ type: 'setOrigin', data: position }); } catch (e) { // ignore possible error } } } - } - - componentDidUpdate(prevProps, prevState) { - if (this.props.activeRouteId !== prevProps.activeRouteId && this.state.routes.length > 0) { - fire('set_main_route', { routeId: this.props.activeRouteId, fitView: true }); - this.updateUrl({ params: { details: null }, replace: true }); - } + }, [urlValues.origin, urlValues.destination, poi, isMobile, dispatch]); - if (this.state.routes.length !== 0 && prevState.routes.length === 0) { - fire('update_map_paddings'); - } - } - - componentWillUnmount() { - fire('clean_routes'); - unListen(this.dragPointHandler); - unListen(this.setPointHandler); - document.body.classList.remove('directions-open'); - fire('update_map_paddings'); - } - - async setTextInput(which, poi) { - if (isNullOrEmpty(poi.address)) { - // fetch missing address - poi.address = await address.fetch(poi); + const initialize = useCallback(() => { + if (!isInitializing) { + return; } - this.setState({ [which + 'InputText']: getInputValue(poi) }); - } - async restorePoints({ origin: originUrlValue, destination: destinationUrlValue }) { - const poiRestorePromises = [ - originUrlValue ? poiFromUrl(originUrlValue) : this.state.origin, - destinationUrlValue ? poiFromUrl(destinationUrlValue) : this.state.destination, - ]; - - try { - const [origin, destination] = await Promise.all(poiRestorePromises); - // Set markers - if (origin) { - window.execOnMapLoaded(() => { - fire('set_origin', origin); - if (!destination) { - fire('fit_map', origin); - } - }); - this.setTextInput('origin', origin); - } - - if (destination) { - window.execOnMapLoaded(() => { - fire('set_destination', destination); - if (!origin) { - fire('fit_map', destination); - } + // deserialize origin/destination points from url or direct passing (coming from a POI) + async function restorePoints() { + try { + const poiRestorePromises = [ + urlValues.origin ? poiFromUrl(urlValues.origin) : null, + urlValues.destination + ? poiFromUrl(urlValues.destination) + : poi + ? Poi.deserialize(poi) + : null, + ]; + const [initialOrigin, initialDestination] = await Promise.all(poiRestorePromises); + dispatch({ + type: 'setParams', + data: { + origin: initialOrigin, + destination: initialDestination, + activeRouteId: urlValues.activeRouteId || 0, + activeDetails: urlValues.activeDetails, + }, }); - this.setTextInput('destination', destination); - } - - this.setState( - { - origin, - destination, - isInitializing: false, - }, - this.update - ); - } catch (e) { - Error.sendOnce( - 'direction_panel', - 'restoreUrl', - `Error restoring Poi from Url ${originUrlValue} / ${destinationUrlValue}`, - e - ); - } - } - - computeRoutes = async () => { - const { origin, destination, vehicle } = this.state; - if (origin && destination) { - this.setState({ - isDirty: false, - isLoading: true, - error: 0, - routes: [], - }); - const currentQueryId = ++this.lastQueryId; - fire('set_origin', origin); - fire('set_destination', destination); - const directionResponse = await DirectionApi.search(origin, destination, vehicle); - // A more recent query was done in the meantime, ignore this result silently - if (currentQueryId !== this.lastQueryId) { - return; + } catch (e) { + Error.sendOnce( + 'direction_panel', + 'restoreUrl', + `Error restoring Poi from Url ${urlValues.origin} / ${urlValues.destination}`, + e + ); } - if (directionResponse && directionResponse.error === 0) { - // Valid, non-empty response - const routes = directionResponse.data.routes - .sort((routeA, routeB) => routeA.duration - routeB.duration) - .map((route, i) => ({ ...route, id: i })); - this.setState({ isLoading: false, error: 0, routes }, () => { - const activeRouteId = - this.props.activeRouteId < this.state.routes.length ? this.props.activeRouteId : 0; - window.execOnMapLoaded(() => { - fire('set_routes', { routes, vehicle, activeRouteId }); - }); - this.updateUrl({ params: { selected: activeRouteId }, replace: true }); - }); - } else { - // Error or empty response - this.setState({ isLoading: false, error: directionResponse.error }); - fire('clean_routes'); - } - } else { - // When both fields are not filled yet or not filled anymore - this.setState({ isLoading: false, isDirty: false, error: 0, routes: [] }); - fire('clean_routes'); - if (origin) { - fire('set_origin', origin); - } else if (destination) { - fire('set_destination', destination); - } + setIsInitializing(false); } - }; - updateUrl({ params = {}, replace = false } = {}) { - const search = updateQueryString({ - mode: this.state.vehicle, - origin: this.state.origin ? poiToUrl(this.state.origin) : null, - destination: this.state.destination ? poiToUrl(this.state.destination) : null, - pt: this.props.isPublicTransportActive ? 'true' : null, - ...params, - }); - const relativeUrl = 'routes/' + search; - - window.app.navigateTo(relativeUrl, window.history.state, { replace }); - } - - update() { - this.updateUrl({ replace: true }); - this.computeRoutes(); - } - - onSelectVehicle = vehicle => { + restorePoints(); + }, [ + urlValues.origin, + urlValues.destination, + urlValues.activeRouteId, + urlValues.activeDetails, + poi, + isInitializing, + dispatch, + ]); + + const onSelectVehicle = vehicle => { Telemetry.add(Telemetry[`${('itinerary_mode_' + vehicle).toUpperCase()}`]); - this.setState({ vehicle, isDirty: true }, this.update); + dispatch({ type: 'setVehicle', data: vehicle }); }; - onClose = () => { + const onClose = () => { Telemetry.add(Telemetry.ITINERARY_CLOSE); - this.props.poi + poi ? window.history.back() // Go back to the poi panel : window.app.navigateTo('/'); }; - reversePoints = () => { + const reversePoints = () => { Telemetry.add(Telemetry.ITINERARY_INVERT); - this.setState( - previousState => ({ - origin: previousState.destination, - destination: previousState.origin, - originInputText: previousState.destinationInputText, - destinationInputText: previousState.originInputText, - isDirty: true, - }), - this.update - ); + dispatch({ type: 'reversePoints' }); }; - changeDirectionPoint = (which, value, point) => { - this.setState( - { - [which]: point, - isDirty: true, - [which + 'InputText']: value || '', - }, - () => { - this.update(); - // Retrieve addresses - if (point && point.type === 'latlon') { - this.setTextInput(which, this.state[which]); - } - } - ); - }; - - setDirectionPoint = poi => { - if (this.state.origin && this.state.destination) { - return; - } - const which = this.state.origin ? 'destination' : 'origin'; - this.setTextInput(which, poi); - - // Update state - // (Call update() that will perform a search and redraw the UI if both fields are set) - this.setState( - { - [which]: poi, - isDirty: true, - }, - this.update - ); - }; - - handleShareClick = (e, handler) => { + const onShareClick = (e, handler) => { Telemetry.add(Telemetry.ITINERARY_SHARE); return handler(e); }; - selectRoute = routeId => { - this.updateUrl({ params: { selected: routeId }, replace: true }); + const selectRoute = routeId => { + dispatch({ type: 'setActiveRoute', data: routeId }); }; - toggleDetails = () => { - if (this.props.isMobile) { - if (this.props.details) { + const toggleDetails = () => { + if (isMobile) { + if (activeDetails) { window.app.navigateBack({ relativeUrl: 'routes/' + updateQueryString({ details: false }), }); } else { - this.updateUrl({ params: { details: true }, replace: false }); + window.app.navigateTo( + 'routes/' + updateQueryString({ details: true }), + window.history.state, + { replace: true } + ); } } else { - this.updateUrl({ params: { details: !this.props.details }, replace: true }); + dispatch({ type: 'setActiveDetails', data: !activeDetails }); } }; - render() { - const { - origin, - destination, - vehicle, - routes, - error, - isLoading, - isDirty, - isInitializing, - originInputText, - destinationInputText, - } = this.state; - - const { activeRouteId, details: activeDetails, isMobile } = this.props; - - const form = ( - <DirectionForm - isLoading={isLoading} - origin={origin} - destination={destination} - originInputText={originInputText} - destinationInputText={destinationInputText} - onChangeDirectionPoint={this.changeDirectionPoint} - onReversePoints={this.reversePoints} - onEmptyOrigin={this.emptyOrigin} - onEmptyDestination={this.emptyDestination} - vehicles={this.vehicles} - onSelectVehicle={this.onSelectVehicle} - activeVehicle={vehicle} - isInitializing={isInitializing} - /> - ); - - const result = ( - <RouteResult - activeRouteId={activeRouteId} - activeDetails={activeDetails} - isLoading={isLoading || (routes.length > 0 && isDirty)} - vehicle={vehicle} - error={error} - routes={routes} - origin={origin} - destination={destination} - toggleDetails={this.toggleDetails} - selectRoute={this.selectRoute} - /> - ); + const form = ( + <DirectionForm + onReversePoints={reversePoints} + onSelectVehicle={onSelectVehicle} + isInitializing={isInitializing} + /> + ); + + const result = <RouteResult toggleDetails={toggleDetails} selectRoute={selectRoute} />; + + return ( + <> + <DirectionMap /> + {isMobile ? ( + <MobileDirectionPanel + form={form} + result={result} + toggleDetails={toggleDetails} + onClose={onClose} + onShareClick={onShareClick} + /> + ) : ( + <DesktopDirectionPanel + form={form} + result={result} + onClose={onClose} + onShareClick={onShareClick} + /> + )} + </> + ); +}; - return isMobile ? ( - <MobileDirectionPanel - form={form} - result={result} - routes={routes} - origin={origin} - destination={destination} - vehicle={vehicle} - toggleDetails={this.toggleDetails} - activeDetails={activeDetails} - activeRouteId={activeRouteId} - onClose={this.onClose} - handleShareClick={this.handleShareClick} - /> - ) : ( - <Panel className="direction-panel" onClose={this.onClose} renderHeader={form}> - <div className="direction-autocomplete_suggestions" /> - {routes.length > 0 && ( - <ShareMenu url={window.location.toString()}> - {openMenu => ( - <Button - className="direction-panel-share-button u-ml-auto u-flex-shrink-0 u-mr-m" - variant="tertiary" - title={_('Share itinerary', 'direction')} - onClick={e => this.handleShareClick(e, openMenu)} - > - <IconShare /> - {_('Share itinerary', 'direction')} - </Button> - )} - </ShareMenu> - )} - <Divider paddingTop={8} paddingBottom={0} /> - {result} - </Panel> - ); - } -} +DirectionPanel.propTypes = { + poi: PropTypes.object, + urlValues: PropTypes.object, +}; -const DirectionPanelFunc = props => { - usePageTitle(_('Directions')); - const { isMobile } = useDevice(); - return <DirectionPanel isMobile={isMobile} {...props} />; +const DirectionPanelWrapper = ({ origin, destination, activeDetails, activeRouteId, ...rest }) => { + return ( + <DirectionPanel urlValues={{ origin, destination, activeDetails, activeRouteId }} {...rest} /> + ); }; -export default DirectionPanelFunc; +export default DirectionPanelWrapper; diff --git a/src/panel/direction/Directions.jsx b/src/panel/direction/Directions.jsx new file mode 100644 index 000000000..06d0486df --- /dev/null +++ b/src/panel/direction/Directions.jsx @@ -0,0 +1,25 @@ +import React, { useEffect } from 'react'; +import { DirectionProvider } from './directionStore'; +import DirectionPanel from './DirectionPanel'; +import { usePageTitle, useI18n } from 'src/hooks'; + +const Directions = props => { + const { _ } = useI18n(); + + usePageTitle(_('Directions')); + + useEffect(() => { + document.body.classList.add('directions-open'); + return () => { + document.body.classList.remove('directions-open'); + }; + }, []); + + return ( + <DirectionProvider> + <DirectionPanel {...props} /> + </DirectionProvider> + ); +}; + +export default Directions; diff --git a/src/panel/direction/MobileDirectionPanel.jsx b/src/panel/direction/MobileDirectionPanel.jsx index 8bf219d41..6402a8a0b 100644 --- a/src/panel/direction/MobileDirectionPanel.jsx +++ b/src/panel/direction/MobileDirectionPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useContext } from 'react'; import { Panel, ShareMenu, FloatingButton, CloseButton } from 'src/components/ui'; import { Flex, IconShare } from '@qwant/qwant-ponents'; import MobileRouteDetails from './MobileRouteDetails'; @@ -7,26 +7,17 @@ import { getAllSteps } from 'src/libs/route_utils'; import { fire } from 'src/libs/customEvents'; import Telemetry from 'src/libs/telemetry'; import { useI18n } from 'src/hooks'; +import { DirectionContext } from './directionStore'; const MARGIN_TOP_OFFSET = 64; // reserve space to display map -const MobileDirectionPanel = ({ - form, - result, - routes, - origin, - destination, - vehicle, - toggleDetails, - activeDetails, - activeRouteId, - onClose, - handleShareClick, -}) => { +const MobileDirectionPanel = ({ form, result, toggleDetails, onClose, onShareClick }) => { const [marginTop, setMarginTop] = useState(0); const [activePreviewRoute, setActivePreviewRoute] = useState(null); const directionPanelRef = useRef(null); const { _ } = useI18n(); + const { state } = useContext(DirectionContext); + const { origin, destination, vehicle, routes, activeRouteId, activeDetails } = state; useEffect(() => { setActivePreviewRoute(null); @@ -78,7 +69,7 @@ const MobileDirectionPanel = ({ {openMenu => ( <FloatingButton title={_('Share itinerary', 'direction')} - onClick={e => handleShareClick(e, openMenu)} + onClick={e => onShareClick(e, openMenu)} icon={<IconShare size={24} />} /> )} diff --git a/src/panel/direction/RouteResult.jsx b/src/panel/direction/RouteResult.jsx index a96486d83..60f9df8ba 100644 --- a/src/panel/direction/RouteResult.jsx +++ b/src/panel/direction/RouteResult.jsx @@ -1,27 +1,28 @@ -/* globals _ */ import React, { useCallback, useEffect, useContext } from 'react'; import PropTypes from 'prop-types'; import { listen, unListen } from 'src/libs/customEvents'; import Telemetry from 'src/libs/telemetry'; import RoutesList from './RoutesList'; -import { SourceFooter, UserFeedbackYesNo } from 'src/components/ui'; -import { useDevice } from 'src/hooks'; +import { UserFeedbackYesNo } from 'src/components/ui'; +import { useDevice, useI18n } from 'src/hooks'; import { PanelContext } from 'src/libs/panelContext'; +import { DirectionContext } from './directionStore'; -const RouteResult = ({ - origin, - destination, - vehicle, - routes = [], - isLoading, - error, - activeRouteId, - activeDetails, - selectRoute, - toggleDetails, -}) => { +const RouteResult = ({ selectRoute, toggleDetails }) => { const { isMobile } = useDevice(); const { size: panelSize } = useContext(PanelContext); + const { state } = useContext(DirectionContext); + const { + origin, + destination, + vehicle, + isLoading, + routes, + activeRouteId, + error, + activeDetails, + } = state; + const { _ } = useI18n(); useEffect(() => { const routeSelectedOnMapHandler = listen('select_road_map', onSelectRoute); @@ -81,23 +82,11 @@ const RouteResult = ({ question={_('Satisfied with the results?')} /> )} - {vehicle === 'publicTransport' && routes.length > 0 && ( - <SourceFooter> - <a href="https://combigo.com/">{_('Results in partnership with Combigo')}</a> - </SourceFooter> - )} </> ); }; RouteResult.propTypes = { - routes: PropTypes.array, - origin: PropTypes.object, - destination: PropTypes.object, - vehicle: PropTypes.string, - isLoading: PropTypes.bool, - error: PropTypes.number, - activeRouteId: PropTypes.number, selectRoute: PropTypes.func.isRequired, toggleDetails: PropTypes.func.isRequired, }; diff --git a/src/panel/direction/directionStore.js b/src/panel/direction/directionStore.js new file mode 100644 index 000000000..0e542a00d --- /dev/null +++ b/src/panel/direction/directionStore.js @@ -0,0 +1,111 @@ +import React, { useReducer, useEffect, useCallback } from 'react'; +import DirectionApi, { modes } from 'src/adapters/direction_api'; +import { useConfig } from 'src/hooks'; + +const initialState = { + origin: null, + destination: null, + vehicles: [], + vehicle: modes.DRIVING, + isLoading: false, + routes: [], + error: 0, + activeRouteId: 0, + activeDetails: false, +}; + +export const directionReducer = (state, action) => { + switch (action.type) { + case 'setOrigin': + return { ...state, origin: action.data }; + case 'setDestination': + return { ...state, destination: action.data }; + case 'setParams': + return { ...state, ...action.data }; + case 'reversePoints': + return { ...state, destination: state.origin, origin: state.destination }; + case 'setVehicle': + return { + ...state, + vehicle: state.vehicles.includes(action.data) ? action.data : modes.DRIVING, + }; + case 'updating': + return { ...state, routes: [], isLoading: true, error: 0 }; + case 'setRoutes': + return { + ...state, + routes: action.data, + isLoading: false, + error: 0, + activeRouteId: state.activeRouteId < action.data.length ? state.activeRouteId : 0, + }; + case 'setError': + return { ...state, routes: [], isLoading: false, error: action.data }; + case 'setActiveRoute': + return { ...state, activeRouteId: action.data, activeDetails: false }; + case 'setActiveDetails': + return { ...state, activeDetails: action.data }; + case 'clearRoutes': + return { ...state, routes: [], isLoading: false, error: 0 }; + case 'reset': + return initialState; + default: + return state; + } +}; + +export const DirectionContext = React.createContext(initialState); + +let lastQueryId = 0; + +export const DirectionProvider = ({ children }) => { + const { + publicTransport: { enabled: ptEnabled }, + } = useConfig('direction'); + const vehicles = [modes.DRIVING, modes.WALKING, modes.CYCLING]; + if (ptEnabled) { + vehicles.splice(1, 0, modes.PUBLIC_TRANSPORT); + } + + const [state, dispatch] = useReducer(directionReducer, { ...initialState, vehicles }); + const { origin, destination, vehicle } = state; + + useEffect(() => { + const computeRoutes = async () => { + dispatch({ type: 'updating' }); + const currentQueryId = ++lastQueryId; + const directionResponse = await DirectionApi.search(origin, destination, vehicle); + // A more recent query was done in the meantime, ignore this result silently + if (currentQueryId !== lastQueryId) { + return; + } + if (directionResponse?.error === 0) { + // Valid, non-empty response + const routes = directionResponse.data.routes + .sort((routeA, routeB) => routeA.duration - routeB.duration) + .map((route, i) => ({ ...route, id: i })); + + dispatch({ type: 'setRoutes', data: routes }); + } else { + dispatch({ type: 'setError', data: directionResponse.error }); + } + }; + + if (origin && destination) { + computeRoutes(); + } else { + dispatch({ type: 'clearRoutes' }); + } + }, [origin, destination, vehicle]); + + // helper actions to avoid using dispatch directly + const setPoint = useCallback((which, point) => { + dispatch({ type: which === 'origin' ? 'setOrigin' : 'setDestination', data: point }); + }, []); + + return ( + <DirectionContext.Provider value={{ state, setPoint, dispatch }}> + {children} + </DirectionContext.Provider> + ); +};