digitransit-ui/app/component/IndexPage.js

542 lines
18 KiB
JavaScript

import PropTypes from 'prop-types';
import React from 'react';
import { intlShape, FormattedMessage } from 'react-intl';
import { matchShape, routerShape } from 'found';
import connectToStores from 'fluxible-addons-react/connectToStores';
import shouldUpdate from 'recompose/shouldUpdate';
import isEqual from 'lodash/isEqual';
import DTAutoSuggest from '@digitransit-component/digitransit-component-autosuggest';
import DTAutosuggestPanel from '@digitransit-component/digitransit-component-autosuggest-panel';
import { getModesWithAlerts } from '@digitransit-search-util/digitransit-search-util-query-utils';
import { createUrl } from '@digitransit-store/digitransit-store-future-route';
import moment from 'moment';
import storeOrigin from '../action/originActions';
import storeDestination from '../action/destinationActions';
import withSearchContext from './WithSearchContext';
import {
getPathWithEndpointObjects,
getStopRoutePath,
parseLocation,
sameLocations,
isItinerarySearchObjects,
PREFIX_NEARYOU,
PREFIX_ITINERARY_SUMMARY,
} from '../util/path';
import { addAnalyticsEvent } from '../util/analyticsUtils';
import { dtLocationShape } from '../util/shapes';
import withBreakpoint from '../util/withBreakpoint';
import Geomover from './Geomover';
import scrollTop from '../util/scroll';
import { LightenDarkenColor } from '../util/colorUtils';
import { getRefPoint } from '../util/apiUtils';
import { isKeyboardSelectionEvent } from '../util/browser';
import LazilyLoad, { importLazy } from './LazilyLoad';
import {
getTransportModes,
getNearYouModes,
useCitybikes,
} from '../util/modeUtils';
const StopRouteSearch = withSearchContext(DTAutoSuggest);
const LocationSearch = withSearchContext(DTAutosuggestPanel);
const modules = {
CtrlPanel: () =>
importLazy(
import('@digitransit-component/digitransit-component-control-panel'),
),
TrafficNowLink: () =>
importLazy(
import('@digitransit-component/digitransit-component-traffic-now-link'),
),
OverlayWithSpinner: () => importLazy(import('./visual/OverlayWithSpinner')),
FavouritesContainer: () => importLazy(import('./FavouritesContainer')),
DatetimepickerContainer: () =>
importLazy(import('./DatetimepickerContainer')),
};
class IndexPage extends React.Component {
static contextTypes = {
intl: intlShape.isRequired,
executeAction: PropTypes.func.isRequired,
getStore: PropTypes.func.isRequired,
router: routerShape.isRequired,
match: matchShape.isRequired,
config: PropTypes.object.isRequired,
};
static propTypes = {
breakpoint: PropTypes.string.isRequired,
origin: dtLocationShape.isRequired,
destination: dtLocationShape.isRequired,
lang: PropTypes.string,
currentTime: PropTypes.number.isRequired,
// eslint-disable-next-line react/no-unused-prop-types
query: PropTypes.object.isRequired,
favouriteModalAction: PropTypes.string,
fromMap: PropTypes.string,
locationState: dtLocationShape.isRequired,
};
static defaultProps = { lang: 'fi' };
constructor(props, context) {
super(props, context);
this.state = {};
}
componentDidMount() {
const { from, to } = this.context.match.params;
/* initialize stores from URL params */
const origin = parseLocation(from);
const destination = parseLocation(to);
// synchronizing page init using fluxible is - hard -
// see navigation conditions in componentDidUpdate below
if (!sameLocations(this.props.origin, origin)) {
this.pendingOrigin = origin;
this.context.executeAction(storeOrigin, origin);
}
if (!sameLocations(this.props.destination, destination)) {
this.pendingDestination = destination;
this.context.executeAction(storeDestination, destination);
}
scrollTop();
}
componentDidUpdate() {
const { origin, destination } = this.props;
if (this.pendingOrigin && isEqual(this.pendingOrigin, origin)) {
delete this.pendingOrigin;
}
if (
this.pendingDestination &&
isEqual(this.pendingDestination, destination)
) {
delete this.pendingDestination;
}
if (this.pendingOrigin || this.pendingDestination) {
// not ready for navigation yet
return;
}
const { router, match, config } = this.context;
const { location } = match;
if (isItinerarySearchObjects(origin, destination)) {
const newLocation = {
...location,
pathname: getPathWithEndpointObjects(
origin,
destination,
PREFIX_ITINERARY_SUMMARY,
),
};
if (newLocation.query.time === undefined) {
newLocation.query.time = moment().unix();
}
delete newLocation.query.setTime;
router.push(newLocation);
} else {
const path = getPathWithEndpointObjects(
origin,
destination,
config.indexPath,
);
if (path !== location.pathname) {
const newLocation = {
...location,
pathname: path,
};
router.replace(newLocation);
}
}
}
onSelectStopRoute = item => {
this.context.router.push(getStopRoutePath(item));
};
clickStopNearIcon = (url, kbdEvent) => {
if (kbdEvent && !isKeyboardSelectionEvent(kbdEvent)) {
return;
}
this.context.router.push(url);
};
onSelectLocation = (item, id) => {
const { router, executeAction } = this.context;
if (item.type === 'FutureRoute') {
router.push(createUrl(item));
} else if (id === 'origin') {
executeAction(storeOrigin, item);
} else {
executeAction(storeDestination, item);
}
};
clickFavourite = favourite => {
addAnalyticsEvent({
category: 'Favourite',
action: 'ClickFavourite',
name: null,
});
this.context.executeAction(storeDestination, favourite);
};
// DT-3551: handle logic for Traffic now link
trafficNowHandler = (e, lang) => {
window.location = `${this.context.config.URL.ROOTLINK}/${
lang === 'fi' ? '' : `${lang}/`
}${this.context.config.trafficNowLink[lang]}`;
};
filterObject = (obj, filter, filterValue) =>
Object.keys(obj).reduce(
(acc, val) =>
obj[val][filter] === filterValue
? {
...acc,
[val]: obj[val],
}
: acc,
{},
);
/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
render() {
const { intl, config } = this.context;
const { trafficNowLink, colors, fontWeights } = config;
const color = colors.primary;
const hoverColor = colors.hover || LightenDarkenColor(colors.primary, -20);
const accessiblePrimaryColor = colors.accessiblePrimary || colors.primary;
const { breakpoint, lang } = this.props;
const origin = this.pendingOrigin || this.props.origin;
const destination = this.pendingDestination || this.props.destination;
const sources = ['Favourite', 'History', 'Datasource'];
const stopAndRouteSearchTargets = ['Stops', 'Routes'];
const locationSearchTargets = [
'Locations',
'CurrentPosition',
'FutureRoutes',
'Stops',
];
if (useCitybikes(config.cityBike?.networks, config)) {
stopAndRouteSearchTargets.push('BikeRentalStations');
locationSearchTargets.push('BikeRentalStations');
}
if (config.includeParkAndRideSuggestions) {
stopAndRouteSearchTargets.push('ParkingAreas');
locationSearchTargets.push('ParkingAreas');
}
const locationSearchTargetsMobile = [
...locationSearchTargets,
'MapPosition',
];
const alertsContext = {
currentTime: this.props.currentTime,
getModesWithAlerts,
feedIds: config.feedIds,
};
const showSpinner =
(origin.type === 'CurrentLocation' && !origin.address) ||
(destination.type === 'CurrentLocation' && !destination.address);
const refPoint = getRefPoint(origin, destination, this.props.locationState);
const locationSearchProps = {
appElement: '#app',
origin,
destination,
lang,
sources,
color,
hoverColor,
accessiblePrimaryColor,
refPoint,
searchPanelText: intl.formatMessage({
id: 'where',
defaultMessage: 'Where to?',
}),
originPlaceHolder: 'search-origin-index',
destinationPlaceHolder: 'search-destination-index',
selectHandler: this.onSelectLocation,
getAutoSuggestIcons: config.getAutoSuggestIcons,
onGeolocationStart: this.onSelectLocation,
fromMap: this.props.fromMap,
fontWeights,
modeIconColors: config.colors.iconColors,
modeSet: config.iconModeSet,
};
const stopRouteSearchProps = {
appElement: '#app',
icon: 'search',
id: 'stop-route-station',
className: 'destination',
placeholder: 'stop-near-you',
selectHandler: this.onSelectStopRoute,
getAutoSuggestIcons: config.getAutoSuggestIcons,
value: '',
lang,
color,
hoverColor,
accessiblePrimaryColor,
sources,
targets: stopAndRouteSearchTargets,
fontWeights,
modeIconColors: config.colors.iconColors,
modeSet: config.iconModeSet,
};
if (config.stopSearchFilter) {
stopRouteSearchProps.filterResults = results =>
results.filter(config.stopSearchFilter);
stopRouteSearchProps.geocodingSize = 40; // increase size to compensate filtering
locationSearchProps.filterResults = results =>
results.filter(config.stopSearchFilter);
}
const transportModes = getTransportModes(config);
const nearYouModes = getNearYouModes(config);
const NearStops = CtrlPanel => {
// Styles are defined by which button type is configured (narrow/wide)
const narrowButtons = config.narrowNearYouButtons;
const modeTitles = this.filterObject(
transportModes,
'availableForSelection',
true,
);
// If nearYouModes is configured, display those. Otherwise, display all configured transport modes
const modes =
nearYouModes?.length > 0 ? nearYouModes : Object.keys(modeTitles);
return config.showNearYouButtons ? (
<>
<CtrlPanel.NearStopsAndRoutes
modeArray={modes}
urlPrefix={`/${PREFIX_NEARYOU}`}
language={lang}
showTitle
alertsContext={alertsContext}
origin={origin}
omitLanguageUrl
onClick={this.clickStopNearIcon}
buttonStyle={narrowButtons ? undefined : config.nearYouButton}
title={narrowButtons ? undefined : config.nearYouTitle}
modes={narrowButtons ? undefined : modeTitles}
modeSet={config.nearbyModeSet || config.iconModeSet}
modeIconColors={config.colors.iconColors}
fontWeights={fontWeights}
/>
</>
) : (
<div className="stops-near-you-text">
<h2>
{' '}
{intl.formatMessage({
id: 'stop-near-you-title',
defaultMessage: 'Stops and lines near you',
})}
</h2>
</div>
);
};
return (
<LazilyLoad modules={modules}>
{({
CtrlPanel,
TrafficNowLink,
OverlayWithSpinner,
FavouritesContainer,
DatetimepickerContainer,
}) =>
breakpoint === 'large' ? (
<div
className={`front-page flex-vertical ${
showSpinner && `blurred`
} fullscreen bp-${breakpoint}`}
>
<div
style={{ display: 'block' }}
className="scrollable-content-wrapper momentum-scroll"
>
<h1 className="sr-only">
<FormattedMessage
id="index.title"
default="Journey Planner"
/>
</h1>
<CtrlPanel
instance="hsl"
language={lang}
origin={origin}
position="left"
fontWeights={fontWeights}
>
<span className="sr-only">
<FormattedMessage
id="search-fields.sr-instructions"
defaultMessage="The search is triggered automatically when origin and destination are set. Changing any search parameters triggers a new search"
/>
</span>
<LocationSearch
targets={locationSearchTargets}
{...locationSearchProps}
/>
<div className="datetimepicker-container">
<DatetimepickerContainer realtime color={color} />
</div>
{!config.hideFavourites && (
<>
<FavouritesContainer
favouriteModalAction={this.props.favouriteModalAction}
onClickFavourite={this.clickFavourite}
lang={lang}
/>
<CtrlPanel.SeparatorLine usePaddingBottom20 />
</>
)}
{!config.hideStopRouteSearch && (
<>
<>{NearStops(CtrlPanel)}</>
<StopRouteSearch {...stopRouteSearchProps} />{' '}
<CtrlPanel.SeparatorLine />
</>
)}
{!trafficNowLink ||
(trafficNowLink[lang] !== '' && (
<TrafficNowLink
lang={lang}
handleClick={this.trafficNowHandler}
/>
))}
</CtrlPanel>
</div>
{(showSpinner && <OverlayWithSpinner />) || null}
</div>
) : (
<div
className={`front-page flex-vertical ${
showSpinner && `blurred`
} bp-${breakpoint}`}
>
{(showSpinner && <OverlayWithSpinner />) || null}
<div
style={{
display: 'block',
backgroundColor: '#ffffff',
}}
>
<CtrlPanel
instance="hsl"
language={lang}
position="bottom"
fontWeights={fontWeights}
>
<LocationSearch
disableAutoFocus
isMobile
targets={locationSearchTargetsMobile}
{...locationSearchProps}
/>
<div className="datetimepicker-container">
<DatetimepickerContainer realtime color={color} />
</div>
<FavouritesContainer
onClickFavourite={this.clickFavourite}
lang={lang}
isMobile
/>
<CtrlPanel.SeparatorLine />
<>{NearStops(CtrlPanel)}</>
<div className="stop-route-search-container">
<StopRouteSearch isMobile {...stopRouteSearchProps} />
</div>
<CtrlPanel.SeparatorLine usePaddingBottom20 />
{!trafficNowLink ||
(trafficNowLink[lang] !== '' && (
<TrafficNowLink
lang={lang}
handleClick={this.trafficNowHandler}
fontWeights={fontWeights}
/>
))}
</CtrlPanel>
</div>
</div>
)
}
</LazilyLoad>
);
}
}
const Index = shouldUpdate(
// update only when origin/destination/breakpoint, favourite store status or language changes
(props, nextProps) => {
return !(
isEqual(nextProps.origin, props.origin) &&
isEqual(nextProps.destination, props.destination) &&
isEqual(nextProps.breakpoint, props.breakpoint) &&
isEqual(nextProps.lang, props.lang) &&
isEqual(nextProps.query, props.query) &&
isEqual(nextProps.locationState, props.locationState)
);
},
)(IndexPage);
const IndexPageWithBreakpoint = withBreakpoint(Index);
const IndexPageWithStores = connectToStores(
IndexPageWithBreakpoint,
[
'OriginStore',
'DestinationStore',
'TimeStore',
'PreferencesStore',
'PositionStore',
],
(context, props) => {
const origin = context.getStore('OriginStore').getOrigin();
const destination = context.getStore('DestinationStore').getDestination();
const locationState = context.getStore('PositionStore').getLocationState();
const { location } = props.match;
const newProps = {};
const { query } = location;
const { favouriteModalAction, fromMap } = query;
newProps.locationState = locationState;
if (favouriteModalAction) {
newProps.favouriteModalAction = favouriteModalAction;
}
if (fromMap === 'origin' || fromMap === 'destination') {
newProps.fromMap = fromMap;
}
newProps.origin = origin;
newProps.destination = destination;
newProps.lang = context.getStore('PreferencesStore').getLanguage();
newProps.currentTime = context
.getStore('TimeStore')
.getCurrentTime()
.unix();
newProps.query = query; // defines itinerary search time & arriveBy
return newProps;
},
);
IndexPageWithStores.contextTypes = {
...IndexPageWithStores.contextTypes,
executeAction: PropTypes.func.isRequired,
config: PropTypes.object.isRequired,
};
const GeoIndexPage = Geomover(IndexPageWithStores);
export { GeoIndexPage as default, IndexPageWithBreakpoint as Component };