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 8c438529b..9a1a8c9a7 100644 --- a/i18n/en-US.yml +++ b/i18n/en-US.yml @@ -136,6 +136,7 @@ common: walk: Walk notifications: email: email + push: push notifications sms: SMS places: custom: custom @@ -359,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: >- @@ -414,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 5c41e72fc..0cc06933a 100644 --- a/i18n/fr.yml +++ b/i18n/fr.yml @@ -145,6 +145,7 @@ common: walk: À pied notifications: email: e-mail + push: notifications push sms: SMS places: custom: divers @@ -372,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: >- @@ -431,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/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 && ( elements like