diff --git a/app/component/itinerary/navigator/NaviCard.js b/app/component/itinerary/navigator/NaviCard.js index 0059c20feb..7518423229 100644 --- a/app/component/itinerary/navigator/NaviCard.js +++ b/app/component/itinerary/navigator/NaviCard.js @@ -1,13 +1,13 @@ import PropTypes from 'prop-types'; -import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import { isRental } from '../../../util/legUtils'; +import React, { useState } from 'react'; +import { isAnyLegPropertyIdentical, isRental } from '../../../util/legUtils'; import { getRouteMode } from '../../../util/modeUtils'; import { configShape, legShape } from '../../../util/shapes'; import Icon from '../../Icon'; import NaviCardExtension from './NaviCardExtension'; import NaviInstructions from './NaviInstructions'; import { LEGTYPE } from './NaviUtils'; +import usePrevious from './hooks/usePrevious'; const iconMap = { BICYCLE: 'icon-icon_cyclist', @@ -27,95 +27,96 @@ const iconMap = { }; export default function NaviCard( - { - leg, - nextLeg, - legType, - cardExpanded, - startTime, - time, - position, - tailLength, - }, + { leg, nextLeg, legType, time, position, tailLength }, { config }, ) { - let mainCardContent; - if (legType === LEGTYPE.PENDING) { - mainCardContent = ( - - ); - } else if (legType === LEGTYPE.END) { - mainCardContent = ; - } else if (!leg && !nextLeg) { + const [cardExpanded, setCardExpanded] = useState(false); + const { isEqual: legChanged } = usePrevious(leg, (prev, current) => + isAnyLegPropertyIdentical(prev, current, ['legId', 'mode']), + ); + const handleClick = () => { + setCardExpanded(!cardExpanded); + }; + + if (legChanged) { + setCardExpanded(false); + } + + if ( + (!leg && !nextLeg) || + legType === LEGTYPE.PENDING || + legType === LEGTYPE.END + ) { return null; - } else { - let iconColor = 'currentColor'; - let iconName; - let instructions = ''; - if (legType === LEGTYPE.TRANSIT) { - const m = getRouteMode(leg.route, config); - iconColor = config.colors.iconColors[`mode-${m}`] || leg.route.color; - iconName = iconMap[m.toUpperCase()]; + } - instructions = `navileg-in-transit`; - } else if ( - legType !== LEGTYPE.WAIT && - legType !== LEGTYPE.WAIT_IN_VEHICLE && - isRental(leg, nextLeg) - ) { - if (leg.mode === 'WALK' && nextLeg?.mode === 'SCOOTER') { - instructions = `navileg-rent-scooter`; - } else { - instructions = 'rent-cycle-at'; - } - iconName = iconMap[leg.mode]; - } else if (legType === LEGTYPE.MOVE) { - instructions = `navileg-${leg?.mode.toLowerCase()}`; - iconName = iconMap.WALK; - } else if (legType === LEGTYPE.WAIT) { - iconName = iconMap.WAIT; - } else if (legType === LEGTYPE.WAIT_IN_VEHICLE) { - iconName = iconMap.WAIT_IN_VEHICLE; + let iconColor = 'currentColor'; + let iconName; + let instructions = ''; + + if (legType === LEGTYPE.TRANSIT) { + const m = getRouteMode(leg.route, config); + iconColor = config.colors.iconColors[`mode-${m}`] || leg.route.color; + iconName = iconMap[m.toUpperCase()]; + + instructions = `navileg-in-transit`; + } else if ( + legType !== LEGTYPE.WAIT && + legType !== LEGTYPE.WAIT_IN_VEHICLE && + isRental(leg, nextLeg) + ) { + if (leg.mode === 'WALK' && nextLeg?.mode === 'SCOOTER') { + instructions = `navileg-rent-scooter`; + } else { + instructions = 'rent-cycle-at'; } + iconName = iconMap[leg.mode]; + } else if (legType === LEGTYPE.MOVE) { + instructions = `navileg-${leg?.mode.toLowerCase()}`; + iconName = iconMap.WALK; + } else if (legType === LEGTYPE.WAIT) { + iconName = iconMap.WAIT; + } else if (legType === LEGTYPE.WAIT_IN_VEHICLE) { + iconName = iconMap.WAIT_IN_VEHICLE; + } - mainCardContent = ( - <> - -
- +
+
+ +
+ +
+
+ +
+
+ {cardExpanded && ( + -
-
- -
- - ); - } - return ( -
-
{mainCardContent}
- {cardExpanded && ( - - )} -
+ )} +
+ ); } @@ -123,8 +124,6 @@ NaviCard.propTypes = { leg: legShape, nextLeg: legShape, legType: PropTypes.string.isRequired, - cardExpanded: PropTypes.bool, - startTime: PropTypes.string, time: PropTypes.number.isRequired, position: PropTypes.shape({ lat: PropTypes.number, @@ -133,10 +132,8 @@ NaviCard.propTypes = { tailLength: PropTypes.number.isRequired, }; NaviCard.defaultProps = { - cardExpanded: false, leg: undefined, nextLeg: undefined, - startTime: '', position: undefined, }; diff --git a/app/component/itinerary/navigator/NaviCardContainer.js b/app/component/itinerary/navigator/NaviCardContainer.js index b57ccc3d94..1bd7fd5ee6 100644 --- a/app/component/itinerary/navigator/NaviCardContainer.js +++ b/app/component/itinerary/navigator/NaviCardContainer.js @@ -3,27 +3,33 @@ import { matchShape, routerShape } from 'found'; import PropTypes from 'prop-types'; import React, { useEffect, useRef, useState } from 'react'; import { intlShape } from 'react-intl'; -import { legTime, legTimeStr } from '../../../util/legUtils'; +import { + isAnyLegPropertyIdentical, + legTime, + legTimeStr, +} from '../../../util/legUtils'; import { configShape, legShape } from '../../../util/shapes'; +import { getTopics, updateClient } from '../ItineraryPageUtils'; import NaviCard from './NaviCard'; import NaviStack from './NaviStack'; +import NaviStarter from './NaviStarter'; import { + DESTINATION_RADIUS, getAdditionalMessages, getItineraryAlerts, getTransitLegState, itinerarySearchPath, LEGTYPE, - DESTINATION_RADIUS, } from './NaviUtils'; -import { updateClient, getTopics } from '../ItineraryPageUtils'; +import usePrevious from './hooks/usePrevious'; const COUNT_AT_LEG_END = 2; // update cycles within DESTINATION_RADIUS from leg.to const TOPBAR_PADDING = 8; // pixels const HIDE_TOPCARD_DURATION = 2000; // milliseconds -function addMessages(incominMessages, newMessages) { +function addMessages(incomingMessages, newMessages) { newMessages.forEach(m => { - incominMessages.set(m.id, m); + incomingMessages.set(m.id, m); }); } @@ -35,7 +41,7 @@ const getLegType = ( interlineWithPreviousLeg, ) => { let legType; - if (time < legTime(firstLeg.start)) { + if (!firstLeg.forceStart && time < legTime(firstLeg.start)) { legType = LEGTYPE.PENDING; } else if (leg) { if (!leg.transitLeg) { @@ -67,20 +73,22 @@ function NaviCardContainer( lastLeg, previousLeg, isJourneyCompleted, + startItinerary, }, context, ) { - const [cardExpanded, setCardExpanded] = useState(false); // All notifications including those user has dismissed. const [messages, setMessages] = useState(new Map()); // notifications that are shown to the user. const [activeMessages, setActiveMessages] = useState([]); const [legChanging, setLegChanging] = useState(false); - const legRef = useRef(currentLeg); + const { isEqual: legChanged } = usePrevious(currentLeg, (prev, current) => + isAnyLegPropertyIdentical(prev, current, ['legId', 'mode']), + ); + const { isEqual: forceStart } = usePrevious(firstLeg?.forceStart); const focusRef = useRef(false); // Destination counter. How long user has been at the destination. * 10 seconds const legEndRef = useRef(0); - const cardRef = useRef(null); const { intl, config, match, router } = context; const handleRemove = index => { const msg = messages.get(activeMessages[index].id); @@ -88,10 +96,6 @@ function NaviCardContainer( setActiveMessages(activeMessages.filter((_, i) => i !== index)); }; - const handleClick = () => { - setCardExpanded(!cardExpanded); - }; - // track only relevant vehicles for the journey. const getNaviTopics = () => getTopics( @@ -117,13 +121,6 @@ function NaviCardContainer( useEffect(() => { const incomingMessages = new Map(); - const legChanged = legRef.current?.legId - ? legRef.current.legId !== currentLeg?.legId - : legRef.current?.mode !== currentLeg?.mode; - if (legChanged) { - legRef.current = currentLeg; - } - // Alerts for NaviStack addMessages( incomingMessages, @@ -177,9 +174,8 @@ function NaviCardContainer( ]); } let timeoutId; - if (legChanged) { + if (legChanged || forceStart) { updateClient(getNaviTopics(), context); - setCardExpanded(false); setLegChanging(true); timeoutId = setTimeout(() => { setLegChanging(false); @@ -232,7 +228,7 @@ function NaviCardContainer( } return () => clearTimeout(timeoutId); - }, [time]); + }, [time, firstLeg]); // LegChange fires animation, we need to keep the old data until card goes out of the view. const l = legChanging ? previousLeg : currentLeg; @@ -259,23 +255,23 @@ function NaviCardContainer( className={`navi-card-container ${className}`} style={{ top: containerTopPosition }} > - + )} {activeMessages.length > 0 && ( )} @@ -303,6 +299,7 @@ NaviCardContainer.propTypes = { lastLeg: legShape, previousLeg: legShape, isJourneyCompleted: PropTypes.bool, + startItinerary: PropTypes.func, }; NaviCardContainer.defaultProps = { @@ -314,6 +311,7 @@ NaviCardContainer.defaultProps = { lastLeg: undefined, previousLeg: undefined, isJourneyCompleted: false, + startItinerary: () => {}, }; NaviCardContainer.contextTypes = { diff --git a/app/component/itinerary/navigator/NaviContainer.js b/app/component/itinerary/navigator/NaviContainer.js index 8fb260d5df..95d1eeb6cc 100644 --- a/app/component/itinerary/navigator/NaviContainer.js +++ b/app/component/itinerary/navigator/NaviContainer.js @@ -49,6 +49,7 @@ function NaviContainer( previousLeg, currentLeg, nextLeg, + startItinerary, } = useRealtimeLegs(relayEnvironment, legs, position); useEffect(() => { @@ -106,6 +107,7 @@ function NaviContainer( lastLeg={lastLeg} isJourneyCompleted={isJourneyCompleted} previousLeg={previousLeg} + startItinerary={startItinerary} /> {isJourneyCompleted && isNavigatorIntroDismissed && ( { + const { logo } = useLogo(config.trafficLightGraphic); + + const handleClick = () => startItinerary(Date.now()); + + return ( +
+
+ {logo ? ( + navigator logo + ) : ( + + )} + +

{time}

+
+
+ +
+
+ ); +}; + +NaviStarter.propTypes = { + time: PropTypes.string.isRequired, + startItinerary: PropTypes.func.isRequired, +}; + +NaviStarter.contextTypes = { + intl: intlShape.isRequired, + config: configShape.isRequired, +}; + +export default NaviStarter; diff --git a/app/component/itinerary/navigator/hooks/usePrevious.js b/app/component/itinerary/navigator/hooks/usePrevious.js new file mode 100644 index 0000000000..ceedd4612f --- /dev/null +++ b/app/component/itinerary/navigator/hooks/usePrevious.js @@ -0,0 +1,27 @@ +import { useRef } from 'react'; + +const usePrevious = (value, comparingFunc) => { + const ref = useRef({ + value, + prev: null, + }); + + const current = ref.current.value; + let isEqual = false; + + if ( + comparingFunc + ? !comparingFunc(current, value) + : JSON.stringify(value) !== JSON.stringify(current) + ) { + ref.current = { + value, + prev: current, + }; + isEqual = true; + } + + return { previous: ref.current.prev, isEqual }; +}; + +export default usePrevious; diff --git a/app/component/itinerary/navigator/hooks/useRealtimeLegs.js b/app/component/itinerary/navigator/hooks/useRealtimeLegs.js index d9434b05f0..27b7ca4cdc 100644 --- a/app/component/itinerary/navigator/hooks/useRealtimeLegs.js +++ b/app/component/itinerary/navigator/hooks/useRealtimeLegs.js @@ -192,7 +192,7 @@ const useRealtimeLegs = (relayEnvironment, initialLegs, position) => { // rtLegMap does not contain legs that have ended in the past as they've been filtered before updates are queried const rtLegs = prev.realTimeLegs.map(l => { const rtLeg = - l.legId && rtLegMap[l.legId] ? { ...rtLegMap[l.legId] } : null; + l.legId && rtLegMap?.[l.legId] ? { ...rtLegMap?.[l.legId] } : null; if (rtLeg) { // If start is frozen, the property is deleted to prevent it from affecting any views if (l.freezeStart) { @@ -217,13 +217,43 @@ const useRealtimeLegs = (relayEnvironment, initialLegs, position) => { // Freezes any leg.start|end in the past rtLegs.forEach(l => { - l.freezeStart = legTime(l.start) <= now; - l.freezeEnd = legTime(l.end) <= now; + l.freezeStart = l.freezeStart || legTime(l.start) <= now; + l.freezeEnd = l.freezeEnd || legTime(l.end) <= now; }); return { ...prev, time: now, realTimeLegs: rtLegs }; }); - }, [queryAndMapRealtimeLegs]); + }, [realTimeLegs, queryAndMapRealtimeLegs]); + + const startItinerary = startTimeInMS => { + if (startTimeInMS < legTime(realTimeLegs[0].start)) { + setTimeAndRealTimeLegs(prev => { + const firstLeg = prev.realTimeLegs[0]; + + if (firstLeg.transitLeg) { + firstLeg.forceStart = true; + return { + ...prev, + time: startTimeInMS, + }; + } + const adjustment = legTime(realTimeLegs[0].start) - startTimeInMS; + + firstLeg.start.scheduledTime = epochToIso(startTimeInMS); + firstLeg.end.scheduledTime = epochToIso( + legTime(realTimeLegs[0].end) - adjustment, + ); + firstLeg.freezeStart = true; + firstLeg.freezeEnd = true; + + return { + ...prev, + time: startTimeInMS, + realTimeLegs: prev.realTimeLegs, + }; + }); + } + }; useEffect(() => { fetchAndSetRealtimeLegs(); @@ -252,6 +282,7 @@ const useRealtimeLegs = (relayEnvironment, initialLegs, position) => { previousLeg, currentLeg, nextLeg, + startItinerary, }; }; diff --git a/app/component/itinerary/navigator/navigator.scss b/app/component/itinerary/navigator/navigator.scss index 5daa226a33..74ce4217d6 100644 --- a/app/component/itinerary/navigator/navigator.scss +++ b/app/component/itinerary/navigator/navigator.scss @@ -1,10 +1,9 @@ -$fixed-width-padding: 16px; - .navi-start-container { padding: 0 10px; + width: 100%; button { - width: 100%; + width: inherit; display: flex; flex-direction: row; align-items: center; @@ -55,7 +54,14 @@ $fixed-width-padding: 16px; .navi-card-container { position: fixed; - width: 100vw; + width: 100%; + padding: 0 var(--space-s); + + svg.mode { + width: 32px; + height: 32px; + color: black; + } &.slide-out { animation: slideUpToTop 3s ease-out forwards; @@ -139,8 +145,7 @@ $fixed-width-padding: 16px; } .navi-top-card { - margin: 0 var(--space-s) 5px var(--space-s); - border-radius: 8px; + border-radius: var(--radius-m); min-height: 70px; color: black; background-color: white !important; @@ -148,7 +153,7 @@ $fixed-width-padding: 16px; align-items: center; letter-spacing: -0.3px; box-shadow: 0 2px 4px 0 rgba(51, 51, 51, 0.2); - width: calc(100vw - #{$fixed-width-padding}); + width: inherit; cursor: default; font-size: $font-size-normal; font-weight: $font-weight-book; @@ -164,7 +169,7 @@ $fixed-width-padding: 16px; } .main-card { - width: 100%; + width: inherit; margin: var(--space-m) var(--space-l); .content { @@ -173,10 +178,7 @@ $fixed-width-padding: 16px; color: black; justify-content: center; - .mode { - width: 32px; - height: 32px; - color: black; + svg.mode { margin-right: var(--space-s); margin-top: var(--space-xxs); } @@ -400,6 +402,40 @@ $fixed-width-padding: 16px; } } +.navi-initializer-container { + display: flex; + flex-direction: column; + gap: var(--space-xxs); + font-weight: 325; + font-size: 14px; + line-height: 21px; + + .navi-initializer-card { + display: flex; + flex-direction: column; + gap: var(--space-xxs); + align-items: center; + justify-content: center; + align-content: center; + flex-wrap: wrap; + background-color: white; + border-radius: var(--radius-m); + padding: var(--space-m); + box-shadow: 0 2px 4px 0 rgba(51, 51, 51, 0.2); + text-align: center; + + &.success { + background-color: $success-color; + gap: var(--space-xs); + + button { + color: white; + background-color: $realtime-color; + } + } + } +} + .navigator-modal-container { position: fixed; width: 100%; @@ -436,20 +472,9 @@ $fixed-width-padding: 16px; } .info-stack { - position: fixed; - height: 69px; + position: relative; letter-spacing: -0.3px; - width: calc(100% - #{$fixed-width-padding}); - margin-right: 8px; - margin-left: 8px; - - div:first-child { - margin-top: 0; - } - - .info-stack-item:not(:first-child) { - margin-top: 5px; - } + width: inherit; &.slide-out { animation: @@ -476,6 +501,7 @@ $fixed-width-padding: 16px; fadeIn 0.5s ease-out forwards; font-size: $font-size-xsmall; font-weight: $font-weight-book; + margin-top: 5px; .icon-container { display: flex; diff --git a/app/component/map/map.scss b/app/component/map/map.scss index 2c2863d710..60ad8751de 100644 --- a/app/component/map/map.scss +++ b/app/component/map/map.scss @@ -57,6 +57,7 @@ div.map { .drawer-container { overflow-y: scroll; + scrollbar-width: none; height: 100%; position: absolute; width: 100%; diff --git a/app/configurations/config.hsl.js b/app/configurations/config.hsl.js index 06949b855a..3ef6325d79 100644 --- a/app/configurations/config.hsl.js +++ b/app/configurations/config.hsl.js @@ -751,6 +751,7 @@ export default { navigationLogo: 'hsl/navigator-logo.svg', thumbsUpGraphic: 'hsl/thumbs-up.svg', + trafficLightGraphic: 'hsl/traffic-light.svg', // features that should not be deployed to production experimental: { diff --git a/app/configurations/images/hsl/traffic-light.svg b/app/configurations/images/hsl/traffic-light.svg new file mode 100644 index 0000000000..f9c8bd7dc5 --- /dev/null +++ b/app/configurations/images/hsl/traffic-light.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/translations.js b/app/translations.js index c1a05d8316..e421534fb6 100644 --- a/app/translations.js +++ b/app/translations.js @@ -1326,7 +1326,9 @@ const translations = { 'navigation-intro-notifications-header': 'TODO_navigation-intro-login-prompt_EN', 'navigation-journey-end': 'Journey has ended', - 'navigation-journey-start': 'Your journey starts {time}', + 'navigation-journey-start': 'Your journey starts at', + 'navigation-journey-start-early-prompt': + 'TODO_navigation-journey-start-early-prompt_EN', 'navigation-mode-canceled': 'TODO_{name} on peruuntunut', 'navigation-mode-early': 'TODO_{name} on etuajassa', 'navigation-mode-late:': 'TODO_{name} on myöhässä', @@ -2617,7 +2619,8 @@ const translations = { 'Pysy ajan tasalla matkasi vaiheista', 'navigation-intro-notifications-header': 'Ilmoitukset ja muutokset', 'navigation-journey-end': 'Matka on päättynyt', - 'navigation-journey-start': 'Matkasi alkaa {time}', + 'navigation-journey-start': 'Matkasi alkaa klo', + 'navigation-journey-start-early-prompt': 'Etkö halua odottaa?', 'navigation-mode-canceled': '{name} on peruuntunut', 'navigation-mode-early': '{name} on etuajassa', 'navigation-mode-late:': '{name} on myöhässä', @@ -5561,7 +5564,9 @@ const translations = { 'navigation-intro-notifications-header': 'TODO_navigation-intro-login-prompt_SV', 'navigation-journey-end': 'Resan är över', - 'navigation-journey-start': 'Din resa börjar {time}', + 'navigation-journey-start': 'Din resa börjar', + 'navigation-journey-start-early-prompt': + 'TODO_navigation-journey-start-early-prompt_SV', 'navigation-mode-canceled': 'TODO_{name} on peruuntunut', 'navigation-mode-early': 'TODO_{name} on etuajassa', 'navigation-mode-late:': 'TODO_{name} on myöhässä', diff --git a/sass/themes/default/_theme.scss b/sass/themes/default/_theme.scss index 45a189be56..4f672ce532 100644 --- a/sass/themes/default/_theme.scss +++ b/sass/themes/default/_theme.scss @@ -42,6 +42,7 @@ $desktop-title-color: $primary-color; $desktop-title-arrow-icon-color: $secondary-color; $infobox-color-generic-blue: #e5f2fa; $info-icon-blue: #0074be; +$success-color: #f1f8eb; /* Component palette */ $title-color: $white;