Reorganize & reformat

This commit is contained in:
Edin Dazdarevic
2017-04-11 10:43:05 +02:00
parent a1151150db
commit 4a8740fb35
22 changed files with 2364 additions and 1353 deletions

View File

@@ -1,65 +0,0 @@
import React from 'react';
import {galleryImageUrl} from '../lib/helpers';
export default class Gallery extends React.Component {
onPrevClick (e) {
this.props.dispatch({type: 'PREV_IMAGE'});
}
onNextClick (e) {
this.props.dispatch({type: 'NEXT_IMAGE'});
}
onImageDotClick (index, e) {
this.props.dispatch({type: 'VIEW_IMAGE', action: {index}})
}
render() {
const {images, imageIndex} = this.props;
if (!images || images.length === 0) {
return null;
}
const showPrev = imageIndex > 0;
const showNext = imageIndex < images.length - 1;
return (
<div className="ld-image-container">
<img src={galleryImageUrl(images[imageIndex])}></img>
{showPrev ?
<div
className='prev-button'
onClick={this.onPrevClick.bind(this)}>
<div>
<svg fill="white" version="1.1" x="0px" y="0px" width="100%" height="100%" viewBox="0 0 512 512">
<path d="M213.7,256L213.7,256L213.7,256L380.9,81.9c4.2-4.3,4.1-11.4-0.2-15.8l-29.9-30.6c-4.3-4.4-11.3-4.5-15.5-0.2L131.1,247.9 c-2.2,2.2-3.2,5.2-3,8.1c-0.1,3,0.9,5.9,3,8.1l204.2,212.7c4.2,4.3,11.2,4.2,15.5-0.2l29.9-30.6c4.3-4.4,4.4-11.5,0.2-15.8 L213.7,256z"></path>
</svg>
</div>
</div>
: null}
{showNext ?
<div
className='next-button'
onClick={this.onNextClick.bind(this)}>
<div>
<svg fill="white" version="1.1" x="0px" y="0px" width="100%" height="100%" viewBox="0 0 512 512">
<path d="M298.3,256L298.3,256L298.3,256L131.1,81.9c-4.2-4.3-4.1-11.4,0.2-15.8l29.9-30.6c4.3-4.4,11.3-4.5,15.5-0.2l204.2,212.7 c2.2,2.2,3.2,5.2,3,8.1c0.1,3-0.9,5.9-3,8.1L176.7,476.8c-4.2,4.3-11.2,4.2-15.5-0.2L131.3,446c-4.3-4.4-4.4-11.5-0.2-15.8 L298.3,256z"></path>
</svg>
</div>
</div>
: null}
<div className="image-dots">
{images.map((img, index) => {
let cls = 'image-dot'
if (index === imageIndex) {
cls += ' selected'
}
return <div key={img} onClick={this.onImageDotClick.bind(this, index)} className={cls}></div>
})}
</div>
</div>)
}
}

View File

@@ -1,564 +0,0 @@
import React from 'react';
import Filters from './Filters';
import Listings from './Listings';
import ListingDetails from './ListingDetails';
import { pacSelectFirst } from '../helpers/googleMaps';
import { loadProperties, loadSeen, loadListing} from '../lib/api'
import { handleMessage } from '../lib/handlers'
import Router from '../lib/router';
class Main extends React.Component {
constructor(props) {
super(props);
const state = {
listingDetails: false,
listings: (new Map()),
imageIndex: 0,
page: 0,
sort: 'relevance',
filters: {
rooms: {},
category: {}
}
}
if (props.initialState) {
props.initialState.sort = props.initialState.sort || state.sort;
state.filters.rooms = props.initialState.rooms;
state.filters.category = props.initialState.category;
state.sort = props.initialState.sort || state.sort;
state.listingId = props.initialState.listingId;
state.bounds = props.initialState.bounds;
state.zoom = props.initialState.zoom;
if (state.listingId) {
state.listingDetails = true;
}
state.filters.minSize = props.initialState.minSize;
state.filters.maxSize = props.initialState.maxSize;
state.filters.minPrice = props.initialState.minPrice;
state.filters.maxPrice = props.initialState.maxPrice;
}
this.state = state;
this.router = new Router(this, props.initialState);
}
dispatch ({type, action = {}}) {
handleMessage({type, action}, this);
}
componentDidMount() {
const uluru = {lat: 43.845031, lng: 18.4019262};
const opts = {
//zoom: 13,
//center: uluru,
streetViewControl: false,
mapTypeControl: false
};
if (!this.state.bounds) {
opts.zoom = 13;
opts.center= uluru;
};
const map = new google.maps.Map(this.refs.map, opts);
window.gmap = map;
//const marker = new google.maps.Marker({
//position: uluru,
//map: map
//});
var control = document.createElement('div');
control.classList.add('filters-btn-toggle');
control.innerHTML = '<button>Filters</button>';
//control.style = "top: 200px;"
control["style"]= "top: 200px;"
var input = document.getElementById('gmaps-places-input');
pacSelectFirst(input);
var options = {
componentRestrictions: {country: "BA"}
};
const regularIdle = () => {
this.dispatch({type: 'UPDATE_ROUTE', action: {params: {
bounds: map.getBounds().toUrlValue(),
zoom: map.getZoom()
}}});
this.dispatch({type: 'MAP_IDLE'});
};
// Check if initial bounds are passed-in
google.maps.event.addListenerOnce(map, 'idle', () => {
if (this.state.bounds) {
const [ lat1, lng1, lat2, lng2 ] = this.state.bounds.split(",");
const sw = new google.maps.LatLng({lat: parseFloat(lat1), lng: parseFloat(lng1)});
const ne = new google.maps.LatLng({lat: parseFloat(lat2), lng: parseFloat(lng2)});
const initialBounds = new google.maps.LatLngBounds(sw, ne);
//map.fitBounds(initialBounds);
const originalMaxZoom = map.maxZoom;
const originalMinZoom = map.minZoom;
map.setOptions({maxZoom: parseInt(this.state.zoom), minZoom: parseInt(this.state.zoom)});
map.fitBounds(initialBounds);
map.setOptions({maxZoom: originalMaxZoom, minZoom: originalMinZoom});
}
});
map.addListener('idle', regularIdle);
var searchBox = new google.maps.places.Autocomplete(input, options);
searchBox.addListener('place_changed', () => {
var place = searchBox.getPlace();
if (!place.geometry) {
return;
}
if (place.geometry.viewport) {
map.fitBounds(place.geometry.viewport);
} else {
map.setCenter(place.geometry.location);
map.setZoom(18);
}
this.dispatch({type: 'SEARCH_PLACE_CHANGED'});
});
control.addEventListener('click', (e) => {
this.setState({
mapClicked: true
});
});
control.index = 1;
map.controls[google.maps.ControlPosition.TOP_RIGHT].push(control);
this.map = map;
// TODO: if state contains listingId reload
if (this.state.listingId) {
loadListing(this.state.listingId).then(l => l.text()).then(l => {
this.dispatch({type: 'VIEW_LISTING_DETAILS', action: {
id: this.state.listingId,
listing: JSON.parse(l)
}});
});
}
}
removeAllMarkers () {
if (this.markers) {
this.markers.forEach((m) => m.marker.setMap(null));
}
}
onCloseClick(e) {
if (this.state.mapClicked) {
setTimeout(() => {
google.maps.event.trigger(this.map, 'resize');
}, 100);
}
this.setState({
mapClicked: false
});
}
findMarker (id) {
if (!this.markers) {
return null;
}
const index = this.markers.findIndex(m => m.id === id);
return this.markers[index];
}
isSeen (id) {
const seen = loadSeen();
return seen.findIndex(s => s === id) !== -1
}
loadPins () {
const map = this.map;
const {
rooms,
minSize,
maxSize,
minPrice,
maxPrice,
category
} = this.state.filters;
const bounds = map.getBounds();
const properties = loadProperties({
bounds: bounds.toUrlValue(),
rooms,
minSize,
maxSize,
minPrice,
maxPrice,
category,
page: this.state.page,
pins: true
});
const markerExists = (id) => {
return this.findMarker(id) != null;
}
properties
.then(p => {
return {
body: p.text(),
totalCount: p.headers.get('X-Total-Count')
};
})
.then(({body, totalCount}) => {
body.then(p => {
const data = JSON.parse(p);
const listingExists = (id) => {
return data.findIndex(l => l._id === id) !== -1
};
const newMarkers = [];
if (this.markers) {
this.markers.forEach((m) => {
if (!listingExists(m.id)) {
m.marker.setMap(null);
} else {
newMarkers.push(m);
}
});
}
for(const [index, prop] of data.entries()) {
const myLatLng = {lat: prop.loc[0], lng: prop.loc[1]};
if (!markerExists(prop._id)) {
const marker = new google.maps.Marker({
position : myLatLng,
map : map,
//title : prop.title,
icon : this.isSeen(prop._id) ? this.visitedMarkerIcon() : this.defaultMarkerIcon(),
id : prop._id
});
marker.addListener('mouseover', () => {
if (marker.id !== this.state.listingId) {
if (this.isSeen(marker.id)) {
marker.setIcon(this.visitedHoveredMarkerIcon());
} else {
marker.setIcon(this.hoveredMarkerIcon());
}
}
});
marker.addListener('mouseout', () => {
if (marker.id !== this.state.listingId) {
if (this.isSeen(marker.id)) {
marker.setIcon(this.visitedMarkerIcon());
} else {
marker.setIcon(this.defaultMarkerIcon());
}
}
});
marker.addListener('click', () => {
// Maybe move out and call when popping state
if (this.state.listingId) {
const prevSelected = this.findMarker(this.state.listingId);
if (prevSelected) {
prevSelected.marker.setIcon(this.visitedMarkerIcon());
}
}
marker.setIcon(this.selectedMarkerIcon());
loadListing(prop._id).then(l => l.text()).then(l => {
this.dispatch({type: 'UPDATE_ROUTE', action: {
toDispatch: {
type: 'VIEW_LISTING_DETAILS', action: {
id: prop._id,
listing: JSON.parse(l)
}
},
params: {
listingId: prop._id
}
}});
//this.dispatch({type: 'UPDATE_ROUTE', action: {type: 'VIEW_LISTING_DETAILS', action: {
//id: prop._id,
//listing: JSON.parse(l)
//}}});
this.dispatch({type: 'VIEW_LISTING_DETAILS', action: {
id: prop._id,
listing: JSON.parse(l)
}});
});
});
newMarkers.push({
marker,
id: prop._id
});
}
}
this.dispatch({
type: 'PINS_LOADED',
action: {
newMarkers
}
});
});
})
}
/*
* Refreshes search
*/
refreshListings(more = false) {
// TODO: move somewhere else
if (!more && this.state.listingId) {
loadListing(this.state.listingId).then(l => l.text()).then(l => {
this.dispatch({type: 'VIEW_LISTING_DETAILS', action: {
id: this.state.listingId,
listing: JSON.parse(l)
}});
});
}
if (!more) {
this.loadPins();
}
const map = this.map;
const {
rooms,
minSize,
maxSize,
minPrice,
maxPrice,
category
} = this.state.filters;
const bounds = map.getBounds();
const properties = loadProperties({
bounds: bounds.toUrlValue(),
rooms,
minSize,
maxSize,
minPrice,
maxPrice,
category,
page: this.state.page,
sort: this.state.sort
});
properties
.then(p => {
return {
body: p.text(),
totalCount: p.headers.get('X-Total-Count')
};
})
.then(({body, totalCount}) => {
body.then(p => {
const data = JSON.parse(p);
this.dispatch({
type: 'LISTINGS_LOADED',
action: {
listings: data,
more,
totalCount
}
});
});
})
}
/*
* Get default marker icon
*/
defaultMarkerIcon () {
const sf = 0.5;
const width = 48;
const height = 64;
const icon = {
url : "static/images/pins_sprite.png",
size : new google.maps.Size(width * sf, height * sf),
scaledSize : new google.maps.Size(730 * sf, 102 * sf),
origin : new google.maps.Point(0, 36 * sf)
}
return icon;
}
/*
* Get visited marker icon
*/
visitedMarkerIcon () {
const sf = 0.5;
const width = 48;
const height = 64;
const icon = {
url : "static/images/pins_sprite.png",
size : new google.maps.Size(width * sf, height * sf),
scaledSize : new google.maps.Size(730 * sf, 102 * sf),
origin : new google.maps.Point(152 * sf, 36 * sf)
}
return icon;
}
/*
* Visited hovered marker icon
*/
visitedHoveredMarkerIcon () {
const sf = 0.5;
const width = 61;
const height = 82;
const icon = {
url : "static/images/pins_sprite.png",
size : new google.maps.Size(width * sf, height * sf),
scaledSize : new google.maps.Size(730 * sf, 102 * sf),
origin : new google.maps.Point(480 * sf, 18 * sf)
}
return icon;
}
/*
* Hovered marker icon
*/
hoveredMarkerIcon () {
const sf = 0.5;
const width = 61;
const height = 82;
const icon = {
url : "static/images/pins_sprite.png",
size : new google.maps.Size(width * sf, height * sf),
scaledSize : new google.maps.Size(730 * sf, 102 * sf),
origin : new google.maps.Point(303 * sf, 18 * sf)
}
return icon;
}
/*
* Selected marker icon
*/
selectedMarkerIcon () {
const sf = 0.5;
const width = 73;
const height = 100;
const icon = {
url : "static/images/pins_sprite.png",
size : new google.maps.Size(width * sf, height * sf),
scaledSize : new google.maps.Size(730 * sf, 102 * sf),
origin : new google.maps.Point(655 * sf, 1 * sf)
}
return icon;
}
renderRightContent() {
const children = [];
if (this.state.listingDetails) {
const listing = this.state.listing; //this.state.listings.get(this.state.listingId);
children.push(<ListingDetails
listing={listing}
imageIndex={this.state.imageIndex}
dispatch={this.dispatch.bind(this)}
descriptionExpanded={this.state.descriptionExpanded}
/>);
} else {
children.push(<Filters filters={this.state.filters} dispatch={this.dispatch.bind(this)} onClose={this.onCloseClick.bind(this)}/>);
children.push(<Listings
sort={this.state.sort}
totalCount={this.state.totalCount}
loadingMore={this.state.loadingMore}
listings={this.state.listings}
dispatch={this.dispatch.bind(this)}
/>);
}
const content = (
<div className="right-content">
{children}
</div>);
return content;
}
render() {
const leftStyle = {};
const rightStyle = {};
const listingDetails = true;
let leftClass = 'left-base';
let rightClass = 'right-base';
if (this.state.mapClicked) {
leftClass = 'left-hidden';
rightClass = 'right-shown';
}
return (
<div id="container">
<div id="header">
<a className="hamburger-menu">K</a>
<span className="title">Kiwi</span>
<input
id="gmaps-places-input"
placeholder="Unesite adresu, naselje ili grad"
className="where-to"
type="text"></input>
<div className="view-types">
<a className="view-type-left">
<i className="btn-select-map fa fa-list"></i>
</a>
<a className="view-type-right">
<i className="view-type-map-icon fa fa-map-marker"></i>
</a>
</div>
</div>
<div id="right" style={rightStyle} className={rightClass}>
{this.renderRightContent()}
</div>
<div id="left" style={leftStyle} className={leftClass}>
<div id="map" ref="map">
</div>
</div>
</div>
)
}
}
export default Main;

View File

@@ -1,29 +0,0 @@
export const pacSelectFirst = (input) => {
// store the original event binding function
var _addEventListener = (input.addEventListener) ? input.addEventListener : input.attachEvent;
function addEventListenerWrapper(type, listener) {
// Simulate a 'down arrow' keypress on hitting 'return' when no pac suggestion is selected,
// and then trigger the original listener.
if (type == "keydown") {
var orig_listener = listener;
listener = function(event) {
var suggestion_selected = $(".pac-item-selected").length > 0;
if (event.which == 13 && !suggestion_selected) {
var simulated_downarrow = $.Event("keydown", {
keyCode: 40,
which: 40
});
orig_listener.apply(input, [simulated_downarrow]);
}
orig_listener.apply(input, [event]);
};
}
_addEventListener.apply(input, [type, listener]);
}
input.addEventListener = addEventListenerWrapper;
input.attachEvent = addEventListenerWrapper;
}

View File

@@ -1,59 +0,0 @@
import React from 'react';
import {render} from 'react-dom';
import Main from './components/Main';
const getInitialState = (url) => {
console.log('PARSING URL:', url);
const params = window.location.search.substr(1).split("&");
const initialState = {
rooms: {},
category: {}
}
for(const param of params) {
const [key, value] = param.split("=");
console.log('analyzing param ', key, value);
if (key === "rooms" && value !== '') {
console.log("IT's ROOMS");
value.split(",").forEach(k => {
console.log("IT's ROOMS", k);
initialState.rooms[parseInt(k)] = true;
});
}
if (key === "category" && value !== '') {
value.split(",").forEach(k => {
initialState.category[parseInt(k)] = true;
});
}
if (key === "sort") {
initialState.sort = value;
}
if (key === "bounds") {
initialState.bounds = value;
}
if (key === "listingId") {
initialState.listingId = value;
}
if (key === "zoom") {
initialState.zoom = parseInt(value);
}
if (["minSize", "maxSize", "minPrice", "maxPrice"].includes(key)) {
initialState[key] = parseFloat(value);
}
}
return initialState;
}
const main = (<Main initialState={getInitialState(window.location)}/>);
render(main, document.getElementById('root'));

View File

@@ -1,53 +0,0 @@
import fetch from 'isomorphic-fetch';
export const loadListing = (id) => {
let url = `http://localhost:3001/api/search/listings/${id}`;
return fetch(url, {
//credentials: 'include'
});
};
export const loadProperties = ({
bounds,
minPrice = '',
maxPrice = '',
minSize = '',
maxSize = '',
rooms = {},
category = {},
page = 1,
pins = false,
sort = ''
}) => {
const allRooms = Object
.keys(rooms)
.filter((v) => rooms[v])
.join(',');
const allCategories = Object
.keys(category)
.filter((v) => category[v])
.join(',');
// TODO: handle errors
//return fetch(process.env.API_URL + '/api/search', {
let url = `http://localhost:3001/api/search/listings?bounds=${bounds}&minPrice=${minPrice}&maxPrice=${maxPrice}&rooms=${allRooms}&minSize=${minSize}&maxSize=${maxSize}&category=${allCategories}&page=${page}&pins=${pins}&sort=${sort}`
return fetch(url, {
//credentials: 'include'
});
}
export const markSeen = (id) => {
const seen = JSON.parse(window.localStorage.getItem('seen') || '[]');
seen.push(id);
window.localStorage.setItem('seen', JSON.stringify(seen));
}
export const loadSeen = (id) => {
const seen = JSON.parse(window.localStorage.getItem('seen') || '[]');
return seen;
//return seen.findIndex(s => s === id) !== -1;
}

View File

@@ -1,294 +0,0 @@
import { markSeen } from './api';
const setMaxPrice = ({ type, action }, component) => {
const maxPrice = parseFloat(action.maxPrice);
component.setState({
page: 0,
filters: {
...component.state.filters,
maxPrice: isNaN(maxPrice) ? undefined : maxPrice,
priceDirty: true
}
});
};
const setMinPrice = ({ type, action }, component) => {
const minPrice = parseFloat(action.minPrice);
component.setState({
page: 0,
filters: {
...component.state.filters,
minPrice: isNaN(minPrice) ? undefined : minPrice,
priceDirty: true
}
});
};
const setMinSize = ({ type, action }, component) => {
const minSize = parseFloat(action.minSize);
component.setState({
page: 0,
filters: {
...component.state.filters,
minSize: isNaN(minSize) ? undefined : minSize,
sizeDirty: true
}
});
};
const setMaxSize = ({ type, action }, component) => {
const maxSize = parseFloat(action.maxSize);
component.setState({
page: 0,
filters: {
...component.state.filters,
maxSize: isNaN(maxSize) ? undefined : maxSize,
sizeDirty: true
}
});
};
const viewListingDetails = ({ type, action }, component) => {
const scrollElem = document.querySelector('.right-content');
component.savedScrollTop = scrollElem.scrollTop;
//component.router.listingId = action.id;
component.setState({
listingDetails: true,
listingId: action.id,
descriptionExpanded: false,
imageIndex: 0,
listing: action.listing
}, () => {
//component.router.update();
markSeen(action.id);
const m = component.findMarker(action.id);
if (m) {
m.marker.setIcon(component.selectedMarkerIcon());
}
scrollElem.scrollTop = 0
});
};
const listingsLoaded = ({ type, action }, component) => {
const currentListings = new Map();
for (const listing of action.listings) {
currentListings.set(listing._id, listing);
}
component.setState({
listings: action.more ? (new Map([...component.state.listings, ...currentListings])) : currentListings,
loadingMore: false,
totalCount: action.totalCount
});
};
const pinsLoaded = ({ type, action }, component) => {
component.setState({
}, () => {
component.markers = action.newMarkers;
});
};
const expandDescription = ({ type, action }, component) => {
component.setState({
descriptionExpanded: true
});
};
const prevImage = ({ type, action }, component) => {
const index = component.state.imageIndex;
if (index > 0) {
component.setState({
imageIndex: index - 1
});
}
};
const nextImage = ({ type, action }, component) => {
const index = component.state.imageIndex;
component.setState({
imageIndex: index + 1
});
};
const viewImage = ({ type, action }, component) => {
component.setState({
imageIndex: action.index
});
};
const searchPlaceChanged = ({ type, action }, component) => {
component.setState({
listingDetails: false,
page: 0
});
};
const setRooms = ({ type, action }, component) => {
const prevRooms = component.state.filters.rooms || {};
component.setState(
{
page: 0,
filters: {
...component.state.filters,
rooms: {
...prevRooms,
[action.rooms]: !prevRooms[action.rooms]
}
}
},
() => {
component.refreshListings();
}
);
};
const updateSearch = ({ type, action }, component) => {
component.setState(
{
filters: {
...component.state.filters,
sizeDirty: false,
priceDirty: false
}
},
() => {
component.refreshListings();
}
);
};
const setCategory = ({type, action}, component) => {
const prevCategory = component.state.filters.category || {};
component.setState(
{
page: 0,
filters: {
...component.state.filters,
category: {
...prevCategory,
[action.category]: !prevCategory[action.category]
}
}
},
() => {
component.refreshListings();
}
);
};
const onListingMouseOver = ({type, action}, component) => {
const marker = component.findMarker(action.id);
if (marker) {
const seen = component.isSeen(action.id);
if (seen) {
marker.marker.setIcon(component.visitedHoveredMarkerIcon());
} else {
marker.marker.setIcon(component.hoveredMarkerIcon());
}
marker.marker.setAnimation(google.maps.Animation.BOUNCE);
setTimeout(() => {
marker.marker.setAnimation(null);
if (seen) {
marker.marker.setIcon(component.visitedMarkerIcon());
} else {
marker.marker.setIcon(component.defaultMarkerIcon());
}
} , 710);
}
};
const backToResults = ({type, action}, component) => {
const prevSelected = component.findMarker(component.state.listingId);
component.setState({
listingId: null,
listingDetails: false
}, () => {
//component.router.update();
if (prevSelected) {
prevSelected.marker.setIcon(component.visitedMarkerIcon());
}
const scrollElem = document.querySelector('.right-content');
scrollElem.scrollTop = component.savedScrollTop;
});
}
const loadMoreListings = ({type, action}, component) => {
const currentPage = component.state.page;
if (currentPage * 20 < component.state.totalCount) {
component.setState({
loadingMore: true,
page: currentPage + 1
}, () => {
component.refreshListings(true);
});
}
}
const mapIdle = ({type, action}, component) => {
component.setState({
page: 0
}, () => {
const scrollElem = document.querySelector('.right-content');
scrollElem.scrollTop = 0;
component.refreshListings();
})
}
const sortChange = ({type, action}, component) => {
component.setState({
sort: action.sort,
page: 0
}, () => {
//component.router.update();
component.refreshListings();
});
}
const updateRoute = ({type, action}, component) => {
component.router.update(action);
}
const handlers = {
SET_MIN_PRICE: setMinPrice,
SET_MAX_PRICE: setMaxPrice,
SET_MIN_SIZE: setMinSize,
SET_MAX_SIZE: setMaxSize,
LISTINGS_LOADED: listingsLoaded,
EXPAND_DESCRIPTION: expandDescription,
PREV_IMAGE: prevImage,
NEXT_IMAGE: nextImage,
VIEW_IMAGE: viewImage,
SEARCH_PLACE_CHANGED: searchPlaceChanged,
SET_ROOMS: setRooms,
VIEW_LISTING_DETAILS: viewListingDetails,
UPDATE_SEARCH: updateSearch,
SET_CATEGORY: setCategory,
ON_LISTING_MOUSE_OVER: onListingMouseOver,
BACK_TO_RESULTS: backToResults,
LOAD_MORE_LISTINGS: loadMoreListings,
MAP_IDLE: mapIdle,
PINS_LOADED: pinsLoaded,
SORT_CHANGE: sortChange,
UPDATE_ROUTE: updateRoute
};
export const handleMessage = ({ type, action }, component) => {
if (!handlers[type]) {
throw new `Unhandled message: ${type}`();
}
return handlers[type]({ type, action }, component);
};

View File

@@ -1,22 +0,0 @@
export const formatPrice = (p) => {
if (isNaN(p)) {
return 'Po dogovoru'
}
return p.toLocaleString('bs') + ' KM';
}
export const formatFilterNumber = (num) => {
if (isNaN(num) || num == null) {
return ''
}
return num;
}
export const galleryImageUrl = (img) =>
img && img.replace("upload/", "upload/w_500/")
export const listingImageUrl = (img) =>
img && img.replace("upload/", "upload/w_205/")

View File

@@ -1,90 +0,0 @@
import clone from 'lodash.clonedeep';
export default class Router {
constructor(comp, initialState) {
this.component = comp;
this.state = clone(initialState) || {};
window.onpopstate = (event) => {
const state = event.state;
if (state) {
if (state.toDispatch) {
this.component.dispatch(state.toDispatch);
}
}
}
}
update (state) {
const params = [];
if (state.params) {
let cloned = clone(state);
if (cloned.params.rooms != null) {
this.state.rooms[cloned.params.rooms] = !this.state.rooms[cloned.params.rooms];
}
if (cloned.params.category != null) {
this.state.category[cloned.params.category] = !this.state.category[cloned.params.category];
}
delete cloned.params['rooms'];
delete cloned.params['category'];
this.state = Object.assign(this.state, cloned.params);
const {
listingId,
minPrice,
maxPrice,
minSize,
maxSize,
bounds,
sort,
rooms = {},
category = {},
zoom
} = this.state;
if (listingId) {
params.push(`listingId=${listingId}`);
}
params.push(`sort=${sort}`);
params.push(`bounds=${bounds}`);
params.push(`zoom=${zoom}`);
if (maxPrice) {
params.push(`maxPrice=${maxPrice}`);
}
if (minPrice) {
params.push(`minPrice=${minPrice}`);
}
if (minSize) {
params.push(`minSize=${minSize}`);
}
if (maxSize) {
params.push(`maxSize=${maxSize}`);
}
params.push(`rooms=${Object.keys(rooms).filter(v => rooms[v]).join(",")}`);
params.push(`category=${Object.keys(category).filter(v => category[v]).join(",")}`);
}
if (state.toDispatch) {
window.history.pushState(state, '', `/?${params.join("&")}`);
} else {
const oldState = window.history.state;
if (oldState) {
const newState = Object.assign(oldState, state);
window.history.replaceState(newState, '',`/?${params.join("&")}`);
} else {
window.history.replaceState(state, '',`/?${params.join("&")}`);
}
}
}
}

View File

@@ -5,7 +5,8 @@
"main": "index.js",
"scripts": {
"dev": "webpack-dev-server --content-base ./dist --hot --inline --host 0.0.0.0",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"format": "prettier-standard 'src/**/*.js'"
},
"author": "",
"license": "ISC",
@@ -21,7 +22,9 @@
"babel-loader": "^6.2.7",
"babel-preset-es2015": "^6.18.0",
"babel-preset-react": "^6.16.0",
"eslint": "^3.19.0",
"prettier": "^0.22.0",
"prettier-standard": "^3.0.1",
"webpack": "^1.13.3",
"webpack-dev-server": "^1.16.2"
}

View File

@@ -1,81 +1,85 @@
import React from "react";
import { formatFilterNumber } from "../lib/helpers";
import React from 'react'
import {formatFilterNumber} from '../lib/helpers'
import {
CATEGORY_FLAT,
CATEGORY_HOUSE,
CATEGORY_OFFICE,
CATEGORY_LAND
} from '../../crawler/enums';
} from '../../../crawler/enums'
export default class Filters extends React.Component {
onCloseClick(e) {
onCloseClick (e) {
if (this.props.onClose) {
this.props.onClose();
this.props.onClose()
}
}
onMaxPriceChange(e) {
const maxPrice = e.target.value;
onMaxPriceChange (e) {
const maxPrice = e.target.value
this.props.dispatch({
type: "SET_MAX_PRICE",
type: 'SET_MAX_PRICE',
action: {maxPrice}
});
})
}
onMinPriceChange(e) {
const minPrice = e.target.value;
onMinPriceChange (e) {
const minPrice = e.target.value
this.props.dispatch({
type: "SET_MIN_PRICE",
type: 'SET_MIN_PRICE',
action: {minPrice}
});
})
}
onMaxSizeChange(e) {
onMaxSizeChange (e) {
this.props.dispatch({
type: "SET_MAX_SIZE",
action: { maxSize: e.target.value }
});
type: 'SET_MAX_SIZE',
action: {maxSize: e.target.value}
})
}
onMinSizeChange(e) {
onMinSizeChange (e) {
this.props.dispatch({
type: "SET_MIN_SIZE",
action: { minSize: e.target.value }
});
type: 'SET_MIN_SIZE',
action: {minSize: e.target.value}
})
}
onRoomsClick(rooms) {
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
onRoomsClick (rooms) {
this.props.dispatch({
type: 'UPDATE_ROUTE',
action: {
params: {rooms}
}});
}
})
this.props.dispatch({type: 'SET_ROOMS', action: {rooms}});
this.props.dispatch({type: 'SET_ROOMS', action: {rooms}})
}
onCategoryClick(category) {
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
onCategoryClick (category) {
this.props.dispatch({
type: 'UPDATE_ROUTE',
action: {
params: {category}
}});
}
})
this.props.dispatch({type: 'SET_CATEGORY', action: {category}});
this.props.dispatch({type: 'SET_CATEGORY', action: {category}})
}
onRefreshClick() {
this.updateSearch();
onRefreshClick () {
this.updateSearch()
}
onKeyPress (e) {
if (e.key === 'Enter') {
this.updateSearch();
this.updateSearch()
}
}
updateSearch () {
const {minPrice, maxPrice, minSize, maxSize} = this.props.filters;
const {minPrice, maxPrice, minSize, maxSize} = this.props.filters
this.props.dispatch({
type: 'UPDATE_ROUTE',
@@ -87,14 +91,14 @@ export default class Filters extends React.Component {
maxSize
}
}
});
this.props.dispatch({ type: "UPDATE_SEARCH" });
})
this.props.dispatch({type: 'UPDATE_SEARCH'})
}
render() {
const { filters } = this.props;
const selectedRooms = val => filters.rooms[val] ? "selected" : "";
const selectedCategory = val => filters.category[val] ? "selected": "";
render () {
const {filters} = this.props
const selectedRooms = val => filters.rooms[val] ? 'selected' : ''
const selectedCategory = val => filters.category[val] ? 'selected' : ''
return (
<div className="filters">
@@ -119,7 +123,7 @@ export default class Filters extends React.Component {
onChange={this.onMinPriceChange.bind(this)}
value={formatFilterNumber(filters.minPrice)}
/>
{" "}
{' '}
DO
<input
@@ -128,13 +132,11 @@ export default class Filters extends React.Component {
value={formatFilterNumber(filters.maxPrice)}
/>
{this.props.filters.priceDirty
?
<i
? <i
onClick={this.onRefreshClick.bind(this)}
className="fa fa-refresh fa-refresh-custom"
aria-hidden="true">
</i>
aria-hidden="true"
/>
: null}
</div>
</div>
@@ -145,24 +147,36 @@ export default class Filters extends React.Component {
<div className="filter-content">
<div
onClick={this.onCategoryClick.bind(this, CATEGORY_FLAT)}
className={`filter-btn property-type-btn ${selectedCategory(CATEGORY_FLAT)}`}>
className={
`filter-btn property-type-btn ${selectedCategory(CATEGORY_FLAT)}`
}
>
Stan
</div>
<div
onClick={this.onCategoryClick.bind(this, CATEGORY_HOUSE)}
className={`filter-btn property-type-btn ${selectedCategory(CATEGORY_HOUSE)}`}>
className={
`filter-btn property-type-btn ${selectedCategory(CATEGORY_HOUSE)}`
}
>
Kuća
</div>
</div>
<div className="filter-content">
<div
onClick={this.onCategoryClick.bind(this, CATEGORY_LAND)}
className={`filter-btn property-type-btn ${selectedCategory(CATEGORY_LAND)}`}>
className={
`filter-btn property-type-btn ${selectedCategory(CATEGORY_LAND)}`
}
>
Zemljište
</div>
<div
onClick={this.onCategoryClick.bind(this, CATEGORY_OFFICE)}
className={`filter-btn property-type-btn ${selectedCategory(CATEGORY_OFFICE)}`}>
className={
`filter-btn property-type-btn ${selectedCategory(CATEGORY_OFFICE)}`
}
>
Poslovni prostor
</div>
</div>
@@ -180,7 +194,7 @@ export default class Filters extends React.Component {
/>
DO
{" "}
{' '}
<input
onKeyPress={this.onKeyPress.bind(this)}
value={formatFilterNumber(filters.maxSize)}
@@ -190,8 +204,8 @@ export default class Filters extends React.Component {
? <i
onClick={this.onRefreshClick.bind(this)}
className="fa fa-refresh fa-refresh-custom"
aria-hidden="true">
</i>
aria-hidden="true"
/>
: null}
</div>
</div>
@@ -221,8 +235,8 @@ export default class Filters extends React.Component {
3
</div>
<div
onClick={this.onRoomsClick.bind(this, "4+")}
className={`filter-btn property-rooms-btn ${selectedRooms("4+")}`}
onClick={this.onRoomsClick.bind(this, '4+')}
className={`filter-btn property-rooms-btn ${selectedRooms('4+')}`}
>
4+
</div>
@@ -237,6 +251,6 @@ export default class Filters extends React.Component {
<div className="clear-both" />
<div className="filter-bottom" />
</div>
);
)
}
}

View File

@@ -0,0 +1,87 @@
import React from 'react'
import {galleryImageUrl} from '../lib/helpers'
export default class Gallery extends React.Component {
onPrevClick (e) {
this.props.dispatch({type: 'PREV_IMAGE'})
}
onNextClick (e) {
this.props.dispatch({type: 'NEXT_IMAGE'})
}
onImageDotClick (index, e) {
this.props.dispatch({type: 'VIEW_IMAGE', action: {index}})
}
render () {
const {images, imageIndex} = this.props
if (!images || images.length === 0) {
return null
}
const showPrev = imageIndex > 0
const showNext = imageIndex < images.length - 1
return (
<div className="ld-image-container">
<img src={galleryImageUrl(images[imageIndex])} />
{showPrev
? <div className="prev-button" onClick={this.onPrevClick.bind(this)}>
<div>
<svg
fill="white"
version="1.1"
x="0px"
y="0px"
width="100%"
height="100%"
viewBox="0 0 512 512"
>
<path
d="M213.7,256L213.7,256L213.7,256L380.9,81.9c4.2-4.3,4.1-11.4-0.2-15.8l-29.9-30.6c-4.3-4.4-11.3-4.5-15.5-0.2L131.1,247.9 c-2.2,2.2-3.2,5.2-3,8.1c-0.1,3,0.9,5.9,3,8.1l204.2,212.7c4.2,4.3,11.2,4.2,15.5-0.2l29.9-30.6c4.3-4.4,4.4-11.5,0.2-15.8 L213.7,256z"
/>
</svg>
</div>
</div>
: null}
{showNext
? <div className="next-button" onClick={this.onNextClick.bind(this)}>
<div>
<svg
fill="white"
version="1.1"
x="0px"
y="0px"
width="100%"
height="100%"
viewBox="0 0 512 512"
>
<path
d="M298.3,256L298.3,256L298.3,256L131.1,81.9c-4.2-4.3-4.1-11.4,0.2-15.8l29.9-30.6c4.3-4.4,11.3-4.5,15.5-0.2l204.2,212.7 c2.2,2.2,3.2,5.2,3,8.1c0.1,3-0.9,5.9-3,8.1L176.7,476.8c-4.2,4.3-11.2,4.2-15.5-0.2L131.3,446c-4.3-4.4-4.4-11.5-0.2-15.8 L298.3,256z"
/>
</svg>
</div>
</div>
: null}
<div className="image-dots">
{images.map((img, index) => {
let cls = 'image-dot'
if (index === imageIndex) {
cls += ' selected'
}
return (
<div
key={img}
onClick={this.onImageDotClick.bind(this, index)}
className={cls}
/>
)
})}
</div>
</div>
)
}
}

View File

@@ -1,47 +1,57 @@
import React from 'react';
import Gallery from './gallery';
import {formatPrice} from '../lib/helpers';
import React from 'react'
import Gallery from './gallery'
import {formatPrice} from '../lib/helpers'
export default class ListingDetails extends React.Component {
onReadMore (e) {
e.preventDefault();
this.props.dispatch({type: 'EXPAND_DESCRIPTION'});
e.preventDefault()
this.props.dispatch({type: 'EXPAND_DESCRIPTION'})
}
onBackClick() {
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
onBackClick () {
this.props.dispatch({
type: 'UPDATE_ROUTE',
action: {
toDispatch: {
type: 'BACK_TO_RESULTS'
},
params: {
listingId: null
}
}});
this.props.dispatch({type: 'BACK_TO_RESULTS'});
}
})
this.props.dispatch({type: 'BACK_TO_RESULTS'})
}
render() {
const {listing, descriptionExpanded} = this.props;
render () {
const {listing, descriptionExpanded} = this.props
if (!listing) {
return null;
return null
}
const descriptionClasses = descriptionExpanded ? "ld-description expanded" : "ld-description";
const images = listing.images.map((image) => ({original: image, thumbnail: image}))
const descriptionClasses = descriptionExpanded
? 'ld-description expanded'
: 'ld-description'
const images = listing.images.map(image => ({
original: image,
thumbnail: image
}))
return (
<div className="ld-container">
<div className="ld-header">
<div className="back-to-results">
<button className="back-to-results-btn"
onClick={this.onBackClick.bind(this)}>
<i className="fa fa-arrow-left" aria-hidden="true"></i>
<button
className="back-to-results-btn"
onClick={this.onBackClick.bind(this)}
>
<i className="fa fa-arrow-left" aria-hidden="true" />
<span>
Nazad na rezultate
</span>
</button>
</div>
<button className="hide-listing">
<i className="fa fa-thumbs-o-down" aria-hidden="true"></i>
<i className="fa fa-thumbs-o-down" aria-hidden="true" />
<span>
Sakrij
</span>
@@ -52,7 +62,8 @@ export default class ListingDetails extends React.Component {
<Gallery
dispatch={this.props.dispatch}
images={listing.images}
imageIndex={this.props.imageIndex} />
imageIndex={this.props.imageIndex}
/>
<div className="ld-price-address-box">
<div className="ld-price">
{formatPrice(listing.price)}
@@ -66,19 +77,19 @@ export default class ListingDetails extends React.Component {
<div className="ld-features">
<div className="ld-feature-box">
<i className="fa fa-bed"></i>
<i className="fa fa-bed" />
{listing.rooms} sobe
</div>
<div className="ld-feature-box">
<i className="fa fa-home"></i>
<i className="fa fa-home" />
{listing.size}m2
</div>
<div className="ld-feature-box">
<i className="fa fa-home"></i>
<i className="fa fa-home" />
{listing.floor}. sprat
</div>
<div className="ld-feature-box">
<i className="fa fa-home"></i>
<i className="fa fa-home" />
--
</div>
</div>
@@ -89,15 +100,15 @@ export default class ListingDetails extends React.Component {
{listing.longDescription}
</div>
{!descriptionExpanded
?
<div className="ld-read-more">
<a href="" onClick={this.onReadMore.bind(this)}>Pročitajte više</a>
? <div className="ld-read-more">
<a href="" onClick={this.onReadMore.bind(this)}>
Pročitajte više
</a>
</div>
: null}
<div className="ld-footer">
<div className="ld-footer" />
</div>
</div>
</div>);
)
}
}

View File

@@ -1,31 +1,34 @@
import React from 'react';
import {findDOMNode} from 'react-dom';
import {formatPrice, listingImageUrl} from '../lib/helpers';
import {loadListing} from '../lib/api';
import React from 'react'
import {findDOMNode} from 'react-dom'
import {formatPrice, listingImageUrl} from '../lib/helpers'
import {loadListing} from '../lib/api'
export default class Listings extends React.Component {
constructor(props) {
super(props);
constructor (props) {
super(props)
this.handleScroll = (e) => {
const node = e.target;
const offset = node.scrollHeight - node.scrollTop - node.clientHeight;
this.handleScroll = e => {
const node = e.target
const offset = node.scrollHeight - node.scrollTop - node.clientHeight
if (this.props && this.props.loadingMore) {
return;
return
}
if (offset < 50) {
this.props.dispatch({type: 'LOAD_MORE_LISTINGS'});
this.props.dispatch({type: 'LOAD_MORE_LISTINGS'})
}
}
}
onListingClick(id) {
onListingClick (id) {
loadListing(id).then(l => l.text()).then(l => {
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
this.props.dispatch({
type: 'UPDATE_ROUTE',
action: {
toDispatch: {
type: 'VIEW_LISTING_DETAILS', action: {
type: 'VIEW_LISTING_DETAILS',
action: {
id,
listing: JSON.parse(l)
}
@@ -33,13 +36,17 @@ export default class Listings extends React.Component {
params: {
listingId: id
}
}});
}
})
this.props.dispatch({type: 'VIEW_LISTING_DETAILS', action: {
this.props.dispatch({
type: 'VIEW_LISTING_DETAILS',
action: {
id,
listing: JSON.parse(l)
}});
});
}
})
})
}
onMouseEnter (id) {
@@ -48,40 +55,46 @@ export default class Listings extends React.Component {
action: {
id
}
});
})
}
renderListings () {
const {listings = (new Map())} = this.props;
const {listings = new Map()} = this.props
if (listings.size === 0) {
return (<div className="listings-no-results">
<i className="fa fa-frown-o fa-frown-o-custom" aria-hidden="true"></i>
return (
<div className="listings-no-results">
<i className="fa fa-frown-o fa-frown-o-custom" aria-hidden="true" />
<h4>Nema rezultata</h4>
<h5>
Nema oglasa koji ispunjavaju vaše uslove pretrage.
</h5>
</div>)
</div>
)
}
const rendered = [];
const rendered = []
for(const l of listings.values()) {
const {images} = l;
for (const l of listings.values()) {
const {images} = l
rendered.push(
<div
key={l._id}
onMouseEnter={this.onMouseEnter.bind(this, l._id)}
className="property-list-item"
onClick={this.onListingClick.bind(this, l._id)}>
onClick={this.onListingClick.bind(this, l._id)}
>
<div className="pli-image">
<img src={listingImageUrl(images[0])} alt=""></img>
<img src={listingImageUrl(images[0])} alt="" />
</div>
<div className="pli-details">
<div className="price">{formatPrice(l.price)}</div>
<div className="description">{l.rooms ? `${l.rooms} sobe, `: null}{l.size ? `${l.size}m2`: null}</div>
<div className="description">
{l.rooms ? `${l.rooms} sobe, ` : null}
{l.size ? `${l.size}m2` : null}
</div>
<div className="address">
<div className="street">
{l.address}
@@ -93,46 +106,50 @@ export default class Listings extends React.Component {
<div className="hours-ago">Prije {l.time}</div>
</div>
</div>
);
)
}
return rendered;
return rendered
}
componentDidMount () {
this.attachScrollListener();
this.attachScrollListener()
}
componentWillUnmount () {
this.removeScrollListener();
this.removeScrollListener()
}
attachScrollListener () {
const listings = findDOMNode(this.refs.listings);
listings.parentNode.addEventListener('scroll', this.handleScroll);
const listings = findDOMNode(this.refs.listings)
listings.parentNode.addEventListener('scroll', this.handleScroll)
}
removeScrollListener () {
const listings = findDOMNode(this.refs.listings);
listings.parentNode.removeEventListener('scroll', this.handleScroll);
const listings = findDOMNode(this.refs.listings)
listings.parentNode.removeEventListener('scroll', this.handleScroll)
}
onSortChange (e) {
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
this.props.dispatch({
type: 'UPDATE_ROUTE',
action: {
params: {
sort: e.target.value
}
}});
}
})
this.props.dispatch({type: 'SORT_CHANGE', action: {
this.props.dispatch({
type: 'SORT_CHANGE',
action: {
sort: e.target.value
}});
}
})
}
render () {
const {listings = (new Map()), totalCount, sort} = this.props;
const {listings = new Map(), totalCount, sort} = this.props
return (
<div ref="listings" className="listings">
@@ -143,7 +160,8 @@ export default class Listings extends React.Component {
value={sort}
onChange={this.onSortChange.bind(this)}
name="listings-type"
id="listings-type">
id="listings-type"
>
<option value="relevance">Relevantno</option>
<option value="newest">Najnovije</option>
<option value="price-min">Cijena: od najmanje</option>
@@ -159,6 +177,7 @@ export default class Listings extends React.Component {
<div className="listings-items">
{this.renderListings()}
</div>
</div>)
</div>
)
}
}

592
web/src/components/Main.js Normal file
View File

@@ -0,0 +1,592 @@
import React from 'react'
import Filters from './Filters'
import Listings from './Listings'
import ListingDetails from './ListingDetails'
import {pacSelectFirst} from '../helpers/googleMaps'
import {loadProperties, loadSeen, loadListing} from '../lib/api'
import {handleMessage} from '../lib/handlers'
import Router from '../lib/router'
class Main extends React.Component {
constructor (props) {
super(props)
const state = {
listingDetails: false,
listings: new Map(),
imageIndex: 0,
page: 0,
sort: 'relevance',
filters: {
rooms: {},
category: {}
}
}
if (props.initialState) {
props.initialState.sort = props.initialState.sort || state.sort
state.filters.rooms = props.initialState.rooms
state.filters.category = props.initialState.category
state.sort = props.initialState.sort || state.sort
state.listingId = props.initialState.listingId
state.bounds = props.initialState.bounds
state.zoom = props.initialState.zoom
if (state.listingId) {
state.listingDetails = true
}
state.filters.minSize = props.initialState.minSize
state.filters.maxSize = props.initialState.maxSize
state.filters.minPrice = props.initialState.minPrice
state.filters.maxPrice = props.initialState.maxPrice
}
this.state = state
this.router = new Router(this, props.initialState)
}
dispatch ({type, action = {}}) {
handleMessage({type, action}, this)
}
componentDidMount () {
const uluru = {lat: 43.845031, lng: 18.4019262}
const opts = {
//zoom: 13,
//center: uluru,
streetViewControl: false,
mapTypeControl: false
}
if (!this.state.bounds) {
opts.zoom = 13
opts.center = uluru
}
const map = new google.maps.Map(this.refs.map, opts)
window.gmap = map
//const marker = new google.maps.Marker({
//position: uluru,
//map: map
//});
var control = document.createElement('div')
control.classList.add('filters-btn-toggle')
control.innerHTML = '<button>Filters</button>'
//control.style = "top: 200px;"
control['style'] = 'top: 200px;'
var input = document.getElementById('gmaps-places-input')
pacSelectFirst(input)
var options = {
componentRestrictions: {country: 'BA'}
}
const regularIdle = () => {
this.dispatch({
type: 'UPDATE_ROUTE',
action: {
params: {
bounds: map.getBounds().toUrlValue(),
zoom: map.getZoom()
}
}
})
this.dispatch({type: 'MAP_IDLE'})
}
// Check if initial bounds are passed-in
google.maps.event.addListenerOnce(map, 'idle', () => {
if (this.state.bounds) {
const [lat1, lng1, lat2, lng2] = this.state.bounds.split(',')
const sw = new google.maps.LatLng({
lat: parseFloat(lat1),
lng: parseFloat(lng1)
})
const ne = new google.maps.LatLng({
lat: parseFloat(lat2),
lng: parseFloat(lng2)
})
const initialBounds = new google.maps.LatLngBounds(sw, ne)
//map.fitBounds(initialBounds);
const originalMaxZoom = map.maxZoom
const originalMinZoom = map.minZoom
map.setOptions({
maxZoom: parseInt(this.state.zoom),
minZoom: parseInt(this.state.zoom)
})
map.fitBounds(initialBounds)
map.setOptions({maxZoom: originalMaxZoom, minZoom: originalMinZoom})
}
})
map.addListener('idle', regularIdle)
var searchBox = new google.maps.places.Autocomplete(input, options)
searchBox.addListener('place_changed', () => {
var place = searchBox.getPlace()
if (!place.geometry) {
return
}
if (place.geometry.viewport) {
map.fitBounds(place.geometry.viewport)
} else {
map.setCenter(place.geometry.location)
map.setZoom(18)
}
this.dispatch({type: 'SEARCH_PLACE_CHANGED'})
})
control.addEventListener('click', e => {
this.setState({
mapClicked: true
})
})
control.index = 1
map.controls[google.maps.ControlPosition.TOP_RIGHT].push(control)
this.map = map
// TODO: if state contains listingId reload
if (this.state.listingId) {
loadListing(this.state.listingId).then(l => l.text()).then(l => {
this.dispatch({
type: 'VIEW_LISTING_DETAILS',
action: {
id: this.state.listingId,
listing: JSON.parse(l)
}
})
})
}
}
removeAllMarkers () {
if (this.markers) {
this.markers.forEach(m => m.marker.setMap(null))
}
}
onCloseClick (e) {
if (this.state.mapClicked) {
setTimeout(
() => {
google.maps.event.trigger(this.map, 'resize')
},
100
)
}
this.setState({
mapClicked: false
})
}
findMarker (id) {
if (!this.markers) {
return null
}
const index = this.markers.findIndex(m => m.id === id)
return this.markers[index]
}
isSeen (id) {
const seen = loadSeen()
return seen.findIndex(s => s === id) !== -1
}
loadPins () {
const map = this.map
const {
rooms,
minSize,
maxSize,
minPrice,
maxPrice,
category
} = this.state.filters
const bounds = map.getBounds()
const properties = loadProperties({
bounds: bounds.toUrlValue(),
rooms,
minSize,
maxSize,
minPrice,
maxPrice,
category,
page: this.state.page,
pins: true
})
const markerExists = id => {
return this.findMarker(id) != null
}
properties
.then(p => {
return {
body: p.text(),
totalCount: p.headers.get('X-Total-Count')
}
})
.then(({body, totalCount}) => {
body.then(p => {
const data = JSON.parse(p)
const listingExists = id => {
return data.findIndex(l => l._id === id) !== -1
}
const newMarkers = []
if (this.markers) {
this.markers.forEach(m => {
if (!listingExists(m.id)) {
m.marker.setMap(null)
} else {
newMarkers.push(m)
}
})
}
for (const [index, prop] of data.entries()) {
const myLatLng = {lat: prop.loc[0], lng: prop.loc[1]}
if (!markerExists(prop._id)) {
const marker = new google.maps.Marker({
position: myLatLng,
map: map,
//title : prop.title,
icon: this.isSeen(prop._id)
? this.visitedMarkerIcon()
: this.defaultMarkerIcon(),
id: prop._id
})
marker.addListener('mouseover', () => {
if (marker.id !== this.state.listingId) {
if (this.isSeen(marker.id)) {
marker.setIcon(this.visitedHoveredMarkerIcon())
} else {
marker.setIcon(this.hoveredMarkerIcon())
}
}
})
marker.addListener('mouseout', () => {
if (marker.id !== this.state.listingId) {
if (this.isSeen(marker.id)) {
marker.setIcon(this.visitedMarkerIcon())
} else {
marker.setIcon(this.defaultMarkerIcon())
}
}
})
marker.addListener('click', () => {
// Maybe move out and call when popping state
if (this.state.listingId) {
const prevSelected = this.findMarker(this.state.listingId)
if (prevSelected) {
prevSelected.marker.setIcon(this.visitedMarkerIcon())
}
}
marker.setIcon(this.selectedMarkerIcon())
loadListing(prop._id).then(l => l.text()).then(l => {
this.dispatch({
type: 'UPDATE_ROUTE',
action: {
toDispatch: {
type: 'VIEW_LISTING_DETAILS',
action: {
id: prop._id,
listing: JSON.parse(l)
}
},
params: {
listingId: prop._id
}
}
})
//this.dispatch({type: 'UPDATE_ROUTE', action: {type: 'VIEW_LISTING_DETAILS', action: {
//id: prop._id,
//listing: JSON.parse(l)
//}}});
this.dispatch({
type: 'VIEW_LISTING_DETAILS',
action: {
id: prop._id,
listing: JSON.parse(l)
}
})
})
})
newMarkers.push({
marker,
id: prop._id
})
}
}
this.dispatch({
type: 'PINS_LOADED',
action: {
newMarkers
}
})
})
})
}
/*
* Refreshes search
*/
refreshListings (more = false) {
// TODO: move somewhere else
if (!more && this.state.listingId) {
loadListing(this.state.listingId).then(l => l.text()).then(l => {
this.dispatch({
type: 'VIEW_LISTING_DETAILS',
action: {
id: this.state.listingId,
listing: JSON.parse(l)
}
})
})
}
if (!more) {
this.loadPins()
}
const map = this.map
const {
rooms,
minSize,
maxSize,
minPrice,
maxPrice,
category
} = this.state.filters
const bounds = map.getBounds()
const properties = loadProperties({
bounds: bounds.toUrlValue(),
rooms,
minSize,
maxSize,
minPrice,
maxPrice,
category,
page: this.state.page,
sort: this.state.sort
})
properties
.then(p => {
return {
body: p.text(),
totalCount: p.headers.get('X-Total-Count')
}
})
.then(({body, totalCount}) => {
body.then(p => {
const data = JSON.parse(p)
this.dispatch({
type: 'LISTINGS_LOADED',
action: {
listings: data,
more,
totalCount
}
})
})
})
}
/*
* Get default marker icon
*/
defaultMarkerIcon () {
const sf = 0.5
const width = 48
const height = 64
const icon = {
url: 'static/images/pins_sprite.png',
size: new google.maps.Size(width * sf, height * sf),
scaledSize: new google.maps.Size(730 * sf, 102 * sf),
origin: new google.maps.Point(0, 36 * sf)
}
return icon
}
/*
* Get visited marker icon
*/
visitedMarkerIcon () {
const sf = 0.5
const width = 48
const height = 64
const icon = {
url: 'static/images/pins_sprite.png',
size: new google.maps.Size(width * sf, height * sf),
scaledSize: new google.maps.Size(730 * sf, 102 * sf),
origin: new google.maps.Point(152 * sf, 36 * sf)
}
return icon
}
/*
* Visited hovered marker icon
*/
visitedHoveredMarkerIcon () {
const sf = 0.5
const width = 61
const height = 82
const icon = {
url: 'static/images/pins_sprite.png',
size: new google.maps.Size(width * sf, height * sf),
scaledSize: new google.maps.Size(730 * sf, 102 * sf),
origin: new google.maps.Point(480 * sf, 18 * sf)
}
return icon
}
/*
* Hovered marker icon
*/
hoveredMarkerIcon () {
const sf = 0.5
const width = 61
const height = 82
const icon = {
url: 'static/images/pins_sprite.png',
size: new google.maps.Size(width * sf, height * sf),
scaledSize: new google.maps.Size(730 * sf, 102 * sf),
origin: new google.maps.Point(303 * sf, 18 * sf)
}
return icon
}
/*
* Selected marker icon
*/
selectedMarkerIcon () {
const sf = 0.5
const width = 73
const height = 100
const icon = {
url: 'static/images/pins_sprite.png',
size: new google.maps.Size(width * sf, height * sf),
scaledSize: new google.maps.Size(730 * sf, 102 * sf),
origin: new google.maps.Point(655 * sf, 1 * sf)
}
return icon
}
renderRightContent () {
const children = []
if (this.state.listingDetails) {
const listing = this.state.listing //this.state.listings.get(this.state.listingId);
children.push(
<ListingDetails
listing={listing}
imageIndex={this.state.imageIndex}
dispatch={this.dispatch.bind(this)}
descriptionExpanded={this.state.descriptionExpanded}
/>
)
} else {
children.push(
<Filters
filters={this.state.filters}
dispatch={this.dispatch.bind(this)}
onClose={this.onCloseClick.bind(this)}
/>
)
children.push(
<Listings
sort={this.state.sort}
totalCount={this.state.totalCount}
loadingMore={this.state.loadingMore}
listings={this.state.listings}
dispatch={this.dispatch.bind(this)}
/>
)
}
const content = (
<div className="right-content">
{children}
</div>
)
return content
}
render () {
const leftStyle = {}
const rightStyle = {}
const listingDetails = true
let leftClass = 'left-base'
let rightClass = 'right-base'
if (this.state.mapClicked) {
leftClass = 'left-hidden'
rightClass = 'right-shown'
}
return (
<div id="container">
<div id="header">
<a className="hamburger-menu">K</a>
<span className="title">Kiwi</span>
<input
id="gmaps-places-input"
placeholder="Unesite adresu, naselje ili grad"
className="where-to"
type="text"
/>
<div className="view-types">
<a className="view-type-left">
<i className="btn-select-map fa fa-list" />
</a>
<a className="view-type-right">
<i className="view-type-map-icon fa fa-map-marker" />
</a>
</div>
</div>
<div id="right" style={rightStyle} className={rightClass}>
{this.renderRightContent()}
</div>
<div id="left" style={leftStyle} className={leftClass}>
<div id="map" ref="map" />
</div>
</div>
)
}
}
export default Main

View File

@@ -0,0 +1,31 @@
export const pacSelectFirst = input => {
// store the original event binding function
var _addEventListener = input.addEventListener
? input.addEventListener
: input.attachEvent
function addEventListenerWrapper (type, listener) {
// Simulate a 'down arrow' keypress on hitting 'return' when no pac suggestion is selected,
// and then trigger the original listener.
if (type == 'keydown') {
var orig_listener = listener
listener = function (event) {
var suggestion_selected = $('.pac-item-selected').length > 0
if (event.which == 13 && !suggestion_selected) {
var simulated_downarrow = $.Event('keydown', {
keyCode: 40,
which: 40
})
orig_listener.apply(input, [simulated_downarrow])
}
orig_listener.apply(input, [event])
}
}
_addEventListener.apply(input, [type, listener])
}
input.addEventListener = addEventListenerWrapper
input.attachEvent = addEventListenerWrapper
}

57
web/src/index.js Normal file
View File

@@ -0,0 +1,57 @@
import React from 'react'
import {render} from 'react-dom'
import Main from './components/Main'
const getInitialState = url => {
console.log('PARSING URL:', url)
const params = window.location.search.substr(1).split('&')
const initialState = {
rooms: {},
category: {}
}
for (const param of params) {
const [key, value] = param.split('=')
console.log('analyzing param ', key, value)
if (key === 'rooms' && value !== '') {
console.log("IT's ROOMS")
value.split(',').forEach(k => {
console.log("IT's ROOMS", k)
initialState.rooms[parseInt(k)] = true
})
}
if (key === 'category' && value !== '') {
value.split(',').forEach(k => {
initialState.category[parseInt(k)] = true
})
}
if (key === 'sort') {
initialState.sort = value
}
if (key === 'bounds') {
initialState.bounds = value
}
if (key === 'listingId') {
initialState.listingId = value
}
if (key === 'zoom') {
initialState.zoom = parseInt(value)
}
if (['minSize', 'maxSize', 'minPrice', 'maxPrice'].includes(key)) {
initialState[key] = parseFloat(value)
}
}
return initialState
}
const main = <Main initialState={getInitialState(window.location)} />
render(main, document.getElementById('root'))

56
web/src/lib/api.js Normal file
View File

@@ -0,0 +1,56 @@
import fetch from 'isomorphic-fetch'
export const loadListing = id => {
let url = `http://localhost:3001/api/search/listings/${id}`
return fetch(
url,
{
//credentials: 'include'
}
)
}
export const loadProperties = (
{
bounds,
minPrice = '',
maxPrice = '',
minSize = '',
maxSize = '',
rooms = {},
category = {},
page = 1,
pins = false,
sort = ''
}
) => {
const allRooms = Object.keys(rooms).filter(v => rooms[v]).join(',')
const allCategories = Object.keys(category)
.filter(v => category[v])
.join(',')
// TODO: handle errors
//return fetch(process.env.API_URL + '/api/search', {
let url = `http://localhost:3001/api/search/listings?bounds=${bounds}&minPrice=${minPrice}&maxPrice=${maxPrice}&rooms=${allRooms}&minSize=${minSize}&maxSize=${maxSize}&category=${allCategories}&page=${page}&pins=${pins}&sort=${sort}`
return fetch(
url,
{
//credentials: 'include'
}
)
}
export const markSeen = id => {
const seen = JSON.parse(window.localStorage.getItem('seen') || '[]')
seen.push(id)
window.localStorage.setItem('seen', JSON.stringify(seen))
}
export const loadSeen = id => {
const seen = JSON.parse(window.localStorage.getItem('seen') || '[]')
return seen
//return seen.findIndex(s => s === id) !== -1;
}

311
web/src/lib/handlers.js Normal file
View File

@@ -0,0 +1,311 @@
import {markSeen} from './api'
const setMaxPrice = ({type, action}, component) => {
const maxPrice = parseFloat(action.maxPrice)
component.setState({
page: 0,
filters: {
...component.state.filters,
maxPrice: isNaN(maxPrice) ? undefined : maxPrice,
priceDirty: true
}
})
}
const setMinPrice = ({type, action}, component) => {
const minPrice = parseFloat(action.minPrice)
component.setState({
page: 0,
filters: {
...component.state.filters,
minPrice: isNaN(minPrice) ? undefined : minPrice,
priceDirty: true
}
})
}
const setMinSize = ({type, action}, component) => {
const minSize = parseFloat(action.minSize)
component.setState({
page: 0,
filters: {
...component.state.filters,
minSize: isNaN(minSize) ? undefined : minSize,
sizeDirty: true
}
})
}
const setMaxSize = ({type, action}, component) => {
const maxSize = parseFloat(action.maxSize)
component.setState({
page: 0,
filters: {
...component.state.filters,
maxSize: isNaN(maxSize) ? undefined : maxSize,
sizeDirty: true
}
})
}
const viewListingDetails = ({type, action}, component) => {
const scrollElem = document.querySelector('.right-content')
component.savedScrollTop = scrollElem.scrollTop
//component.router.listingId = action.id;
component.setState(
{
listingDetails: true,
listingId: action.id,
descriptionExpanded: false,
imageIndex: 0,
listing: action.listing
},
() => {
//component.router.update();
markSeen(action.id)
const m = component.findMarker(action.id)
if (m) {
m.marker.setIcon(component.selectedMarkerIcon())
}
scrollElem.scrollTop = 0
}
)
}
const listingsLoaded = ({type, action}, component) => {
const currentListings = new Map()
for (const listing of action.listings) {
currentListings.set(listing._id, listing)
}
component.setState({
listings: action.more
? new Map([...component.state.listings, ...currentListings])
: currentListings,
loadingMore: false,
totalCount: action.totalCount
})
}
const pinsLoaded = ({type, action}, component) => {
component.setState({}, () => {
component.markers = action.newMarkers
})
}
const expandDescription = ({type, action}, component) => {
component.setState({
descriptionExpanded: true
})
}
const prevImage = ({type, action}, component) => {
const index = component.state.imageIndex
if (index > 0) {
component.setState({
imageIndex: index - 1
})
}
}
const nextImage = ({type, action}, component) => {
const index = component.state.imageIndex
component.setState({
imageIndex: index + 1
})
}
const viewImage = ({type, action}, component) => {
component.setState({
imageIndex: action.index
})
}
const searchPlaceChanged = ({type, action}, component) => {
component.setState({
listingDetails: false,
page: 0
})
}
const setRooms = ({type, action}, component) => {
const prevRooms = component.state.filters.rooms || {}
component.setState(
{
page: 0,
filters: {
...component.state.filters,
rooms: {
...prevRooms,
[action.rooms]: !prevRooms[action.rooms]
}
}
},
() => {
component.refreshListings()
}
)
}
const updateSearch = ({type, action}, component) => {
component.setState(
{
filters: {
...component.state.filters,
sizeDirty: false,
priceDirty: false
}
},
() => {
component.refreshListings()
}
)
}
const setCategory = ({type, action}, component) => {
const prevCategory = component.state.filters.category || {}
component.setState(
{
page: 0,
filters: {
...component.state.filters,
category: {
...prevCategory,
[action.category]: !prevCategory[action.category]
}
}
},
() => {
component.refreshListings()
}
)
}
const onListingMouseOver = ({type, action}, component) => {
const marker = component.findMarker(action.id)
if (marker) {
const seen = component.isSeen(action.id)
if (seen) {
marker.marker.setIcon(component.visitedHoveredMarkerIcon())
} else {
marker.marker.setIcon(component.hoveredMarkerIcon())
}
marker.marker.setAnimation(google.maps.Animation.BOUNCE)
setTimeout(
() => {
marker.marker.setAnimation(null)
if (seen) {
marker.marker.setIcon(component.visitedMarkerIcon())
} else {
marker.marker.setIcon(component.defaultMarkerIcon())
}
},
710
)
}
}
const backToResults = ({type, action}, component) => {
const prevSelected = component.findMarker(component.state.listingId)
component.setState(
{
listingId: null,
listingDetails: false
},
() => {
//component.router.update();
if (prevSelected) {
prevSelected.marker.setIcon(component.visitedMarkerIcon())
}
const scrollElem = document.querySelector('.right-content')
scrollElem.scrollTop = component.savedScrollTop
}
)
}
const loadMoreListings = ({type, action}, component) => {
const currentPage = component.state.page
if (currentPage * 20 < component.state.totalCount) {
component.setState(
{
loadingMore: true,
page: currentPage + 1
},
() => {
component.refreshListings(true)
}
)
}
}
const mapIdle = ({type, action}, component) => {
component.setState(
{
page: 0
},
() => {
const scrollElem = document.querySelector('.right-content')
scrollElem.scrollTop = 0
component.refreshListings()
}
)
}
const sortChange = ({type, action}, component) => {
component.setState(
{
sort: action.sort,
page: 0
},
() => {
//component.router.update();
component.refreshListings()
}
)
}
const updateRoute = ({type, action}, component) => {
component.router.update(action)
}
const handlers = {
SET_MIN_PRICE: setMinPrice,
SET_MAX_PRICE: setMaxPrice,
SET_MIN_SIZE: setMinSize,
SET_MAX_SIZE: setMaxSize,
LISTINGS_LOADED: listingsLoaded,
EXPAND_DESCRIPTION: expandDescription,
PREV_IMAGE: prevImage,
NEXT_IMAGE: nextImage,
VIEW_IMAGE: viewImage,
SEARCH_PLACE_CHANGED: searchPlaceChanged,
SET_ROOMS: setRooms,
VIEW_LISTING_DETAILS: viewListingDetails,
UPDATE_SEARCH: updateSearch,
SET_CATEGORY: setCategory,
ON_LISTING_MOUSE_OVER: onListingMouseOver,
BACK_TO_RESULTS: backToResults,
LOAD_MORE_LISTINGS: loadMoreListings,
MAP_IDLE: mapIdle,
PINS_LOADED: pinsLoaded,
SORT_CHANGE: sortChange,
UPDATE_ROUTE: updateRoute
}
export const handleMessage = ({type, action}, component) => {
if (!handlers[type]) {
throw new `Unhandled message: ${type}`()
}
return handlers[type]({type, action}, component)
}

20
web/src/lib/helpers.js Normal file
View File

@@ -0,0 +1,20 @@
export const formatPrice = p => {
if (isNaN(p)) {
return 'Po dogovoru'
}
return p.toLocaleString('bs') + ' KM'
}
export const formatFilterNumber = num => {
if (isNaN(num) || num == null) {
return ''
}
return num
}
export const galleryImageUrl = img =>
img && img.replace('upload/', 'upload/w_500/')
export const listingImageUrl = img =>
img && img.replace('upload/', 'upload/w_205/')

95
web/src/lib/router.js Normal file
View File

@@ -0,0 +1,95 @@
import clone from 'lodash.clonedeep'
export default class Router {
constructor (comp, initialState) {
this.component = comp
this.state = clone(initialState) || {}
window.onpopstate = event => {
const state = event.state
if (state) {
if (state.toDispatch) {
this.component.dispatch(state.toDispatch)
}
}
}
}
update (state) {
const params = []
if (state.params) {
let cloned = clone(state)
if (cloned.params.rooms != null) {
this.state.rooms[cloned.params.rooms] = !this.state.rooms[
cloned.params.rooms
]
}
if (cloned.params.category != null) {
this.state.category[cloned.params.category] = !this.state.category[
cloned.params.category
]
}
delete cloned.params['rooms']
delete cloned.params['category']
this.state = Object.assign(this.state, cloned.params)
const {
listingId,
minPrice,
maxPrice,
minSize,
maxSize,
bounds,
sort,
rooms = {},
category = {},
zoom
} = this.state
if (listingId) {
params.push(`listingId=${listingId}`)
}
params.push(`sort=${sort}`)
params.push(`bounds=${bounds}`)
params.push(`zoom=${zoom}`)
if (maxPrice) {
params.push(`maxPrice=${maxPrice}`)
}
if (minPrice) {
params.push(`minPrice=${minPrice}`)
}
if (minSize) {
params.push(`minSize=${minSize}`)
}
if (maxSize) {
params.push(`maxSize=${maxSize}`)
}
params.push(
`rooms=${Object.keys(rooms).filter(v => rooms[v]).join(',')}`
)
params.push(
`category=${Object.keys(category).filter(v => category[v]).join(',')}`
)
}
if (state.toDispatch) {
window.history.pushState(state, '', `/?${params.join('&')}`)
} else {
const oldState = window.history.state
if (oldState) {
const newState = Object.assign(oldState, state)
window.history.replaceState(newState, '', `/?${params.join('&')}`)
} else {
window.history.replaceState(state, '', `/?${params.join('&')}`)
}
}
}
}

View File

@@ -1,5 +1,5 @@
module.exports = {
entry: ["./index.js"],
entry: ["./src/index.js"],
output: {
path: __dirname + "/dist",
filename: "app.bundle.js",

File diff suppressed because it is too large Load Diff