diff --git a/js/App.js b/js/App.js index 0e059ee..6dbcaee 100644 --- a/js/App.js +++ b/js/App.js @@ -1,26 +1,27 @@ //@flow -import React, { Component } from "react"; -import { StatusBar } from "react-native"; -import { ThemeContext, getTheme } from "react-native-material-ui"; -import rxjsconfig from "recompose/rxjsObservableConfig"; -import setObservableConfig from "recompose/setObservableConfig"; -import { NativeRouter, Route, Redirect, AndroidBackButton, Switch } from "react-router-native"; -import Nav from "./pages/Nav"; -import FoodItemDetail from "./pages/FoodItemDetail"; -import PlaceDetail from "./pages/PlaceDetail"; -import CreateFoodItem from "./pages/CreateFoodItem"; -import AuthManager from "./AuthManager"; -import LoginPage from "./pages/LoginPage"; -import LandingPage from "./pages/LandingPage"; -import ZipcodePage from "./pages/ZipcodePage"; -import { AppContainer } from "./components/AppContainer"; - -import theme from "./ui-theme"; - +import rxjsconfig from 'recompose/rxjsObservableConfig'; +import setObservableConfig from 'recompose/setObservableConfig'; setObservableConfig(rxjsconfig); +import React, { Component } from 'react'; +import { StatusBar } from 'react-native'; +import { ThemeContext, getTheme } from 'react-native-material-ui'; +import { NativeRouter, Route, Redirect, AndroidBackButton, Switch } from 'react-router-native'; +import Nav from './pages/Nav'; +import FoodItemDetail from './pages/FoodItemDetail'; +import PlaceDetail from './pages/PlaceDetail'; +import CreateFoodItem from './pages/CreateFoodItem'; +import LoginPage from './pages/LoginPage'; +import LandingPage from './pages/LandingPage'; +import ZipcodePage from './pages/ZipcodePage'; +import { AppContainer } from './components/AppContainer'; + +import theme from './ui-theme'; + +console.disableYellowBox = true; + export default class App extends Component { - static displayName = "App"; + static displayName = 'App'; render() { return ( @@ -29,7 +30,7 @@ export default class App extends Component { - + @@ -38,16 +39,7 @@ export default class App extends Component { - { - if (!AuthManager.isLoggedIn()) { - const search = props.location.search + "&" || "?"; - return ; - } - return ; - }} - /> + diff --git a/js/AuthManager.js b/js/AuthManager.js index 149d61e..ce7ffbf 100644 --- a/js/AuthManager.js +++ b/js/AuthManager.js @@ -13,6 +13,7 @@ export type AUTH_PROVIDER = | typeof AUTH_PROVIDER_AUTH0; export type User = { + id: string, name: string, token: string, tokenExpires: number, @@ -21,6 +22,11 @@ export type User = { provider: AUTH_PROVIDER, }; +export type EmailAndPassword = { + email: string, + password: string, +}; + class AuthManager { user: ?User; @@ -28,15 +34,17 @@ class AuthManager { return this.user && this.user.token; }; - getAuthHeader = () => { - const { token, provider } = this.user || {}; + getEmail = () => { + return this.user && this.user.email; + }; - switch (provider) { - case AUTH_PROVIDER_FACEBOOK: - return { authorization: `facebook-token ${token}` }; - default: - return {}; - } + getId = () => { + return this.user && this.user.id; + }; + + getAuthHeader = () => { + const { token } = this.user || {}; + return { authorization: `auth0-token ${token || ''}` }; }; isLoggedIn = () => { @@ -54,17 +62,19 @@ class AuthManager { } this.setUser({ + email: auth0User.email, token: auth0User.accessToken, tokenExpires: +moment().add(auth0User.expiresIn, 'seconds'), + id: auth0User.idToken, }); }; - setUser = ({ token, tokenExpires }) => { - if (!token || +moment(tokenExpires) <= +moment()) { + setUser = user => { + if (!user.token || +moment(user.tokenExpires) <= +moment()) { this.user = null; AsyncStorage.removeItem('user'); } else { - this.user = { token, tokenExpires }; + this.user = user; AsyncStorage.setItem('user', JSON.stringify(this.user)); } }; @@ -73,8 +83,8 @@ class AuthManager { const user = JSON.parse(await AsyncStorage.getItem('user')); if (user) { this.setUser(user); - return this.user; } + return this.isLoggedIn(); }; authenticate = async ({ email, password }) => { diff --git a/js/apis/Auth0.js b/js/apis/Auth0.js index 44e7b52..2d652ae 100644 --- a/js/apis/Auth0.js +++ b/js/apis/Auth0.js @@ -1,11 +1,14 @@ // @flow import Auth0 from 'react-native-auth0'; +import { type EmailAndPassword } from '../AuthManager'; let auth0Instance = null; export type Auth0User = { + email: string, accessToken: string, expiresIn: number, + idToken: string, }; const getAuth0Instance = () => { @@ -18,13 +21,16 @@ const getAuth0Instance = () => { ); }; -export const loginAuth0 = async ({ email: username, password }): Promise => { +export const loginAuth0 = async ({ email, password }: EmailAndPassword): Promise => { const creds = await getAuth0Instance().auth.passwordRealm({ - username, + username: email, password, realm: 'Username-Password-Authentication', }); - return creds; + return { + email, + ...creds, + }; }; export const createAuth0User = async ({ email, username, password }) => { diff --git a/js/apis/FavesApi.js b/js/apis/FavesApi.js new file mode 100644 index 0000000..f3ec904 --- /dev/null +++ b/js/apis/FavesApi.js @@ -0,0 +1,44 @@ +import { fetchRequest } from './FetchApi'; +import AuthManager from '../AuthManager'; + +const withLoggedInEmail = fn => async (...args) => { + await AuthManager.checkIsAuthed(); + + const email = AuthManager.getEmail(); + + if (!email) { + throw new Error('you must be logged in to view favorites'); + } + + return fn(email, ...args); +}; + +export const fetchFaves = withLoggedInEmail(async email => { + return fetchRequest({ + endpoint: `/faves`, + method: 'POST', + body: { email }, + }); +}); + +export const putFaves = withLoggedInEmail(async (email, foodItemIds) => { + return fetchRequest({ + endpoint: '/fave', + method: 'PUT', + body: { + email, + foodItemIds, + }, + }); +}); + +export const deleteFaves = withLoggedInEmail(async (email, foodItemIds) => { + return fetchRequest({ + endpoint: '/fave', + method: 'DELETE', + body: { + email, + foodItemIds, + }, + }); +}); diff --git a/js/apis/FetchApi.js b/js/apis/FetchApi.js index abc2336..4cf0eb2 100644 --- a/js/apis/FetchApi.js +++ b/js/apis/FetchApi.js @@ -1,26 +1,26 @@ // @flow -import { BASE_URL } from "../constants/AppConstants"; -import AuthManager from "../AuthManager"; +import { BASE_URL } from '../constants/AppConstants'; +import AuthManager from '../AuthManager'; export const fetchRequest = async ({ endpoint, method, headers = {}, - body + body, }: { endpoint: string, method: string, headers?: Object, - body?: Object + body?: Object, }) => { - const res = await fetch(`https://${BASE_URL}${endpoint}`, { + const res = await fetch(`http://${BASE_URL}${endpoint}`, { method, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...AuthManager.getAuthHeader(), - ...headers + ...headers, }, - ...(body ? { body: JSON.stringify(body) } : {}) + ...(body ? { body: JSON.stringify(body) } : {}), }); if (res.status === 401) { @@ -36,10 +36,10 @@ export const fetchRequest = async ({ export const fetchRequestBinary = async ({ endpoint, body }: { endpoint: string, body: any }) => { return fetch(`http://${BASE_URL}${endpoint}`, { - method: "POST", + method: 'POST', headers: { - ...AuthManager.getAuthHeader() + ...AuthManager.getAuthHeader(), }, - body + body, }); }; diff --git a/js/components/FoodItemTile.js b/js/components/FoodItemTile.js index 909b597..f2cd6ea 100644 --- a/js/components/FoodItemTile.js +++ b/js/components/FoodItemTile.js @@ -20,9 +20,22 @@ const PlaceNameAndDistance = withPlace( } ); +const EmptyFoodItemTile = () => { + return ( + + + + + Loading... + + + + ); +}; + export default pure(({ foodItem }: { foodItem: FoodItemRecord }) => { if (!foodItem) { - return ; + return ; } return ( diff --git a/js/components/TopToolbar.js b/js/components/TopToolbar.js index 1dc0578..d650919 100644 --- a/js/components/TopToolbar.js +++ b/js/components/TopToolbar.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import { View, TextInput, TouchableOpacity, SafeAreaView } from 'react-native'; import { Toolbar, Icon } from 'react-native-material-ui'; -import { path, pipe, not, cond, isNil, compose, always } from 'ramda'; +import { path, cond, test } from 'ramda'; import queryString from 'query-string'; import { getSearch, getViewMode } from '../helpers/RouteHelpers'; import FoodItemSaveBtn from '../components/FoodItemSaveBtn'; @@ -31,19 +31,11 @@ class TopToolbar extends Component { const { router } = this.props; const { history } = router; - const notNil = compose( - not, - isNil - ); - - const goBackTo = pipe( - path(['route', 'location', 'search']), - queryString.parse, - path(['backto']), - cond([[notNil, history.replace], [always(true), history.goBack]]) - ); - - goBackTo(router); + if (history.length > 1) { + history.goBack(); + } else { + history.replace('/list/food'); + } }; toggleMapList = () => { @@ -86,10 +78,15 @@ class TopToolbar extends Component { const showSearch = /^\/list/.test(route); const showSearchClear = filter.search.length > 0; - const searchPlaceholder = /\/food/.test(route) ? 'Search Food...' : 'Search Places...'; + + const searchPlaceholder = cond([ + [test(/\/food/), () => 'Search Food...'], + [test(/\/places/), () => 'Search Places...'], + [test(/\/faves/), () => 'Search favorites'], + ])(route); return ( - + {!showSearch && ( )} {showSearch && ( - + + props$.combineLatest(AuthManager.checkIsAuthed(), (props, isAuthed) => { + return { + ...props, + isAuthed, + }; + }) +); + +export const withAuthRedirect = compose( + withRouterContext, + withAuthed, + lifecycle({ + componentDidMount() { + if (!this.props.isAuthed) { + const pathname = getPathname(this.props); + this.props.router.history.replace(loginWithBackto(pathname)); + } + }, + }) +); diff --git a/js/enhancers/favesEnhancer.js b/js/enhancers/favesEnhancer.js new file mode 100644 index 0000000..a9c4f9e --- /dev/null +++ b/js/enhancers/favesEnhancer.js @@ -0,0 +1,52 @@ +//@flow +import mapPropsStream from 'recompose/mapPropsStream'; +import Faves$, { emit as emitFaves, emitOne as emitFave } from '../streams/FavesStream'; +import { fetchFaves, putFaves, deleteFaves } from '../apis/FavesApi'; +import { fromJS, get } from 'immutable'; + +const fetchAndEmitFaves = async () => { + const faves = fromJS(await fetchFaves()); + emitFaves(faves); +}; + +const buildTempFave = foodItemId => + fromJS({ + food_item_id: foodItemId, + date: Date.now(), + }); + +export const withFaves = mapPropsStream(props$ => + props$.combineLatest(Faves$, (props, faves) => { + return { + ...props, + faves, + getFaves: async () => { + try { + await fetchAndEmitFaves(); + } catch (err) { + console.log(err); // eslint-disable-line no-console + } + }, + addFave: async foodItemId => { + try { + await putFaves([foodItemId]); + emitFave(buildTempFave(foodItemId)); + } catch (err) { + console.log(err); // eslint-disable-line no-console + } + }, + deleteFave: async foodItemId => { + try { + await deleteFaves([foodItemId]); + + const idx = faves.findIndex(fave => get(fave, 'id') === foodItemId); + if (idx >= 0) { + emitFaves(faves.delete(idx)); + } + } catch (err) { + console.log(err); // eslint-disable-line no-console + } + }, + }; + }) +); diff --git a/js/enhancers/foodItemEnhancers.js b/js/enhancers/foodItemEnhancers.js index eec438d..ab673a5 100644 --- a/js/enhancers/foodItemEnhancers.js +++ b/js/enhancers/foodItemEnhancers.js @@ -9,10 +9,7 @@ import { pipe, path, toLower, equals } from 'ramda'; type FindPredicate = (left: A) => (right: A) => Boolean; -const getName: (f: FoodItemRecord) => String = pipe( - path(['name']), - toLower -); +const getName: (f: FoodItemRecord) => String = pipe(path(['name']), toLower); const matchesName: FindPredicate = left => right => { return equals(getName(left), getName(right)); @@ -33,7 +30,7 @@ export const withFoodItems = mapPropsStream(props$ => props$.combineLatest(FoodItems$, (props, foodItems) => { return { ...props, - foodItemsMap: foodItems && foodItems, + foodItemsMap: foodItems, }; }) ); diff --git a/js/enhancers/routeEnhancers.js b/js/enhancers/routeEnhancers.js index 2720c72..0e25982 100644 --- a/js/enhancers/routeEnhancers.js +++ b/js/enhancers/routeEnhancers.js @@ -1,7 +1,6 @@ -// @flow import { getContext, withProps, compose } from 'recompose'; import { shape, func, string } from 'prop-types'; -import { path } from 'ramda'; +import { path, pathOr } from 'ramda'; import { getViewMode } from '../helpers/RouteHelpers'; export type routeMatch = { @@ -66,3 +65,14 @@ export const withReplaceRoute = compose( }; }) ); + +export const withCurrentPath = compose( + withRouterContext, + withProps(props => { + const { pathname, search } = pathOr({}, ['router', 'route', 'location'], props); + + return { + currentPath: `${pathname}${search || '?'}`, + }; + }) +); diff --git a/js/helpers/RouteHelpers.js b/js/helpers/RouteHelpers.js index 8a659f2..cb8625e 100644 --- a/js/helpers/RouteHelpers.js +++ b/js/helpers/RouteHelpers.js @@ -1,7 +1,9 @@ // @flow -import { curry, pipe, pathOr } from 'ramda'; +import { curry, pipe, pathOr, path } from 'ramda'; import queryString from 'query-string'; +const PARAM_BACK_TO = 'backto'; + type RouteTo = { pathname: string, search?: string, @@ -18,8 +20,8 @@ export const routeWithQuery = curry( } ); -export const routeWithTitle = curry( - (pathname: string, routeTitle: string): RouteTo => routeWithQuery(pathname, { routeTitle }) +export const routeWithTitle = curry((pathname: string, routeTitle: string): RouteTo => + routeWithQuery(pathname, { routeTitle }) ); export const getSearch = pipe( @@ -27,7 +29,10 @@ export const getSearch = pipe( queryString.parse ); -export const getViewMode = pipe( - getSearch, - pathOr('list', ['viewMode']) -); +export const getViewMode = pipe(getSearch, pathOr('list', ['viewMode'])); + +export const getBackTo = pipe(getSearch, path([PARAM_BACK_TO])); + +export const loginWithBackto = (backto: string) => `/login?${PARAM_BACK_TO}=${backto}`; + +export const getPathname = pathOr('/list/food', ['router', 'route', 'location', 'pathname']); diff --git a/js/helpers/debounce.js b/js/helpers/debounce.js index 752bf35..98c6ca7 100644 --- a/js/helpers/debounce.js +++ b/js/helpers/debounce.js @@ -1,20 +1,24 @@ // @flow -export default (fn: Function, delay?: number = 0): (() => Promise) => { - let timeoutId; +export default (fn: Function, delay?: number = 100): (() => Promise) => { + let cacheTimeout; + let cachedResult; return (...args) => { - if (timeoutId) { - clearTimeout(timeoutId); + if (cacheTimeout) { + clearTimeout(cacheTimeout); } - return new Promise((resolve, reject) => { - timeoutId = setTimeout(() => { + + if (!cachedResult) { + cachedResult = new Promise((resolve, reject) => { try { resolve(fn(...args)); } catch (err) { reject(err); - } finally { - timeoutId = null; } - }, delay); - }); + }); + } + + cacheTimeout = setTimeout(() => (cachedResult = null), delay); + + return cachedResult; }; }; diff --git a/js/pages/CreateFoodItem.js b/js/pages/CreateFoodItem.js index ca5d704..e56ad5d 100644 --- a/js/pages/CreateFoodItem.js +++ b/js/pages/CreateFoodItem.js @@ -29,6 +29,7 @@ import { getCategoryText } from '../helpers/CategoryHelpers'; import { getQuantityDropdownText } from '../helpers/QuantityHelpers'; import QuantityModal from '../modals/QuantityModal'; import { wrapModalComponent } from '../modals/FullScreenModal'; +import { withAuthRedirect } from '../enhancers/authEnhancers'; type Props = { foodItem: typeof FoodItemRecord, @@ -171,6 +172,7 @@ const updatePlace = ({ foodItem, setFoodItem, emitPlace }: Props) => placeDetail }; export default compose( + withAuthRedirect, withCreateFoodItemState, withPlaceId, withPlaceForFoodItem, diff --git a/js/pages/Faves.js b/js/pages/Faves.js deleted file mode 100644 index dfd6e6f..0000000 --- a/js/pages/Faves.js +++ /dev/null @@ -1,23 +0,0 @@ -// @flow -import React, { Component } from 'react'; -import { Text, View } from 'react-native'; -import theme from '../ui-theme'; - -type Props = { - route: Object, - navigator: Object, -}; - -export default class Faves extends Component { - static displayName = 'Faves'; - - props: Props; - - render() { - return ( - - Faves! - - ); - } -} diff --git a/js/pages/FavesPage.js b/js/pages/FavesPage.js new file mode 100644 index 0000000..a3d5f0d --- /dev/null +++ b/js/pages/FavesPage.js @@ -0,0 +1,58 @@ +// @flow +import React from 'react'; +import { View, ScrollView, RefreshControl } from 'react-native'; +import { compose, pure, withState, withHandlers, lifecycle } from 'recompose'; +import { withFoodItems } from '../enhancers/foodItemEnhancers'; +import { Map, get } from 'immutable'; +import typeof FoodItemRecord from '../records/FoodItemRecord'; +import { withFaves } from '../enhancers/favesEnhancer'; +import type { Faves, Fave } from '../streams/FavesStream'; +import FoodItemTile from '../components/FoodItemTile'; +import { withAuthRedirect } from '../enhancers/authEnhancers'; + +type Props = { + faves: Faves, + onRefresh: () => Promise, + onPulldown: () => {}, + isRefreshing: boolean, + viewMode: string, +}; + +const FavesList = ({ faves, isRefreshing, onPulldown }: Props) => { + const refreshing = isRefreshing || !faves; + return ( + + }> + {faves && + faves.map((fave: Faves, index: number) => { + return ; + })} + + + ); +}; + +export default compose( + pure, + withAuthRedirect, + withFaves, + withState('isRefreshing', 'setRefreshing', true), + withHandlers({ + onPulldown: ({ setRefreshing, getFaves }) => async () => { + try { + setRefreshing(true); + await getFaves(); + } catch (error) { + console.error(error); // eslint-disable-line no-console + } finally { + setRefreshing(false); + } + }, + }), + lifecycle({ + componentDidMount() { + this.props.onPulldown(); + }, + }) +)(FavesList); diff --git a/js/pages/FoodItemDetail.js b/js/pages/FoodItemDetail.js index 10420cd..df6326e 100644 --- a/js/pages/FoodItemDetail.js +++ b/js/pages/FoodItemDetail.js @@ -19,21 +19,24 @@ import { withFoodItem } from '../enhancers/foodItemEnhancers'; import { withPlaceForFoodItem } from '../enhancers/placeEnhancers'; import Carousel from 'react-native-looped-carousel'; import CountBadge from '../components/CountBadge'; -import { routeWithTitle } from '../helpers/RouteHelpers'; +import { routeWithTitle, loginWithBackto } from '../helpers/RouteHelpers'; import { Link } from 'react-router-native'; import moment from 'moment'; import { withUpdateQuantity } from '../enhancers/quantityEnhancers'; import { getQuantityLabelText } from '../helpers/QuantityHelpers'; -import AuthManager from '../AuthManager'; import RouterButton from 'react-router-native-button'; import QuantityModal from '../modals/QuantityModal'; import { withImages } from '../enhancers/imagesEnhancers'; import { openImagePicker } from '../helpers/ImagePickerHelpers'; -import { identity } from 'ramda'; +import { identity, pathOr } from 'ramda'; import Spinner from 'react-native-loading-spinner-overlay'; import ImageRecord from '../records/ImageRecord'; -import { List } from 'immutable'; +import { List, get } from 'immutable'; import Snackbar from 'react-native-snackbar'; +import { withAuthed } from '../enhancers/authEnhancers'; +import { withFaves } from '../enhancers/favesEnhancer'; +import debounce from '../helpers/debounce'; +import { withCurrentPath, withReplaceRoute } from '../enhancers/routeEnhancers'; const { foodItemDetails: style } = theme; @@ -94,7 +97,7 @@ type Props = { place: PlaceRecord, currentImage: number, quantityModalOpen: boolean, - isLoggedIn: boolean, + isAuthed: boolean, changeCurrentImage: (index: number) => void, updateAmount: (quantity: Quantity) => void, updateQuantity: (arg: { foodItemId: string, quantity: Quantity }) => void, @@ -103,6 +106,9 @@ type Props = { addImage: (arg: { foodItemId: string, imageUri: string }) => Promise, imagesLoading: boolean, setImagesLoading: (loading: boolean) => void, + addToFaves: () => void, + isFave: boolean, + deleteFromFaves: () => void, }; export const FoodItemDetail = (props: Props) => { @@ -111,12 +117,15 @@ export const FoodItemDetail = (props: Props) => { place, currentImage, changeCurrentImage, - isLoggedIn, + isAuthed, addPhoto, toggleQuantityModal, quantityModalOpen, updateAmount, imagesLoading, + addToFaves, + isFave, + deleteFromFaves, } = props; if (!foodItem || !place) { @@ -155,34 +164,48 @@ export const FoodItemDetail = (props: Props) => { Last updated at {moment(foodItem.lastupdated).format('h:mm A on MMM D, YYYY')} - {isLoggedIn && ( + {isAuthed && (