mirror of
https://gitlab.com/wheres-the-tp/ui-mobile.git
synced 2026-01-25 04:34:55 -06:00
places detail view and tile hours
This commit is contained in:
parent
e23f279b65
commit
33303d7339
14 changed files with 251 additions and 90 deletions
|
|
@ -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}`;
|
||||
};
|
||||
|
|
|
|||
37
js/components/FoodItemTile.js
Normal file
37
js/components/FoodItemTile.js
Normal file
|
|
@ -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 <View />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={routeWithTitle(`/foodItem/${foodItem.id || ''}`, foodItem.name)}
|
||||
underlayColor={theme.itemTile.pressHighlightColor}
|
||||
>
|
||||
<View>
|
||||
<TileBox>
|
||||
<Thumbnail thumb={foodItem.thumbImage} />
|
||||
<View style={{ paddingTop: 15 }}>
|
||||
<StrongText>
|
||||
{foodItem.name || ''}
|
||||
</StrongText>
|
||||
<SubText>
|
||||
{`${place.name || ''} - ${foodItem.distance} mi`}
|
||||
</SubText>
|
||||
</View>
|
||||
</TileBox>
|
||||
</View>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
|
@ -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) =>
|
||||
<TouchableOpacity {...others}>
|
||||
<View style={{ flexDirection: 'row', marginTop: 5 }}>
|
||||
<Icon name={glyph} size={35} color={underlayColor} style={{ marginRight: 10 }} />
|
||||
<Text style={{ lineHeight: 26 }}>{text}</Text>
|
||||
<Icon name={glyph} size={35} color={color} style={{ marginRight: 10 }} />
|
||||
<Text style={{ lineHeight: 26 }}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
));
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
<View
|
||||
|
|
@ -49,24 +44,3 @@ export const TileBox = ({ children, style = {} }: { children?: any, style?: Obje
|
|||
<View style={{ height: 70, flexDirection: 'row', backgroundColor: 'white', ...style }}>
|
||||
{children}
|
||||
</View>;
|
||||
|
||||
export const FoodItemTile = pure(({ foodItem = {}, place = {} }: { foodItem: FoodItemRecord, place: PlaceRecord }) =>
|
||||
<Link
|
||||
to={routeWithTitle(`/foodItem/${foodItem.id || ''}`, foodItem.name)}
|
||||
underlayColor={theme.itemTile.pressHighlightColor}
|
||||
>
|
||||
<View>
|
||||
<TileBox>
|
||||
<Thumbnail thumb={foodItem.thumbImage} />
|
||||
<View style={{ paddingTop: 15 }}>
|
||||
<StrongText>
|
||||
{foodItem.name || ''}
|
||||
</StrongText>
|
||||
<SubText>
|
||||
{`${place.name || ''} - ${foodItem.distance} mi`}
|
||||
</SubText>
|
||||
</View>
|
||||
</TileBox>
|
||||
</View>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string>) => {
|
||||
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<string> };
|
||||
export default pure(({ place = {}, distance = 999, categories = new List() }: PlaceTileProps) => {
|
||||
export default pure(({ place, distance = 999, categories = new List() }: PlaceTileProps) => {
|
||||
if (!place) {
|
||||
return <View />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={routeWithTitle(`/place/${place.id || ''}`, place.name)}
|
||||
|
|
@ -19,7 +32,7 @@ export default pure(({ place = {}, distance = 999, categories = new List() }: Pl
|
|||
>
|
||||
<View style={{ height: 70, flexDirection: 'row', backgroundColor: 'white' }}>
|
||||
<Thumbnail thumb={place.thumb} />
|
||||
<View style={{ paddingTop: 15 }}>
|
||||
<View style={{ paddingTop: 5 }}>
|
||||
<StrongText>
|
||||
{`${place.name} - ${distance} mi`}
|
||||
</StrongText>
|
||||
|
|
@ -27,7 +40,7 @@ export default pure(({ place = {}, distance = 999, categories = new List() }: Pl
|
|||
{`${categories.interpose(', ').reduce((str, token) => str + token, '')}`}
|
||||
</SubText>
|
||||
<SubText>
|
||||
{'hours'}
|
||||
{getHoursText(place.hours)}
|
||||
</SubText>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ export class FoodItemDetail extends Component {
|
|||
|
||||
render() {
|
||||
const { foodItem, place } = this.props;
|
||||
|
||||
if (!foodItem || !place) {
|
||||
return <View />;
|
||||
}
|
||||
|
||||
const { images, quantity } = foodItem;
|
||||
const viewableImages = images.filter(image => !!image);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
}));
|
||||
|
||||
// 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 <View />;
|
||||
}
|
||||
|
||||
const { photos } = place;
|
||||
const { currentImage } = this.state;
|
||||
|
||||
return (
|
||||
<View style={theme.page.container}>
|
||||
<Text>{id}</Text>
|
||||
</View>
|
||||
<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.size &&
|
||||
foodItems.map(foodItem => <FoodItemTile key={foodItem.id} foodItem={foodItem} place={place} />)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhance = compose(pure);
|
||||
const enhance = compose(pure, withPlaceIdFromRoute, withPlace, withFoodItemsForPlace);
|
||||
|
||||
export default enhance(PlaceDetail);
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ const PlacesList = withFoodItemsGroupedByPlace(
|
|||
<View>
|
||||
{foodItemsByPlace
|
||||
.map((foodItems: Map<string, FoodItemRecord>, placeId: string) => {
|
||||
const Comp = withPlaceProps(PlaceTile);
|
||||
return <Comp key={placeId} placeId={placeId} foodItems={foodItems} />;
|
||||
const EnhancedPlaceTile = withPlaceProps(PlaceTile);
|
||||
return <EnhancedPlaceTile key={placeId} placeId={placeId} foodItems={foodItems} />;
|
||||
})
|
||||
.toList()}
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -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<string>,
|
||||
icon: string,
|
||||
opening_hours: {
|
||||
open_now: boolean,
|
||||
periods: Array<{
|
||||
|
|
@ -41,6 +42,8 @@ export type GooglePlaceObj = {
|
|||
},
|
||||
};
|
||||
|
||||
export type Period = Map<string, any>;
|
||||
|
||||
export type Place = {
|
||||
id: string,
|
||||
name: string,
|
||||
|
|
@ -50,8 +53,9 @@ export type Place = {
|
|||
phoneNumber: string,
|
||||
googleMapsUrl: string,
|
||||
photos: List<string>,
|
||||
icon: string,
|
||||
thumb: string,
|
||||
hours: List<string>,
|
||||
periods: List<Map<string, Period>>,
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ export default {
|
|||
color: '#0E6E9E',
|
||||
},
|
||||
foodItemDetails: {
|
||||
actionIconColor: COLOR.blue500,
|
||||
actionIconColor: '#0E6E9E',
|
||||
},
|
||||
placeDetails: {
|
||||
actionIconColor: '#0E6E9E',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue