diff --git a/lib/actions/user.js b/lib/actions/user.js index 8fdf799eb..16157daa1 100644 --- a/lib/actions/user.js +++ b/lib/actions/user.js @@ -10,7 +10,8 @@ import { setQueryParam } from './form' import { routeTo } from './ui' import { TRIPS_PATH } from '../util/constants' import { secureFetch } from '../util/middleware' -import { isNewUser } from '../util/user' +import { isBlank } from '../util/ui' +import { isNewUser, positionHomeAndWorkFirst } from '../util/user' // Middleware API paths. const API_MONITORED_TRIP_PATH = '/api/secure/monitoredtrip' @@ -78,13 +79,18 @@ export function fetchAuth0Token (auth0) { } /** - * Updates the redux state with the provided user data, - * and also fetches monitored trips if requested, i.e. when + * Updates the redux state with the provided user data, including + * placing the Home and Work locations at the beginning of the list + * of saved places for rendering in several UI components. + * + * Also, fetches monitored trips if requested, i.e. when * - initializing the user state with an existing persisted user, or * - POST-ing a user for the first time. */ function setUser (user, fetchTrips) { return function (dispatch, getState) { + positionHomeAndWorkFirst(user) + dispatch(setCurrentUser(user)) if (fetchTrips) { @@ -129,7 +135,8 @@ export function fetchOrInitializeUser (auth0User) { const isNewAccount = status === 'error' || (user && user.result === 'ERR') const userData = isNewAccount ? createNewUser(auth0User) : user - // Set uset in redux state. (Fetch trips for existing users.) + // Set user in redux state. + // (This sorts saved places, and, for existing users, fetches trips.) dispatch(setUser(userData, !isNewAccount)) } } @@ -146,6 +153,11 @@ export function createOrUpdateUser (userData, silentOnSuccess = false) { const { id } = userData // Middleware ID, NOT auth0 (or similar) id. let requestUrl, method + // Before persisting, filter out entries from userData.savedLocations with blank addresses. + userData.savedLocations = userData.savedLocations.filter( + ({ address }) => !isBlank(address) + ) + // Determine URL and method to use. const isCreatingUser = isNewUser(loggedInUser) if (isCreatingUser) { @@ -156,19 +168,20 @@ export function createOrUpdateUser (userData, silentOnSuccess = false) { method = 'PUT' } - const { data, message, status } = await secureFetch(requestUrl, accessToken, apiKey, method, { + const { data: returnedUser, message, status } = await secureFetch(requestUrl, accessToken, apiKey, method, { body: JSON.stringify(userData) }) // TODO: improve the UI feedback messages for this. - if (status === 'success' && data) { + if (status === 'success' && returnedUser) { if (!silentOnSuccess) { alert('Your preferences have been saved.') } // Update application state with the user entry as saved - // (as returned) by the middleware. (Fetch trips if creating user.) - dispatch(setUser(data, isCreatingUser)) + // (as returned) by the middleware. + // (This sorts saved places, and, for existing users, fetches trips.) + dispatch(setUser(returnedUser, isCreatingUser)) } else { alert(`An error was encountered:\n${JSON.stringify(message)}`) } @@ -291,6 +304,7 @@ export function toggleSnoozeTrip (trip) { export function confirmAndDeleteUserMonitoredTrip (tripId) { return async function (dispatch, getState) { if (!confirm('Would you like to remove this trip?')) return + const { accessToken, apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) const requestUrl = `${apiBaseUrl}${API_MONITORED_TRIP_PATH}/${tripId}` @@ -417,3 +431,35 @@ export function planNewTripFromMonitoredTrip (monitoredTrip) { }, 300) } } + +/** + * Saves the given place data at the specified index for the logged-in user. + * Note: places with blank addresses will not appear in persistence. + */ +export function saveUserPlace (placeToSave, placeIndex) { + return function (dispatch, getState) { + const { loggedInUser } = getState().user + + if (placeIndex === 'new') { + loggedInUser.savedLocations.push(placeToSave) + } else { + loggedInUser.savedLocations[placeIndex] = placeToSave + } + + dispatch(createOrUpdateUser(loggedInUser, true)) + } +} + +/** + * Delete the place data at the specified index for the logged-in user. + */ +export function deleteUserPlace (placeIndex) { + return function (dispatch, getState) { + if (!confirm('Would you like to remove this place?')) return + + const { loggedInUser } = getState().user + loggedInUser.savedLocations.splice(placeIndex, 1) + + dispatch(createOrUpdateUser(loggedInUser, true)) + } +} diff --git a/lib/components/app/responsive-webapp.js b/lib/components/app/responsive-webapp.js index 7b2b9b27d..045b03d75 100644 --- a/lib/components/app/responsive-webapp.js +++ b/lib/components/app/responsive-webapp.js @@ -6,7 +6,7 @@ import coreUtils from '@opentripplanner/core-utils' import PropTypes from 'prop-types' import React, { Component } from 'react' import { connect } from 'react-redux' -import { Redirect, Route, Switch, withRouter } from 'react-router' +import { Route, Switch, withRouter } from 'react-router' import PrintLayout from './print-layout' import * as authActions from '../../actions/auth' @@ -16,6 +16,7 @@ import * as formActions from '../../actions/form' import * as locationActions from '../../actions/location' import * as mapActions from '../../actions/map' import * as uiActions from '../../actions/ui' +import { RedirectWithQuery } from '../form/connected-links' import { getAuth0Config } from '../../util/auth' import { ACCOUNT_PATH, @@ -23,6 +24,9 @@ import { AUTH0_SCOPE, ACCOUNT_SETTINGS_PATH, CREATE_ACCOUNT_PATH, + CREATE_ACCOUNT_PLACES_PATH, + CREATE_ACCOUNT_VERIFY_PATH, + PLACES_PATH, TRIPS_PATH, URL_ROOT } from '../../util/constants' @@ -30,6 +34,7 @@ import { ComponentContext } from '../../util/contexts' import { getActiveItinerary, getTitle } from '../../util/state' import AfterSignInScreen from '../user/after-signin-screen' import BeforeSignInScreen from '../user/before-signin-screen' +import FavoritePlaceScreen from '../user/places/favorite-place-screen' import SavedTripList from '../user/monitored-trip/saved-trip-list' import SavedTripScreen from '../user/monitored-trip/saved-trip-screen' import UserAccountScreen from '../user/user-account-screen' @@ -158,11 +163,11 @@ const mapStateToProps = (state, ownProps) => { activeItinerary: getActiveItinerary(state.otp), activeSearchId: state.otp.activeSearchId, currentPosition: state.otp.location.currentPosition, - query: state.otp.currentQuery, - searches: state.otp.searches, - mobileScreen: state.otp.ui.mobileScreen, initZoomOnLocate: state.otp.config.map && state.otp.config.map.initZoomOnLocate, + mobileScreen: state.otp.ui.mobileScreen, modeGroups: state.otp.config.modeGroups, + query: state.otp.currentQuery, + searches: state.otp.searches, title } } @@ -231,17 +236,24 @@ class RouterWrapperWithAuth0 extends Component { ]} render={() => } /> + - + + + + { + const { config, currentQuery, location, transitIndex, user } = state.otp + const { currentPosition, nearbyStops, sessionSearches } = location + const activeSearch = getActiveSearch(state.otp) + const query = activeSearch ? activeSearch.query : currentQuery + + const stateToProps = { + currentPosition, + geocoderConfig: config.geocoder, + nearbyStops, + sessionSearches, + showUserSettings: getShowUserSettings(state.otp), + stopsIndex: transitIndex.stops, + userLocationsAndRecentPlaces: [...user.locations, ...user.recentPlaces] + } + // Set the location prop only if includeLocation is specified, else leave unset. + // Otherwise, the StyledLocationField component will use the fixed undefined/null value as location + // and will not respond to user input. + if (includeLocation) { + stateToProps.location = query[ownProps.locationType] + } + + return stateToProps + } + + const mapDispatchToProps = { + addLocationSearch: locationActions.addLocationSearch, + findNearbyStops: apiActions.findNearbyStops, + getCurrentPosition: locationActions.getCurrentPosition, + ...actions + } + + return connect(mapStateToProps, mapDispatchToProps)(StyledLocationField) +} diff --git a/lib/components/form/connected-links.js b/lib/components/form/connected-links.js new file mode 100644 index 000000000..d7fd9da77 --- /dev/null +++ b/lib/components/form/connected-links.js @@ -0,0 +1,39 @@ +import React from 'react' +import { connect } from 'react-redux' +import { LinkContainer } from 'react-router-bootstrap' +import { Redirect } from 'react-router' +import { Link } from 'react-router-dom' + +/** + * This function enhances the routing components imported above + * by preserving the itinerary search query from the redux state + * when redirecting the user between the main map and account-related pages, + * so that when the user returns to the map, the itinerary that was previously + * displayed is shown again. + * Implementers only need to specify the 'to' route and + * do not need to hook to redux store to retrieve the itinerary search query. + * @param RoutingComponent The routing component to enhance. + * @returns A new component that passes the redux search params to + * the RoutingComponent's 'to' prop. + */ +const withQueryParams = RoutingComponent => + ({ children, queryParams, to, ...props }) => ( + + {children} + + ) + +// For connecting to the redux store +const mapStateToProps = (state, ownProps) => { + return { + queryParams: state.router.location.search + } +} + +// Enhance routing components, connect the result to redux, +// and export. +export default { + LinkWithQuery: connect(mapStateToProps)(withQueryParams(Link)), + LinkContainerWithQuery: connect(mapStateToProps)(withQueryParams(LinkContainer)), + RedirectWithQuery: connect(mapStateToProps)(withQueryParams(Redirect)) +} diff --git a/lib/components/form/connected-location-field.js b/lib/components/form/connected-location-field.js index 1f497f84f..6be2c99d6 100644 --- a/lib/components/form/connected-location-field.js +++ b/lib/components/form/connected-location-field.js @@ -7,13 +7,10 @@ import { InputGroupAddon, MenuItemA } from '@opentripplanner/location-field/lib/styled' -import { connect } from 'react-redux' import styled from 'styled-components' -import { clearLocation, onLocationSelected } from '../../actions/map' -import { addLocationSearch, getCurrentPosition } from '../../actions/location' -import { findNearbyStops } from '../../actions/api' -import { getActiveSearch, getShowUserSettings } from '../../util/state' +import * as mapActions from '../../actions/map' +import connectLocationField from './connect-location-field' const StyledLocationField = styled(LocationField)` width: 100%; @@ -55,31 +52,10 @@ const StyledLocationField = styled(LocationField)` } ` -// connect to redux store - -const mapStateToProps = (state, ownProps) => { - const { config, currentQuery, location, transitIndex, user } = state.otp - const { currentPosition, nearbyStops, sessionSearches } = location - const activeSearch = getActiveSearch(state.otp) - const query = activeSearch ? activeSearch.query : currentQuery - return { - currentPosition, - geocoderConfig: config.geocoder, - location: query[ownProps.locationType], - nearbyStops, - sessionSearches, - showUserSettings: getShowUserSettings(state.otp), - stopsIndex: transitIndex.stops, - userLocationsAndRecentPlaces: [...user.locations, ...user.recentPlaces] - } -} - -const mapDispatchToProps = { - addLocationSearch, - findNearbyStops, - getCurrentPosition, - onLocationSelected, - clearLocation -} - -export default connect(mapStateToProps, mapDispatchToProps)(StyledLocationField) +export default connectLocationField(StyledLocationField, { + actions: { + clearLocation: mapActions.clearLocation, + onLocationSelected: mapActions.onLocationSelected + }, + includeLocation: true +}) diff --git a/lib/components/form/intermediate-place-field.js b/lib/components/form/intermediate-place-field.js index 9d3f9aaae..59c1650ba 100644 --- a/lib/components/form/intermediate-place-field.js +++ b/lib/components/form/intermediate-place-field.js @@ -8,13 +8,10 @@ import { MenuItemA } from '@opentripplanner/location-field/lib/styled' import React, {Component} from 'react' -import { connect } from 'react-redux' import styled from 'styled-components' -import { clearLocation } from '../../actions/map' -import { addLocationSearch, getCurrentPosition } from '../../actions/location' -import { findNearbyStops } from '../../actions/api' -import { getShowUserSettings } from '../../util/state' +import * as mapActions from '../../actions/map' +import connectLocationField from './connect-location-field' const StyledIntermediatePlace = styled(LocationField)` width: 100%; @@ -78,27 +75,8 @@ class IntermediatePlaceField extends Component { } } -// connect to redux store - -const mapStateToProps = (state, ownProps) => { - const { config, location, transitIndex, user } = state.otp - const { currentPosition, nearbyStops, sessionSearches } = location - return { - currentPosition, - geocoderConfig: config.geocoder, - nearbyStops, - sessionSearches, - showUserSettings: getShowUserSettings(state.otp), - stopsIndex: transitIndex.stops, - userLocationsAndRecentPlaces: [...user.locations, ...user.recentPlaces] +export default connectLocationField(IntermediatePlaceField, { + actions: { + clearLocation: mapActions.clearLocation } -} - -const mapDispatchToProps = { - addLocationSearch, - findNearbyStops, - getCurrentPosition, - clearLocation -} - -export default connect(mapStateToProps, mapDispatchToProps)(IntermediatePlaceField) +}) diff --git a/lib/components/mobile/navigation-bar.js b/lib/components/mobile/navigation-bar.js index cac315e1c..74788126b 100644 --- a/lib/components/mobile/navigation-bar.js +++ b/lib/components/mobile/navigation-bar.js @@ -1,12 +1,12 @@ import PropTypes from 'prop-types' import React, { Component } from 'react' import { Navbar } from 'react-bootstrap' -import FontAwesome from 'react-fontawesome' import { connect } from 'react-redux' import { setMobileScreen } from '../../actions/ui' import AppMenu from '../app/app-menu' import NavLoginButtonAuth0 from '../../components/user/nav-login-button-auth0' +import Icon from '../narrative/icon' import { accountLinks, getAuth0Config } from '../../util/auth' class MobileNavigationBar extends Component { @@ -40,7 +40,15 @@ class MobileNavigationBar extends Component { {showBackButton - ?
+ ? ( +
+ +
+ ) : }
diff --git a/lib/components/narrative/save-trip-button.js b/lib/components/narrative/save-trip-button.js index 5a46e6af7..af80a07fa 100644 --- a/lib/components/narrative/save-trip-button.js +++ b/lib/components/narrative/save-trip-button.js @@ -1,8 +1,8 @@ import React from 'react' import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { connect } from 'react-redux' -import { LinkContainer } from 'react-router-bootstrap' +import { LinkContainerWithQuery } from '../form/connected-links' import { CREATE_TRIP_PATH } from '../../util/constants' import { itineraryCanBeMonitored } from '../../util/itinerary' import { getActiveItinerary } from '../../util/state' @@ -14,8 +14,7 @@ import { getActiveItinerary } from '../../util/state' const SaveTripButton = ({ itinerary, loggedInUser, - persistence, - queryParams + persistence }) => { // We are dealing with the following states: // 1. Persistence disabled => just return null @@ -69,9 +68,9 @@ const SaveTripButton = ({ } return ( - + {button} - + ) } @@ -82,8 +81,7 @@ const mapStateToProps = (state, ownProps) => { return { itinerary: getActiveItinerary(state.otp), loggedInUser: state.user.loggedInUser, - persistence, - queryParams: state.router.location.search + persistence } } diff --git a/lib/components/user/account-page.js b/lib/components/user/account-page.js index 3807dc563..fac69d7f0 100644 --- a/lib/components/user/account-page.js +++ b/lib/components/user/account-page.js @@ -1,10 +1,15 @@ import { withAuthenticationRequired } from '@auth0/auth0-react' +import { replace } from 'connected-react-router' import React, { Component } from 'react' import { Col, Row } from 'react-bootstrap' import { connect } from 'react-redux' import * as uiActions from '../../actions/ui' -import { CREATE_ACCOUNT_PATH } from '../../util/constants' +import { + CREATE_ACCOUNT_PATH, + CREATE_ACCOUNT_TERMS_PATH, + CREATE_ACCOUNT_VERIFY_PATH +} from '../../util/constants' import { RETURN_TO_CURRENT_ROUTE } from '../../util/ui' import withLoggedInUserSupport from './with-logged-in-user-support' import DesktopNav from '../app/desktop-nav' @@ -15,18 +20,25 @@ import SubNav from './sub-nav' * wrap any user account page (e.g., SavedTripList or account settings). */ class AccountPage extends Component { - componentDidMount () { - const { loggedInUser, routeTo } = this.props + /** + * If a user signed up in Auth0 and did not complete the New Account wizard + * (and they are not on or have not just left the Terms and Conditions page), + * make the user finish set up their accounts first. + * monitoredTrips should not be null otherwise. + * NOTE: This check applies to any route that makes use of this component. + */ + _checkAccountCreated = () => { + const { isTermsOrVerifyPage, loggedInUser, routeTo } = this.props - if (!loggedInUser.hasConsentedToTerms) { - // If a user signed up in Auth0 and did not complete the New Account wizard - // make the user finish set up their accounts first. - // monitoredTrips should not be null otherwise. - // NOTE: This check applies to any route that makes use of this component. - routeTo(CREATE_ACCOUNT_PATH) + if (!loggedInUser.hasConsentedToTerms && !isTermsOrVerifyPage) { + routeTo(CREATE_ACCOUNT_PATH, null, replace) } } + componentDidMount () { + this._checkAccountCreated() + } + render () { const {children, subnav = true} = this.props return ( @@ -49,7 +61,10 @@ class AccountPage extends Component { // connect to the redux store const mapStateToProps = (state, ownProps) => { + const currentPath = state.router.location.pathname return { + isTermsOrVerifyPage: + currentPath === CREATE_ACCOUNT_TERMS_PATH || currentPath === CREATE_ACCOUNT_VERIFY_PATH, loggedInUser: state.user.loggedInUser, trips: state.user.loggedInUserMonitoredTrips } diff --git a/lib/components/user/back-link.js b/lib/components/user/back-link.js new file mode 100644 index 000000000..7f2cc82e5 --- /dev/null +++ b/lib/components/user/back-link.js @@ -0,0 +1,27 @@ +import React from 'react' +import { Button } from 'react-bootstrap' +import styled from 'styled-components' + +import { IconWithMargin } from './styled' + +const StyledButton = styled(Button)` + display: block; + padding: 0; +` + +const navigateBack = () => window.history.back() + +/** + * Back link that navigates to the previous location in browser history. + */ +const BackLink = () => ( + + + Back + +) + +export default BackLink diff --git a/lib/components/user/back-to-trip-planner.js b/lib/components/user/back-to-trip-planner.js index ce3cb9894..fd0787da8 100644 --- a/lib/components/user/back-to-trip-planner.js +++ b/lib/components/user/back-to-trip-planner.js @@ -1,13 +1,18 @@ import React from 'react' import styled from 'styled-components' -import { Link } from 'react-router-dom' -const Container = styled.div`` +import { LinkWithQuery } from '../form/connected-links' +import { IconWithMargin } from './styled' + +const StyledLinkWithQuery = styled(LinkWithQuery)` + display: block; +` const BackToTripPlanner = () => ( - - ← Back to trip planner - + + + Back to trip planner + ) export default BackToTripPlanner diff --git a/lib/components/user/existing-account-display.js b/lib/components/user/existing-account-display.js index 3c698befc..8ee8b2107 100644 --- a/lib/components/user/existing-account-display.js +++ b/lib/components/user/existing-account-display.js @@ -1,6 +1,9 @@ import React from 'react' +import NotificationPrefsPane from './notification-prefs-pane' +import FavoritePlacesList from './places/favorite-places-list' import StackedPaneDisplay from './stacked-pane-display' +import TermsOfUsePane from './terms-of-use-pane' /** * This component handles the existing account display. @@ -10,20 +13,20 @@ const ExistingAccountDisplay = props => { // and to its own blur/change/submit event handlers that automate the state. // We forward the props to each pane so that their individual controls // can be wired to be managed by Formik. - const { onCancel, panes } = props + const { onCancel } = props const paneSequence = [ { - pane: panes.locations, + pane: FavoritePlacesList, props, title: 'My locations' }, { - pane: panes.notifications, + pane: NotificationPrefsPane, props, title: 'Notifications' }, { - pane: panes.terms, + pane: TermsOfUsePane, props: { ...props, disableCheckTerms: true }, title: 'Terms' } diff --git a/lib/components/user/favorite-locations-pane.js b/lib/components/user/favorite-locations-pane.js deleted file mode 100644 index 0bced0b30..000000000 --- a/lib/components/user/favorite-locations-pane.js +++ /dev/null @@ -1,151 +0,0 @@ -import { Field, FieldArray } from 'formik' -import memoize from 'lodash.memoize' -import React, { Component } from 'react' -import { - Button, - ControlLabel, - FormControl, - FormGroup, - InputGroup -} from 'react-bootstrap' -import FontAwesome from 'react-fontawesome' -import styled from 'styled-components' - -// Styles. -const fancyAddLocationCss = ` - background-color: #337ab7; - color: #fff; -` -const StyledAddon = styled(InputGroup.Addon)` - min-width: 40px; -` -const NewLocationAddon = styled(StyledAddon)` - ${fancyAddLocationCss} -` -const NewLocationFormControl = styled(FormControl)` - ${fancyAddLocationCss} - ::placeholder { - color: #fff; - } - &:focus { - background-color: unset; - color: unset; - ::placeholder { - color: unset; - } - } -` - -// Helper filter functions. -export const isHome = loc => loc.type === 'home' -export const isWork = loc => loc.type === 'work' - -/** - * Helper function that adds a new address to the Formik state - * using the Formik-provided arrayHelpers object. - */ -function addNewAddress (arrayHelpers, e) { - const value = (e.target.value || '').trim() - if (value.length > 0) { - arrayHelpers.push({ - address: value, - icon: 'map-marker', - type: 'custom' - }) - - // Empty the input box value so the user can enter their next location. - e.target.value = '' - } -} - -/** - * User's saved locations editor. - * TODO: Discuss and improve handling of location details (type, coordinates...). - */ -class FavoriteLocationsPane extends Component { - _handleNewAddressKeyDown = memoize( - arrayHelpers => e => { - if (e.keyCode === 13) { - // On the user pressing enter (keyCode 13) on the new location input, - // add new address to user's savedLocations... - addNewAddress(arrayHelpers, e) - - // ... but don't submit the form. - e.preventDefault() - } - } - ) - - _handleNewAddressBlur = memoize( - arrayHelpers => e => { - addNewAddress(arrayHelpers, e) - } - ) - - render () { - const { values: userData } = this.props - const { savedLocations } = userData - const homeLocation = savedLocations.find(isHome) - const workLocation = savedLocations.find(isWork) - - return ( -
- Add the places you frequent often to save time planning trips: - - ( - <> - {savedLocations.map((loc, index) => { - const isHomeOrWork = loc === homeLocation || loc === workLocation - return ( - - - - - - - {!isHomeOrWork && ( - - - - )} - - - ) - })} - - {/* For adding a new location. */} - - - - - - - - - - )} - /> -
- ) - } -} - -export default FavoriteLocationsPane diff --git a/lib/components/user/monitored-trip/trip-basics-pane.js b/lib/components/user/monitored-trip/trip-basics-pane.js index df3403602..9c44f737b 100644 --- a/lib/components/user/monitored-trip/trip-basics-pane.js +++ b/lib/components/user/monitored-trip/trip-basics-pane.js @@ -12,6 +12,7 @@ import { connect } from 'react-redux' import styled from 'styled-components' import * as userActions from '../../../actions/user' +import { getErrorStates } from '../../../util/ui' import TripStatus from './trip-status' import TripSummary from './trip-summary' @@ -78,26 +79,21 @@ class TripBasicsPane extends Component { } render () { - const { errors, isCreating, itineraryExistence, touched, values: monitoredTrip } = this.props + const { errors, isCreating, itineraryExistence, values: monitoredTrip } = this.props const { itinerary } = monitoredTrip if (!itinerary) { return
No itinerary to display.
} else { - // Show an error indicaton when monitoredTrip.tripName is not blank (from the form's validation schema) - // and that tripName is not already used. - let tripNameValidationState = null - if (touched.tripName) { - tripNameValidationState = errors.tripName ? 'error' : null - } + // Show an error indication when + // - monitoredTrip.tripName is not blank and that tripName is not already used. + // - no day is selected (show a combined error indication). + const errorStates = getErrorStates(this.props) - // Show a combined error indicaton when no day is selected. let monitoredDaysValidationState = null allDays.forEach(({ name }) => { - if (touched[name]) { - if (!monitoredDaysValidationState) { - monitoredDaysValidationState = errors[name] ? 'error' : null - } + if (!monitoredDaysValidationState) { + monitoredDaysValidationState = errorStates[name] } }) @@ -107,7 +103,7 @@ class TripBasicsPane extends Component { Selected itinerary: - + Please provide a name for this trip: {/* onBlur, onChange, and value are passed automatically. */} diff --git a/lib/components/user/nav-login-button.js b/lib/components/user/nav-login-button.js index 7bbadee78..d39a97962 100644 --- a/lib/components/user/nav-login-button.js +++ b/lib/components/user/nav-login-button.js @@ -1,9 +1,10 @@ import PropTypes from 'prop-types' import React, { Component } from 'react' import { MenuItem, NavDropdown, NavItem } from 'react-bootstrap' -import { LinkContainer } from 'react-router-bootstrap' import styled from 'styled-components' +import { LinkContainerWithQuery } from '../form/connected-links' + const Avatar = styled.img` height: 2em; margin: -15px 0; @@ -76,9 +77,9 @@ export default class NavLoginButton extends Component { {displayedName} {links && links.map((link, i) => ( - + {link.text} - + ))} diff --git a/lib/components/user/new-account-wizard.js b/lib/components/user/new-account-wizard.js index 3eb0e906b..2ff4553c8 100644 --- a/lib/components/user/new-account-wizard.js +++ b/lib/components/user/new-account-wizard.js @@ -1,6 +1,11 @@ import React, { Component } from 'react' +import AccountSetupFinishPane from './account-setup-finish-pane' +import NotificationPrefsPane from './notification-prefs-pane' +import FavoritePlacesList from './places/favorite-places-list' import SequentialPaneDisplay from './sequential-pane-display' +import TermsOfUsePane from './terms-of-use-pane' +import VerifyEmailPane from './verify-email-pane' /** * This component is the new account wizard. @@ -24,38 +29,46 @@ class NewAccountWizard extends Component { // We forward the props to each pane so that their individual controls // can be wired to be managed by Formik. const props = this.props - const { panes, values: userData } = props + const { activePaneId, values: userData } = props const { hasConsentedToTerms, notificationChannel = 'email' } = userData const paneSequence = { + // Verify will redirect according to email verification state + // and thus doesn't carry a nextId. + verify: { + hideNavigation: true, + pane: VerifyEmailPane, + props, + title: 'Verify your email address' + }, terms: { disableNext: !hasConsentedToTerms, nextId: 'notifications', onNext: this._handleCreateNewUser, - pane: panes.terms, + pane: TermsOfUsePane, props, title: 'Create a new account' }, notifications: { disableNext: notificationChannel === 'sms' && !userData.phoneNumber, nextId: 'places', - pane: panes.notifications, + pane: NotificationPrefsPane, prevId: 'terms', props, title: 'Notification preferences' }, places: { nextId: 'finish', - pane: panes.locations, + pane: FavoritePlacesList, prevId: 'notifications', props, title: 'Add your locations' }, finish: { - pane: panes.finish, + pane: AccountSetupFinishPane, prevId: 'places', props, title: 'Account setup complete!' @@ -64,7 +77,7 @@ class NewAccountWizard extends Component { return ( ) diff --git a/lib/components/user/notification-prefs-pane.js b/lib/components/user/notification-prefs-pane.js index 748528e49..b59b45e27 100644 --- a/lib/components/user/notification-prefs-pane.js +++ b/lib/components/user/notification-prefs-pane.js @@ -16,7 +16,7 @@ import Input from 'react-phone-number-input/input' import styled, { css } from 'styled-components' import * as yup from 'yup' -import { isBlank } from '../../util/ui' +import { getErrorStates, isBlank } from '../../util/ui' const allowedNotificationChannels = [ { @@ -64,20 +64,6 @@ const FlushLink = styled(Button)` padding-right: 0; ` -/** - * @param {*} props The props from which to extract the Formik state to test. - * @param {*} field THe field name to test. - * @returns One of the Bootstrao validationState values. - */ -function getErrorState (props, field) { - const { errors, touched } = props - - // one of the Bootstrap validationState values. - return errors[field] && touched[field] - ? 'error' - : null -} - const INVALID_PHONE_MSG = 'Please enter a valid phone number.' const VALIDATION_CODE_MSG = 'Please enter 6 digits for the validation code.' const codeValidationSchema = yup.object({ @@ -310,7 +296,7 @@ class PhoneNumberEditor extends Component { const isPhoneInvalid = !isPossiblePhoneNumber(newPhoneNumber) const showPhoneError = isPhoneInvalid && !isBlank(newPhoneNumber) const phoneErrorState = showPhoneError ? 'error' : null - const codeErrorState = getErrorState(this.props, 'validationCode') + const codeErrorState = getErrorStates(this.props).validationCode return ( <> diff --git a/lib/components/user/places/favorite-place-row.js b/lib/components/user/places/favorite-place-row.js new file mode 100644 index 000000000..37fa60677 --- /dev/null +++ b/lib/components/user/places/favorite-place-row.js @@ -0,0 +1,130 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Button } from 'react-bootstrap' +import styled, { css } from 'styled-components' + +import { LinkContainerWithQuery } from '../../form/connected-links' +import Icon from '../../narrative/icon' + +const FIELD_HEIGHT_PX = '60px' + +const Container = styled.div` + align-items: stretch; + display: flex; + height: ${FIELD_HEIGHT_PX}; + margin-bottom: 10px; +` +const PlaceButton = styled(Button)` + align-items: center; + display: flex; + flex: 1 0 0; + overflow: hidden; + text-align: left; + width: inherit; +` + +const GreyIcon = styled(Icon)` + color: #888; + margin-right: 10px; +` + +const PlaceContent = styled.span` + display: inline-block; + + & * { + color: #888; + display: block; + } + + & *:first-child { + color: inherit; + } +` + +const deleteCss = css` + margin-left: 4px; + width: ${FIELD_HEIGHT_PX}; +` + +const DeleteButton = styled(Button)` + ${deleteCss} +` + +const DeletePlaceholder = styled.span` + ${deleteCss} +` + +const MESSAGES = { + EDIT: 'Edit this place', + DELETE: 'Delete this place' +} + +/** + * Renders a clickable button for editing a user's favorite place, + * and lets the user delete the place. + */ +const FavoritePlaceRow = ({ isFixed, onDelete, path, place }) => { + if (place) { + const { address, icon, name, type } = place + return ( + + + + + + {name && {name}} + {address || `Set your ${type} address`} + + + + + {/* For fixed places, show Delete only if an address has been provided. */} + {(!isFixed || address) + ? ( + + + + ) + : } + + ) + } else { + // If no place is passed, render the Add place button instead. + return ( + + + + + Add another place + + + + + ) + } +} + +FavoritePlaceRow.propTypes = { + /** Whether the place is fixed (e.g. 'Home', 'Work' are fixed.) */ + isFixed: PropTypes.bool, + /** Called when the delete button is clicked. */ + onDelete: PropTypes.func, + /** The path to navigate to on click. */ + path: PropTypes.string.isRequired, + /** The place to render. */ + place: PropTypes.shape({ + address: PropTypes.string, + icon: PropTypes.string.isRequired, + name: PropTypes.string, + type: PropTypes.string.isRequired + }) +} + +export default FavoritePlaceRow diff --git a/lib/components/user/places/favorite-place-screen.js b/lib/components/user/places/favorite-place-screen.js new file mode 100644 index 000000000..c8ba6f4df --- /dev/null +++ b/lib/components/user/places/favorite-place-screen.js @@ -0,0 +1,187 @@ +import { withAuthenticationRequired } from '@auth0/auth0-react' +import clone from 'clone' +import { Form, Formik } from 'formik' +import coreUtils from '@opentripplanner/core-utils' +import React, { Component } from 'react' +import { Button } from 'react-bootstrap' +import { connect } from 'react-redux' +import styled from 'styled-components' +import * as yup from 'yup' + +import AccountPage from '../account-page' +import * as userActions from '../../../actions/user' +import BackLink from '../back-link' +import FormNavigationButtons from '../form-navigation-buttons' +import { PageHeading } from '../styled' +import { CREATE_ACCOUNT_PLACES_PATH } from '../../../util/constants' +import { navigateBack, RETURN_TO_CURRENT_ROUTE } from '../../../util/ui' +import { isHomeOrWork, PLACE_TYPES } from '../../../util/user' +import withLoggedInUserSupport from '../with-logged-in-user-support' +import PlaceEditor from './place-editor' + +const { isMobile } = coreUtils.ui + +// Styled components +const SaveButton = styled(Button)` + float: right; +` +const BLANK_PLACE = { + ...PLACE_TYPES.custom, + address: '', + name: '' +} + +// Make space between place details and form buttons. +const Container = styled.div` + margin-bottom: 100px; +` + +// The form fields to validate. +const validationSchemaShape = { + address: yup.string().required('Please set a location for this place'), + name: yup.string().required('Please enter a name for this place') +} + +/** + * Lets the user edit the details (address, type, nickname) + * of a new or existing favorite place. + */ +class FavoritePlaceScreen extends Component { + /** + * Silently save the changes to the loggedInUser, and go to the previous URL. + */ + _handleSave = async placeToSave => { + // Update the icon for the place type. + placeToSave.icon = PLACE_TYPES[placeToSave.type].icon + + // Save changes to loggedInUser. + const { placeIndex, saveUserPlace } = this.props + await saveUserPlace(placeToSave, placeIndex) + + // Return to previous location when done. + navigateBack() + } + + /** + * Based on the URL, returns an existing place or a new place for editing, + * or null if the requested place is not found. + */ + _getPlaceToEdit = user => { + const { isNewPlace, placeIndex } = this.props + return isNewPlace + ? BLANK_PLACE + : (user.savedLocations + ? user.savedLocations[placeIndex] + : null + ) + } + + /** + * Obtains a schema that validates the given place name against + * other place names used by the user, including 'Work' and 'Home'. + */ + _getFullValidationSchema = (places, place) => { + const otherPlaceNames = (place + ? places.filter(pl => pl.name !== place.name) + : places + ).map(pl => pl.name) + + const clonedSchemaShape = clone(validationSchemaShape) + clonedSchemaShape.name = yup.string() + .required('Please enter a name for this place') + .notOneOf(otherPlaceNames, 'You are already using this name for another place. Please enter a different name.') + + return yup.object(clonedSchemaShape) + } + + render () { + const { isCreating, isNewPlace, loggedInUser } = this.props + // Get the places as shown (and not as retrieved from db), so that the index passed from URL applies + // (indexes 0 and 1 are for Home and Work locations). + const place = this._getPlaceToEdit(loggedInUser) + const isFixed = place && isHomeOrWork(place) + const isMobileView = isMobile() + + let heading + if (!place) { + heading = 'Place not found' + } else if (isNewPlace) { + heading = 'Add a new place' + } else if (isFixed) { + heading = `Edit ${place.name}` + } else { + heading = 'Edit place' + } + + return ( + + {isMobileView && } + + { + // We pass the Formik props below to the components rendered so that individual controls + // can be wired to be managed by Formik. + props => { + return ( +
+
+ {isMobileView && place && Save} + {heading} +
+ + {place + ? + :

Sorry, the requested place was not found.

+ } +
+ + {!isMobileView && } + + ) + } + } +
+
+ ) + } +} + +// connect to the redux store + +const mapStateToProps = (state, ownProps) => { + const { params, path } = ownProps.match + const placeIndex = params.id + return { + isCreating: path.startsWith(CREATE_ACCOUNT_PLACES_PATH), + isNewPlace: placeIndex === 'new', + loggedInUser: state.user.loggedInUser, + placeIndex + } +} + +const mapDispatchToProps = { + saveUserPlace: userActions.saveUserPlace +} + +export default withLoggedInUserSupport( + withAuthenticationRequired( + connect(mapStateToProps, mapDispatchToProps)(FavoritePlaceScreen), + RETURN_TO_CURRENT_ROUTE + ), + true +) diff --git a/lib/components/user/places/favorite-places-list.js b/lib/components/user/places/favorite-places-list.js new file mode 100644 index 000000000..c82348bee --- /dev/null +++ b/lib/components/user/places/favorite-places-list.js @@ -0,0 +1,52 @@ +import React from 'react' +import { ControlLabel } from 'react-bootstrap' +import { connect } from 'react-redux' + +import * as userActions from '../../../actions/user' +import { CREATE_ACCOUNT_PLACES_PATH, PLACES_PATH } from '../../../util/constants' +import { isHomeOrWork } from '../../../util/user' +import FavoritePlaceRow from './favorite-place-row' + +/** + * Renders an editable list user's favorite locations, and lets the user add a new one. + * Additions, edits, and deletions of places take effect immediately. + */ +const FavoritePlacesList = ({ deleteUserPlace, isCreating, loggedInUser }) => { + const { savedLocations } = loggedInUser + return ( +
+ Add the places you frequent often to save time planning trips: + + {savedLocations.map((place, index) => ( + deleteUserPlace(index)} + path={`${isCreating ? CREATE_ACCOUNT_PLACES_PATH : PLACES_PATH}/${index}`} + place={place} + /> + ) + )} + + {/* For adding a new place. */} + +
+ ) +} + +// connect to the redux store + +const mapStateToProps = (state, ownProps) => { + const path = state.router.location.pathname + const isCreating = path === CREATE_ACCOUNT_PLACES_PATH + return { + isCreating, + loggedInUser: state.user.loggedInUser + } +} + +const mapDispatchToProps = { + deleteUserPlace: userActions.deleteUserPlace +} + +export default connect(mapStateToProps, mapDispatchToProps)(FavoritePlacesList) diff --git a/lib/components/user/places/place-editor.js b/lib/components/user/places/place-editor.js new file mode 100644 index 000000000..9329011f9 --- /dev/null +++ b/lib/components/user/places/place-editor.js @@ -0,0 +1,131 @@ +import { Field } from 'formik' +import coreUtils from '@opentripplanner/core-utils' +import React, { Component } from 'react' +import { + FormControl, + FormGroup, + HelpBlock, + ToggleButton, + ToggleButtonGroup +} from 'react-bootstrap' +import styled from 'styled-components' + +import Icon from '../../narrative/icon' +import { getErrorStates } from '../../../util/ui' +import { CUSTOM_PLACE_TYPES, isHomeOrWork } from '../../../util/user' +import { + makeLocationFieldLocation, + PlaceLocationField +} from './place-location-field' + +const { isMobile } = coreUtils.ui + +// Styled components +const LargeIcon = styled(Icon)` + font-size: 150%; +` +const FixedPlaceIcon = styled(LargeIcon)` + margin-right: 10px; + padding-top: 6px; +` +const FlexContainer = styled.div` + display: flex; +` +const FlexFormGroup = styled(FormGroup)` + flex-grow: 1; +` +const StyledToggleButtonGroup = styled(ToggleButtonGroup)` + & > label { + padding: 5px; + } +` + +const MESSAGES = { + SET_PLACE_NAME: 'Set place name' +} + +/** + * Contains the fields for editing a favorite place. + * This component uses Formik props that are passed + * within the Formik context set up by FavoritePlaceScreen. + */ +class PlaceEditor extends Component { + _handleLocationChange = ({ location }) => { + const { setValues, values } = this.props + const { lat, lon, name } = location + setValues({ + ...values, + address: name, + lat, + lon + }) + } + + render () { + const { errors, handleBlur, handleChange, values: place } = this.props + const isFixed = isHomeOrWork(place) + const errorStates = getErrorStates(this.props) + + return ( +
+ {!isFixed && ( + <> + + {/* onBlur, onChange, and value are passed automatically. */} + + + {errors.name && {errors.name}} + + + + + {Object.values(CUSTOM_PLACE_TYPES).map(({ icon, text, type }) => ( + + + + ))} + + + + )} + + + {/* For fixed places, just show the icon for place type instead of all inputs and selectors */} + {isFixed && } + + + + + {errors.address && {errors.address}} + + +
+ ) + } +} + +export default PlaceEditor diff --git a/lib/components/user/places/place-location-field.js b/lib/components/user/places/place-location-field.js new file mode 100644 index 000000000..31a75fdba --- /dev/null +++ b/lib/components/user/places/place-location-field.js @@ -0,0 +1,77 @@ +import LocationField from '@opentripplanner/location-field' +import { + DropdownContainer, + FormGroup, + Input, + InputGroup, + InputGroupAddon, + MenuItemA, + MenuItemHeader, + MenuItemLi, + MenuItemList +} from '@opentripplanner/location-field/lib/styled' +import styled from 'styled-components' + +import connectLocationField from '../../form/connect-location-field' + +/** + * Create a LocationField location object from a persisted user location object. + */ +export function makeLocationFieldLocation (favoriteLocation) { + const { address, lat, lon } = favoriteLocation + return { + lat, + lon, + name: address + } +} + +// Style and connect LocationField to redux store. +const StyledLocationField = styled(LocationField)` + margin-bottom: 0; + width: 100%; + ${DropdownContainer} { + width: 0; + & > button { + display: none; + } + } + ${FormGroup} { + display: block; + } + ${Input} { + display: table-cell; + font-size: 100%; + line-height: 20px; + padding: 0; + width: 100%; + + ::placeholder { + color: #999; + } + } + ${InputGroup} { + border: none; + width: 100%; + } + ${InputGroupAddon} { + display: none; + } + ${MenuItemList} { + position: absolute; + ${props => props.static ? 'width: 100%;' : ''} + } + ${MenuItemA} { + &:focus, &:hover { + color: inherit; + text-decoration: none; + } + } + ${MenuItemA}, ${MenuItemHeader}, ${MenuItemLi} { + ${props => props.static ? 'padding-left: 0; padding-right: 0;' : ''} + } +` +/** + * Styled LocationField for setting a favorite place locations using the geocoder. + */ +export const PlaceLocationField = connectLocationField(StyledLocationField) diff --git a/lib/components/user/sequential-pane-display.js b/lib/components/user/sequential-pane-display.js index 5fa4e75f6..4229ffc6a 100644 --- a/lib/components/user/sequential-pane-display.js +++ b/lib/components/user/sequential-pane-display.js @@ -1,6 +1,8 @@ import PropTypes from 'prop-types' import React, { Component } from 'react' +import { connect } from 'react-redux' +import * as uiActions from '../../actions/ui' import FormNavigationButtons from './form-navigation-buttons' import { SequentialPaneContainer } from './styled' @@ -9,23 +11,21 @@ import { SequentialPaneContainer } from './styled' */ class SequentialPaneDisplay extends Component { static propTypes = { - initialPaneId: PropTypes.string.isRequired, + activePaneId: PropTypes.string.isRequired, paneSequence: PropTypes.object.isRequired } - constructor (props) { - super(props) - - this.state = { - activePaneId: props.initialPaneId - } + /** + * Routes to the next pane URL. + */ + _routeTo = nextId => { + const { parentPath, routeTo } = this.props + routeTo(`${parentPath}/${nextId}`) } _handleToNextPane = async e => { - const { paneSequence } = this.props - const { activePaneId } = this.state - const currentPane = paneSequence[activePaneId] - const nextId = currentPane.nextId + const { activePane } = this.props + const nextId = activePane.nextId if (nextId) { // Don't submit the form if there are more steps to complete. @@ -33,52 +33,62 @@ class SequentialPaneDisplay extends Component { // Execute pane-specific action, if any (e.g. save a user account) // when clicking next. - if (typeof currentPane.onNext === 'function') { - await currentPane.onNext() + if (typeof activePane.onNext === 'function') { + await activePane.onNext() } - - this.setState({ - activePaneId: nextId - }) + this._routeTo(nextId) } } _handleToPrevPane = () => { - const { paneSequence } = this.props - const { activePaneId } = this.state - this.setState({ - activePaneId: paneSequence[activePaneId].prevId - }) + const { activePane } = this.props + this._routeTo(activePane.prevId) } render () { - const { paneSequence } = this.props - const { activePaneId } = this.state - const activePane = paneSequence[activePaneId] - const { disableNext, nextId, pane: Pane, prevId, props, title } = activePane + const { activePane = {} } = this.props + const { disableNext, hideNavigation, nextId, pane: Pane, prevId, props, title } = activePane return ( <>

{title}

+ - + {Pane && } - + {!hideNavigation && ( + + )} ) } } -export default SequentialPaneDisplay +// connect to the redux store + +const mapStateToProps = (state, ownProps) => { + const { activePaneId, paneSequence } = ownProps + const { pathname } = state.router.location + return { + activePane: paneSequence[activePaneId], + parentPath: pathname.substr(0, pathname.lastIndexOf('/')) + } +} + +const mapDispatchToProps = { + routeTo: uiActions.routeTo +} + +export default connect(mapStateToProps, mapDispatchToProps)(SequentialPaneDisplay) diff --git a/lib/components/user/styled.js b/lib/components/user/styled.js index b957d7e68..35019af1c 100644 --- a/lib/components/user/styled.js +++ b/lib/components/user/styled.js @@ -1,6 +1,8 @@ import { Panel } from 'react-bootstrap' import styled from 'styled-components' +import Icon from '../narrative/icon' + export const PageHeading = styled.h2` margin: 10px 0px 45px 0px; ` @@ -75,3 +77,7 @@ export const TripPanelFooter = styled(Panel.Footer)` border-bottom-left-radius: 0; } ` + +export const IconWithMargin = styled(Icon)` + margin-right: 0.5em; +` diff --git a/lib/components/user/sub-nav.js b/lib/components/user/sub-nav.js index bbfdbdbd7..8d17c03a0 100644 --- a/lib/components/user/sub-nav.js +++ b/lib/components/user/sub-nav.js @@ -1,7 +1,7 @@ import React from 'react' import { Button } from 'react-bootstrap' -import { LinkContainer } from 'react-router-bootstrap' +import { LinkContainerWithQuery } from '../form/connected-links' import { SubNavContainer, SubNavLinks } from './styled' import { ACCOUNT_SETTINGS_PATH, TRIPS_PATH } from '../../util/constants' @@ -13,12 +13,12 @@ const SubNav = ({title = 'My account'}) => (

{title}

- + - - + + - +
diff --git a/lib/components/user/user-account-screen.js b/lib/components/user/user-account-screen.js index cab8245ac..cc886a581 100644 --- a/lib/components/user/user-account-screen.js +++ b/lib/components/user/user-account-screen.js @@ -1,5 +1,4 @@ import { withAuthenticationRequired } from '@auth0/auth0-react' -import clone from 'clone' import { Form, Formik } from 'formik' import React, { Component } from 'react' import { connect } from 'react-redux' @@ -8,15 +7,10 @@ import * as yup from 'yup' import AccountPage from './account-page' import * as uiActions from '../../actions/ui' import * as userActions from '../../actions/user' +import { CREATE_ACCOUNT_PATH } from '../../util/constants' import { RETURN_TO_CURRENT_ROUTE } from '../../util/ui' -import { isNewUser } from '../../util/user' -import AccountSetupFinishPane from './account-setup-finish-pane' import ExistingAccountDisplay from './existing-account-display' -import FavoriteLocationsPane, { isHome, isWork } from './favorite-locations-pane' import NewAccountWizard from './new-account-wizard' -import NotificationPrefsPane from './notification-prefs-pane' -import TermsOfUsePane from './terms-of-use-pane' -import VerifyEmailScreen from './verify-email-screen' import withLoggedInUserSupport from './with-logged-in-user-support' // The validation schema for the form fields. @@ -32,61 +26,14 @@ const validationSchema = yup.object({ storeTripHistory: yup.boolean() }) -/** - * Makes a copy of the logged-in user data for the Formik initial state, with: - * - the 'home' and 'work' locations at the top of the savedLocations list - * so they are always shown and shown at the top of the FavoriteLocationsPane. - * Note: In the returned value, savedLocations is always a valid array. - * - initial values for phone number/code fields used by Formik. - */ -function cloneForFormik (userData) { - const clonedUser = clone(userData) - const { savedLocations = [] } = clonedUser - - const homeLocation = savedLocations.find(isHome) || { - address: '', - icon: 'home', - type: 'home' - } - const workLocation = savedLocations.find(isWork) || { - address: '', - icon: 'briefcase', - type: 'work' - } - const reorderedLocations = [ - homeLocation, - workLocation, - ...savedLocations.filter(loc => loc !== homeLocation && loc !== workLocation) - ] - - clonedUser.savedLocations = reorderedLocations - return clonedUser -} - /** * This screen handles creating/updating OTP user account settings. */ class UserAccountScreen extends Component { - constructor (props) { - super(props) - - this.state = { - // Capture whether user is a new user at this stage, and retain that value as long as this screen is active. - // Reminder: When a new user progresses through the account steps, - // isNewUser(loggedInUser) will change to false as the database gets updated. - isNewUser: isNewUser(props.loggedInUser) - } - } - _updateUserPrefs = async (userData, silentOnSucceed = false) => { // TODO: Change state of Save button while the update action takes place. - // In userData.savedLocations, filter out entries with blank addresses. - const newUserData = clone(userData) - newUserData.savedLocations = newUserData.savedLocations.filter( - ({ address }) => address && address.length - ) - await this.props.createOrUpdateUser(newUserData, silentOnSucceed) + await this.props.createOrUpdateUser(userData, silentOnSucceed) // TODO: Handle UI feedback (currently an alert() dialog inside createOrUpdateUser). } @@ -117,32 +64,25 @@ class UserAccountScreen extends Component { this._handleExit() } - // Make an index of pane components, so we don't render all panes at once on every render. - _panes = { - terms: TermsOfUsePane, - notifications: NotificationPrefsPane, - locations: FavoriteLocationsPane, - finish: AccountSetupFinishPane - } - // TODO: Update title bar during componentDidMount. render () { const { auth0, + isCreating, + itemId, loggedInUser, phoneFormatOptions, requestPhoneVerificationSms, verifyPhoneNumber } = this.props - const { isNewUser } = this.state return ( - + { - let formContents - let DisplayComponent - if (isNewUser) { - if (!auth0.user.email_verified) { - // Check and prompt for email verification first to avoid extra user wait. - formContents = - } else { - // New users are shown "wizard" (step-by-step) mode - // (includes when a "new" user clicks "My account" from the account menu in the nav bar). - DisplayComponent = NewAccountWizard - } - } else { - // Existing users are shown all panes together. - DisplayComponent = ExistingAccountDisplay - } - if (DisplayComponent) { - formContents = ( + const DisplayComponent = isCreating ? NewAccountWizard : ExistingAccountDisplay + + return ( +
- ) - } - - return ( - - {formContents} ) } @@ -202,7 +124,12 @@ class UserAccountScreen extends Component { // connect to the redux store const mapStateToProps = (state, ownProps) => { + const { params, url } = ownProps.match + const isCreating = url.startsWith(CREATE_ACCOUNT_PATH) + const { step } = params return { + isCreating, + itemId: step, loggedInUser: state.user.loggedInUser, phoneFormatOptions: state.otp.config.phoneFormatOptions } diff --git a/lib/components/user/verify-email-screen.js b/lib/components/user/verify-email-pane.js similarity index 73% rename from lib/components/user/verify-email-screen.js rename to lib/components/user/verify-email-pane.js index 3664f5cdc..a66b8f011 100644 --- a/lib/components/user/verify-email-screen.js +++ b/lib/components/user/verify-email-pane.js @@ -3,7 +3,9 @@ import { Button } from 'react-bootstrap' import { connect } from 'react-redux' import styled from 'styled-components' +import * as uiActions from '../../actions/ui' import * as userActions from '../../actions/user' +import { CREATE_ACCOUNT_TERMS_PATH } from '../../util/constants' const DivSpacer = styled.div` margin-top: ${props => props.space || 2}em; @@ -16,14 +18,23 @@ const DivSpacer = styled.div` * (One way to make sure the parent page fetches the latest email verification status * is to simply reload the page.) */ -class VerifyEmailScreen extends Component { +class VerifyEmailPane extends Component { _handleEmailVerified = () => window.location.reload() + componentDidMount () { + const { emailVerified, routeTo } = this.props + if (emailVerified) { + // If the user got to this screen, it is assumed they just signed up, + // so once their email is verified, automatically go to the + // next screen, which is the terms page. + routeTo(CREATE_ACCOUNT_TERMS_PATH) + } + } + render () { const { resendVerificationEmail } = this.props return (
-

Verify your email address

Please check your email inbox and follow the link in the message to verify your email address before finishing your account setup. @@ -62,7 +73,8 @@ const mapStateToProps = () => { } const mapDispatchToProps = { - resendVerificationEmail: userActions.resendVerificationEmail + resendVerificationEmail: userActions.resendVerificationEmail, + routeTo: uiActions.routeTo } -export default connect(mapStateToProps, mapDispatchToProps)(VerifyEmailScreen) +export default connect(mapStateToProps, mapDispatchToProps)(VerifyEmailPane) diff --git a/lib/util/constants.js b/lib/util/constants.js index f1cb7bedc..34e2b6a09 100644 --- a/lib/util/constants.js +++ b/lib/util/constants.js @@ -7,7 +7,11 @@ export const PERSISTENCE_STRATEGY_OTP_MIDDLEWARE = 'otp_middleware' export const ACCOUNT_PATH = '/account' export const ACCOUNT_SETTINGS_PATH = `${ACCOUNT_PATH}/settings` export const TRIPS_PATH = `${ACCOUNT_PATH}/trips` +export const PLACES_PATH = `${ACCOUNT_PATH}/places` export const CREATE_ACCOUNT_PATH = `${ACCOUNT_PATH}/create` +export const CREATE_ACCOUNT_TERMS_PATH = `${CREATE_ACCOUNT_PATH}/terms` +export const CREATE_ACCOUNT_VERIFY_PATH = `${CREATE_ACCOUNT_PATH}/verify` +export const CREATE_ACCOUNT_PLACES_PATH = `${CREATE_ACCOUNT_PATH}/places` export const CREATE_TRIP_PATH = `${TRIPS_PATH}/new` // Gets the root URL, e.g. https://otp-instance.example.com:8080, computed once for all. diff --git a/lib/util/ui.js b/lib/util/ui.js index 58e4e1e1b..f947128f7 100644 --- a/lib/util/ui.js +++ b/lib/util/ui.js @@ -23,3 +23,24 @@ export function getCurrentRoute () { export const RETURN_TO_CURRENT_ROUTE = { returnTo: getCurrentRoute } + +/** + * Computes the Bootstrap error states based on Formik's validation props. + * @param {*} props The Formik props from which to extract the error states. + * @returns An object where each field is set to 'error' if the + * corresponding Formik props denote an error for that field. + */ +export function getErrorStates (props) { + const { errors, touched } = props + const errorStates = {} + Object.keys(errors).forEach(name => { + errorStates[name] = touched[name] && errors[name] && 'error' + }) + + return errorStates +} + +/** + * Browser navigate back. + */ +export const navigateBack = () => window.history.back() diff --git a/lib/util/user.js b/lib/util/user.js index 827896cd2..041a74368 100644 --- a/lib/util/user.js +++ b/lib/util/user.js @@ -1,4 +1,82 @@ - +/** + * Determines whether a loggedInUser is a new user + * that needs to complete the new account wizard. + */ export function isNewUser (loggedInUser) { return !loggedInUser.hasConsentedToTerms } + +// Helper functions to determine if +// a location is home or work. +export const isHome = loc => loc.type === 'home' +export const isWork = loc => loc.type === 'work' +export const isHomeOrWork = loc => isHome(loc) || isWork(loc) + +/** + * An index of common place types (excluding Home and Work), + * each type including a display name and the FontAwesome icon name. + * Add the supported place types below as needed. + */ +export const CUSTOM_PLACE_TYPES = { + custom: { + icon: 'map-marker', + name: 'Custom', + type: 'custom' + }, + dining: { + icon: 'cutlery', + name: 'Dining', + type: 'dining' + } +} + +/** + * The above, with Home and Work locations added. + */ +export const PLACE_TYPES = { + ...CUSTOM_PLACE_TYPES, + home: { + icon: 'home', + name: 'Home', + type: 'home' + }, + work: { + icon: 'briefcase', + name: 'Work', + type: 'work' + } +} + +// Defaults for home and work +const BLANK_HOME = { + ...PLACE_TYPES.home, + address: '' +} +const BLANK_WORK = { + ...PLACE_TYPES.work, + address: '' +} + +/** + * Moves the 'home' and 'work' locations of the provided user to the beginning of + * the savedLocations list, so they are always shown at the top of the FavoritePlacesPane. + * The name field is set to 'Home' and 'Work' regardless of the value that was persisted. + */ +export function positionHomeAndWorkFirst (userData) { + // Note: cloning is not necessary as the data is obtained from fetch. + const { savedLocations = [] } = userData + + const homeLocation = savedLocations.find(isHome) || BLANK_HOME + const workLocation = savedLocations.find(isWork) || BLANK_WORK + + homeLocation.name = BLANK_HOME.name + workLocation.name = BLANK_WORK.name + + const reorderedLocations = [ + homeLocation, + workLocation, + ...savedLocations.filter(loc => loc !== homeLocation && loc !== workLocation) + ] + + userData.savedLocations = reorderedLocations +}