fetch place details on mount

This commit is contained in:
Bart Akeley 2018-08-12 09:51:44 -05:00
parent 1bec0f4565
commit 2d6eb7e787
5 changed files with 230 additions and 228 deletions

View file

@ -1,7 +1,7 @@
// @flow
import { type GooglePlaceObj } from '../records/PlaceRecord';
import { GoogleAPIKey } from '../constants/AppConstants';
// import { concat } from 'ramda';
import { memoize } from 'ramda';
const placesDetailUrl = `https://maps.googleapis.com/maps/api/place/details/json?key=${GoogleAPIKey}`;
const photosUrl = `https://maps.googleapis.com/maps/api/place/photo?key=${GoogleAPIKey}`;
@ -16,27 +16,40 @@ type GoogleFindPlaceResponse = {
const milesToMeters = miles => Math.ceil(miles > 0 ? miles / 0.00062137 : 0);
export const getPlaceDetails = async ({
distance,
placeId,
}: {
distance: number,
placeId: ?string,
}): Promise<GooglePlaceObj> => {
if (!placeId || typeof placeId !== 'string') {
throw new Error('placeid looks wrong');
/**
* always return a promise and swallow exceptions so as not to break the stream
*/
const safeWrapAjax = ajaxFn =>
memoize((...args) => {
return ajaxFn(...args).catch(error => {
console.log('ERROR: ', error); //eslint-disable-line no-console
return null;
});
});
export const getPlaceDetails = safeWrapAjax(
async ({
distance,
placeId,
}: {
distance: number,
placeId: ?string,
}): Promise<GooglePlaceObj> => {
if (!placeId || typeof placeId !== 'string') {
throw new Error('placeid looks wrong');
}
const res = await fetch(`${placesDetailUrl}&placeid=${placeId}`);
const { error_message, result }: GooglePlaceDetailsResponse = await res.json();
if (error_message) {
throw new Error(error_message);
}
return { ...result, distance };
}
const res = await fetch(`${placesDetailUrl}&placeid=${placeId}`);
const { error_message, result }: GooglePlaceDetailsResponse = await res.json();
if (error_message) {
throw new Error(error_message);
}
return { ...result, distance };
};
);
export const getURLForPhotoReference = (opts?: { maxheight?: number, maxwidth?: number } = {}) => (
photoReference: string
@ -59,50 +72,52 @@ export const getURLForPhotoReference = (opts?: { maxheight?: number, maxwidth?:
// return (await fetch(reqUrl)).toJson();
// };
export const findNearbyPlaces = async ({
location,
search,
radius,
}: {
location?: Position,
search?: string,
radius: number,
}): Promise<any> => {
if (!location) {
return null;
}
const {
coords: { latitude, longitude },
} = location;
const keyword = search ? `keyword=${encodeURIComponent(search)}` : '';
const loc = `location=${latitude},${longitude}`;
const rad = `radius=${milesToMeters(radius)}`;
const reqUrl = `${placesFromTextUrl}&${keyword}&${loc}&${rad}&type=restaurant`;
let error;
let places;
try {
const response: GoogleFindPlaceResponse = await (await fetch(reqUrl)).json();
// if (response.next_page_token) {
// const page = await getPageResults({ pageToken: response.next_page_token });
// return concat(response.results, page.results);
// }
if (response.status !== 'OK') {
throw new Error('google find places request failed');
export const findNearbyPlaces = safeWrapAjax(
async ({
location,
search,
radius,
}: {
location?: Position,
search?: string,
radius: number,
}): Promise<any> => {
if (!location) {
return null;
}
places = response.results;
} catch (err) {
console.log(error); // eslint-disable-line no-console
error = err;
const {
coords: { latitude, longitude },
} = location;
const keyword = search ? `keyword=${encodeURIComponent(search)}` : '';
const loc = `location=${latitude},${longitude}`;
const rad = `radius=${milesToMeters(radius)}`;
const reqUrl = `${placesFromTextUrl}&${keyword}&${loc}&${rad}&type=restaurant`;
let error;
let places;
try {
const response: GoogleFindPlaceResponse = await (await fetch(reqUrl)).json();
// if (response.next_page_token) {
// const page = await getPageResults({ pageToken: response.next_page_token });
// return concat(response.results, page.results);
// }
if (response.status !== 'OK') {
throw new Error('google find places request failed');
}
places = response.results;
} catch (err) {
console.log(error); // eslint-disable-line no-console
error = err;
}
return {
error,
location,
places,
};
}
return {
error,
location,
places,
};
};
);

View file

@ -2,10 +2,26 @@
import withProps from 'recompose/withProps';
import mapPropsStream from 'recompose/mapPropsStream';
import compose from 'recompose/compose';
import Places$ from '../streams/PlacesStream';
import Places$, { emitter as emitPlace } from '../streams/PlacesStream';
import FoodItems$ from '../streams/FoodItemsStream';
import { path } from 'ramda';
import { List } from 'immutable';
import { buildPlaceRecord } from '../records/PlaceRecord';
import { getPlaceDetails } from '../apis/GooglePlacesApi';
export const fetchPlaceDetails = async ({
distance,
placeId,
}: {
distance: number,
placeId: ?string,
}) => {
const place = await getPlaceDetails({
distance,
placeId,
});
emitPlace(buildPlaceRecord(place));
};
export const withPlaces = mapPropsStream(props$ =>
props$.combineLatest(Places$, (props, places) => {
@ -29,6 +45,7 @@ export const withPlace = compose(
return {
...props,
place,
fetchPlaceDetails,
};
})
);

View file

@ -1,8 +1,8 @@
// @flow
import React, { Component } from 'react';
import React from 'react';
import { View, Image, ScrollView, Linking } from 'react-native';
import theme from '../ui-theme';
import { compose, pure } from 'recompose';
import { compose, pure, withState, withHandlers, lifecycle } from 'recompose';
import typeof PlaceRecord from '../records/PlaceRecord';
import {
withPlace,
@ -27,112 +27,100 @@ const contentTileStyle = {
paddingRight: 20,
};
export class PlaceDetail extends Component {
static displayName = 'PlaceDetail';
type Props = {
place: ?PlaceRecord,
foodItems: ?List<FoodItemRecord>,
currentImage: number,
setCurrentImage: (idx: number) => void,
viewOnMap: () => void,
fetchPlaceDetails: (arg: { distance: number, placeId: string }) => Promise<void>,
};
props: {
place: ?PlaceRecord,
foodItems: ?List<FoodItemRecord>,
};
state: {
currentImage: number,
};
state = {
currentImage: 0,
};
changeCurrentImage = (currentImage: number) => this.setState({ currentImage });
// TODO placeholder implementation until we get a backend
addToFaves = () =>
this.setState(prevState => ({
isFavorite: !prevState.isFavorite,
}));
viewOnMap = () => {
const { place } = this.props;
if (!place) {
return;
}
const { latitude, longitude } = place;
const url = `geo:${latitude}, ${longitude}`;
Linking.canOpenURL(url)
.then(supported => {
if (supported) {
return Linking.openURL(url);
}
})
.catch(err => console.error('Failed to open geo URL', err)); // eslint-disable-line
};
// todo: need to get the item from the stream by this id
render() {
const { place, foodItems } = this.props;
if (!place) {
return <View />;
}
const { photos } = place;
const { currentImage } = this.state;
return (
<ScrollView style={theme.page.container}>
<View style={{ height: 200 }}>
{photos.size === 1 && <Image style={stretchedStyle} source={{ uri: photos.first() }} />}
{photos.size > 1 && (
<Carousel
autoplay={false}
onAnimateNextPage={this.changeCurrentImage}
style={stretchedStyle}>
{photos.map(uri => (
<Image key={uri} style={{ flex: 1, resizeMode: 'stretch' }} source={{ uri }} />
))}
</Carousel>
)}
<CountBadge currentImage={currentImage + 1} totalCount={photos.size} />
</View>
<View style={{ height: 50, marginBottom: 10, ...contentTileStyle }}>
<View style={{ marginTop: 15 }}>
<StrongText>{place.address}</StrongText>
</View>
</View>
<View
style={{
flexBasis: 75,
...contentTileStyle,
marginBottom: 10,
paddingTop: 5,
paddingBottom: 10,
}}>
<IconButton
glyph="stars"
text="Add to Faves"
onPress={this.addToFaves}
color={style.actionIconColor}
/>
<IconButton
glyph="map"
text="View on a Map"
onPress={this.viewOnMap}
color={style.actionIconColor}
/>
</View>
<View style={{ flex: 2, ...contentTileStyle, paddingTop: 10 }}>
<StrongText>Products</StrongText>
{!!foodItems &&
foodItems.map(foodItem => (
<FoodItemTile key={foodItem.id} foodItem={foodItem} place={place} />
))}
</View>
</ScrollView>
);
const PlaceDetail = ({ place, foodItems, currentImage, setCurrentImage, viewOnMap }: Props) => {
if (!place) {
return <View />;
}
}
const enhance = compose(pure, withPlaceIdFromRoute, withPlace, withFoodItemsForPlace);
const { photos } = place;
export default enhance(PlaceDetail);
return (
<ScrollView style={theme.page.container}>
<View style={{ height: 200, flexGrow: 0 }}>
{photos.size === 1 && <Image style={stretchedStyle} source={{ uri: photos.first() }} />}
{photos.size > 1 && (
<Carousel autoplay={false} onAnimateNextPage={setCurrentImage} style={stretchedStyle}>
{photos.map(uri => (
<Image key={uri} style={{ flex: 1, resizeMode: 'stretch' }} source={{ uri }} />
))}
</Carousel>
)}
<CountBadge currentImage={currentImage + 1} totalCount={photos.size} />
</View>
<View
style={{
flexDirection: 'column',
...contentTileStyle,
marginBottom: 10,
paddingTop: 5,
paddingBottom: 10,
}}>
<View style={{ marginTop: 15, marginBottom: 20 }}>
<StrongText>{place.address}</StrongText>
</View>
<IconButton
glyph="favorite"
text="Add to Faves"
onPress={() => {}}
color={style.actionIconColor}
/>
<IconButton
glyph="map"
text="View on a Map"
onPress={viewOnMap}
color={style.actionIconColor}
/>
</View>
<View style={{ ...contentTileStyle, paddingTop: 10 }}>
<StrongText>Products</StrongText>
{!!foodItems &&
foodItems.map(foodItem => (
<FoodItemTile key={foodItem.id} foodItem={foodItem} place={place} />
))}
</View>
</ScrollView>
);
};
export default compose(
pure,
withPlaceIdFromRoute,
withPlace,
withFoodItemsForPlace,
withState('currentImage', 'setCurrentImage', 0),
withHandlers({
viewOnMap: (props: Props) => () => {
const { place } = props;
if (!place) {
return;
}
const { latitude, longitude } = place;
const url = `geo:${latitude}, ${longitude}`;
Linking.canOpenURL(url)
.then(supported => {
if (supported) {
return Linking.openURL(url);
}
})
.catch(err => console.error('Failed to open geo URL', err)); // eslint-disable-line
},
}),
lifecycle({
componentDidMount() {
this.props.fetchPlaceDetails({
distance: this.props.place.distance,
placeId: this.props.place.id,
});
},
})
)(PlaceDetail);

View file

@ -96,43 +96,40 @@ const getPhotoUrls = pipe(
map(getURLForPhotoReference({ maxheight: 600 }))
);
export const buildPlaceRecord = memoizeWith(
(place: ?GooglePlaceObj) => place && place.place_id,
(place: ?GooglePlaceObj) => {
if (!place) {
return;
}
const {
place_id,
name,
formatted_address,
geometry: { location = {} } = {},
formatted_phone_number,
url,
opening_hours = {},
distance,
} = place;
const photos = fromJS(getPhotoUrls(place));
const thumb = getThumb(place);
return new PlaceRecord({
id: place_id,
name: name,
address: formatted_address,
latitude: location.lat,
longitude: location.lng,
distance,
phoneNumber: formatted_phone_number,
googleMapsUrl: url,
photos,
thumb,
hours: fromJS(opening_hours.weekday_text),
periods: fromJS(opening_hours.periods),
openNow: opening_hours.open_now,
});
export const buildPlaceRecord = (place: ?GooglePlaceObj) => {
if (!place) {
return;
}
);
const {
place_id,
name,
formatted_address,
geometry: { location = {} } = {},
formatted_phone_number,
url,
opening_hours = {},
distance,
} = place;
const photos = fromJS(getPhotoUrls(place));
const thumb = getThumb(place);
return new PlaceRecord({
id: place_id,
name: name,
address: formatted_address,
latitude: location.lat,
longitude: location.lng,
distance,
phoneNumber: formatted_phone_number,
googleMapsUrl: url,
photos,
thumb,
hours: fromJS(opening_hours.weekday_text),
periods: fromJS(opening_hours.periods),
openNow: opening_hours.open_now,
});
};
export default PlaceRecord;

View file

@ -3,7 +3,7 @@ import { ReplaySubject, Observable } from 'rxjs';
import { buildPlaceRecord } from '../records/PlaceRecord';
import { Map } from 'immutable';
import { findNearbyPlaces, getPlaceDetails } from '../apis/GooglePlacesApi';
import { memoize, path } from 'ramda';
import { path } from 'ramda';
import { type GooglePlaceObj } from '../records/PlaceRecord';
import { setById } from '../helpers/ImmutableHelpers';
import location$ from './LocationStream';
@ -15,21 +15,6 @@ import geodist from 'geodist';
const getGeoDist = begin => end => geodist(begin, end, { exact: true });
/**
* always return a promise and swallow exceptions so as not to break the stream
*/
const safeWrapAjax = ajaxFn =>
memoize((...args) => {
return ajaxFn(...args).catch(error => {
console.log('ERROR: ', error); //eslint-disable-line no-console
return null;
});
});
const safeFindNearbyPlaces = safeWrapAjax(findNearbyPlaces);
const safeGetPlaceDetails = safeWrapAjax(getPlaceDetails);
const placesSubject = new ReplaySubject();
export function emitter(val?: ?PlaceRecord) {
@ -40,7 +25,7 @@ filter$.subscribe(() => emitter(null));
foodItems$
.mergeMap((foodItems = Map()) => Observable.from(foodItems.toArray()))
.mergeMap(safeGetPlaceDetails)
.mergeMap(getPlaceDetails)
.map(buildPlaceRecord)
.subscribe(emitter);
@ -48,7 +33,7 @@ location$
.combineLatest(filter$)
.debounceTime(200)
.mergeMap(([location, filter]: [?Position, FilterRecord]) => {
return safeFindNearbyPlaces({
return findNearbyPlaces({
location,
radius: filter.radius,
search: filter.search,