diff --git a/js/apis/PlaceDetailsApi.js b/js/apis/PlaceDetailsApi.js index 3bce083..9a1cef2 100644 --- a/js/apis/PlaceDetailsApi.js +++ b/js/apis/PlaceDetailsApi.js @@ -1,8 +1,9 @@ // @flow const apiKey = 'AIzaSyBfMm1y6JayCbXrQmgAG1R3ka4ZOJno_5E'; const placesUrl = `https://maps.googleapis.com/maps/api/place/details/json?key=${apiKey}`; +const photosUrl = `https://maps.googleapis.com/maps/api/place/photo?key=${apiKey}`; -export const getPlaceDetails = async (placeid: string) => { +export const getPlaceDetails = async (placeid: ?string) => { if (!placeid || typeof placeid !== 'string') { throw new Error('placeid looks wrong'); } @@ -16,3 +17,19 @@ export const getPlaceDetails = async (placeid: string) => { } return result; }; + +export const getURLForPhotoReference = (opts?: { maxheight?: number, maxwidth?: number } = {}) => ( + photoReference: string +) => { + if (!photoReference) { + return ''; + } + + const { maxheight, maxwidth } = opts; + + const maxHeight = `&maxheight=${maxheight || 600}`; + const maxWidth = maxwidth ? `&maxwidth=${maxwidth}` : ''; + const photoref = `&photoreference=${photoReference}`; + + return `${photosUrl}${photoref}${maxHeight}${maxWidth}`; +}; diff --git a/js/components/FoodItemTile.js b/js/components/FoodItemTile.js new file mode 100644 index 0000000..4f2853d --- /dev/null +++ b/js/components/FoodItemTile.js @@ -0,0 +1,37 @@ +// @flow +import React from 'react'; +import { View } from 'react-native'; +import { pure } from 'recompose'; +import FoodItemRecord from '../records/FoodItemRecord'; +import typeof PlaceRecord from '../records/PlaceRecord'; +import theme from '../ui-theme'; +import { Link } from 'react-router-native'; +import { routeWithTitle } from '../helpers/RouteHelpers'; +import { TileBox, StrongText, SubText, Thumbnail } from './ItemTile'; + +export default pure(({ foodItem, place = {} }: { foodItem: FoodItemRecord, place: PlaceRecord }) => { + if (!foodItem) { + return ; + } + + return ( + + + + + + + {foodItem.name || ''} + + + {`${place.name || ''} - ${foodItem.distance} mi`} + + + + + + ); +}); diff --git a/js/components/IconButton.js b/js/components/IconButton.js index 48b8f85..dc9ee73 100644 --- a/js/components/IconButton.js +++ b/js/components/IconButton.js @@ -4,13 +4,15 @@ import { Text, View, TouchableOpacity } from 'react-native'; import { pure } from 'recompose'; import { Icon } from 'react-native-material-ui'; -type Props = { glyph: string, text: string, route: string, onPress: Function, underlayColor?: string }; +type Props = { glyph: string, text: string, route: string, onPress: Function, color?: string }; -export default pure(({ glyph, text, underlayColor = 'grey', ...others }: Props) => ( +export default pure(({ glyph, text, color = 'grey', ...others }: Props) => - - {text} + + + {text} + -)); +); diff --git a/js/components/ItemTile.js b/js/components/ItemTile.js index d9d1d7d..c31530c 100644 --- a/js/components/ItemTile.js +++ b/js/components/ItemTile.js @@ -1,12 +1,7 @@ //@flow import React from 'react'; import { Text, View, Image } from 'react-native'; -import { pure } from 'recompose'; -import FoodItemRecord from '../records/FoodItemRecord'; -import typeof PlaceRecord from '../records/PlaceRecord'; import theme from '../ui-theme'; -import { Link } from 'react-router-native'; -import { routeWithTitle } from '../helpers/RouteHelpers'; export const Thumbnail = ({ thumb }: { thumb: ?string }) => {children} ; - -export const FoodItemTile = pure(({ foodItem = {}, place = {} }: { foodItem: FoodItemRecord, place: PlaceRecord }) => - - - - - - - {foodItem.name || ''} - - - {`${place.name || ''} - ${foodItem.distance} mi`} - - - - - -); diff --git a/js/components/PlaceTile.js b/js/components/PlaceTile.js index cb0a347..923962a 100644 --- a/js/components/PlaceTile.js +++ b/js/components/PlaceTile.js @@ -8,10 +8,23 @@ import { Link } from 'react-router-native'; import { Thumbnail, StrongText, SubText } from './ItemTile'; import { routeWithTitle } from '../helpers/RouteHelpers'; +const getHoursText = (hours: List) => { + if (!hours) { + return 'not listed'; + } + // Note: js Date return 0-Sun/6-Sat where google return 0-Mon/6-Sun + // Immutable.List is smart enough to know that a negative index is counted from the last index + return hours.get(new Date().getDay() - 1); +}; + import theme from '../ui-theme'; type PlaceTileProps = { place: PlaceRecord, distance: number, categories: List }; -export default pure(({ place = {}, distance = 999, categories = new List() }: PlaceTileProps) => { +export default pure(({ place, distance = 999, categories = new List() }: PlaceTileProps) => { + if (!place) { + return ; + } + return ( - + {`${place.name} - ${distance} mi`} @@ -27,7 +40,7 @@ export default pure(({ place = {}, distance = 999, categories = new List() }: Pl {`${categories.interpose(', ').reduce((str, token) => str + token, '')}`} - {'hours'} + {getHoursText(place.hours)} diff --git a/js/enhancers/placeEnhancers.js b/js/enhancers/placeEnhancers.js index d4eb3c6..b3b16bc 100644 --- a/js/enhancers/placeEnhancers.js +++ b/js/enhancers/placeEnhancers.js @@ -3,6 +3,7 @@ import withProps from 'recompose/withProps'; import mapPropsStream from 'recompose/mapPropsStream'; import compose from 'recompose/compose'; import Places$ from '../streams/PlacesStream'; +import FoodItems$ from '../streams/FoodItemsStream'; import { path } from 'ramda'; export const withPlaces = mapPropsStream(props$ => @@ -40,3 +41,13 @@ export const withPlaceForFoodItem = mapPropsStream(props$ => }; }) ); + +export const withFoodItemsForPlace = mapPropsStream(props$ => + props$.combineLatest(FoodItems$, (props, foodItems) => { + const placeId = props.placeId; + return { + ...props, + foodItems: foodItems.toList().filter(foodItem => placeId === foodItem.placeId), + }; + }) +); diff --git a/js/pages/Food.js b/js/pages/Food.js index 211e1c7..4f24a6d 100644 --- a/js/pages/Food.js +++ b/js/pages/Food.js @@ -2,7 +2,7 @@ import React, { Component, PropTypes } from 'react'; import { View, ScrollView } from 'react-native'; import theme from '../ui-theme'; -import { FoodItemTile } from '../components/ItemTile'; +import FoodItemTile from '../components/FoodItemTile'; import { ActionButton } from 'react-native-material-ui'; import { routeWithTitle } from '../helpers/RouteHelpers'; import FoodItemList from '../components/FoodItemList'; diff --git a/js/pages/FoodItemDetail.js b/js/pages/FoodItemDetail.js index d70b0e9..c2f0c46 100644 --- a/js/pages/FoodItemDetail.js +++ b/js/pages/FoodItemDetail.js @@ -57,6 +57,11 @@ export class FoodItemDetail extends Component { render() { const { foodItem, place } = this.props; + + if (!foodItem || !place) { + return ; + } + const { images, quantity } = foodItem; const viewableImages = images.filter(image => !!image); diff --git a/js/pages/PlaceDetail.js b/js/pages/PlaceDetail.js index b750a3b..9992bfe 100644 --- a/js/pages/PlaceDetail.js +++ b/js/pages/PlaceDetail.js @@ -1,31 +1,112 @@ // @flow import React, { Component } from 'react'; -import { Text, View } from 'react-native'; +import { View, Image, ScrollView } from 'react-native'; import theme from '../ui-theme'; import { compose, pure } from 'recompose'; +import typeof PlaceRecord from '../records/PlaceRecord'; +import { withPlace, withPlaceIdFromRoute, withFoodItemsForPlace } from '../enhancers/placeEnhancers'; +import Carousel from 'react-native-looped-carousel'; +import CountBadge from '../components/CountBadge'; +import { StrongText } from '../components/ItemTile'; +import IconButton from '../components/IconButton'; +import { type List } from 'immutable'; +import typeof FoodItemRecord from '../records/FoodItemRecord'; +import FoodItemTile from '../components/FoodItemTile'; + +const { placeDetails: style } = theme; + +const stretchedStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }; + +const contentTileStyle = { + backgroundColor: 'white', + paddingLeft: 20, + paddingRight: 20, +}; export class PlaceDetail extends Component { static displayName = 'PlaceDetail'; props: { - match: { - params: { - id: string, - }, - }, + place: ?PlaceRecord, + foodItems: ?List, }; + 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, + })); + + // todo - build a map view and open it here + viewOnMap = () => {}; + // todo: need to get the item from the stream by this id render() { - const { match: { params: { id } } } = this.props; + const { place, foodItems } = this.props; + + if (!place) { + return ; + } + + const { photos } = place; + const { currentImage } = this.state; + return ( - - {id} - + + + {photos.size === 1 && } + {photos.size > 1 && + + {photos.map(uri => + + )} + } + + + + + + {place.address} + + + + + + + + + Products + {!!foodItems && + foodItems.size && + foodItems.map(foodItem => )} + + ); } } -const enhance = compose(pure); +const enhance = compose(pure, withPlaceIdFromRoute, withPlace, withFoodItemsForPlace); export default enhance(PlaceDetail); diff --git a/js/pages/Places.js b/js/pages/Places.js index 94c78fa..05fc9ba 100644 --- a/js/pages/Places.js +++ b/js/pages/Places.js @@ -17,8 +17,8 @@ const PlacesList = withFoodItemsGroupedByPlace( {foodItemsByPlace .map((foodItems: Map, placeId: string) => { - const Comp = withPlaceProps(PlaceTile); - return ; + const EnhancedPlaceTile = withPlaceProps(PlaceTile); + return ; }) .toList()} diff --git a/js/records/PlaceRecord.js b/js/records/PlaceRecord.js index db8034f..c0064b0 100644 --- a/js/records/PlaceRecord.js +++ b/js/records/PlaceRecord.js @@ -1,5 +1,7 @@ // @flow -import { List, Record, fromJS } from 'immutable'; +import { List, Map, Record, fromJS } from 'immutable'; +import { pipe, pathOr, map, head, memoizeWith } from 'ramda'; +import { getURLForPhotoReference } from '../apis/PlaceDetailsApi'; export type GooglePlaceObj = { place_id: string, @@ -24,7 +26,6 @@ export type GooglePlaceObj = { formatted_phone_number: string, url: string, photos: Array, - icon: string, opening_hours: { open_now: boolean, periods: Array<{ @@ -41,6 +42,8 @@ export type GooglePlaceObj = { }, }; +export type Period = Map; + export type Place = { id: string, name: string, @@ -50,8 +53,9 @@ export type Place = { phoneNumber: string, googleMapsUrl: string, photos: List, - icon: string, + thumb: string, hours: List, + periods: List>, openNow: boolean, }; @@ -64,39 +68,54 @@ const FoodRecordDefaults: Place = { phoneNumber: '', googleMapsUrl: '', photos: new List(), - icon: '', + thumb: '', hours: new List(), + periods: new List(), openNow: false, }; const PlaceRecord = Record(FoodRecordDefaults, 'PlaceRecord'); -export const buildPlaceRecord = (place: GooglePlaceObj) => { - const { - place_id, - name, - formatted_address, - geometry: { location = {} } = {}, - formatted_phone_number, - url, - photos, - icon, - opening_hours = {}, - } = place; +const getPhotos = pathOr([], ['photos']); - return new PlaceRecord({ - id: place_id, - name: name, - address: formatted_address, - latitude: location.lat, - longitude: location.lng, - phoneNumber: formatted_phone_number, - googleMapsUrl: url, - photos: fromJS(photos), - icon: icon, - hours: fromJS(opening_hours.weekday_text), - openNow: opening_hours.open_now, - }); -}; +const getPhotoRef = photo => photo.photo_reference || ''; + +const getThumb = pipe(getPhotos, head, getPhotoRef, getURLForPhotoReference({ maxheight: 200 })); + +const getPhotoUrls = pipe(getPhotos, map(getPhotoRef), map(getURLForPhotoReference({ maxheight: 600 }))); + +export const buildPlaceRecord = memoizeWith( + (place: GooglePlaceObj) => place.place_id, + (place: GooglePlaceObj) => { + const { + place_id, + name, + formatted_address, + geometry: { location = {} } = {}, + formatted_phone_number, + url, + opening_hours = {}, + } = 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, + phoneNumber: formatted_phone_number, + googleMapsUrl: url, + photos, + thumb, + hours: fromJS(opening_hours.weekday_text), + periods: fromJS(opening_hours.periods), + open: !!opening_hours.open_now, + openNow: opening_hours.open_now, + }); + } +); export default PlaceRecord; diff --git a/js/streams/PlacesStream.js b/js/streams/PlacesStream.js index cf19efb..4f4a5b9 100644 --- a/js/streams/PlacesStream.js +++ b/js/streams/PlacesStream.js @@ -1,7 +1,7 @@ // @flow import { buildPlaceRecord } from '../records/PlaceRecord'; import { Map } from 'immutable'; -import { foodItemsRaw$ } from './FoodItemsStream'; +import FoodItems$ from './FoodItemsStream'; import { getPlaceDetails } from '../apis/PlaceDetailsApi'; import { memoize } from 'ramda'; @@ -17,7 +17,9 @@ const safeGetPlaceDetails = memoize(placeId => { }); }); -const uniquePlaceIds$ = foodItemsRaw$.map(({ placeId }) => placeId).distinct(); +const uniquePlaceIds$ = FoodItems$.mergeMap(foodItemMap => { + return foodItemMap.map(foodItem => foodItem.placeId).toArray(); +}).distinct(); const placeRecords$ = uniquePlaceIds$.mergeMap(safeGetPlaceDetails).map(buildPlaceRecord); diff --git a/js/ui-theme.js b/js/ui-theme.js index a6c037b..f3a434d 100644 --- a/js/ui-theme.js +++ b/js/ui-theme.js @@ -60,6 +60,9 @@ export default { color: '#0E6E9E', }, foodItemDetails: { - actionIconColor: COLOR.blue500, + actionIconColor: '#0E6E9E', + }, + placeDetails: { + actionIconColor: '#0E6E9E', }, }; diff --git a/jsconfig.json b/jsconfig.json index c98b6e0..5e5d259 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,9 +1,6 @@ { - "compilerOptions": { - "allowJs": true, - "allowSyntheticDefaultImports": true - }, - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "compilerOptions": { + "allowSyntheticDefaultImports": true + }, + "exclude": ["node_modules"] +}