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 && (
)}
- {!isLoggedIn && (
+ {!isAuthed && (
)}
- {/* */}
- {isLoggedIn && (
+ {isAuthed && isFave ? (
+
+ ) : (
+
+ )}
+ {isAuthed && (
)}
@@ -198,9 +221,10 @@ export default compose(
withPlaceForFoodItem,
withUpdateQuantity,
withImages,
- withProps(() => ({
- isLoggedIn: AuthManager.isLoggedIn(),
- })),
+ withAuthed,
+ withFaves,
+ withCurrentPath,
+ withReplaceRoute,
withState('currentImage', 'changeCurrentImage', 0),
withState('quantityModalOpen', 'setQuantityModalOpen', false),
withState('imagesLoading', 'setImagesLoading', true),
@@ -230,21 +254,51 @@ export default compose(
await updateQuantity({ foodItemId: foodItem.id, quantity });
Snackbar.show({ title: 'Food updated.', backgroundColor: 'black', color: 'white' });
},
- toggleQuantityModal: ({ quantityModalOpen, setQuantityModalOpen }) => () => {
- setQuantityModalOpen(!quantityModalOpen);
- },
+ toggleQuantityModal: ({ quantityModalOpen, setQuantityModalOpen }) =>
+ debounce(() => {
+ setQuantityModalOpen(!quantityModalOpen);
+ }, 500),
+ addToFaves: ({ addFave, foodItemId, isAuthed, replaceRoute }) =>
+ debounce(() => {
+ if (!isAuthed) {
+ replaceRoute(loginWithBackto(`/foodItem/${foodItemId}?loginAction=add-to-faves`));
+ } else {
+ addFave(foodItemId);
+ }
+ }, 500),
+ deleteFromFaves: ({ deleteFave, foodItemId }) => () => deleteFave(foodItemId),
}),
+ withProps(props => ({
+ isFave: props.faves && !!props.faves.find(fave => get(fave, 'id') === props.foodItemId),
+ loginAction: pathOr('', [1], /loginAction=(.+)(&?.*$)/.exec(props.currentPath)),
+ })),
onlyUpdateForKeys([
'foodItem',
'place',
'currentImage',
'quantityModalOpen',
'imagesLoading',
- 'isLoggedIn',
+ 'isAuthed',
+ 'faves',
]),
lifecycle({
componentDidMount() {
- const { getImages, foodItemId, setImagesLoading } = this.props;
+ const {
+ getImages,
+ foodItemId,
+ setImagesLoading,
+ loginAction,
+ setQuantityModalOpen,
+ addToFaves,
+ } = this.props;
+
+ if (loginAction === 'open-quantity-modal') {
+ setQuantityModalOpen(true);
+ }
+
+ if (loginAction === 'add-to-faves') {
+ addToFaves();
+ }
getImages({
foodItemId,
diff --git a/js/pages/FoodList.js b/js/pages/FoodList.js
index 8c568a1..198273e 100644
--- a/js/pages/FoodList.js
+++ b/js/pages/FoodList.js
@@ -1,22 +1,23 @@
// @flow
-import React from "react";
-import { View, Text, ScrollView, RefreshControl } from "react-native";
-import FoodItemTile from "../components/FoodItemTile";
-import FoodItemList from "../components/FoodItemList";
-import typeof FoodItemRecord from "../records/FoodItemRecord";
-import { withFoodItemsAsSeq } from "../enhancers/foodItemEnhancers";
-import { type SetSeq } from "immutable";
-import { compose, pure, withState, withHandlers, lifecycle } from "recompose";
-import { withFilter } from "../enhancers/filterEnhancers";
-import { withRouterContext } from "../enhancers/routeEnhancers";
-import FullScreenMessage from "../components/FullScreenMessage";
+import React from 'react';
+import { View, Text, ScrollView, RefreshControl } from 'react-native';
+import FoodItemTile from '../components/FoodItemTile';
+import FoodItemList from '../components/FoodItemList';
+import typeof FoodItemRecord from '../records/FoodItemRecord';
+import { withFoodItemsAsSeq } from '../enhancers/foodItemEnhancers';
+import { type SetSeq } from 'immutable';
+import { compose, pure, withState, withHandlers, lifecycle } from 'recompose';
+import { withFilter } from '../enhancers/filterEnhancers';
+import { withRouterContext } from '../enhancers/routeEnhancers';
+import FullScreenMessage from '../components/FullScreenMessage';
+import { withFaves } from '../enhancers/favesEnhancer';
type Props = {
foodItemsSeq: SetSeq,
isRefreshing: boolean,
onRefresh: () => Promise,
onPulldown: () => {},
- isFilterDirty: boolean
+ isFilterDirty: boolean,
};
const FoodList = (props: Props) => {
@@ -36,8 +37,7 @@ const FoodList = (props: Props) => {
{!showNoResults && (
}
- >
+ refreshControl={}>
{(foodItem: FoodItemRecord) => }
@@ -52,23 +52,24 @@ export default compose(
withRouterContext,
withFoodItemsAsSeq,
withFilter,
- withState("isRefreshing", "setRefreshing", false),
+ withFaves,
+ withState('isRefreshing', 'setRefreshing', false),
withHandlers({
- onPulldown: ({ setRefreshing, onRefresh, router }) => async () => {
+ onPulldown: ({ setRefreshing, onRefresh, getFaves, router }) => async () => {
try {
setRefreshing(true);
- await onRefresh();
+ await Promise.all([onRefresh(), getFaves()]);
} catch (error) {
console.error(error); // eslint-disable-line no-console
- router.history.push("/zipcode");
+ router.history.push('/zipcode');
} finally {
setTimeout(() => setRefreshing(false), 1000);
}
- }
+ },
}),
lifecycle({
componentDidMount() {
this.props.onPulldown();
- }
+ },
})
)(FoodList);
diff --git a/js/pages/LoginPage.js b/js/pages/LoginPage.js
index 5f869c5..4f335bc 100644
--- a/js/pages/LoginPage.js
+++ b/js/pages/LoginPage.js
@@ -6,6 +6,7 @@ import { withRouterContext } from '../enhancers/routeEnhancers';
import AuthManager from '../AuthManager';
import theme from '../ui-theme';
import { Divider, Icon } from 'react-native-material-ui';
+import { getBackTo } from '../helpers/RouteHelpers';
const auth0ErrToStr = message => {
if (typeof message === 'string') {
@@ -78,7 +79,11 @@ const LoginPageComponent = ({
alignItems: 'center',
justifyContent: 'flex-start',
}}>
- {error && {error}}
+ {error && (
+
+ {error}
+
+ )}
Sign In
@@ -91,7 +96,7 @@ const LoginPageComponent = ({
onChangeText={setEmail}
style={{
fontSize: 16,
- paddingLeft: 0
+ paddingLeft: 0,
}}
/>
@@ -108,7 +113,7 @@ const LoginPageComponent = ({
onChangeText={setUsername}
style={{
fontSize: 16,
- paddingLeft: 0
+ paddingLeft: 0,
}}
/>
@@ -126,7 +131,7 @@ const LoginPageComponent = ({
onChangeText={setPassword}
style={{
fontSize: 16,
- paddingLeft: 0
+ paddingLeft: 0,
}}
/>
@@ -134,7 +139,7 @@ const LoginPageComponent = ({
-
+
@@ -160,7 +165,7 @@ const LoginPageComponent = ({
style={{
flexDirection: 'row',
marginTop: 0,
- padding:16,
+ padding: 16,
alignItems: 'center',
justifyContent: 'center',
}}>
@@ -188,10 +193,11 @@ export default compose(
withState('username', 'setUsername', ''),
withState('isNewUser', 'setIsNewUser', false),
withHandlers({
- checkAuth: ({ router }) => async () => {
+ checkAuth: props => async () => {
const isLoggedIn = await AuthManager.checkIsAuthed();
if (isLoggedIn) {
- router.history.goBack();
+ const backTo = getBackTo(props);
+ return backTo ? props.router.history.replace(backTo) : props.router.history.goBack();
}
},
}),
diff --git a/js/pages/Nav.js b/js/pages/Nav.js
index 8049efd..4d29c2d 100644
--- a/js/pages/Nav.js
+++ b/js/pages/Nav.js
@@ -11,11 +11,15 @@ import { pathOr } from 'ramda';
import uiTheme from '../ui-theme';
import ProfilePage from './ProfilePage';
import AuthManager from '../AuthManager';
+import FavesPage from './FavesPage';
type Props = {
activeTab: string,
router: routerContext,
setRoute: (route: string) => () => void,
+ location: {
+ pathname: string,
+ },
};
const Nav = (props: Props) => {
@@ -25,7 +29,8 @@ const Nav = (props: Props) => {
{activeTab === 'food' && }
{activeTab === 'places' && }
- {activeTab === 'profile' && }
+ {activeTab === 'faves' && }
+ {activeTab === 'profile' && }
@@ -63,10 +68,10 @@ const Nav = (props: Props) => {
}}
/>
void,
+ push: (route: string) => void,
},
},
- isLoggedIn: boolean,
+ isAuthed: boolean,
};
export const ProfilePage = (props: Props) => {
@@ -35,13 +37,21 @@ export const ProfilePage = (props: Props) => {
error,
toggleGPS,
router: { history },
- isLoggedIn,
+ isAuthed,
} = props;
const usingGPS = zipcode === 'usegps';
return (
-
- Profile
-
+
+ Profile
+
Use My Location
@@ -70,7 +80,7 @@ export const ProfilePage = (props: Props) => {
)}
{!!error && {error}}
- {isLoggedIn && (
+ {isAuthed && (
{
textStyle={{ color: palette.accentColor }}
/>
)}
- {!isLoggedIn && (
+ {!isAuthed && (
,
- icon: string,
-};
-
-export default {
- food: {
- title: 'Food',
- component: Food,
- icon: 'local-pizza',
- },
- places: {
- title: 'Places',
- component: Places,
- icon: 'restaurant',
- },
- faves: {
- title: 'Faves',
- component: Faves,
- icon: 'stars',
- },
- foodDetail: {
- title: 'Food Details',
- component: FoodItemDetail,
- icon: 'local-pizza',
- },
- placeDetail: {
- title: 'Place Details',
- component: PlaceDetail,
- icon: 'domain',
- },
-};
-
-export const routingContextPropTypes = {
- router: shape({
- history: shape({
- push: func.isRequired,
- replace: func.isRequired,
- goBack: func.isRequired,
- entries: arrayOf(
- shape({
- pathname: string.isRequired,
- search: string.isRequired,
- })
- ),
- }).isRequired,
- }).isRequired,
-};
-
-export type RoutingContextFlowTypes = {
- router: {
- history: {
- push: Function,
- replace: Function,
- goBack: Function,
- entries: Array<{
- pathname: string,
- search: string,
- }>,
- },
- route: {
- location: {
- pathname: string,
- state: ?Object,
- },
- },
- },
- match: {
- params: {
- type?: string,
- },
- },
-};
diff --git a/js/streams/FavesStream.js b/js/streams/FavesStream.js
new file mode 100644
index 0000000..cb5ff88
--- /dev/null
+++ b/js/streams/FavesStream.js
@@ -0,0 +1,41 @@
+//@flow
+import { BehaviorSubject } from 'rxjs';
+import { Record, List, get } from 'immutable';
+import filter$ from './FilterStream';
+import foodItems$ from './FoodItemsStream';
+import { test } from 'ramda';
+
+export type Fave = {
+ food_item_id: string,
+ date: number,
+};
+export type Faves = List;
+
+const observable: BehaviorSubject = new BehaviorSubject(null);
+
+export function emit(fave: ?Faves) {
+ observable.next(fave);
+}
+
+export function emitOne(fave: ?Fave) {
+ observable.first().subscribe(currentFaves => observable.next(currentFaves.push(fave)));
+}
+
+export default observable
+ .combineLatest(foodItems$)
+ .map(([faves, foodItems]) => {
+ return faves && faves.map(fave => get(foodItems, get(fave, 'food_item_id')));
+ })
+ .combineLatest(filter$)
+ .map(([faveFoodItems, filter]) => {
+ const filterTest = filter.search && test(new RegExp(filter.search, 'i'));
+ return (
+ faveFoodItems &&
+ faveFoodItems.filter(faveFoodItem => {
+ if (!faveFoodItem) {
+ return false;
+ }
+ return !filterTest || filterTest(faveFoodItem.name);
+ })
+ );
+ });