diff --git a/i18n/en-US.yml b/i18n/en-US.yml index 1032cc22d..81d7aee92 100644 --- a/i18n/en-US.yml +++ b/i18n/en-US.yml @@ -52,6 +52,8 @@ actions: emailVerificationResent: The email verification message has been resent. genericError: "An error was encountered: {err}" itineraryExistenceCheckFailed: Error checking whether your selected trip is possible. + mustBeLoggedInToSavePlace: Please log in to save locations. + placeRemembered: The settings for this place have been saved. preferencesSaved: Your preferences have been saved. smsInvalidCode: The code you entered is invalid. Please try again. smsResendThrottled: >- diff --git a/i18n/es.yml b/i18n/es.yml index a8624868d..3012c18ed 100644 --- a/i18n/es.yml +++ b/i18n/es.yml @@ -30,20 +30,21 @@ actions: No se puede guardar el plan: este plan no se pudo guardar debido a la falta de capacidad en uno o más vehículos. Por favor, vuelva a planificar su viaje. - maxTripRequestsExceeded: Número de solicitudes de viaje superadas sin resultados - válidos + maxTripRequestsExceeded: Número de solicitudes de viaje superadas sin resultados válidos saveItinerariesError: "No se pudieron guardar los itinerarios: {err}" setDateError: "Error al establecer la fecha:" setGroupSizeError: "No se pudo establecer el tamaño del grupo:" setPaymentError: "Error al configurar la información de pago:" setRequestStatusError: "Error al establecer el estado de la solicitud:" location: + deniedAccessAlert: > + El acceso a tu ubicación está bloqueado. + + Para utilizar tu ubicación actual, activa los permisos de ubicación desde + tu navegador y vuelve a cargar la página. geolocationNotSupportedError: Su navegador no admite la geolocalización unknownPositionError: Error desconocido al obtener la posición userDeniedPermission: Usuario sin permiso - deniedAccessAlert: "El acceso a tu ubicación está bloqueado.\nPara utilizar tu - ubicación actual, activa los permisos de ubicación desde tu navegador y vuelve - a cargar la página. \n" map: currentLocation: (Ubicación actual) user: @@ -51,11 +52,9 @@ actions: authTokenError: Error al obtener un token de autorización. confirmDeleteMonitoredTrip: ¿Desea eliminar este viaje? confirmDeletePlace: ¿Quiere eliminar este lugar? - emailVerificationResent: El mensaje de verificación de correo electrónico ha sido - reenviado. + emailVerificationResent: El mensaje de verificación de correo electrónico ha sido reenviado. genericError: "Se ha encontrado un error: {err}" - itineraryExistenceCheckFailed: Comprobación de errores para ver si el viaje seleccionado - es posible. + itineraryExistenceCheckFailed: Comprobación de errores para ver si el viaje seleccionado es posible. preferencesSaved: Sus preferencias se han guardado. smsInvalidCode: El código introducido no es válido. Por favor, inténtelo de nuevo. smsResendThrottled: >- @@ -112,12 +111,12 @@ common: "yes": Sí itineraryDescriptions: calories: "{calories, number} kcal" + fareUnknown: No hay información de las tarifas noItineraryToDisplay: No hay itinerario que mostrar. relativeCo2: > {co2} de CO₂ en {isMore, select, true {más} other {menos} } que conducir solo transfers: "{transfers, plural, =0 {} one {# transferencia} other {# transferencias}}" - fareUnknown: No hay información de las tarifas linkOpensNewWindow: (Abre una nueva ventana) modes: bicycle_rent: Compartir bicicleta @@ -178,8 +177,8 @@ components: tooManyPlaces: Se alcanzó el máximo de lugares intermedios AdvancedOptions: bannedRoutes: Seleccionar rutas prohibidas… - preferredRoutes: Seleccionar rutas preferidas… bikeTolerance: Tolerancia de las bicicletas + preferredRoutes: Seleccionar rutas preferidas… walkTolerance: Tolerancia al andar AfterSignInScreen: mainTitle: Redirigiendo… @@ -241,8 +240,7 @@ components: destination: destino origin: origen planTripTooltip: Planificar viaje - validationMessage: "Por favor, defina los siguientes campos para planificar un - viaje: {issues}" + validationMessage: "Por favor, defina los siguientes campos para planificar un viaje: {issues}" BeforeSignInScreen: mainTitle: Iniciando sesión message: > @@ -373,6 +371,48 @@ components: NotificationPrefsPane: noDeviceForPush: Regístrese con la aplicación móvil para acceder a esta configuración. notificationChannelPrompt: "Recibir notificaciones para sus viajes guardados por:" + OTP2ErrorRenderer: + LOCATION_NOT_FOUND: + body: >- + {inputFieldsCount, plural, =0 {} one {localización es} other + {localizaciones son}} no están cerca de ninguna calle. + header: No se puede acceder a la ubicación + NO_STOPS_IN_RANGE: + body: >- + {inputFieldsCount, plural, =0 {} one {ubicación es} other {ubicaciones + son}} no están cerca de ninguna parada del transporte. + header: Sin paradas cerca + NO_TRANSIT_CONNECTION: + body: >- + No se encontró ninguna conexión entre tu origen y destino en el día + seleccionado, utilizando los tipos de vehículos que seleccionaste. + header: Sin conexiones + NO_TRANSIT_CONNECTION_IN_SEARCH_WINDOW: + body: >- + Se encontró una conexión, pero estaba fuera de las opciones de la + búsqueda, intenta ajustar las opciones de la búsqueda, usando los tipos + de vehículos que seleccionaste. + header: Sin conexiones por el criterio de la búsqueda + OUTSIDE_BOUNDS: + body: >- + {inputFieldsCount, plural, =0 {} one {localización es} other + {localizaciones son}} no están en los límites del planificador de + viajes. + header: Ubicación fuera de los límites + OUTSIDE_SERVICE_PERIOD: + body: >- + La fecha específicada está fuera del rango de los datos disponibles para + la búsqueda de los viajes. + header: Fuera de plazo + SYSTEM_ERROR: + body: Se ha producido un error desconocido en la búsqueda. + header: Error en el planificador de los viajes + WALKING_BETTER_THAN_TRANSIT: + body: Puede completar esta ruta más rápido caminando. + header: El transporte público no es la forma más rápida de hacer este viaje + inputFields: + FROM: Procedencia + TO: Destino PhoneNumberEditor: changeNumber: Cambiar número de teléfono invalidCode: Introduzca 6 dígitos para el código de validación. @@ -502,8 +542,7 @@ components: header: ¡La sesión está a punto de terminar! keepSession: Continuar sesión SimpleRealtimeAnnotation: - usingRealtimeInfo: Este viaje utiliza información de tráfico y retrasos en tiempo - real + usingRealtimeInfo: Este viaje utiliza información de tráfico y retrasos en tiempo real StackedPaneDisplay: savePreferences: Guardar preferencias StopScheduleTable: @@ -566,19 +605,16 @@ components: travelingAt: Viajando a {milesPerHour} vehicleName: Vehículo {vehicleNumber} TripBasicsPane: - checkingItineraryExistence: Comprobación de la existencia de itinerarios para - cada día de la semana… + checkingItineraryExistence: Comprobación de la existencia de itinerarios para cada día de la semana… selectAtLeastOneDay: Por favor, seleccione al menos un día para el seguimiento. tripDaysPrompt: ¿Qué días hace este viaje? - tripIsAvailableOnDaysIndicated: Su viaje está disponible en los días de la semana - indicados anteriormente. + tripIsAvailableOnDaysIndicated: Su viaje está disponible en los días de la semana indicados anteriormente. tripNamePrompt: "Por favor, indique un nombre para este viaje:" tripNotAvailableOnDay: El viaje no está disponible el {repeatedDay} unsavedChangesExistingTrip: >- Todavía no ha guardado su viaje. Si abandona la página, los cambios se perderán. - unsavedChangesNewTrip: Todavía no ha guardado su nuevo viaje. Si abandona la página, - se perderá. + unsavedChangesNewTrip: Todavía no ha guardado su nuevo viaje. Si abandona la página, se perderá. TripNotificationsPane: advancedSettings: Configuración avanzada altRouteRecommended: Se recomienda una ruta alternativa o un punto de transferencia @@ -738,41 +774,6 @@ components: switcher: Botón de cambio WelcomeScreen: prompt: ¿A donde quiere ir? - OTP2ErrorRenderer: - SYSTEM_ERROR: - header: Error en el planificador de los viajes - body: Se ha producido un error desconocido en la búsqueda. - LOCATION_NOT_FOUND: - body: '{inputFieldsCount, plural, =0 {} one {localización es} other {localizaciones - son}} no están cerca de ninguna calle.' - header: No se puede acceder a la ubicación - NO_STOPS_IN_RANGE: - header: Sin paradas cerca - body: '{inputFieldsCount, plural, =0 {} one {ubicación es} other {ubicaciones - son}} no están cerca de ninguna parada del transporte.' - NO_TRANSIT_CONNECTION: - body: No se encontró ninguna conexión entre tu origen y destino en el día seleccionado, - utilizando los tipos de vehículos que seleccionaste. - header: Sin conexiones - inputFields: - FROM: Procedencia - TO: Destino - WALKING_BETTER_THAN_TRANSIT: - body: Puede completar esta ruta más rápido caminando. - header: El transporte público no es la forma más rápida de hacer este viaje - OUTSIDE_SERVICE_PERIOD: - header: Fuera de plazo - body: La fecha específicada está fuera del rango de los datos disponibles para - la búsqueda de los viajes. - OUTSIDE_BOUNDS: - header: Ubicación fuera de los límites - body: '{inputFieldsCount, plural, =0 {} one {localización es} other {localizaciones - son}} no están en los límites del planificador de viajes.' - NO_TRANSIT_CONNECTION_IN_SEARCH_WINDOW: - body: Se encontró una conexión, pero estaba fuera de las opciones de la búsqueda, - intenta ajustar las opciones de la búsqueda, usando los tipos de vehículos - que seleccionaste. - header: Sin conexiones por el criterio de la búsqueda config: accessModes: bicycle: Tránsito + Bicicleta Personal diff --git a/i18n/fr.yml b/i18n/fr.yml index dfce0604c..a9d07c904 100644 --- a/i18n/fr.yml +++ b/i18n/fr.yml @@ -2,8 +2,7 @@ _id: fr _name: Exemple de traduction pour OTP-react-redux en français actions: callTaker: - callQuerySaveError: "Erreur lors de l'enregistrement des requêtes pour l'appel - : {err}" + callQuerySaveError: "Erreur lors de l'enregistrement des requêtes pour l'appel : {err}" callSaveError: "Impossible d'enregistrer l'appel : {err}" checkSessionError: "Erreur durant la session d'authentification : {err}" couldNotFindCallError: >- @@ -18,11 +17,9 @@ actions: true {aller} other {retour} } planifié préalablement pour cette demande. Voulez-vous continuer ? - deleteItinerariesError: "Erreur lors de la suppression du trajet pour le groupe - :" + deleteItinerariesError: "Erreur lors de la suppression du trajet pour le groupe :" deleteNoteError: "Erreur lors de la suppression d'une note sur le groupe :" - editSubmitterNotesError: "Erreur lors de la modification des notes du demandeur - :" + editSubmitterNotesError: "Erreur lors de la modification des notes du demandeur :" fetchFieldTripError: "Erreur de chargement du groupe scolaire : {err}" fetchFieldTripsError: "Erreur lors du chargement des groupes scolaires : {err}" fetchTripsForDateError: >- @@ -46,8 +43,7 @@ actions: 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. + 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: @@ -61,8 +57,9 @@ actions: Le message de vérification de votre adresse e-mail a été envoyé de nouveau. genericError: "Une erreur s'est produite : {err}" - itineraryExistenceCheckFailed: Erreur lors de la vérification de la validité du - trajet choisi. + itineraryExistenceCheckFailed: Erreur lors de la vérification de la validité du trajet choisi. + mustBeLoggedInToSavePlace: Veuillez vous connecter pour enregistrer des lieux. + placeRemembered: Les informations pour ce lieu ont été enregistrées. preferencesSaved: Vos préférences ont été enregistrées. smsInvalidCode: Le code saisi est incorrect. Veuillez réessayer. smsResendThrottled: >- @@ -351,10 +348,8 @@ components: header: Options de recherche NarrativeItinerariesHeader: changeSortDir: Changer l'ordre de tri - howToFindResults: Pour afficher les résultats, utilisez l'en-tête Trajets trouvés - plus bas. - itinerariesFound: "{itineraryNum, plural, one {# trajet trouvé} other {# trajets - trouvés} }" + howToFindResults: Pour afficher les résultats, utilisez l'en-tête Trajets trouvés plus bas. + itinerariesFound: "{itineraryNum, plural, one {# trajet trouvé} other {# trajets trouvés} }" numIssues: "{issueNum, plural, one {# problème} other {# problèmes} }" resultsSortedBy: >- Résultats triés par {sortSelected}. Pour modifier l'ordre, utilisez le @@ -421,7 +416,8 @@ components: header: Echec de la recherche WALKING_BETTER_THAN_TRANSIT: body: >- - Vous pouvez effectuer ce trajet plus rapidement sans prendre les transports. + Vous pouvez effectuer ce trajet plus rapidement sans prendre les + transports. header: Moins rapide en transports inputFields: FROM: point de départ @@ -666,8 +662,7 @@ components: unknownState: État du trajet inconnu untogglePause: Reprendre inactive: - description: Reprenez le suivi pour obtenir des dernières conditions de votre - trajet. + description: Reprenez le suivi pour obtenir des dernières conditions de votre trajet. heading: Suivi suspendu nextTripNotPossible: description: > @@ -686,8 +681,7 @@ components: déterminées. heading: Conditions du trajet indéterminées snoozed: - description: Reprenez le suivi pour obtenir des dernières conditions de votre - trajet. + description: Reprenez le suivi pour obtenir des dernières conditions de votre trajet. heading: Suivi suspendu jusqu'à demain upcoming: nextTripBegins: >- diff --git a/lib/actions/ui.js b/lib/actions/ui.js index 9ea85829e..3b254fa61 100644 --- a/lib/actions/ui.js +++ b/lib/actions/ui.js @@ -403,7 +403,7 @@ export function setLocale(locale) { if (loggedInUser) { loggedInUser.preferredLocale = matchedLocale - dispatch(createOrUpdateUser(loggedInUser, false)) + dispatch(createOrUpdateUser(loggedInUser)) } } } diff --git a/lib/actions/user.js b/lib/actions/user.js index d630bfb33..4c5336bff 100644 --- a/lib/actions/user.js +++ b/lib/actions/user.js @@ -3,6 +3,7 @@ import clone from 'clone' import coreUtils from '@opentripplanner/core-utils' import isEmpty from 'lodash.isempty' import qs from 'qs' +import toast from 'react-hot-toast' import { convertToPlace, @@ -52,6 +53,22 @@ export const rememberStop = createAction('REMEMBER_STOP') const rememberLocalUserPlace = createAction('REMEMBER_LOCAL_USER_PLACE') const deleteRecentPlace = createAction('DELETE_LOCAL_USER_RECENT_PLACE') +export const UserActionResult = { + FAILURE: 1, + SUCCESS: 0 +} + +function genericErrorAlert(intl, messageObject) { + if (intl) { + alert( + intl.formatMessage( + { id: 'actions.user.genericError' }, + { err: JSON.stringify(messageObject) } + ) + ) + } +} + function createNewUser(auth0User) { return { accessibilityRoutingByDefault: false, @@ -289,10 +306,9 @@ export function fetchOrInitializeUser(auth0User) { * Updates (or creates) a user entry in the middleware, * then, if that was successful, updates the redux state with that user. * @param userData the user entry to persist. - * @param silentOnSuccess true to suppress the confirmation if the operation is successful (e.g. immediately after user accepts the terms). * @param intl the react-intl formatter */ -export function createOrUpdateUser(userData, silentOnSuccess = false, intl) { +export function createOrUpdateUser(userData, intl) { return async function (dispatch, getState) { const { accessToken, apiBaseUrl, apiKey, loggedInUser } = getMiddlewareVariables(getState()) @@ -322,25 +338,15 @@ export function createOrUpdateUser(userData, silentOnSuccess = false, intl) { body: JSON.stringify(userData) }) - // TODO: improve the UI feedback messages for this. if (status === 'success' && returnedUser) { - if (!silentOnSuccess && intl) { - alert(intl.formatMessage({ id: 'actions.user.preferencesSaved' })) - } - // Update application state with the user entry as saved // (as returned) by the middleware. // (This sorts saved places, and, for existing users, fetches trips.) dispatch(setUser(returnedUser, isCreatingUser)) + return UserActionResult.SUCCESS } else { - if (intl) { - alert( - intl.formatMessage( - { id: 'actions.user.genericError' }, - { err: JSON.stringify(message) } - ) - ) - } + genericErrorAlert(intl, message) + return UserActionResult.FAILURE } } } @@ -379,12 +385,7 @@ export function deleteUser(userData, auth0, intl) { // Log out user and route them to the home page. auth0.logout({ returnTo: window.location.origin }) } else { - alert( - intl.formatMessage( - { id: 'actions.user.genericError' }, - { err: JSON.stringify(message) } - ) - ) + genericErrorAlert(intl, message) } } } @@ -402,9 +403,10 @@ export function resendVerificationEmail(intl) { const requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}/verification-email` const { status } = await secureFetch(requestUrl, accessToken, apiKey) - // TODO: improve the UI feedback messages for this. if (status === 'success') { - alert(intl.formatMessage({ id: 'actions.user.emailVerificationResent' })) + toast.success( + intl.formatMessage({ id: 'actions.user.emailVerificationResent' }) + ) } } } @@ -447,10 +449,11 @@ export function createOrUpdateUserMonitoredTrip( } ) - // TODO: improve the UI feedback messages for this. if (status === 'success' && data) { - if (!silentOnSuccess) { - alert(intl.formatMessage({ id: 'actions.user.preferencesSaved' })) + if (!silentOnSuccess && intl) { + toast.success( + intl.formatMessage({ id: 'actions.user.preferencesSaved' }) + ) } // Reload user's monitored trips after add/update. @@ -461,12 +464,7 @@ export function createOrUpdateUserMonitoredTrip( // Finally, navigate to the saved trips page. dispatch(routeTo(TRIPS_PATH)) } else { - alert( - intl.formatMessage( - { id: 'actions.user.genericError' }, - { err: JSON.stringify(message) } - ) - ) + genericErrorAlert(intl, message) } } } @@ -527,12 +525,7 @@ export function confirmAndDeleteUserMonitoredTrip(tripId, intl) { // Reload user's monitored trips after deletion. dispatch(fetchMonitoredTrips()) } else { - alert( - intl.formatMessage( - { id: 'actions.user.genericError' }, - { err: JSON.stringify(message) } - ) - ) + genericErrorAlert(intl, message) } } } @@ -575,12 +568,7 @@ export function requestPhoneVerificationSms(newPhoneNumber, intl) { // Refetch user and update application state with new phone number and verification status. dispatch(fetchOrInitializeUser()) } else { - alert( - intl.formatMessage( - { id: 'actions.user.genericError' }, - { err: JSON.stringify(message) } - ) - ) + genericErrorAlert(intl, message) } } else { // Alert user if they have been throttled. @@ -694,7 +682,7 @@ export function saveUserPlace(placeToSave, placeIndex, intl) { loggedInUser.savedLocations[placeIndex] = placeToSave } - dispatch(createOrUpdateUser(loggedInUser, true, intl)) + return dispatch(createOrUpdateUser(loggedInUser, intl)) } } @@ -714,7 +702,7 @@ export function deleteLoggedInUserPlace(placeIndex, intl) { const { loggedInUser } = getState().user loggedInUser.savedLocations.splice(placeIndex, 1) - dispatch(createOrUpdateUser(loggedInUser, true, intl)) + dispatch(createOrUpdateUser(loggedInUser, intl)) } } @@ -750,28 +738,38 @@ export function deleteUserPlace(place, intl) { * Remembers a place for the logged-in or local user * according to the persistence strategy. */ -export function rememberPlace(placeTypeLocation) { +export function rememberPlace(placeTypeLocation, intl) { return function (dispatch, getState) { const { otp, user } = getState() const persistenceMode = getPersistenceMode(otp.config.persistence) const { loggedInUser } = user - if (persistenceMode.isOtpMiddleware && loggedInUser) { - // For middleware loggedInUsers, this method should only be triggered by the - // 'Save as home' or 'Save as work' links from OTP UI's EndPointOverlay/EndPoint. - const { location } = placeTypeLocation - if (isHomeOrWork(location)) { - // Find the index of the place in the loggedInUser.savedLocations - const placeIndex = loggedInUser.savedLocations.findIndex( - (loc) => loc.type === location.type - ) - if (placeIndex > -1) { - // Convert to loggedInUser saved place - dispatch(saveUserPlace(convertToPlace(location), placeIndex)) + if (persistenceMode.isOtpMiddleware) { + if (loggedInUser) { + // For middleware loggedInUsers, this method should only be triggered by the + // 'Save as home' or 'Save as work' links from OTP UI's EndPointOverlay/EndPoint. + const { location } = placeTypeLocation + if (isHomeOrWork(location)) { + // Find the index of the place in the loggedInUser.savedLocations + const placeIndex = loggedInUser.savedLocations.findIndex( + (loc) => loc.type === location.type + ) + if (placeIndex > -1) { + // Convert to loggedInUser saved place + return dispatch( + saveUserPlace(convertToPlace(location), placeIndex, intl) + ) + } } + } else { + alert( + intl.formatMessage({ id: 'actions.user.mustBeLoggedInToSavePlace' }) + ) } + return UserActionResult.FAILURE } else if (persistenceMode.isLocalStorage) { dispatch(rememberLocalUserPlace(placeTypeLocation)) + return UserActionResult.SUCCESS } } } diff --git a/lib/components/app/responsive-webapp.js b/lib/components/app/responsive-webapp.js index 0ae74b8a7..fec45036f 100644 --- a/lib/components/app/responsive-webapp.js +++ b/lib/components/app/responsive-webapp.js @@ -10,6 +10,7 @@ import { MapProvider } from 'react-map-gl' import { QueryParamProvider } from 'use-query-params' import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5' import { Route, Switch, withRouter } from 'react-router' +import { Toaster } from 'react-hot-toast' import coreUtils from '@opentripplanner/core-utils' import isEqual from 'lodash.isequal' import PropTypes from 'prop-types' @@ -336,6 +337,7 @@ class RouterWrapperWithAuth0 extends Component { // Don't render anything until the locale/localized messages have been initialized. const router = localizedMessages && ( + { +type Props = ComponentProps & { + forgetPlace: (place: string, intl: IntlShape) => void + rememberPlace: (arg: UserLocationAndType, intl: IntlShape) => number +} + +const ConnectedEndpointsOverlay = ({ + forgetPlace, + rememberPlace, + ...otherProps +}: Props): JSX.Element => { const intl = useIntl() const _forgetPlace = useCallback( (place) => { - props.forgetPlace(place, intl) + forgetPlace(place, intl) }, - [props, intl] + [forgetPlace, intl] + ) + + const _rememberPlace = useCallback( + async (placeTypeLocation) => { + const result = await rememberPlace(placeTypeLocation, intl) + if (result === UserActionResult.SUCCESS) { + toastOnPlaceSaved(convertToPlace(placeTypeLocation.location), intl) + } + }, + [rememberPlace, intl] + ) + return ( + ) - return } // connect to the redux store +// TODO: Add TypeScript to this section. -const mapStateToProps = (state) => { +const mapStateToProps = (state: any) => { const { viewedRoute } = state.otp.ui // If the route viewer is active, do not show itinerary on map. // mainPanelContent is null whenever the trip planner is active. @@ -36,13 +67,13 @@ const mapStateToProps = (state) => { // Use query from active search (if a search has been made) or default to // current query is no search is available. - const activeSearch = getActiveSearch(state) + const activeSearch: any = getActiveSearch(state) const query = activeSearch ? activeSearch.query : state.otp.currentQuery const showUserSettings = getShowUserSettings(state) const { from, to } = query // Intermediate places doesn't trigger a re-plan, so for now default to // current query. FIXME: Determine with TriMet if this is desired behavior. - const places = state.otp.currentQuery.intermediatePlaces.filter((p) => p) + const places = state.otp.currentQuery.intermediatePlaces.filter((p: any) => p) return { fromLocation: from, diff --git a/lib/components/user/places/favorite-place-screen.js b/lib/components/user/places/favorite-place-screen.js index 900a123ff..e6c83b57b 100644 --- a/lib/components/user/places/favorite-place-screen.js +++ b/lib/components/user/places/favorite-place-screen.js @@ -25,6 +25,7 @@ import { isHomeOrWork, PLACE_TYPES } from '../../../util/user' import withLoggedInUserSupport from '../with-logged-in-user-support' import { InlineLoading } from '../../narrative/loading' import PageTitle from '../../util/page-title' +import { toastOnPlaceSaved } from '../../util/toasts' import PlaceEditor from './place-editor' @@ -70,7 +71,10 @@ class FavoritePlaceScreen extends Component { // Save changes to loggedInUser. const { intl, placeIndex, saveUserPlace } = this.props - await saveUserPlace(placeToSave, placeIndex, intl) + const result = await saveUserPlace(placeToSave, placeIndex, intl) + if (result === userActions.UserActionResult.SUCCESS) { + toastOnPlaceSaved(placeToSave, intl) + } // Return to previous location when done. navigateBack() diff --git a/lib/components/user/user-account-screen.js b/lib/components/user/user-account-screen.js index 78e756d01..ab20fcf01 100644 --- a/lib/components/user/user-account-screen.js +++ b/lib/components/user/user-account-screen.js @@ -7,6 +7,7 @@ import { injectIntl } from 'react-intl' import { withAuthenticationRequired } from '@auth0/auth0-react' import clone from 'clone' import React, { Component } from 'react' +import toast from 'react-hot-toast' import * as uiActions from '../../actions/ui' import * as userActions from '../../actions/user' @@ -53,9 +54,12 @@ class UserAccountScreen extends Component { ) { passedUserData.notificationChannel = notificationChannel.join(',') } - await createOrUpdateUser(passedUserData, silentOnSucceed, intl) + const result = await createOrUpdateUser(userData, intl) - // TODO: Handle UI feedback (currently an alert() dialog inside createOrUpdateUser). + // If needed, display a toast notification on success. + if (result === userActions.UserActionResult.SUCCESS && !silentOnSucceed) { + toast.success(intl.formatMessage({ id: 'actions.user.preferencesSaved' })) + } } /** diff --git a/lib/components/util/toasts.tsx b/lib/components/util/toasts.tsx new file mode 100644 index 000000000..e1a635453 --- /dev/null +++ b/lib/components/util/toasts.tsx @@ -0,0 +1,27 @@ +import { IntlShape } from 'react-intl' +import React from 'react' +import toast from 'react-hot-toast' + +import { getPlaceMainText } from '../../util/user' +import { UserSavedLocation } from '../user/types' + +// Note: the HTML for toasts is rendered outside of the IntlProvider context, +// so intl.formatMessage and others have to be used instead of tags. + +/** + * Helper that will display a toast notification when a place is saved. + */ +export function toastOnPlaceSaved( + place: UserSavedLocation, + intl: IntlShape +): void { + toast.success( + + {getPlaceMainText(place, intl)} +
+ {intl.formatMessage({ + id: 'actions.user.placeRemembered' + })} +
+ ) +} diff --git a/lib/util/user.js b/lib/util/user.js index 0c12afcd0..c3d78a62c 100644 --- a/lib/util/user.js +++ b/lib/util/user.js @@ -138,7 +138,7 @@ export function convertToLegacyLocation(place) { lon, // HACK: If a place name and address are provided, put the address in parentheses // to mimic the existing LocationField behavior for "work" and "home". - // TODO: use addInParentheses from location-field (requires passing an intl contexty). + // TODO: use addInParentheses from location-field (requires passing an intl context). name: isHomeOrWork(place) ? address : address && name diff --git a/package.json b/package.json index 596d7a0e2..785bbf7e0 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "react-dom": "<17.0.0", "react-draggable": "^4.4.3", "react-ga": "^3.3.0", + "react-hot-toast": "^2.4.1", "react-intl": "^5.20.10", "react-loading-skeleton": "^2.1.1", "react-map-gl": "^7.0.15", diff --git a/yarn.lock b/yarn.lock index 071239597..10b8965d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9261,6 +9261,11 @@ globrex@^0.1.2: resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== +goober@^2.1.10: + version "2.1.13" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c" + integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ== + graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.3, graceful-fs@^4.2.4, graceful-fs@^4.2.8: version "4.2.8" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" @@ -15272,6 +15277,13 @@ react-ga@^3.3.0: resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-3.3.0.tgz#c91f407198adcb3b49e2bc5c12b3fe460039b3ca" integrity sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ== +react-hot-toast@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" + integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ== + dependencies: + goober "^2.1.10" + react-indiana-drag-scroll@^2.0.1: version "2.1.0" resolved "https://registry.yarnpkg.com/react-indiana-drag-scroll/-/react-indiana-drag-scroll-2.1.0.tgz#37654eae8caced01cdecc8bce55f0382871a021d"