diff --git a/example-config.yml b/example-config.yml index b725f59a4..63fbf78cc 100644 --- a/example-config.yml +++ b/example-config.yml @@ -68,6 +68,7 @@ persistence: # otp_middleware: # apiBaseUrl: https://otp-middleware.example.com # apiKey: your-middleware-api-key +# supportsPushNotifications: true # If not set, push notification settings will not be shown. ### Adding additional menu items to the main menu items. Use the separator flag ### to include a separator line if you have groups of menu items diff --git a/i18n/en-US.yml b/i18n/en-US.yml index 613156575..9a1a8c9a7 100644 --- a/i18n/en-US.yml +++ b/i18n/en-US.yml @@ -34,8 +34,14 @@ actions: setPaymentError: "Error setting payment info:" setRequestStatusError: "Error setting request status:" location: + deniedAccessAlert: > + Access to your location is blocked. + + To use your current location, enable location permissions from your + browser, and reload the page. geolocationNotSupportedError: Geolocation not supported by your browser unknownPositionError: Unknown error getting position + userDeniedPermission: User denied permission map: currentLocation: (Current Location) user: @@ -130,6 +136,7 @@ common: walk: Walk notifications: email: email + push: push notifications sms: SMS places: custom: custom @@ -353,10 +360,8 @@ components: description: The content you requested is not available. header: Content not found NotificationPrefsPane: - description: You can receive notifications about trips you frequently take. - noneSelect: Don't notify me - notificationChannelPrompt: How would you like to receive notifications? - notificationEmailDetail: "Notification emails will be sent to:" + noDeviceForPush: Register your device using the mobile app to access push notifications. + notificationChannelPrompt: "Receive notifications about your saved trips via:" OTP2ErrorRenderer: LOCATION_NOT_FOUND: body: >- @@ -408,7 +413,6 @@ components: prompt: "Enter your phone number for SMS notifications:" requestNewCode: Request a new code sendVerificationText: Send verification text - smsDetail: "SMS notifications will be sent to:" verificationCode: "Verification code:" verificationInstructions: > Please check the SMS messaging app on your mobile phone for a text message diff --git a/i18n/es.yml b/i18n/es.yml index 99f42d57f..ab2d2ec11 100644 --- a/i18n/es.yml +++ b/i18n/es.yml @@ -133,6 +133,7 @@ common: walk: Caminar notifications: email: correo electrónico + push: notificaciones push sms: Mensaje de texto places: custom: personalizado @@ -359,8 +360,9 @@ components: header: No se encontró el contenido NotificationPrefsPane: description: Puede recibir notificaciones sobre los viajes que realiza con frecuencia. + noDeviceForPush: Regístrese con la aplicación móvil para acceder a esta configuración. noneSelect: No enviar notificaciones - notificationChannelPrompt: ¿Cómo desea recibir las notificaciones? + notificationChannelPrompt: "Recibir notificaciones para sus viajes guardados por:" notificationEmailDetail: "Los correos electrónicos de notificación se enviarán a:" PhoneNumberEditor: changeNumber: Cambiar número de teléfono @@ -375,7 +377,6 @@ components: texto: requestNewCode: Solicitar un nuevo código sendVerificationText: Enviar texto de verificación - smsDetail: "Las notificaciones por mensaje de texto se enviarán a:" verificationCode: "Código de verificación:" verificationInstructions: > Por favor, compruebe en la aplicación de mensajería de texto de su diff --git a/i18n/fr.yml b/i18n/fr.yml index 742db4275..0cc06933a 100644 --- a/i18n/fr.yml +++ b/i18n/fr.yml @@ -38,8 +38,14 @@ actions: setPaymentError: "Erreur sur les coordonnées de paiement :" setRequestStatusError: "Erreur sur l'état de la requête :" location: + deniedAccessAlert: > + L'accès à votre position est refusé. + + Pour utiliser votre emplacement actuel, permettez-en l'accès depuis votre + navigateur, et ouvrez de nouveau cette page. geolocationNotSupportedError: La géolocalisation n'est pas prise en charge par votre navigateur. unknownPositionError: Erreur inconnue lors de la détection de votre emplacement. + userDeniedPermission: Refusé par l'utilisateur map: currentLocation: (Emplacement actuel) user: @@ -139,6 +145,7 @@ common: walk: À pied notifications: email: e-mail + push: notifications push sms: SMS places: custom: divers @@ -178,7 +185,7 @@ components: AdvancedOptions: bannedRoutes: Choisissez les lignes à éviter… bikeTolerance: Tolérance au vélo - preferredRoutes: Choisissez les lignes preferées + preferredRoutes: Choisissez les lignes préferées walkTolerance: Tolérance à la marche AfterSignInScreen: mainTitle: Redirection... @@ -366,12 +373,8 @@ components: description: Le contenu que vous avez demandé n'est pas disponible. header: Contenu introuvable NotificationPrefsPane: - description: >- - Vous pouvez recevoir des notifications sur les trajets que vous effectuez - fréquemment. - noneSelect: Ne pas me notifier - notificationChannelPrompt: Comment voulez-vous recevoir vos notifications ? - notificationEmailDetail: "Les courriers de notification seront envoyés à :" + noDeviceForPush: Inscrivez-vous avec l'application mobile pour accéder à ce paramètre. + notificationChannelPrompt: "Recevoir des notifications sur vos trajets par :" OTP2ErrorRenderer: LOCATION_NOT_FOUND: body: >- @@ -425,7 +428,6 @@ components: prompt: "Entrez votre numéro de téléphone pour les SMS de notification :" requestNewCode: Envoyer un nouveau code sendVerificationText: Envoyer le SMS de vérification - smsDetail: "Les SMS de notification seront envoyés au :" verificationCode: "Code de vérification :" verificationInstructions: > Un SMS vous a été envoyé avec un code de vérification. Veuillez taper ce diff --git a/i18n/i18n-exceptions.json b/i18n/i18n-exceptions.json index db534b0e4..60f5855cd 100644 --- a/i18n/i18n-exceptions.json +++ b/i18n/i18n-exceptions.json @@ -1,5 +1,10 @@ { "groups": { + "common.notifications.*": [ + "email", + "sms", + "push" + ], "components.OTP2ErrorRenderer.*.body": [ "LOCATION_NOT_FOUND", "NO_STOPS_IN_RANGE", diff --git a/i18n/ko.yml b/i18n/ko.yml index d7528bcc5..1b339d183 100644 --- a/i18n/ko.yml +++ b/i18n/ko.yml @@ -119,6 +119,7 @@ common: walk: 걷기 notifications: email: 이메일 + push: 푸시 알림 sms: SMS places: custom: 사용자 정의 @@ -324,10 +325,7 @@ components: description: 요청한 콘텐츠를 사용할 수 없습니다. header: 콘텐츠를 찾을 수 없음 NotificationPrefsPane: - description: 자주 가는 트립에 대한 알림을 받을 수 있습니다. - noneSelect: 알림 거부 - notificationChannelPrompt: 알림을 어떻게 받고 싶습니까? - notificationEmailDetail: "알림 이메일이 다음으로 전송됩니다:" + notificationChannelPrompt: "저장된 여행의 알림을 받는 방법:" PhoneNumberEditor: changeNumber: 번호 변경 invalidCode: 확인 코드 6 자리를 입력하세요. @@ -338,7 +336,6 @@ components: prompt: "SMS 알림 수신을 위한 전화번호를 입력하세요:" requestNewCode: 새 코드 요청 sendVerificationText: 확인 텍스트 전송 - smsDetail: "SMS 알림이 다음으로 전송됩니다:" verificationCode: "확인 코드:" verificationInstructions: | 휴대폰의 SMS 메시지 앱에서 인증 코드를 확인하고 아래에 코드를 입력하세요(코드는 10분 후에 만료됩니다). diff --git a/i18n/vi.yml b/i18n/vi.yml index d4f6f93a0..f7cf241bb 100644 --- a/i18n/vi.yml +++ b/i18n/vi.yml @@ -128,6 +128,7 @@ common: walk: Đi bộ notifications: email: e-mail + push: thông báo đẩy sms: tin nhắn places: custom: phong tục @@ -333,12 +334,7 @@ components: description: Nội dung bạn yêu cầu không có sẵn. header: Không tìm thấy nội dung NotificationPrefsPane: - description: >- - Bạn có thể nhận được thông báo về các chuyến đi bạn thường xuyên thực - hiện. - noneSelect: Đừng thông báo cho tôi - notificationChannelPrompt: Bạn muốn nhận thông báo như thế nào? - notificationEmailDetail: "Email thông báo sẽ được gửi đến:" + notificationChannelPrompt: "Nhận thông báo về các chuyến đi đã lưu bằng:" PhoneNumberEditor: changeNumber: Thay đổi số điện thoại invalidCode: Vui lòng nhập 6 chữ số cho mã xác thực. @@ -349,7 +345,6 @@ components: prompt: "Nhập số điện thoại của bạn để nhận thông báo SMS:" requestNewCode: Yêu cầu một mã mới sendVerificationText: Gửi văn bản xác minh - smsDetail: "Thông báo SMS sẽ được gửi đến:" verificationCode: "Mã xác nhận:" verificationInstructions: > Vui lòng kiểm tra ứng dụng nhắn tin SMS trên điện thoại di động của bạn để diff --git a/i18n/zh.yml b/i18n/zh.yml index 79990f348..58e9a6d0c 100644 --- a/i18n/zh.yml +++ b/i18n/zh.yml @@ -119,6 +119,7 @@ common: walk: 步行 notifications: email: 电子邮件 + push: 推送通知 sms: 短信 places: custom: 习俗 @@ -325,10 +326,7 @@ components: description: 您要求的内容不存在. header: 未找到内容 NotificationPrefsPane: - description: 你可以收到关于你常用行程的通知. - noneSelect: 不要通知我 - notificationChannelPrompt: 您希望如何接收通知? - notificationEmailDetail: "通知邮件将被发送至:" + notificationChannelPrompt: "如何接收已保存行程的通知:" PhoneNumberEditor: changeNumber: 更改电话号码 invalidCode: 请输入6位数的验证码. @@ -339,7 +337,6 @@ components: prompt: "输入你的电话号码以便收到短信通知:" requestNewCode: 申请一个新的代码 sendVerificationText: 发送验证短信 - smsDetail: "短信通知将被发送到:" verificationCode: "验证码:" verificationInstructions: | 请检查您手机上的短信应用查看是否有验证码的短信并输入以下代码 (代码在10分钟后失效). diff --git a/lib/actions/apiV2.js b/lib/actions/apiV2.js index 3b9eba694..0edb46d0e 100644 --- a/lib/actions/apiV2.js +++ b/lib/actions/apiV2.js @@ -46,6 +46,7 @@ import { zoomToPlace } from './map' const { generateCombinations, generateOtp2Query } = coreUtils.queryGen const { getTripOptionsFromQuery, getUrlParams } = coreUtils.query +const { convertGraphQLResponseToLegacy } = coreUtils.itinerary const { randId } = coreUtils.storage const LIGHT_GRAY = '666666' @@ -782,47 +783,6 @@ const pickupDropoffTypeToOtp1 = (otp2Type) => { } } -/** - * Converts a leg from GraphQL format to legacy REST format. - * @param leg OTP2 GraphQL style leg - * @returns REST shaped leg - */ -const processLeg = (leg) => ({ - ...leg, - agencyBrandingUrl: leg.agency?.url, - agencyName: leg.agency?.name, - agencyUrl: leg.agency?.url, - alerts: aggregateAlerts( - leg.agency?.alerts, - leg.route?.alerts, - leg.to?.stop?.alerts, - leg.from?.stop?.alerts - ), - alightRule: pickupDropoffTypeToOtp1(leg.dropoffType), - boardRule: pickupDropoffTypeToOtp1(leg.pickupType), - dropOffBookingInfo: { - latestBookingTime: leg.dropOffBookingInfo - }, - from: { - ...leg.from, - stopCode: leg.from.stop?.code, - stopId: leg.from.stop?.gtfsId - }, - route: leg.route?.shortName, - routeColor: leg.route?.color, - routeId: leg.route?.id, - routeLongName: leg.route?.longName, - routeShortName: leg.route?.shortName, - routeTextColor: leg.route?.textColor, - to: { - ...leg.to, - stopCode: leg.to.stop?.code, - stopId: leg.to.stop?.gtfsId - }, - tripHeadsign: leg.trip?.tripHeadsign, - tripId: leg.trip?.gtfsId -}) - const queryParamConfig = { modeButtons: DelimitedArrayParam } export function routingQuery(searchId = null, updateSearchInReducer) { @@ -919,7 +879,7 @@ export function routingQuery(searchId = null, updateSearchInReducer) { dispatch(setItineraryView(ItineraryView.LIST)) - combinations.forEach((combo) => { + combinations.forEach((combo, index) => { const query = generateOtp2Query(combo) dispatch( createGraphQLQueryAction( @@ -962,7 +922,7 @@ export function routingQuery(searchId = null, updateSearchInReducer) { const withCollapsedShortNames = response.data?.plan?.itineraries?.map((itin) => ({ ...itin, - legs: itin.legs?.map(processLeg) + legs: itin.legs?.map(convertGraphQLResponseToLegacy) })) /* It is possible for a NO_TRANSIT_CONNECTION error to be @@ -982,6 +942,7 @@ export function routingQuery(searchId = null, updateSearchInReducer) { } return { + index, response: { plan: { ...response.data?.plan, diff --git a/lib/actions/field-trip.js b/lib/actions/field-trip.js index ee77d2ca1..da34ec27b 100644 --- a/lib/actions/field-trip.js +++ b/lib/actions/field-trip.js @@ -687,7 +687,7 @@ function checkValidityAndCapacity(state, request) { // iterate through itineraries to check validity and assign field trip // groups - response.plan.itineraries.forEach((itinerary) => { + response.plan.itineraries.forEach((itinerary, itinIdx) => { let itineraryCapacity = Number.POSITIVE_INFINITY // check each individual trip to see if there aren't any trips in this @@ -744,12 +744,13 @@ function checkValidityAndCapacity(state, request) { // A field trip response is guaranteed to have only one itinerary, so it // ok to set the itinerary by response as an array with a single // itinerary. - assignedItinerariesByResponse[responseIdx] = [ - { - ...itinerary, - fieldTripGroupSize: Math.min(itineraryCapacity, remainingGroupSize) - } - ] + if (!assignedItinerariesByResponse[responseIdx]) { + assignedItinerariesByResponse[responseIdx] = {} + } + assignedItinerariesByResponse[responseIdx][itinIdx] = { + ...itinerary, + fieldTripGroupSize: Math.min(itineraryCapacity, remainingGroupSize) + } remainingGroupSize -= itineraryCapacity } }) @@ -844,8 +845,12 @@ function makeFieldTripPlanRequests(request, outbound, intl) { // I do not believe that it is worth the effort. I am instead in favor of // re-building field trip from the ground up as a separate application. setInterval(() => { - const activeItineraries = getActiveItineraries(getState()) - if (activeItineraries.length >= numRequests) { + const searchResponse = getState().otp.searches[searchId]?.response + const activeItineraries = + searchResponse?.reduce((prev, resp) => { + return (prev += resp?.plan?.itineraries?.length || 0) + }, 0) || 0 + if (activeItineraries >= numRequests) { resolve() } }, 20) diff --git a/lib/actions/location.tsx b/lib/actions/location.tsx index a336efb18..c62e9b740 100644 --- a/lib/actions/location.tsx +++ b/lib/actions/location.tsx @@ -2,6 +2,7 @@ import { createAction } from 'redux-actions' import { Dispatch } from 'redux' import { IntlShape } from 'react-intl' +import { isMobile } from '@opentripplanner/core-utils/lib/ui' import { setLocationToCurrent } from './map' @@ -47,9 +48,29 @@ export function getCurrentPosition( // On error (error) => { console.log('error getting current position', error) - // FIXME, analyze error code to produce better error message. - // See https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError - dispatch(receivedPositionError({ error })) + // On desktop, after user clicks "Use location" from the location fields, + // show an alert and explain if location is blocked. + // TODO: Consider moving the handling of unavailable location to the location-field component. + if (!isMobile() && error.code === 1) { + window.alert( + intl.formatMessage({ + id: 'actions.location.deniedAccessAlert' + }) + ) + } + const newError = { ...error } + if (error.code === 1) { + // i18n for user-denied location message (error.code = 1 on secure origins). + if ( + window.location.protocol === 'https:' || + window.location.host.startsWith('localhost:') + ) { + newError.message = intl.formatMessage({ + id: 'actions.location.userDeniedPermission' + }) + } + } + dispatch(receivedPositionError({ error: newError })) }, // Options { enableHighAccuracy: true } diff --git a/lib/components/app/responsive-webapp.js b/lib/components/app/responsive-webapp.js index e135c16b1..0ae74b8a7 100644 --- a/lib/components/app/responsive-webapp.js +++ b/lib/components/app/responsive-webapp.js @@ -153,11 +153,9 @@ class ResponsiveWebapp extends Component { } } - // Test location availability on load, - // so it is reported correctly by the location fields. - getCurrentPosition(intl) - if (isMobile()) { + // Test location availability on load + getCurrentPosition(intl) // Also, watch for changes in position on mobile navigator.geolocation.watchPosition( // On success diff --git a/lib/components/form/call-taker/advanced-options.js b/lib/components/form/call-taker/advanced-options.js index 450c8c67f..dcf81ee32 100644 --- a/lib/components/form/call-taker/advanced-options.js +++ b/lib/components/form/call-taker/advanced-options.js @@ -2,9 +2,13 @@ // FIXME: Remove the following eslint rule exception. /* eslint-disable jsx-a11y/label-has-for */ import * as TripFormClasses from '@opentripplanner/trip-form/lib/styled' +import { + DropdownSelector, + SliderSelector, + SubmodeSelector +} from '@opentripplanner/trip-form' import { FormattedMessage, injectIntl } from 'react-intl' import { hasBike } from '@opentripplanner/core-utils/lib/itinerary' -import { SliderSelector, SubmodeSelector } from '@opentripplanner/trip-form' import isEmpty from 'lodash.isempty' import React, { Component, lazy, Suspense } from 'react' import styled from 'styled-components' @@ -155,9 +159,9 @@ class AdvancedOptions extends Component { }) } - _setWaklTolerance = ({ walkTolerance }) => { + _setWalkTolerance = ({ walkReluctance }) => { this.props.setUrlSearch({ - walkTolerance + walkReluctance }) } @@ -224,17 +228,19 @@ class AdvancedOptions extends Component { justifyContent: 'space-between' }} > - {hasBike(currentModes?.map((m) => m.mode).join(',') || '') ? ( diff --git a/lib/components/narrative/metro/attribute-utils.tsx b/lib/components/narrative/metro/attribute-utils.tsx index 36a12b90b..c48afd0c8 100644 --- a/lib/components/narrative/metro/attribute-utils.tsx +++ b/lib/components/narrative/metro/attribute-utils.tsx @@ -9,7 +9,7 @@ export const getFirstTransitLegStop = ( itinerary.legs?.find((leg: Leg) => leg?.from?.vertexType === 'TRANSIT')?.from ?.name -export const getFlexAttirbutes = ( +export const getFlexAttributes = ( itinerary: Itinerary ): { isCallAhead: boolean diff --git a/lib/components/narrative/metro/metro-itinerary.tsx b/lib/components/narrative/metro/metro-itinerary.tsx index e86a593e3..cbd0f7194 100644 --- a/lib/components/narrative/metro/metro-itinerary.tsx +++ b/lib/components/narrative/metro/metro-itinerary.tsx @@ -28,7 +28,7 @@ import ItineraryBody from '../line-itin/connected-itinerary-body' import NarrativeItinerary from '../narrative-itinerary' import SimpleRealtimeAnnotation from '../simple-realtime-annotation' -import { getFirstTransitLegStop, getFlexAttirbutes } from './attribute-utils' +import { getFlexAttributes } from './attribute-utils' import DepartureTimesList, { SetActiveItineraryHandler } from './departure-times-list' @@ -202,23 +202,17 @@ class MetroItinerary extends NarrativeItinerary { static ModesAndRoutes = MetroItineraryRoutes _onMouseEnter = () => { - const { active, index, setVisibleItinerary, visibleItinerary } = this.props + const { active, index, setVisibleItinerary, visible } = this.props // Set this itinerary as visible if not already visible. - const visibleNotSet = - visibleItinerary === null || visibleItinerary === undefined - const isVisible = - visibleItinerary === index || (active === index && visibleNotSet) + const isVisible = visible || active if (typeof setVisibleItinerary === 'function' && !isVisible) { setVisibleItinerary({ index }) } } _onMouseLeave = () => { - const { index, setVisibleItinerary, visibleItinerary } = this.props - if ( - typeof setVisibleItinerary === 'function' && - visibleItinerary === index - ) { + const { setVisibleItinerary, visible } = this.props + if (typeof setVisibleItinerary === 'function' && visible) { setVisibleItinerary({ index: null }) } } @@ -266,7 +260,7 @@ class MetroItinerary extends NarrativeItinerary { const { SvgIcon } = this.context const { isCallAhead, isContinuousDropoff, isFlexItinerary, phone } = - getFlexAttirbutes(itinerary) + getFlexAttributes(itinerary) const { fareCurrency, transitFare } = getFare(itinerary, defaultFareType) diff --git a/lib/components/narrative/narrative-itineraries.js b/lib/components/narrative/narrative-itineraries.js index 30200a5cf..4fa470537 100644 --- a/lib/components/narrative/narrative-itineraries.js +++ b/lib/components/narrative/narrative-itineraries.js @@ -43,6 +43,15 @@ import Loading from './loading' import NarrativeItinerariesErrors from './narrative-itineraries-errors' import NarrativeItinerariesHeader from './narrative-itineraries-header' +/** Creates a start time object for the given itinerary. */ +function makeStartTime(itinerary) { + return { + itinerary, + legs: itinerary.legs, + realtime: firstTransitLegIsRealtime(itinerary) + } +} + function doMergeItineraries(itineraries) { const mergedItineraries = itineraries .reduce((prev, cur, curIndex) => { @@ -60,46 +69,51 @@ function doMergeItineraries(itineraries) { // Only process itineraries less than 24 hours in the future differenceInDays(updatedItinerary.startTime, Date.now()) < 1 ) { - const duplicateItin = updatedItineraries[duplicateIndex] + const duplicateFoundItin = updatedItineraries[duplicateIndex] // TODO: MERGE ROUTE NAMES - // Add only new start time to existing itinerary + // Add only new start time to existing itinerary. + // The existing itinerary is the earliest between + // this itinerary (updatedItinerary) and duplicateItin. + // This is because alternate routes are only added to the first non-duplicate itinerary, + // and we show alternate routes for the first (i.e. earliest) non-duplicate itinerary found. + let duplicateItin = duplicateFoundItin + let itinCopyToAdd = updatedItinerary + if (duplicateFoundItin.startTime > updatedItinerary.startTime) { + duplicateItin = updatedItinerary + duplicateItin.startTimes = duplicateFoundItin.allStartTimes + updatedItineraries[duplicateIndex] = updatedItinerary + itinCopyToAdd = duplicateFoundItin + } + if (!duplicateItin.allStartTimes) { - duplicateItin.allStartTimes = [ - { - itinerary: duplicateItin, - legs: duplicateItin.legs, - realtime: firstTransitLegIsRealtime(duplicateItin) - } - ] + duplicateItin.allStartTimes = [makeStartTime(duplicateItin)] } // Only add new time if it doesn't already exist. It would be better to use // the uniqueness feature of Set, but unfortunately objects are never equal if ( !duplicateItin.allStartTimes.find( - (time) => getFirstLegStartTime(time.legs) === cur.startTime + (time) => + getFirstLegStartTime(time.legs) === itinCopyToAdd.startTime ) ) { - duplicateItin.allStartTimes.push({ - itinerary: updatedItinerary, - legs: cur.legs, - realtime: firstTransitLegIsRealtime(cur) - }) + duplicateItin.allStartTimes.push(makeStartTime(itinCopyToAdd)) } // Some legs will be the same, but have a different route // This map catches those and stores the alternate routes so they can be displayed duplicateItin.legs = duplicateItin.legs.map((leg, index) => { const newLeg = clone(leg) - if (leg?.routeId !== cur.legs[index]?.routeId) { + const curLeg = itinCopyToAdd.legs[index] + const curLegRouteId = curLeg?.routeId + if (curLegRouteId && leg?.routeId && leg?.routeId !== curLegRouteId) { if (!newLeg.alternateRoutes) { newLeg.alternateRoutes = {} } - const { routeId } = cur.legs?.[index] - newLeg.alternateRoutes[routeId] = { + newLeg.alternateRoutes[curLegRouteId] = { // We save the entire leg to the alternateRoutes object so in // the future, we can draw the leg on the map as an alternate route - ...cur.legs?.[index] + ...curLeg } } return newLeg @@ -546,7 +560,7 @@ class NarrativeItineraries extends Component { } const reduceErrorsFromResponse = (acc, cur) => { - const { routingErrors } = cur?.plan + const { routingErrors } = cur?.plan || {} if (routingErrors) { routingErrors.forEach((routingError) => { const { code, inputField } = routingError diff --git a/lib/components/user/monitored-trip/trip-basics-pane.tsx b/lib/components/user/monitored-trip/trip-basics-pane.tsx index 3ab97bf9e..b2aa897cc 100644 --- a/lib/components/user/monitored-trip/trip-basics-pane.tsx +++ b/lib/components/user/monitored-trip/trip-basics-pane.tsx @@ -18,9 +18,9 @@ import styled from 'styled-components' import type { IntlShape, WrappedComponentProps } from 'react-intl' import * as userActions from '../../../actions/user' +import { FieldSet } from '../styled' import { getErrorStates } from '../../../util/ui' import { getFormattedDayOfWeekPlural } from '../../../util/monitored-trip' -import { labelStyle } from '../styled' import FormattedDayOfWeek from '../../util/formatted-day-of-week' import FormattedDayOfWeekCompact from '../../util/formatted-day-of-week-compact' import FormattedValidationError from '../../util/formatted-validation-error' @@ -65,12 +65,7 @@ const ALL_DAYS = [ ] as const // Styles. -const AvailableDays = styled.fieldset` - /* Format like labels. */ - legend { - ${labelStyle} - } - +const AvailableDays = styled(FieldSet)` & > span { border: 1px solid #ccc; border-left: none; diff --git a/lib/components/user/monitored-trip/trip-notifications-pane.tsx b/lib/components/user/monitored-trip/trip-notifications-pane.tsx index a6702f08a..50145d780 100644 --- a/lib/components/user/monitored-trip/trip-notifications-pane.tsx +++ b/lib/components/user/monitored-trip/trip-notifications-pane.tsx @@ -1,10 +1,11 @@ import { Alert, FormControl } from 'react-bootstrap' import { ExclamationTriangle } from '@styled-icons/fa-solid/ExclamationTriangle' import { Field, FormikProps } from 'formik' -import { FormattedMessage, IntlShape, useIntl } from 'react-intl' +import { FormattedList, FormattedMessage, IntlShape, useIntl } from 'react-intl' import React, { Component, ComponentType, FormEvent, ReactNode } from 'react' import styled from 'styled-components' +import { FieldSet } from '../styled' import { IconWithText } from '../../util/styledIcon' // Element styles @@ -36,16 +37,6 @@ const Summary = styled.summary` margin-bottom: 5px; ` -const NotificationSettings = styled.fieldset` - /* Format like labels. */ - legend { - border: none; - font-size: inherit; - font-weight: 700; - margin-bottom: 5px; - } -` - /** * A label followed by a dropdown control. */ @@ -188,7 +179,8 @@ class TripNotificationsPane extends Component { render(): JSX.Element { const { notificationChannel, values } = this.props - const areNotificationsDisabled = notificationChannel === 'none' + const areNotificationsDisabled = + notificationChannel === 'none' || !notificationChannel?.length // Define a common trip delay field for simplicity, set to the smallest between the // retrieved departure/arrival delay attributes. const commonDelayThreshold = Math.min( @@ -213,24 +205,25 @@ class TripNotificationsPane extends Component { ) } else { + const selectedChannels = notificationChannel + .split(',') + .filter((channel) => channel?.length) + .map((channel) => ( + + )) notificationSettingsContent = ( - +
- ) - } - : { - channel: ( - - ) - } - } + values={{ + channel: ( + + ) + }} /> @@ -296,7 +289,7 @@ class TripNotificationsPane extends Component { - +
) } diff --git a/lib/components/user/notification-prefs-pane.tsx b/lib/components/user/notification-prefs-pane.tsx index 56aa6ab20..127fcb10c 100644 --- a/lib/components/user/notification-prefs-pane.tsx +++ b/lib/components/user/notification-prefs-pane.tsx @@ -1,12 +1,13 @@ +import { connect } from 'react-redux' import { Field, FormikProps } from 'formik' import { FormattedMessage } from 'react-intl' -import { FormGroup } from 'react-bootstrap' -import React, { Fragment } from 'react' +import { ListGroup, ListGroupItem } from 'react-bootstrap' +import React from 'react' import styled from 'styled-components' -import ButtonGroup from '../util/button-group' +import { GRAY_ON_WHITE } from '../util/colors' -import { FakeLabel, InlineStatic } from './styled' +import { FieldSet } from './styled' import { PhoneVerificationSubmitHandler } from './phone-verification-form' import { User } from './types' import PhoneNumberEditor, { @@ -14,6 +15,7 @@ import PhoneNumberEditor, { } from './phone-number-editor' interface Props extends FormikProps { + allowedNotificationChannels: string[] loggedInUser: User onRequestPhoneVerificationCode: PhoneCodeRequestHandler onSendPhoneVerificationCode: PhoneVerificationSubmitHandler @@ -22,92 +24,112 @@ interface Props extends FormikProps { } } -const allowedNotificationChannels = ['email', 'sms', 'none'] +const allNotificationChannels = ['email', 'sms', 'push'] +const emailAndSms = ['email', 'sms'] // Styles -// HACK: Preserve container height. -const Details = styled.div` - min-height: 60px; - margin-bottom: 15px; +const NotificationOption = styled(ListGroupItem)` + align-items: flex-start; + display: flex; + + /* Match bootstrap's spacing between checkbox and label */ + & > span:first-child { + flex-shrink: 0; + width: 20px; + } + + label { + display: block; + font-weight: normal; + margin-bottom: 0; + } + label::first-letter { + text-transform: uppercase; + } + label + span { + color: ${GRAY_ON_WHITE}; + } ` /** * User notification preferences pane. */ const NotificationPrefsPane = ({ - loggedInUser, + allowedNotificationChannels, onRequestPhoneVerificationCode, onSendPhoneVerificationCode, phoneFormatOptions, values: userData // Formik prop }: Props): JSX.Element => { - const { email, isPhoneNumberVerified, phoneNumber } = loggedInUser - const { notificationChannel } = userData + const { email, isPhoneNumberVerified, phoneNumber, pushDevices } = userData return ( -
-

- -

- - - - - - {allowedNotificationChannels.map((type) => { - // TODO: If removing the Save/Cancel buttons on the account screen, - // persist changes immediately when onChange is triggered. - const inputId = `notification-channel-${type}` - const isChecked = notificationChannel === type - return ( - - {/* Note: labels are placed after inputs so that the CSS focus selector can be easily applied. */} +
+ + + + + {allowedNotificationChannels.map((type) => { + // TODO: If removing the Save/Cancel buttons on the account screen, + // persist changes immediately when onChange is triggered. + const inputId = `notification-channel-${type}` + const inputDescriptionId = `${inputId}-description` + return ( + + - + + - - ) - })} - - -
- {notificationChannel === 'email' && ( - - - - - {email} - - )} - {notificationChannel === 'sms' && ( - - )} -
-
+ {type === 'email' ? ( + {email} + ) : type === 'sms' ? ( + + ) : ( + + {pushDevices ? ( + // TODO: i18n + `${pushDevices} devices registered` + ) : ( + + )} + + )} + + + ) + })} + + ) } -export default NotificationPrefsPane +const mapStateToProps = (state: any) => { + const { supportsPushNotifications } = + state.otp.config.persistence?.otp_middleware || {} + return { + allowedNotificationChannels: supportsPushNotifications + ? allNotificationChannels + : emailAndSms, + phoneFormatOptions: state.otp.config.phoneFormatOptions + } +} + +export default connect(mapStateToProps)(NotificationPrefsPane) diff --git a/lib/components/user/phone-number-editor.tsx b/lib/components/user/phone-number-editor.tsx index 7e9113a32..b3dc52efc 100644 --- a/lib/components/user/phone-number-editor.tsx +++ b/lib/components/user/phone-number-editor.tsx @@ -6,11 +6,11 @@ import React, { Component, createRef, Fragment } from 'react' import styled from 'styled-components' import { getAriaPhoneNumber } from '../../util/a11y' +import { GRAY_ON_WHITE } from '../util/colors' import { isBlank } from '../../util/ui' import InvisibleA11yLabel from '../util/invisible-a11y-label' -import SpanWithSpace from '../util/span-with-space' -import { ControlStrip, FakeLabel, InlineStatic } from './styled' +import { ControlStrip } from './styled' import PhoneChangeForm, { PhoneChangeSubmitHandler } from './phone-change-form' import PhoneVerificationForm, { PhoneVerificationSubmitHandler @@ -18,8 +18,8 @@ import PhoneVerificationForm, { export type PhoneCodeRequestHandler = (phoneNumber: string) => void -const PlainLink = styled(SpanWithSpace)` - color: inherit; +const PlainLink = styled.a` + color: ${GRAY_ON_WHITE}; &:hover { text-decoration: none; } @@ -34,6 +34,7 @@ const blankState = { } interface Props { + descriptorId: string initialPhoneNumber?: string initialPhoneNumberVerified?: boolean intl: IntlShape @@ -169,7 +170,7 @@ class PhoneNumberEditor extends Component { } render() { - const { initialPhoneNumber, phoneFormatOptions } = this.props + const { descriptorId, initialPhoneNumber, phoneFormatOptions } = this.props const { isEditing, phoneNumberReceived, @@ -220,9 +221,6 @@ class PhoneNumberEditor extends Component { return ( <> - - {ariaAlertContent} - {isEditing ? ( { /> ) : ( - - - - - - {shownPhoneNumber} - - {/* Invisible parentheses for no-CSS and screen readers */} - ( - {isPending ? ( - - - - ) : ( - - - - )} - ) - + {/* Use an anchor so that the aria-label applies and phone actions can be performed, + if necessary. Styling will make the text appear plain (mostly). */} + + {shownPhoneNumber} + + {/* Invisible parentheses for no-CSS and screen readers */} + ( + {isPending ? ( + + + + ) : ( + + + + )} + ) )} + + {ariaAlertContent} + {isPending && !isEditing && ( extends Component> { routeTo(`${parentPath}/${nextId}`) } + h1Ref = React.createRef() + + _focusHeader = () => { + this.h1Ref?.current?.focus() + } + _handleToNextPane = async (e: MouseEvent