30 Commits

Author SHA1 Message Date
GotPPay
7a5f7242ac new structure; code polish 2017-10-31 20:20:09 +01:00
GotPPay
a63c108259 crawler upgrade, server upgrade 2017-10-30 22:54:56 +01:00
GotPPay
039e34237d . 2017-10-16 12:51:37 +02:00
GotPPay
5d90e5efcb Ispravljen bug pozivanja nepostojeće funkcije 2017-10-16 12:42:18 +02:00
GotPPay
1743171cfd Rental crawler 2017-10-16 11:36:21 +02:00
GotPPay
b2787ebda5 Rental crawler 2017-10-16 11:34:26 +02:00
Edin Dazdarevic
aea928fdef Update README.md 2017-06-26 01:11:49 +02:00
Edin Dazdarevic
84db719521 Disable welcome page for now 2017-06-08 12:22:48 +02:00
Edin Dazdarevic
7a231a1b04 Use localhost api server 2017-06-08 12:14:42 +02:00
Edin Dazdarevic
8f57f91d32 Welcome page 2017-04-21 01:55:13 +02:00
Edin Dazdarevic
344877beda More small fixes 2017-04-17 15:46:27 +02:00
Edin Dazdarevic
c556f52b1c Small fixes 2017-04-17 15:17:45 +02:00
Edin Dazdarevic
a9664dbcc0 Small fixes 2017-04-17 13:37:41 +02:00
Edin Dazdarevic
9de077df32 Bug fix: google places not working on mobile correctly 2017-04-17 12:45:44 +02:00
Edin Dazdarevic
746d28d0fd Filters view on mobile 2017-04-17 12:02:02 +02:00
Edin Dazdarevic
880f7a3f65 Modal not working on iOS 2017-04-15 03:01:55 +02:00
Edin Dazdarevic
0b2ddaef9e More mobile fixes 2017-04-15 01:59:41 +02:00
Edin Dazdarevic
04a4283371 Mobile map fix when listingId provided in URL 2017-04-15 01:30:38 +02:00
Edin Dazdarevic
cfa14ed590 Contact modal for mobile fixes 2017-04-14 17:01:27 +02:00
Edin Dazdarevic
7c40035f6f Mobile adjustments 2017-04-14 02:00:38 +02:00
Edin Dazdarevic
1c133d21bc Minor adjustments 2017-04-13 21:27:33 +02:00
Edin Dazdarevic
233437e1c1 Contact done 2017-04-13 17:23:46 +02:00
Edin Dazdarevic
6ef680a7d3 Random stuff 2017-04-12 15:33:30 +02:00
Edin Dazdarevic
6a4c02d01a Contact form UI 2017-04-12 13:08:06 +02:00
Edin Dazdarevic
8792abcd9f Clear input of focus (Google places) 2017-04-11 14:31:02 +02:00
Edin Dazdarevic
e65a6a48e8 Progress on contact 2017-04-11 14:23:58 +02:00
Edin Dazdarevic
4a8740fb35 Reorganize & reformat 2017-04-11 10:43:05 +02:00
Edin Dazdarevic
a1151150db Prostor.ba crawler 2017-04-10 05:28:37 +02:00
Edin Dazdarevic
738720aa13 Use cloudinary 2017-04-10 02:50:40 +02:00
Edin Dazdarevic
5f4e3a01d3 Add price & size to routes 2017-04-09 23:57:29 +02:00
59 changed files with 12343 additions and 31941 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules
.DS_Store
crawler/build
backend/build
npm-debug.log

View File

@@ -1,28 +1,37 @@
# kivi.ba
Kivi je najbolji nacin da nadjete svoj novi dom.
## Getting started
### Web
Dragi developeru, potrebno je da uradis sljedece:
1. cd web
2. yarn install
3. npm run dev
4. visit http://localhost:8080
5. profit!
Ukljucen je webpack hot module replacement + webpack-dev-server tako da se sve izmjene (osim CSS-a) odmah vide jer se browser sam realoada.
### Crawler
## 1. Cloning repo
Trenutno postoji samo jedan crawler a to je `olx.js`
`git clone git@github.com:edazdarevic/kivi.git`
1. cd crawler
2. npm run dev
3. node build/crawler.js
4. profit!
`cd kivi`
## 2. Start MongoDB
## 3. Build crawler and crawl some data
`cd crawler`
`npm install`
`webpack`
`PROSTOR_FROM_PAGE=1 PROSTOR_TO_PAGE=10 MONGO_URL=mongodb://localhost:27017/kivi CLOUDINARY_URL=cloudinary://845665345722369:Nw7KYvLs0xkzt6BmE-d_LU6H2LY@kivi node build/crawler.js`
## 4. Start backend server
`cd backend`
`npm install`
`webpack & webpack & node build/server.js`
## 5. Start front-end dev server
`cd web`
`npm install`
`npm run dev`
## 6. Visit http://localhost:8080

View File

@@ -1,3 +0,0 @@
{
"presets": ["es2015", "es2017"],
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"babel-core": "^6.24.0",
"babel-loader": "^6.4.1",
"babel-polyfill": "^6.23.0",
"babel-preset-es2015": "^6.24.0",
"babel-preset-es2017": "^6.22.0",
"body-parser": "^1.17.1",
"cookie-parser": "^1.4.3",
"date-fns": "^1.28.2",
"express": "^4.15.2",
"isomorphic-fetch": "^2.2.1",
"mongodb": "^2.2.25"
}
}

View File

@@ -1,147 +1,219 @@
import express from 'express'
import express from 'express';
import bodyParser from 'body-parser';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
var hr = require('date-fns/locale/hr');
import parseDate from 'date-fns/format';
import moment from 'moment';
var MongoClient = require('mongodb').MongoClient;
var ObjectID = require('mongodb').ObjectID;
import {STATUS_NORMAL, STATUS_RESERVED, STATUS_SOLD} from '../common/enums';
var hr = require ('date-fns/locale/hr');
var MongoClient = require ('mongodb').MongoClient;
var ObjectID = require ('mongodb').ObjectID;
var url = 'mongodb://localhost:27017/kivi';
require("babel-polyfill");
require ('babel-polyfill');
const router = express.Router({mergeParams: true})
const router = express.Router ({mergeParams: true});
const PORT = process.env.PORT || 3001;
const AGENTURA_KEY = process.env.AGENTURA_KEY || '1somethingverysecret';
let db;
router.get('/search/listings/:id', async (req, res, next) => {
router.post ('/contact/:listingId', async (req, res, next) => {
try {
const id = req.params.id;
const listingId = req.params.listingId;
const body = req.body;
const listings = db.collection('listings');
const listing = await listings.findOne({_id: new ObjectID(id)});
if (listing) {
res.json(listing);
} else {
res.status(404);
const contactRequests = db.collection ('contact_requests');
if (!body.email) {
res.status (422);
res.end ('Email is required');
return;
}
res.end();
if (!body.name) {
res.status (422);
res.end ('Name is required');
return;
}
const result = await contactRequests.insertOne ({
name: body.name,
email: body.email,
listingId,
message: body.message,
phone: body.phone,
alert: body.alert,
});
res.status (200);
res.end ();
} catch (e) {
console.log('error:', e);
next(e);
console.log ('error:', e);
next (e);
}
});
router.get('/search/listings', async (req, res, next) => {
router.get ('/search/listings/:id', async (req, res, next) => {
try {
const bounds = req.query.bounds || '';
const minPrice = req.query.minPrice;
const maxPrice = req.query.maxPrice;
const minSize = req.query.minSize;
const maxSize = req.query.maxSize;
const rooms = req.query.rooms;
const adType = req.query.adType;
const category = req.query.category;
const sort = req.query.sort;
const page = req.query.page || 0;
const pins = req.query.pins || false;
const id = req.params.id;
const properties = db.collection('listings');
let query = {};
const listings = db.collection ('listings');
const listing = await listings.findOne ({_id: new ObjectID (id)});
if (listing) {
res.json (listing);
} else {
res.status (404);
}
res.end ();
} catch (e) {
console.log ('error:', e);
next (e);
}
});
router.get ('/search/listings', async (req, res, next) => {
try {
console.log ('Search listings');
const bounds = req.query.bounds || '';
const minPrice = req.query.minPrice;
const maxPrice = req.query.maxPrice;
const minSize = req.query.minSize;
const maxSize = req.query.maxSize;
const rooms = req.query.rooms;
const adType = req.query.adType;
const category = req.query.category;
const sort = req.query.sort;
const page = req.query.page || 0;
const pins = req.query.pins || false;
const properties = db.collection ('listings');
let query = {};
//Get only ads with location
query = Object.assign (query, {
has_map: true,
});
//AND
//Do not show sold or reserved properity
query = Object.assign (query, {
status: STATUS_NORMAL,
});
//AND
//Show ads that fall inside visible map
if (bounds) {
const [lat1, lng1, lat2, lng2] = bounds.split(',').map(parseFloat)
const box = [[lat1, lng1], [lat2, lng2]];
const [lat1, lng1, lat2, lng2] = bounds.split (',').map (parseFloat);
const box = [[lat1, lng1], [lat2, lng2]];
query = Object.assign(query, {
query = Object.assign (query, {
loc: {
"$geoWithin": {
"$box": box
}
}
$geoWithin: {
$box: box,
},
},
});
}
//AND
//Show only selected type of ads (selling or renting)
if (adType) {
query = Object.assign(query, {
adType: parseInt(adType)
query = Object.assign (query, {
adType: parseInt (adType),
});
}
//AND
//Match price
if (minPrice || maxPrice) {
const price = {}
const price = {};
if (minPrice) {
price["$gte"] = parseFloat(minPrice);
price['$gte'] = parseFloat (minPrice);
}
if (maxPrice) {
price["$lte"] = parseFloat(maxPrice);
price['$lte'] = parseFloat (maxPrice);
}
query = Object.assign(query, {
price
query = Object.assign (query, {
price,
});
}
const and = [];
//AND
//Match number of rooms
if (rooms) {
const allRooms = rooms.split(',');
const or = allRooms.map(val => {
if (val === '4+') {
return {
rooms: {
"$gte": 4
}
}
const roomCount = [];
let fourPlus = false;
const allRooms = rooms.split (',');
allRooms.map (val => {
if (parseInt (val) !== 4) {
roomCount.push (parseInt (val));
} else {
fourPlus = true;
}
return {
rooms: parseFloat(val)
};
});
and.push({ "$or": or });
if (fourPlus) {
query = Object.assign (query, {
rooms: {$gte: 4},
});
} else {
query = Object.assign (query, {
rooms: {$in: roomCount},
});
}
}
//AND
//Match size
if (minSize || maxSize) {
const size = {}
const size = {};
if (minSize) {
size["$gte"] = parseFloat(minSize);
size['$gte'] = parseFloat (minSize);
}
if (maxSize) {
size["$lte"] = parseFloat(maxSize);
size['$lte'] = parseFloat (maxSize);
}
query = Object.assign(query, {
size
query = Object.assign (query, {
size,
});
}
//AND
//Match category
if (category) {
const allCategories = category.split(',');
const or = allCategories.map(val => {
return {
category: parseInt(val)
};
const categoryCount = [];
const allCategories = category.split (',').map (val => {
categoryCount.push (parseInt (val));
});
and.push({ "$or": or });
}
if (and.length > 0) {
query = Object.assign(query, {
"$and": and
query = Object.assign (query, {
category: {$in: categoryCount},
});
}
console.log('QUERY: ', query);
const cnt = await properties.find(query).count();
console.log ('QUERY: ', query);
const cnt = await properties.find (query).count ();
res.header('X-Total-Count', cnt);
res.header ('X-Total-Count', cnt);
const getSort = () => {
if (sort === 'price-min') {
@@ -156,77 +228,80 @@ router.get('/search/listings', async (req, res, next) => {
}
};
let all = properties.find(query, {
let all = properties.find (query, {
//"sort": [['field1','asc'], ['field2','desc']]
"sort": getSort()
sort: getSort (),
});
const isPins = pins === "true";
const isPins = pins === 'true';
if (!isPins) {
all = await all.skip(20 * page).limit(20).toArray();
all = await all.skip (20 * page).limit (20).toArray ();
} else {
all = await all.toArray();
all = await all.toArray ();
}
if (all.length > 0) {
res.header('X-Last-Record-Id', [...all].pop()._id);
res.header ('X-Last-Record-Id', [...all].pop ()._id);
}
if (isPins) {
res.json(all.map(val => {
return {
_id: val._id,
loc: val.loc
}
}));
res.json (
all.map (val => {
return {
_id: val._id,
loc: val.loc,
};
})
);
} else {
res.json(all.map(({_id,
address,
images,
price,
rooms,
size,
time
}) => ({
_id,
address,
images: [images[0]],
price,
rooms,
size,
time: distanceInWordsToNow(
new Date(time),
{locale: hr}
)
})));
res.json (
all.map (({_id, address, images, price, rooms, size, time}) => ({
_id,
address,
images: [images[0]],
price,
rooms,
size,
time: distanceInWordsToNow (moment (time, 'DD.MM.YYYY'), {
locale: hr,
}),
realTime: time,
}))
);
}
res.end();
res.end ();
} catch (e) {
console.log('error:', e);
next(e);
console.log ('error:', e);
next (e);
}
});
const app = express ();
app.use (bodyParser.json ());
const app = express()
app.use(bodyParser.json());
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, X-Last-Record-Id, X-Total-Count");
res.header("Access-Control-Expose-Headers", "X-Last-Record-Id, X-Total-Count");
res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.header('Access-Control-Allow-Credentials', 'true');
next();
app.use (function (req, res, next) {
res.header ('Access-Control-Allow-Origin', '*');
res.header (
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept, X-Last-Record-Id, X-Total-Count'
);
res.header (
'Access-Control-Expose-Headers',
'X-Last-Record-Id, X-Total-Count'
);
res.header ('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header ('Access-Control-Allow-Credentials', 'true');
next ();
});
app.use('/api', router);
app.use ('/api', router);
MongoClient.connect(url).then((database) => {
MongoClient.connect (url).then (database => {
db = database;
db.collection('listings').createIndex({loc: "2d"});
app.listen(PORT, () => console.log('Express server running at localhost: ' + PORT));
db.collection ('listings').createIndex ({loc: '2d'});
app.listen (PORT, () =>
console.log ('Express server running at localhost: ' + PORT)
);
});

View File

@@ -7,7 +7,7 @@ module.exports = {
filename: 'build/server.js'
},
target: 'node',
externals: fs.readdirSync(path.resolve(__dirname, 'node_modules')).reduce((ext, mod) => {
externals: fs.readdirSync(path.resolve(__dirname, '../node_modules')).reduce((ext, mod) => {
ext[mod] = 'commonjs ' + mod
return ext
}, {}),

File diff suppressed because it is too large Load Diff

3
common/.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["es2015", "react", "stage-3"]
}

View File

@@ -7,4 +7,10 @@ export const CATEGORY_FLAT = 0;
export const CATEGORY_HOUSE = 1;
export const CATEGORY_OFFICE = 2;
export const CATEGORY_LAND = 3;
export const CATEGORY_APARTMENT = 4;
export const CATEGORY_GARAGE = 5;
export const STATUS_NORMAL = 0;
export const STATUS_RESERVED = 1;
export const STATUS_SOLD = 2;

View File

@@ -13,13 +13,18 @@ import {
} from 'source-map-support';
import 'dotenv/config';
import OlxCrawler from './specific/olx';
import ProstorCrawler from './specific/prostor';
import RentalCrawler from './specific/rental';
import MongoSaver from './savers/mongo'
install(); // for source maps to work
let crawlers = [
new OlxCrawler(process.env.OLX_FROM_PAGE, process.env.OLX_TO_PAGE, process.env.OLX_MAX_RESULTS)
//new OlxCrawler(process.env.OLX_FROM_PAGE, process.env.OLX_TO_PAGE, process.env.OLX_MAX_RESULTS),
new ProstorCrawler(parseInt(process.env.PROSTOR_FROM_PAGE), parseInt(process.env.PROSTOR_TO_PAGE), parseInt(process.env.PROSTOR_MAX_RESULTS)),
new RentalCrawler(parseInt(process.env.RENTAL_FROM_PAGE), parseInt(process.env.RENTAL_TO_PAGE), parseInt(process.env.RENTAL_MAX_RESULTS))
];
let savers = [
new MongoSaver(process.env.MONGO_URL)
];

10
crawler/detalji Normal file
View File

@@ -0,0 +1,10 @@
kategorije :
kuća = 1
stan = 2
apartman = 3
poslovni prostor = 4
zemljište = 5
garaža = 6
Datum spremiti u formatu dan.mjesec.godina, u polje "time"

View File

@@ -1,32 +0,0 @@
{
"name": "stan",
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"babel": "^6.5.2",
"babel-core": "^6.18.2",
"babel-loader": "^6.2.7",
"babel-plugin-transform-async-to-generator": "^6.16.0",
"babel-polyfill": "^6.16.0",
"babel-preset-es2015": "^6.18.0",
"cheerio": "^0.22.0",
"dotenv": "^2.0.0",
"fetch": "^1.1.0",
"json-loader": "^0.5.4",
"mongodb": "^2.2.11",
"node-fetch": "^1.6.3",
"source-map-support": "^0.4.6",
"twilio": "^2.11.0"
},
"devDependencies": {
"webpack": "^1.13.3"
},
"scripts": {
"dev": "webpack",
"prod": "webpack -p",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}

View File

@@ -33,8 +33,9 @@ export default class MongoSaver {
return results[key]
});
this.collection.update({ url: results.url }, resultsForMongo, { upsert: true });
// this.collection.insert(resultsForMongo);
for(const doc of resultsForMongo) {
this.collection.update({ url: doc.url }, doc, { upsert: true });
}
}
async close() {

View File

@@ -3,6 +3,7 @@
let fetch = require('node-fetch');
let cheerio = require('cheerio');
let fs = require('fs');
let cloudinary = require('cloudinary');
import {
AD_TYPE_SALE,
@@ -11,7 +12,7 @@ import {
CATEGORY_HOUSE,
CATEGORY_OFFICE,
CATEGORY_LAND
} from '../enums';
} from '../../common/enums';
export default class OlxCrawler {
@@ -68,6 +69,14 @@ export default class OlxCrawler {
images.push(img);
}
const uploadPromises = images.map(img => {
const imgFixed = eval(`'${img}'`);
return cloudinary.uploader.upload(eval(`'${img}'`));
});
const uploadResults = await Promise.all(uploadPromises);
const cloudinaryImages = uploadResults.map(ur => ur.url);
if (matches && matches.length >= 3) {
lat = matches[1];
lng = matches[2];
@@ -99,12 +108,12 @@ export default class OlxCrawler {
lat,
lng,
loc: [parseFloat(lat), parseFloat(lng)],
images
images: cloudinaryImages
};
return data;
} catch (e) {
console.error('Exception caught: ' + e);
console.error('Exception caught: ' + e.message);
}
return null;

262
crawler/specific/prostor.js Normal file
View File

@@ -0,0 +1,262 @@
'use strict';
let fetch = require ('node-fetch');
let cheerio = require ('cheerio');
let fs = require ('fs');
let cloudinary = require ('cloudinary');
let FormData = require ('form-data');
import {
AD_TYPE_SALE,
IGNORED_USERNAMES,
CATEGORY_FLAT,
CATEGORY_HOUSE,
CATEGORY_OFFICE,
CATEGORY_LAND,
STATUS_NORMAL,
STATUS_RESERVED,
STATUS_SOLD,
} from '../../common/enums';
export default class ProstorCrawler {
constructor (fromPage = 0, toPage = 10, maxResults = 1000) {
this.fromPage = fromPage;
this.toPage = toPage;
this.maxResults = maxResults;
}
async indexSingle (url) {
try {
const res = await fetch (url);
const body = await res.text ();
const $ = cheerio.load (body);
const title = $ (
'#nav_center_sub > div.content_area_1_left > div:nth-child(1) > h1'
).text ();
const category = $ (
'#nav_center_sub > div.content_area_1_left > div.bottom10 > div.content_lr_in_show > div:nth-child(3) > div:nth-child(4) > div.size_rs > span'
).text ();
const price = $ (
'#nav_center_sub > div.content_area_1_left > div.bottom10 > div.content_lr_in_show > div:nth-child(1) > div.size_rs > strong'
).text ();
const size = $ (
'#nav_center_sub > div.content_area_1_left > div.bottom10 > div.content_lr_in_show > div:nth-child(4) > div:nth-child(7) > div.size_rs > span'
).text ();
const rooms = $ (
'#nav_center_sub > div.content_area_1_left > div.bottom10 > div.content_lr_in_show > div:nth-child(4) > div:nth-child(2) > div.size_rs > span'
).text ();
const address = $ (
'#nav_center_sub > div.content_area_1_left > div.bottom10 > div.content_lr_in_show > div:nth-child(3) > div:nth-child(3) > div.size_rs > span'
).text ();
//const location = $('#artikal_glavni_div > div.artikal_lijevo > div.op.pop.mobile-lokacija').attr('data-content');
//const adType = $('#artikal_glavni_div > div.artikal_lijevo > div:nth-child(15) > div:nth-child(2) > div.df2').text();
const time = $ (
'#nav_center_sub > div.content_area_1_right > div.bottom_d > div > strong:nth-child(1)'
).text ();
//const olxId = $('#artikal_glavni_div > div.artikal_lijevo > div:nth-child(15) > div:nth-child(4) > div.df2').text();
const descriptions = $ (
'#nav_center_sub > div.content_area_1_left > div.bottom10 > div.content_ll_in_show > div:nth-child(1)'
).text ();
const floor = $ (
'#nav_center_sub > div.content_area_1_left > div.bottom10 > div.content_lr_in_show > div:nth-child(4) > div:nth-child(6) > div.size_rs'
).text ();
const latLngRe = /marker=([0-9]+\.[0-9]+)\,\s*([0-9]+\.[0-9]+)/g;
var hasMap = false;
var tmpTitle = title.toUpperCase ();
var status = STATUS_NORMAL;
if (tmpTitle.indexOf ('PRODANO') !== -1) status = STATUS_SOLD;
if (tmpTitle.indexOf ('REZERVISANO') !== -1) status = STATUS_RESERVED;
//const latLngRe = /LatLng\(([0-9]+\.[0-9]+)\,\s+([0-9]+\.[0-9]+)\)/g;
const matches = latLngRe.exec (body);
let lng = '', lat = '';
hasMap = false;
if (matches && matches.length >= 3) {
lat = matches[1];
lng = matches[2];
hasMap = true;
}
//console.log({
//lat,
//lng,
//floor,
//descriptions,
//time,
//price,
//size,
//category,
//title
//});
//const imgRe = /href":("[^"]*")/g;
const images = [];
//const imgMatches = body.match(imgRe);
const parseRooms = rooms =>
parseInt (
[...rooms].filter (c => !isNaN (c)).filter (c => c.trim ()).join ()
);
const parsePrice = price => parseFloat (price.replace ('.', ''));
$ ('.fancybox').each ((i, elem) => {
const img = $ (elem).attr ('href');
images.push (img);
});
//for (let i = 0; imgMatches && i < imgMatches.length; i++) {
//let img = imgMatches[i].replace("href\":", "")
//img = img.replace("\"", "");
//img = img.replace("\"", "");
//images.push(img);
//}
//const uploadPromises = images.map(img => {
//return cloudinary.uploader.upload(img);
//});
//const uploadResults = await Promise.all(uploadPromises);
//const cloudinaryImages = uploadResults.map(ur => ur.url);
const parsedPrice = parsePrice (price);
let parsedRooms;
if (rooms === 'Garsonjera') {
parsedRooms = 0;
} else {
parsedRooms = parseRooms (rooms);
}
const data = {
category: this.getCategoryId (category),
url,
title,
price: isNaN (parsedPrice) ? price : parsedPrice,
size: parseFloat (size),
rooms: parsedRooms,
floor: parseInt (floor),
address,
adType: AD_TYPE_SALE,
time,
shortDescription: title,
longDescription: descriptions,
lat,
lng,
loc: [parseFloat (lat), parseFloat (lng)],
hasMap,
status,
//images: cloudinaryImages
images,
};
console.log (data);
return data;
} catch (e) {
console.error ('Exception caught: ' + e.message);
}
return null;
}
async indexPage (pageNr, maxResults = 1000) {
try {
console.log ('Starting to index page: ' + pageNr);
const url = `http://prostor.ba/index.php`;
const data = new FormData ();
data.append ('sortCombo', 'e.date_create DESC');
data.append ('command', '');
data.append ('action', 'show');
data.append ('page', pageNr);
data.append ('param', 'ponuda.inc.php');
data.append ('checkNO', 0);
data.append ('order', 'e.date_create DESC');
data.append ('reset', 0);
data.append ('estate_action', 1);
data.append ('Itemid', 785);
const res = await fetch (url, {
method: 'POST',
body: data,
});
const body = await res.text ();
const $ = cheerio.load (body);
const hrefs = [];
$ ('.nekret_box').each ((i, elem) => {
const href = $ (elem).find ('a').first ().attr ('href');
hrefs.push (`http://prostor.ba/${href}`);
});
const results = {};
for (const href of hrefs) {
console.log (`indexing: ${href}`);
const singleData = await this.indexSingle (href);
if (singleData) {
results[href] = singleData;
}
await this.sleep (500);
}
return results;
} catch (e) {
console.error ('Exception caught:' + e);
}
}
getCategoryId (category) {
if (category === 'Stan') {
return CATEGORY_FLAT;
} else if (category === 'Zemljište') {
return CATEGORY_LAND;
} else if (category === 'Kuća') {
return CATEGORY_HOUSE;
} else if (category === 'Poslovni prostor') {
return CATEGORY_OFFICE;
}
}
async sleep (ms) {
return new Promise (resolve => setTimeout (resolve, ms));
}
async indexPages (start, end, maxResults = 1000) {
let results = {};
for (let i = start; i <= end; i++) {
let result = await this.indexPage (i, maxResults);
Object.assign (results, result);
await this.sleep (5000);
}
return results;
}
async crawl () {
let results = await this.indexPages (
this.fromPage,
this.toPage,
this.maxResults
);
return results;
}
}

424
crawler/specific/rental.js Normal file
View File

@@ -0,0 +1,424 @@
'use strict';
let fetch = require ('node-fetch');
let cheerio = require ('cheerio');
let fs = require ('fs');
let cloudinary = require ('cloudinary');
let FormData = require ('form-data');
import {
AD_TYPE_SALE,
IGNORED_USERNAMES,
CATEGORY_FLAT,
CATEGORY_HOUSE,
CATEGORY_OFFICE,
CATEGORY_LAND,
CATEGORY_APARTMENT,
CATEGORY_GARAGE,
STATUS_NORMAL,
STATUS_RESERVED,
STATUS_SOLD,
} from '../../common/enums';
export default class RentalCrawler {
constructor (fromPage = 0, toPage = 10, maxResults = 1000) {
console.log ('Rental Crawler');
this.fromPage = fromPage;
this.toPage = toPage;
this.maxResults = maxResults;
}
async indexSingle (url) {
try {
const res = await fetch (url);
const body = await res.text ();
const $ = cheerio.load (body);
var title;
var category;
var price;
var size;
var rooms;
var address;
var descriptions;
var floor;
var floor;
var time;
var lat;
var lng;
var hasMap;
var status;
//No JSON string -> No map
try {
let completeData;
let dataJsonString;
let dataJson;
const startN = 5;
const lastN = 15;
for (let i = startN; i <= lastN; i++) {
try {
completeData = $ (
'body > div.container-fluid > div.container > div:nth-child(2) > div.col-xs-12.col-sm-12.col-md-12.col-lg-9.content-main > div:nth-child(' +
i +
') > script'
).text ();
dataJsonString = completeData.slice (21, -1);
dataJson = JSON.parse (dataJsonString);
break;
} catch (e) {
console.log ('No JSON string');
if (i === lastN) throw e;
}
}
title = dataJson['re_realEstates_portalName'];
category = this.getCategoryIdfromNumber (
parseInt (dataJson['re_types_id'])
); //categories from JSON string doesn't match categories in ENUMS
price = parseFloat (dataJson['re_realEstates_price']);
size = parseFloat (dataJson['re_realEstates_area']);
rooms = parseInt (dataJson['re_realEstates_roomsNO']);
address = dataJson['re_realEstates_address'];
//descriptions = dataJson["re_realEstates_description"];
floor = parseInt (dataJson['re_realEstates_floorNO']);
let timeArray = dataJson['re_realEstates_inserted']
.slice (0, dataJson['re_realEstates_inserted'].indexOf (' '))
.split ('-');
time = timeArray[2] + '.' + timeArray[1] + '.' + timeArray[0];
lat = dataJson['re_realEstates_latitude'];
lng = dataJson['re_realEstates_longitude'];
hasMap = true;
} catch (e) {
console.log ('error : ' + e);
//This ad has no JSON string, informations should be retrieved using HTML selectors
time = undefined;
lat = 0;
lng = 0;
hasMap = false;
price =
parseFloat (
$ (
'body > div.container-fluid > div.container > div:nth-child(2) > div.col-xs-12.col-sm-12.col-md-12.col-lg-9.content-main > div:nth-child(1) > div > div > div.col-xs-12.col-sm-4.box-details > div.prices > span.pull-left'
)
.text ()
.replace (',', '')
.replace ('.', '')
) / 100;
const propsList = {};
$ (
'body > div.container-fluid > div.container > div:nth-child(2) > div.col-xs-12.col-sm-12.col-md-12.col-lg-9.content-main > div:nth-child(1) > div > div > div.col-xs-12.col-sm-4.box-details > div.body'
)
.contents ()
.map ((i, elem) => {
const entry = $ (elem).text ().trim ().split (':');
if (entry[0]) propsList[entry[0]] = entry[1];
});
address = propsList['Ulica'];
size =
parseFloat (
propsList['Površina'].replace (',', '').replace ('.', '')
) / 100;
rooms = parseInt (propsList['Broj soba']);
floor = parseInt (propsList['Spratnost']);
title = $ (
'div.container-fluid > div.container > div.row.content-top > div.col-xs-12.col-sm-6.col-md-9 > div.description.pull-left > h1'
).text ();
descriptions = $ ('#b1 > div > div > div').text ();
const fullCategory = $ (
'body > div.container-fluid > div.container > div:nth-child(2) > div.col-xs-12.col-sm-12.col-md-12.col-lg-9.content-main > div:nth-child(1) > div > div > div.col-xs-12.col-sm-4.box-details > div.title > p'
)
.text ()
.split (',', 3);
category = fullCategory.size > 2
? this.getCategoryIdfromText (fullCategory[0] + fullCategory[1])
: this.getCategoryIdfromText (fullCategory[0]);
}
descriptions = $ ('#b1 > div > div > div').text ();
status = this.getStatusIdFromText (
$ ('#a1 > div.box-badges > div').text ()
);
const images = [];
$ ('.img-gallery').contents ().map ((i, elem) => {
const tmp = $ (elem).attr ('data-preview');
if (tmp) images.push (tmp);
});
const data = {
category,
url,
title,
price,
size,
rooms,
floor,
address,
adType: AD_TYPE_SALE,
time,
shortDescription: title,
longDescription: descriptions,
lat,
lng,
loc: [parseFloat (lat), parseFloat (lng)],
hasMap,
status,
//images: cloudinaryImages
images,
};
return data;
} catch (e) {
console.error ('Exception caught: ' + e.message);
}
return null;
}
async indexPage (pageNr, maxResults = 1000) {
try {
console.log ('Starting to index page: ' + pageNr);
const url = 'http://www.rental.ba/pretraga/prodaja-1/stranica-' + pageNr;
/*
const data = new FormData();
data.append('sales', 1); // Mislim da ovo definiše oglase tipa prodaje
data.append('re_types_id', ''); //odnosi se na tip nekretnine (kuća, stan, apartman,...)
data.append('full_text', '');
data.append('re_realEstates_code', '');
data.append('re_realEstates_price_max', '');
data.append('re_realEstates_price_min', '');
data.append('re_realEstates_area_min', '');
data.append('re_realEstates_area_max', '');
data.append('re_realEstates_roomsNO_min', '');
data.append('re_realEstates_roomsNO_max', '');
data.append('re_realEstates_floorNO_min', '');
data.append('re_realEstates_floorNO_max', '');
data.append('re_subTypes_id', 1);
*/
const res = await fetch (url, {
method: 'POST',
//body: data
});
const body = await res.text ();
const $ = cheerio.load (body);
const hrefs = [];
$ ('.middle').each ((i, elem) => {
const href = $ (elem).find ('a').first ().attr ('href');
hrefs.push (href);
});
const results = {};
for (const href of hrefs) {
console.log (`indexing: ${href}`);
const singleData = await this.indexSingle (href);
if (singleData) {
results[href] = singleData;
}
await this.sleep (500);
}
return results;
} catch (e) {
console.error ('Exception caught:' + e);
}
}
getCategoryIdfromNumber (category) {
switch (category) {
case 1:
return CATEGORY_HOUSE;
case 2:
return CATEGORY_FLAT;
case 3:
return CATEGORY_APARTMENT;
case 4:
return CATEGORY_OFFICE;
case 5:
return CATEGORY_LAND;
case 6:
return CATEGORY_GARAGE;
}
}
getCategoryIdfromText (category) {
switch (category) {
case 'samostojeća':
return CATEGORY_HOUSE;
case 'dvojna':
return CATEGORY_HOUSE;
case 'kuća u nizu':
return CATEGORY_HOUSE;
case 'stambeno-poslovni objekt':
return CATEGORY_HOUSE;
case 'prizemnica':
return CATEGORY_HOUSE;
case 'kuća na moru':
return CATEGORY_HOUSE;
case 'kuća u izgradnji':
return CATEGORY_HOUSE;
case 'dvorac':
return CATEGORY_HOUSE;
case 'apartmanska kuća':
return CATEGORY_HOUSE;
case 'porodična kuća':
return CATEGORY_HOUSE;
case 'vikend kuća':
return CATEGORY_HOUSE;
case 'luksuzna kuća':
return CATEGORY_HOUSE;
case 'kamena':
return CATEGORY_HOUSE;
case 'vila':
return CATEGORY_HOUSE;
case 'splav':
return CATEGORY_HOUSE;
case 'stan u zgradi':
return CATEGORY_FLAT;
case 'stan u kući':
return CATEGORY_FLAT;
case 'stan višeetažni':
return CATEGORY_FLAT;
case 'stan višeetažni u kući':
return CATEGORY_FLAT;
case 'stan u starijoj zgradi':
return CATEGORY_FLAT;
case 'stan u novogradnji':
return CATEGORY_FLAT;
case 'stan u neboderu':
return CATEGORY_FLAT;
case 'Korišten stan u novogradnji':
return CATEGORY_FLAT;
case 'apartman na moru':
return CATEGORY_APARTMENT;
case 'apartman u planini':
return CATEGORY_APARTMENT;
case 'unutrašnje garažno mjesto':
return CATEGORY_GARAGE;
case 'unutrašnje parkirno mjesto':
return CATEGORY_GARAGE;
case 'građevinsko':
return CATEGORY_LAND;
case 'građevinsko stambeno':
return CATEGORY_LAND;
case 'zemljište, ostalo':
return CATEGORY_LAND;
case 'odmaralište':
return CATEGORY_LAND;
case 'oranica':
return CATEGORY_LAND;
case 'šuma':
return CATEGORY_LAND;
case 'livada':
return CATEGORY_LAND;
case 'građevinsko M2':
return CATEGORY_LAND;
case 'građevinsko M1':
return CATEGORY_LAND;
case 'građevinsko - turističko':
return CATEGORY_LAND;
case 'građevinsko - poslovno':
return CATEGORY_LAND;
case 'otok':
return CATEGORY_LAND;
case 'poljoprivredno':
return CATEGORY_LAND;
case 'lokal':
return CATEGORY_OFFICE;
case 'ured':
return CATEGORY_OFFICE;
case 'skladište ili garaža':
return CATEGORY_OFFICE;
case 'radionica':
return CATEGORY_OFFICE;
case 'tvornica':
return CATEGORY_OFFICE;
case 'restoran':
return CATEGORY_OFFICE;
case 'sportski centar':
return CATEGORY_OFFICE;
case 'ordinacija':
return CATEGORY_OFFICE;
case 'kiosk':
return CATEGORY_OFFICE;
case 'auto-praonica':
return CATEGORY_OFFICE;
case 'poslovna zgrada':
return CATEGORY_OFFICE;
case 'skladište':
return CATEGORY_OFFICE;
case 'garaža':
return CATEGORY_OFFICE;
case 'hotel':
return CATEGORY_OFFICE;
case 'pansion':
return CATEGORY_OFFICE;
case 'apartmanska zgrada':
return CATEGORY_OFFICE;
case 'trgovina':
return CATEGORY_OFFICE;
case 'prodajno skladišni':
return CATEGORY_OFFICE;
case 'proizvodno skladišni':
return CATEGORY_OFFICE;
case 'Kancelarije':
return CATEGORY_OFFICE;
case 'Poslovni prostor':
return CATEGORY_OFFICE;
}
}
getStatusIdFromText (status) {
if (status === 'Prodato') return STATUS_SOLD;
return STATUS_NORMAL;
}
async sleep (ms) {
return new Promise (resolve => setTimeout (resolve, ms));
}
async indexPages (start, end, maxResults = 1000) {
let results = {};
for (let i = start; i <= end; i++) {
let result = await this.indexPage (i, maxResults);
Object.assign (results, result);
await this.sleep (5000);
}
return results;
}
async crawl () {
let results = await this.indexPages (
this.fromPage,
this.toPage,
this.maxResults
);
return results;
}
}

View File

@@ -4,17 +4,14 @@ module.exports = {
output: {
path: __dirname + "/build",
filename: "crawler.js",
devtool: 'source-map'
filename: "crawler.js"
},
module: {
loaders: [{
test: /.js?$/,
loader: 'babel-loader',
exclude: /node_modules/,
presets: ['es2015'],
plugins: ['transform-async-to-generator']
exclude: /node_modules/
}, {
test: /.json?$/,
loader: 'json-loader',

File diff suppressed because it is too large Load Diff

5276
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "kivi",
"version": "1.0.0",
"description": "",
"main": "",
"scripts": {
"web:dev": "webpack-dev-server --content-base ./web/dist --config ./web/webpack.config --hot --inline --host 0.0.0.0",
"test": "echo \"Error: no test specified\" && exit 1",
"format": "prettier-standard 'src/**/*.js'"
},
"author": "",
"license": "ISC",
"dependencies": {
"babel-core": "^6.24.0",
"babel": "^6.5.2",
"babel-plugin-transform-async-to-generator": "^6.16.0",
"babel-loader": "^6.4.1",
"babel-polyfill": "^6.23.0",
"babel-preset-es2015": "^6.24.0",
"babel-preset-es2017": "^6.22.0",
"body-parser": "^1.17.1",
"cookie-parser": "^1.4.3",
"date-fns": "^1.28.2",
"express": "^4.15.2",
"isomorphic-fetch": "^2.2.1",
"moment": "^2.18.1",
"mongodb": "^2.2.25",
"cheerio": "^0.22.0",
"cloudinary": "^1.8.0",
"dotenv": "^2.0.0",
"fetch": "^1.1.0",
"form-data": "^2.1.4",
"json-loader": "^0.5.4",
"source-map-support": "^0.4.6",
"twilio": "^2.11.0",
"babel-preset-stage-3": "^6.22.0",
"lodash.clonedeep": "^4.5.0",
"lodash.merge": "^4.6.0",
"react": "^15.3.2",
"react-dom": "^15.3.2"
},
"devDependencies": {
"babel-core": "^6.18.2",
"babel-loader": "^6.2.7",
"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",
"babel-preset-es2015": "^6.24.1"
}
}

View File

@@ -1,64 +0,0 @@
import React from 'react';
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={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,103 +0,0 @@
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'});
}
onBackClick() {
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
toDispatch: {
type: 'BACK_TO_RESULTS'
},
params: {
listingId: null
}
}});
this.props.dispatch({type: 'BACK_TO_RESULTS'});
}
render() {
const {listing, descriptionExpanded} = this.props;
if (!listing) {
return null;
}
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>
<span>
Nazad na rezultate
</span>
</button>
</div>
<button className="hide-listing">
<i className="fa fa-thumbs-o-down" aria-hidden="true"></i>
<span>
Sakrij
</span>
</button>
</div>
<div className="ld-details">
<Gallery
dispatch={this.props.dispatch}
images={listing.images}
imageIndex={this.props.imageIndex} />
<div className="ld-price-address-box">
<div className="ld-price">
{formatPrice(listing.price)}
</div>
<div className="ld-address">
<div className="">{listing.address}</div>
<div className="">{listing.location}</div>
</div>
</div>
<div className="ld-features">
<div className="ld-feature-box">
<i className="fa fa-bed"></i>
{listing.rooms} sobe
</div>
<div className="ld-feature-box">
<i className="fa fa-home"></i>
{listing.size}m2
</div>
<div className="ld-feature-box">
<i className="fa fa-home"></i>
{listing.floor}. sprat
</div>
<div className="ld-feature-box">
<i className="fa fa-home"></i>
--
</div>
</div>
<div className="ld-check-availability">
<button>Kontaktiraj</button>
</div>
<div className={descriptionClasses}>
{listing.longDescription}
</div>
{!descriptionExpanded
?
<div className="ld-read-more">
<a href="" onClick={this.onReadMore.bind(this)}>Pročitajte više</a>
</div>
: null}
<div className="ld-footer">
</div>
</div>
</div>);
}
}

View File

@@ -1,166 +0,0 @@
import React from 'react';
import {findDOMNode} from 'react-dom';
import {formatPrice} from '../lib/helpers';
import {loadListing} from '../lib/api';
export default class Listings extends React.Component {
constructor(props) {
super(props);
this.handleScroll = (e) => {
const node = e.target;
const offset = node.scrollHeight - node.scrollTop - node.clientHeight;
if (this.props && this.props.loadingMore) {
return;
}
if (offset < 50) {
this.props.dispatch({type: 'LOAD_MORE_LISTINGS'});
}
}
}
onListingClick(id) {
loadListing(id).then(l => l.text()).then(l => {
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
toDispatch: {
type: 'VIEW_LISTING_DETAILS', action: {
id,
listing: JSON.parse(l)
}
},
params: {
listingId: id
}
}});
this.props.dispatch({type: 'VIEW_LISTING_DETAILS', action: {
id,
listing: JSON.parse(l)
}});
});
}
onMouseEnter (id) {
this.props.dispatch({
type: 'ON_LISTING_MOUSE_OVER',
action: {
id
}
});
}
renderListings () {
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>
<h4>Nema rezultata</h4>
<h5>
Nema oglasa koji ispunjavaju vaše uslove pretrage.
</h5>
</div>)
}
const rendered = [];
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)}>
<div className="pli-image">
<img src={images[0]} alt=""></img>
</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="address">
<div className="street">
{l.address}
</div>
<div className="location">
{l.location}
</div>
</div>
<div className="hours-ago">Prije {l.time}</div>
</div>
</div>
);
}
return rendered;
}
componentDidMount () {
this.attachScrollListener();
}
componentWillUnmount () {
this.removeScrollListener();
}
attachScrollListener () {
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);
}
onSortChange (e) {
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
params: {
sort: e.target.value
}
}});
this.props.dispatch({type: 'SORT_CHANGE', action: {
sort: e.target.value
}});
}
render () {
const {listings = (new Map()), totalCount, sort} = this.props;
return (
<div ref="listings" className="listings">
<div className="listings-header">
<div className="select-container">
<div className="select-group">
<select
value={sort}
onChange={this.onSortChange.bind(this)}
name="listings-type"
id="listings-type">
<option value="relevance">Relevantno</option>
<option value="newest">Najnovije</option>
<option value="price-min">Cijena: od najmanje</option>
<option value="price-max">Cijena: od najvece</option>
</select>
</div>
</div>
<div className="listings-count">
{totalCount} rezultata
</div>
</div>
<div className="listings-items">
{this.renderListings()}
</div>
</div>)
}
}

View File

@@ -1,560 +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;
}
}
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;

25037
web/dist/app.bundle.js vendored

File diff suppressed because one or more lines are too long

5
web/dist/index.html vendored
View File

@@ -3,12 +3,15 @@
<head>
<title>KIVI - Najbolji način da pronađeš svoj dom</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--<meta name="viewport" content="width=device-width, initial-scale=1">-->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css">
<link href="https://fonts.googleapis.com/css?family=Arimo" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="welcome.css">
</head>
<body>
<div id="root">

314
web/dist/main.css vendored
View File

@@ -1,3 +1,4 @@
* { box-sizing: border-box; }
body {
margin: 0;
height: 100%;
@@ -72,7 +73,9 @@ html {
}
.right-content {
overflow-y: auto;
/*overflow-y: auto;*/
overflow-y: scroll; /* has to be scroll, not auto */
-webkit-overflow-scrolling: touch;
height: 100%;
padding: 10px 10px 0;
}
@@ -167,18 +170,17 @@ html {
border-radius: 5px 0 0 5px;
}
.view-type-right.selected,
.view-type-left.selected {
background-color: #b6d53b;
color: #fff;
}
.view-type-right {
border-radius: 0px 5px 5px 0;
margin-left: -1px;
}
.filter-bottom {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 50px;
}
.filter-title {
width: 40%;
@@ -253,7 +255,7 @@ html {
}
.filter-btn.more-filters {
float: right;
/*float: right;*/
width: 145px;
}
@@ -384,6 +386,8 @@ html {
.address {
overflow: hidden;
font-size: 12px;
height: 50px;
margin-bottom: 20px;
}
.street {
@@ -456,29 +460,100 @@ html {
}
}
.listings-filter {
display: none;
}
.filter-bottom {
display: none;
}
@media (max-width : 768px) {
.address {
margin-bottom: 0;
}
.hours-ago {
display: none;
}
.filter-bottom {
position: fixed;
bottom: 0;
left: 0;
width: 99%;
height: 50px;
display: flex;
text-align: center;
padding-left: 10px;
padding-right: 0;
align-items: baseline;
}
.filter-bottom .confirm {
background-color: #b6d53b;
}
.listings-count {
display: none;
}
.listings-filter {
right: 5px;
min-width: 150px;
/*text-align: right;*/
/*display: block;*/
display: inline-block;
border: 1px solid #b6d53b;
padding: 8px 12px;
cursor: pointer;
margin: 0 6px 6px 0;
color: #2d3138;
font-size: 14px;
letter-spacing: .4px;
border-radius: 3px;
user-select: none;
touch-action: manipulation;
vertical-align: middle;
text-align: center;
flex-grow: 1;
text-overflow: ellipsis;
overflow: hidden;
}
#right {
width: 100%;
float: none;
}
.filters-btn-toggle {
margin-top: 90px;
margin-top: 70px;
margin-right: 10px;
display: block;
min-width: 35px;
padding: 5px 10px;
border: 1px solid #d8d8d8;
background: hsla(0,0%,100%,.9);
color: #878698;
border-radius: 3px;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
}
.filters-btn-toggle button {
font-size: 1.2em;
-webkit-appearance: none;
-webkit-border-radius: 0;
border: 0;
background: #fff;
}
.filters-close-button {
display: block;
}
.left-hidden {
position: absolute;
left: -100%;
}
.right-shown {
display: block;
}
@@ -487,8 +562,14 @@ html {
display: none;
}
.left-absolute {
position:absolute;
}
.left-base {
display: block;
position: static;
}
#header .title {
@@ -508,7 +589,7 @@ html {
}
.where-to:focus {
/*width: 85%;*/
width: 85%;
}
.view-types {
@@ -517,7 +598,7 @@ html {
.filter-row {
display: block;
padding: 11px 0;
padding: 5px 0;
border: 0;
}
@@ -541,6 +622,7 @@ html {
.filters {
border: none;
padding: 0 15px;
height: 100%;
}
.filters-close {
@@ -559,6 +641,23 @@ html {
.value-between-box {
justify-content: space-around;
}
.map-list-view {
width: 100%;
border: 1px solid red;
/*overflow-y: auto;*/
overflow-y: scroll; /* has to be scroll, not auto */
-webkit-overflow-scrolling: touch;
height: 100%;
padding-top: 60px;
}
.right-content .listings {
display: none;
}
}
/*
@@ -639,11 +738,13 @@ html {
}
.ld-image-container {
height: 375px;
max-height: 335px;
width: 500px;
text-align: center;
position: relative;
user-select: none;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0,0,0,0);
}
.ld-image-container .prev-button {
@@ -652,6 +753,7 @@ html {
left: 0px;
height: 100%;
width: 50%;
user-select: none;
}
.ld-image-container .next-button {
@@ -660,6 +762,7 @@ html {
right: 0px;
height: 100%;
width: 50%;
user-select: none;
}
.ld-image-container .prev-button div,
@@ -676,7 +779,7 @@ html {
.ld-image-container img {
/*width: 100%;*/
/*height: 100%;*/
max-height: 375px;
max-height: 335px;
max-width: 500px;
}
@@ -756,7 +859,8 @@ html {
padding: 15px 0;
}
.ld-check-availability button {
.ld-check-availability button,
.contact-form button {
font-size: 18px;
padding: 15px 0;
background-color: #51bc6a;
@@ -780,7 +884,8 @@ html {
@media (max-width: 768px) {
.ld-image-container {
.ld-image-container,
.ld-image-container img {
width: 100%;
height: 100%;
}
@@ -828,3 +933,170 @@ h5 {
color: #2d3138;
}
@media (min-width : 768px) {
.modal > div {
width: 460px;
position: relative;
margin: 5% auto;
padding: 5px 20px 13px 20px;
border-radius: 10px;
background: #fff;
}
}
.modal {
position: fixed;
font-family: Arial, Helvetica, sans-serif;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 99999;
-webkit-transition: opacity 400ms ease-in;
-moz-transition: opacity 400ms ease-in;
transition: opacity 400ms ease-in;
/*pointer-events: none;*/
pointer-events: auto;
}
.modal h3 {
color: #575a60;
font-size: 18px;
font-weight: 400;
letter-spacing: .3px;
}
.close {
color: #575a60;
line-height: 25px;
position: absolute;
right: 5px;
top: 5px;
text-align: center;
width: 24px;
text-decoration: none;
font-size: 1.2em;
}
.contact-form input, textarea {
padding: 10px;
border: 1px solid #e2e2e6;
width: 420px;
border-radius: 3px;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
font-size: 14px;
color: #212126;
letter-spacing: .2px;
}
.contact-form-email-phone input {
}
.contact-form-email-phone input:first-child {
margin-right: 5px;
width: 250px;
}
.contact-form-email-phone input:last-child {
margin-left: 5px;
width: 160px;
}
.contact-form-name,
.contact-form-message,
.contact-form-email-phone {
margin-top: 15px;
margin-bottom: 15px;
}
.contact-form-message textarea {
height: 300px;
}
.contact-form-footer {
text-align: center;
padding-top: 20px;
padding-bottom: 15px;
}
.contact-form-alert input {
width: 15px;
}
.contact-form-alert span {
cursor: pointer;
}
@media (max-width: 768px) {
.modal h3 {
text-align: center;
}
.modal > div {
position: relative;
margin: 0;
padding: 5px 20px 13px 20px;
border-radius: 0;
background: #fff;
height: 100%;
width: 100%;
}
.contact-form input,
.contact-form textarea {
width: 100%;
}
.contact-form-email-phone input:first-child {
width: 100%;
margin: 0px;
}
.contact-form-email-phone input:last-child {
margin: 0;
width: 100%;
}
.contact-form-email-phone input:last-child {
width: 100%;
margin-top: 15px;
margin-left: 0px;
}
.contact-form-alert input {
width: 20px
}
.contact-form-alert {
text-align: center;
}
.contact-form-message textarea {
height: 150px;
}
.image-dot {
width: 7px;
height: 7px;
margin: 0 3px;
}
}
.noselect {
-webkit-tap-highlight-color: rgba(0,0,0,0);
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently
supported by Chrome and Opera */
}
input.validation-failed {
border: 2px solid red;
}

BIN
web/dist/static/images/logo.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
web/dist/static/images/mo-bg1.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 35 KiB

BIN
web/dist/static/images/sa-bg.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

BIN
web/dist/static/images/sa-bg2.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
web/dist/static/images/sa-bg3.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

BIN
web/dist/static/map.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

65
web/dist/welcome.css vendored Normal file
View File

@@ -0,0 +1,65 @@
/*.welcome-container div {*/
/*border: 1px solid red;*/
/*}*/
.welcome-container h1 {
font-size: 2em;
text-align: center;
}
.welcome-container h2 {
padding-bottom: 25px;
color: #2d3138;
font-size: 26px;
font-weight: 200;
text-align: center;
letter-spacing: .59px;
}
.welcome-container-bg {
/*background-color: rgb(92, 192, 99);*/
background-image: url('static/map.jpg');
/*background-image: url('static/images/sa-bg.jpg');*/
/*background-position: center;*/
-moz-filter: blur(5px);
-o-filter: blur(5px);
-ms-filter: blur(5px);
filter: blur(5px);
content: "";
position: fixed;
left: 0;
right: 0;
z-index: -1;
width: 100%;
height: 100%;
}
.welcome-container {
position: fixed;
left: 0;
right: 0;
z-index: 0;
margin-left: 20px;
margin-right: 20px;
height: 100%;
padding: 100px;
}
.welcome-content {
/*height: 100%;*/
margin: 0 auto;
width: 600px;
background-color: hsla(0,0%,100%,.95);
box-shadow: 0 2px 4px 0 rgba(73,73,73,.1);
padding: 50px;
}
.welcome-content .gmaps-places-input-welcome {
width: 100%;
}

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,57 +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);
}
}
console.log('initial state dump', initialState);
console.log('initial state ROOMS', initialState.rooms);
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,14 +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;
}

View File

@@ -1,64 +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, 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}`);
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,28 +0,0 @@
{
"name": "web",
"version": "1.0.0",
"description": "",
"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"
},
"author": "",
"license": "ISC",
"dependencies": {
"babel-preset-stage-3": "^6.22.0",
"lodash.clonedeep": "^4.5.0",
"lodash.merge": "^4.6.0",
"react": "^15.3.2",
"react-dom": "^15.3.2"
},
"devDependencies": {
"babel-core": "^6.18.2",
"babel-loader": "^6.2.7",
"babel-preset-es2015": "^6.18.0",
"babel-preset-react": "^6.16.0",
"prettier": "^0.22.0",
"webpack": "^1.13.3",
"webpack-dev-server": "^1.16.2"
}
}

View File

@@ -0,0 +1,144 @@
import React from "react";
import { saveContactRequest } from "../lib/api";
export default class ContactModal extends React.Component {
onContactCloseClick(e) {
e.preventDefault();
this.props.dispatch({
type: "CLOSE_CONTACT"
});
}
onSubmit(e) {
e.preventDefault();
const { name, email, message, phone, alert } = this.props.contact;
if (!name || !email) {
this.props.dispatch({
type: "INVALID_CONTACT"
});
} else {
this.props.dispatch({
type: "SUBMIT_CONTACT_START"
});
saveContactRequest(this.props.listingId, {
name,
email,
phone,
message,
alert
})
.then(l => l.text())
.then(res => {
this.props.dispatch({
type: "SUBMIT_CONTACT_END"
});
})
.catch(e => {
// TODO: should we have a global view for rendering errors
console.error(e);
});
}
}
onFieldChange(field, e) {
this.props.dispatch({
type: "UPDATE_CONTACT_INFO",
action: {
field,
value: e.target.value
}
});
}
onAlertToggle(e) {
const alert = this.props.contact.alert;
this.props.dispatch({
type: "UPDATE_CONTACT_INFO",
action: {
field: "alert",
value: !alert
}
});
}
render() {
const {
message,
email,
phone,
name,
alert: doAlert,
nameInvalid,
emailInvalid,
sending
} = this.props.contact;
const nameValidationClass = nameInvalid ? "validation-failed" : "";
const emailValidationClass = emailInvalid ? "validation-failed" : "";
return (
<div className="modal contact-form">
<div>
<a
href="#close"
title="Zatvori"
className="close"
onClick={this.onContactCloseClick.bind(this)}
>
<i className="fa fa-times" aria-hidden="true" />
</a>
<form onSubmit={this.onSubmit.bind(this)}>
<h3>Kontaktirajte prodavca</h3>
<div className="contact-form-name">
<input
value={name}
className={nameValidationClass}
onChange={this.onFieldChange.bind(this, "name")}
placeholder="Ime i prezime"
type="text"
/>
</div>
<div className="contact-form-email-phone">
<input
value={email}
className={emailValidationClass}
onChange={this.onFieldChange.bind(this, "email")}
placeholder="Email adresa"
type="email"
name="email"
/>
<input
value={phone}
onChange={this.onFieldChange.bind(this, "phone")}
placeholder="Telefon (opcionalno)"
type="text"
/>
</div>
<div className="contact-form-message">
<textarea
onChange={this.onFieldChange.bind(this, "message")}
value={message}
/>
</div>
<div className="contact-form-alert noselect">
<label>
<input
type="checkbox"
onChange={this.onAlertToggle.bind(this)}
checked={doAlert}
/>
Javi mi kada se objavi sličan oglas
</label>
</div>
<div className="contact-form-footer">
<button disabled={sending}>Pošalji poruku</button>
</div>
</form>
</div>
</div>
);
}
}

View File

@@ -5,7 +5,7 @@ import {
CATEGORY_HOUSE,
CATEGORY_OFFICE,
CATEGORY_LAND
} from '../../crawler/enums';
} from "../../../common/enums";
export default class Filters extends React.Component {
onCloseClick(e) {
@@ -15,16 +15,20 @@ export default class Filters extends React.Component {
}
onMaxPriceChange(e) {
const maxPrice = e.target.value;
this.props.dispatch({
type: "SET_MAX_PRICE",
action: { maxPrice: e.target.value }
action: { maxPrice }
});
}
onMinPriceChange(e) {
const minPrice = e.target.value;
this.props.dispatch({
type: "SET_MIN_PRICE",
action: { minPrice: e.target.value }
action: { minPrice }
});
}
@@ -43,40 +47,70 @@ export default class Filters extends React.Component {
}
onRoomsClick(rooms) {
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
params: {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: {
params: { 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() {
onRefreshClick(closeFilters) {
this.updateSearch();
if (closeFilters) {
this.props.dispatch({
type: "CLOSE_FILTERS"
});
}
}
onKeyPress (e) {
if (e.key === 'Enter') {
onKeyPress(e) {
if (e.key === "Enter") {
this.updateSearch();
}
}
updateSearch () {
updateSearch() {
const { minPrice, maxPrice, minSize, maxSize } = this.props.filters;
this.props.dispatch({
type: "UPDATE_ROUTE",
action: {
params: {
minPrice,
maxPrice,
minSize,
maxSize
}
}
});
this.props.dispatch({ type: "UPDATE_SEARCH" });
}
onResetSearch(e) {
this.props.dispatch({
type: "RESET_FILTERS"
});
}
render() {
const { filters } = this.props;
const selectedRooms = val => filters.rooms[val] ? "selected" : "";
const selectedCategory = val => filters.category[val] ? "selected": "";
const selectedCategory = val => filters.category[val] ? "selected" : "";
return (
<div className="filters">
@@ -110,13 +144,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>
@@ -127,24 +159,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>
@@ -172,8 +216,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>
@@ -217,7 +261,18 @@ export default class Filters extends React.Component {
</div>
</div>
<div className="clear-both" />
<div className="filter-bottom" />
<div className="filter-bottom">
<div onClick={this.onResetSearch.bind(this)} className="filter-btn">
Poništi
</div>
<div
onClick={this.onRefreshClick.bind(this, true)}
className="filter-btn confirm"
>
Potvrdi
</div>
</div>
</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={index}
onClick={this.onImageDotClick.bind(this, index)}
className={cls}
/>
)
})}
</div>
</div>
)
}
}

View File

@@ -0,0 +1,116 @@
import React from 'react'
import Gallery from './Gallery'
import {formatPrice, formatRooms, formatFloor} from '../lib/helpers'
import ContactModal from './ContactModal';
export default class ListingDetails extends React.Component {
onReadMore (e) {
e.preventDefault()
this.props.dispatch({type: 'EXPAND_DESCRIPTION'})
}
onBackClick () {
this.props.dispatch({
type: 'UPDATE_ROUTE',
action: {
toDispatch: {
type: 'BACK_TO_RESULTS'
},
params: {
listingId: null
}
}
})
this.props.dispatch({type: 'BACK_TO_RESULTS'})
}
onContactClick () {
this.props.dispatch({
type: 'OPEN_CONTACT'
});
}
render () {
const {listing, descriptionExpanded, contactFormOpen} = this.props
if (!listing) {
return null
}
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" />
<span>
Nazad na rezultate
</span>
</button>
</div>
</div>
<div className="ld-details">
<Gallery
dispatch={this.props.dispatch}
images={listing.images}
imageIndex={this.props.imageIndex}
/>
<div className="ld-price-address-box">
<div className="ld-price">
{formatPrice(listing.price)}
</div>
<div className="ld-address">
<div className="">{listing.address}</div>
<div className="">{listing.location}</div>
</div>
</div>
<div className="ld-features">
<div className="ld-feature-box">
<i className="fa fa-bed" />
{formatRooms(listing.rooms)}
</div>
<div className="ld-feature-box">
<i className="fa fa-home" />
{listing.size}m2
</div>
<div className="ld-feature-box">
<i className="fa fa-home" />
{formatFloor(listing.floor)}
</div>
<div className="ld-feature-box">
<i className="fa fa-home" />
--
</div>
</div>
<div className="ld-check-availability">
<button onClick={this.onContactClick.bind(this)}>Kontaktiraj</button>
</div>
<div className={descriptionClasses}>
{listing.longDescription}
</div>
{!descriptionExpanded
? <div className="ld-read-more">
<a href="" onClick={this.onReadMore.bind(this)}>
Pročitajte više
</a>
</div>
: null}
<div className="ld-footer" />
</div>
</div>
)
}
}

View File

@@ -0,0 +1,214 @@
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)
this.handleTouchMove = e => {
const node = e.target
const offset = node.scrollHeight - node.scrollTop - node.clientHeight
console.log('HANDLE TOUCH MOVE OFFSET', offset)
}
this.handleScroll = e => {
console.log('scrolling');
const node = e.target
const offset = node.scrollHeight - node.scrollTop - node.clientHeight
if (this.props && this.props.loadingMore) {
return
}
if (offset < 50) {
this.props.dispatch({type: 'LOAD_MORE_LISTINGS'})
}
}
}
onListingClick (id) {
loadListing(id).then(l => l.text()).then(l => {
this.props.dispatch({
type: 'UPDATE_ROUTE',
action: {
toDispatch: {
type: 'VIEW_LISTING_DETAILS',
action: {
id,
listing: JSON.parse(l)
}
},
params: {
listingId: id
}
}
})
this.props.dispatch({
type: 'VIEW_LISTING_DETAILS',
action: {
id,
listing: JSON.parse(l)
}
})
})
}
onMouseEnter (id) {
this.props.dispatch({
type: 'ON_LISTING_MOUSE_OVER',
action: {
id
}
})
}
renderListings () {
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" />
<h4>Nema rezultata</h4>
<h5>
Nema oglasa koji ispunjavaju vaše uslove pretrage.
</h5>
</div>
)
}
const rendered = []
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)}
>
<div className="pli-image">
<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="address">
<div className="street">
{l.address}
</div>
<div className="location">
{l.location}
</div>
</div>
<div className="hours-ago">Prije {l.time}</div>
</div>
</div>
)
}
return rendered
}
componentDidMount () {
this.attachScrollListener()
}
componentWillUnmount () {
this.removeScrollListener()
}
attachScrollListener () {
const listings = findDOMNode(this.refs.listings)
listings.parentNode.addEventListener('scroll', this.handleScroll)
listings.parentNode.addEventListener('touchmove', this.handleTouchMove)
}
removeScrollListener () {
const listings = findDOMNode(this.refs.listings)
listings.parentNode.removeEventListener('scroll', this.handleScroll)
listings.parentNode.removeEventListener('touchmove', this.handleTouchMove)
}
onSortChange (e) {
this.props.dispatch({
type: 'UPDATE_ROUTE',
action: {
params: {
sort: e.target.value
}
}
})
this.props.dispatch({
type: 'SORT_CHANGE',
action: {
sort: e.target.value
}
})
}
onFiltersClick (e) {
e.preventDefault()
this.props.dispatch({
type: 'OPEN_FILTERS'
})
}
resultsString (count) {
if (count == "1") {
return "rezultat"
}
return "rezultata"
}
render () {
const {listings = new Map(), totalCount, sort} = this.props
return (
<div ref="listings" className="listings">
<div className="listings-header">
<div className="select-container">
<div className="select-group">
<select
value={sort}
onChange={this.onSortChange.bind(this)}
name="listings-type"
id="listings-type"
>
<option value="relevance">Relevantno</option>
<option value="newest">Najnovije</option>
<option value="price-min">Cijena: od najmanje</option>
<option value="price-max">Cijena: od najvece</option>
</select>
</div>
</div>
<div className="listings-count">
{totalCount} {this.resultsString(totalCount)}
</div>
<div
onClick={this.onFiltersClick.bind(this)}
className="listings-filter">
Filteri
</div>
</div>
<div className="listings-items">
{this.renderListings()}
</div>
</div>
)
}
}

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

@@ -0,0 +1,650 @@
import React from 'react'
import Filters from './Filters'
import Listings from './Listings'
import ListingDetails from './ListingDetails'
import ContactModal from './ContactModal'
import {pacSelectFirst} from '../helpers/googleMaps'
import {loadProperties, loadSeen, loadListing} from '../lib/api'
import {handleMessage} from '../lib/handlers'
import Router from '../lib/router'
import {isMobile} from '../lib/helpers'
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: {},
status : {}
},
mobileView: 'MAP',
contact: {
message: '',
name: '',
email: '',
phone: '',
valid: true
}
}
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>Filteri</button>'
//control.style = "top: 200px;"
// TODO: enable this
//control['style'] = 'top: 200px;'
var input = document.getElementById('gmaps-places-input')
pacSelectFirst(input)
input.addEventListener('focus', (e) => {
e.target.value = '';
});
var options = {
componentRestrictions: {country: 'BA'},
types: [ 'geocode' ]
}
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})
if (isMobile()) {
document.getElementById('left').classList.add("left-absolute");
this.addAbsoluteLeftInRender = true;
}
}
})
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) {
console.log('we have viewport', place);
if (this.state.mobileView === 'MAP') {
map.fitBounds(place.geometry.viewport)
} else {
this.dispatch({type: 'MOBILE_MAP_VIEW'})
map.fitBounds(place.geometry.viewport)
}
map.setZoom(map.zoom + 1);
} else {
map.setCenter(place.geometry.location)
map.setZoom(18)
}
document.activeElement.blur();
this.dispatch({type: 'SEARCH_PLACE_CHANGED'})
})
control.addEventListener('click', e => {
this.dispatch({
type: 'OPEN_FILTERS'
})
})
control.index = 1
map.controls[google.maps.ControlPosition.TOP_RIGHT].push(control)
this.map = map
}
removeAllMarkers () {
if (this.markers) {
this.markers.forEach(m => m.marker.setMap(null))
}
}
onCloseClick (e) {
if (this.state.filtersOpen) {
console.log('FILTERS WERE OPEN')
//setTimeout(
//() => {
//google.maps.event.trigger(this.map, 'resize')
//},
//100
//)
}
this.dispatch({
type: 'CLOSE_FILTERS'
})
//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
contactFormOpen={this.state.contactFormOpen}
contact={this.state.contact}
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
}
onMobileListViewClick (e) {
e.preventDefault()
this.dispatch({
type: 'MOBILE_LIST_VIEW'
})
}
onMobileMapViewClick (e) {
e.preventDefault()
this.dispatch({
type: 'MOBILE_MAP_VIEW'
})
}
render () {
const leftStyle = {}
const rightStyle = {}
const listingDetails = true
let leftClass = 'left-base'
let rightClass = 'right-base'
if (this.state.listingId || this.state.filtersOpen) {
leftClass = this.addAbsoluteLeftInRender ? 'left-absolute' : ''
rightClass = 'right-shown'
}
const {contactFormOpen} = this.state
const isMapView = this.state.mobileView === 'MAP'
return (
<div id="container">
{contactFormOpen ? <ContactModal
listingId={this.state.listing._id}
contact={this.state.contact}
dispatch={this.dispatch.bind(this)} /> : null}
<div id="header">
<a className="hamburger-menu">K</a>
<span className="title">KIVI</span>
<input
id="gmaps-places-input"
placeholder="Unesite adresu, naselje ili grad"
className="where-to"
type="text"
/>
<div className="view-types">
<a onClick={this.onMobileListViewClick.bind(this)} className={!isMapView ? "view-type-left selected": "view-type-left"}>
<i className="btn-select-map fa fa-list" />
</a>
<a onClick={this.onMobileMapViewClick.bind(this)} className={isMapView ? "view-type-right selected": "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}>
{this.state.mobileView === 'LIST' && !this.state.listingDetails &&
<div className={ this.state.filtersOpen ? "map-list-view hide": "map-list-view" }>
<Listings
sort={this.state.sort}
totalCount={this.state.totalCount}
loadingMore={this.state.loadingMore}
listings={this.state.listings}
dispatch={this.dispatch.bind(this)}
/>
</div>}
<div id="map" ref="map" className={this.state.mobileView !== 'MAP' && "hide"} />
</div>
</div>
)
}
}
export default Main

View File

@@ -0,0 +1,81 @@
import React from 'react'
import { pacSelectFirst } from '../helpers/googleMaps'
export default class Welcome extends React.Component {
constructor (props) {
super(props)
this.state = {
type: 'SALE'
}
}
componentDidMount () {
const options = {
componentRestrictions: { country: 'BA' },
types: ['geocode']
}
const input = document.getElementById('gmaps-places-input-welcome')
const searchBox = new google.maps.places.Autocomplete(input, options)
pacSelectFirst(input)
input.addEventListener('focus', e => {
e.target.value = ''
})
searchBox.addListener('place_changed', () => {
const place = searchBox.getPlace()
if (place.geometry.viewport) {
const bounds = place.geometry.viewport.toUrlValue()
this.props.onSearch({
bounds,
type: this.state.type
})
} else {
const location = place.geometry.location
this.props.onSearch({
location,
type: this.state.type
})
}
})
}
onSaleClick () {
this.setState({
type: 'SALE'
})
}
onRentClick () {
this.setState({
type: 'RENT'
})
}
render () {
return (
<div>
<div className='welcome-container-bg'>
</div>
<div className='welcome-container'>
<div className='welcome-content'>
<h1>KIVI</h1>
<h2>Pronađi svoj novi dom!</h2>
<button
onClick={this.onSaleClick.bind(this)}>Kupovina</button>
<button onClick={this.onRentClick.bind(this)}>Iznajmljivanje</button>
<input
type='text'
placeholder='Unesite adresu, naselje ili grad'
className='where-to'
id='gmaps-places-input-welcome'
/>
</div>
</div>
</div>
)
}
}

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
}

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

@@ -0,0 +1,84 @@
import React from 'react'
import {render} from 'react-dom'
import Main from './components/Main'
import Welcome from './components/Welcome'
const getInitialState = url => {
const params = window.location.search.substr(1).split('&')
const initialState = {
rooms: {},
category: {}
}
for (const param of params) {
const [key, value] = param.split('=')
if (key === 'rooms' && value !== '') {
initialState.rooms = {}
value.split(',').forEach(k => {
initialState.rooms[parseInt(k)] = true
})
}
if (key === 'category' && value !== '') {
initialState.category = {}
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 === 'type') {
initialState.type = value
}
if (key === 'zoom') {
initialState.zoom = parseInt(value)
}
if (['minSize', 'maxSize', 'minPrice', 'maxPrice'].includes(key)) {
initialState[key] = parseFloat(value)
}
}
return initialState
}
const root = document.getElementById('root')
const initialState = getInitialState(window.location)
const renderMain = (additionalState = {}) => {
const main = <Main initialState={{...initialState, ...additionalState}} />
render(main, root)
}
renderMain()
// disable temp
/*
if (Object.keys(initialState).length === 2 &&
window.localStorage.getItem('lastLoad') == null) {
const onSearch = ({bounds, type, location}) => {
window.location = `/?bounds=${bounds}&type=${type}`
//renderMain({
//bounds,
//type
//})
}
const welcome = <Welcome onSearch={onSearch} />
render(welcome, root)
} else {
renderMain()
}
*/

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

@@ -0,0 +1,74 @@
import fetch from 'isomorphic-fetch'
const BASE_URL = 'localhost';
//const BASE_URL = '192.168.0.13';
export const saveContactRequest = (listingId, params) => {
let url = `http://${BASE_URL}:3001/api/contact/${listingId}`
return fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
});
}
export const loadListing = id => {
let url = `http://${BASE_URL}: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://${BASE_URL}: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;
}

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

@@ -0,0 +1,440 @@
import {markSeen} from './api'
import {defaultContactMessage, listingUrl} from './helpers'
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 getScrollElem = (component) => {
const {mobileView} = component.state
const scrollElem = mobileView === 'LIST' ?
document.querySelector('.map-list-view') : document.querySelector('.right-content')
return scrollElem
}
const viewListingDetails = ({type, action}, component) => {
const scrollElem = getScrollElem(component)
component.savedScrollTop = scrollElem.scrollTop
component.setState(
{
listingDetails: true,
listingId: action.id,
descriptionExpanded: false,
imageIndex: 0,
listing: action.listing
},
() => {
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,
listingId: null,
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
},
() => {
if (prevSelected) {
prevSelected.marker.setIcon(component.visitedMarkerIcon())
}
//const scrollElem = document.querySelector('.right-content')
const scrollElem = getScrollElem(component)
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 = getScrollElem(component)
//const scrollElem = document.querySelector('.right-content')
scrollElem.scrollTop = 0
component.refreshListings()
}
)
}
const sortChange = ({type, action}, component) => {
component.setState(
{
sort: action.sort,
page: 0
},
() => {
component.refreshListings()
}
)
}
const updateRoute = ({type, action}, component) => {
component.router.update(action)
}
const openContact = ({type, action}, component) => {
component.setState({
contactFormOpen: true,
contact: {
...component.state.contact,
message: defaultContactMessage(listingUrl(component.state.listingId)),
emailInvalid: false,
nameInvalid: false,
name: '',
email: '',
phone: ''
}
})
}
const closeContact = ({type, action}, component) => {
component.setState({
contactFormOpen: false
})
}
const updateContactInfo = ({type, action}, component) => {
let nameInvalid = component.state.contact.nameInvalid
let emailInvalid = component.state.contact.emailInvalid
if (action.field === 'name') {
nameInvalid = !action.value
}
if (action.field === 'email') {
emailInvalid = !action.value
}
component.setState({
contact: {
...component.state.contact,
[action.field]: action.value,
...{nameInvalid, emailInvalid}
}
})
}
const invalidContact = ({type, action}, component) => {
const {name, email} = component.state.contact
component.setState({
contact: {
...component.state.contact,
...{nameInvalid: !name, emailInvalid: !email}
}
})
}
const submitContactStart = ({type, action}, component) => {
component.setState({
contact: {
sending: true
}
})
}
const submitContactEnd = ({type, action}, component) => {
component.setState({
contactFormOpen: false,
contact: {
sending: false,
}
})
}
const mobileMapView = ({type, action}, component) => {
component.setState({
mobileView: 'MAP'
}, () => {
backToResults({type, action}, component)
})
}
const mobileListView = ({type, action}, component) => {
component.setState({
mobileView: 'LIST'
}, () => {
backToResults({type, action}, component)
})
}
const openFilters = ({type, action}, component) => {
component.setState({
filtersOpen: true
})
}
const closeFilters = ({type, action}, component) => {
component.setState({
filtersOpen: false
})
}
const resetFilters = ({type, action}, component) => {
component.setState({
filters: {
rooms: {},
category: {}
}
}, () => {
component.router.reset()
})
}
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,
OPEN_CONTACT: openContact,
CLOSE_CONTACT: closeContact,
UPDATE_CONTACT_INFO: updateContactInfo,
SUBMIT_CONTACT_START: submitContactStart,
SUBMIT_CONTACT_END: submitContactEnd,
INVALID_CONTACT: invalidContact,
MOBILE_MAP_VIEW: mobileMapView,
MOBILE_LIST_VIEW: mobileListView,
OPEN_FILTERS: openFilters,
CLOSE_FILTERS: closeFilters,
RESET_FILTERS: resetFilters
}
export const handleMessage = ({type, action}, component) => {
if (!handlers[type]) {
throw new `Unhandled message: ${type}`()
}
console.log(type, action);
return handlers[type]({type, action}, component)
}

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

@@ -0,0 +1,68 @@
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/')
export const defaultContactMessage = (url) => {
return `Pozdrav,
Našao/Našla sam vaš oglas na portalu Kivi za sljedeću nekretninu:
${url}
Želim da me kontaktirate kako bih dobio/dobila više informacija.
S poštovanjem
`
}
export const listingUrl = (id) => {
// TODO: fix this once removing hardcoded values
return `http://localhost:8080/?listingId=${id}`
}
export const isMobile = () => window.matchMedia("(max-width: 768px)").matches
export const formatRooms = (rooms) => {
const val = parseInt(rooms)
if (isNaN(val)) {
return '--'
}
if (val === 0) {
return "Garsonjera"
}
if ([2, 3, 4].includes(val)) {
return `${val} sobe`
}
return `${val} soba`
}
export const formatFloor = (floor) => {
const val = parseInt(floor)
if (isNaN(val)) {
return '--'
}
return `${val}. sprat`
}

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

@@ -0,0 +1,107 @@
import clone from 'lodash.clonedeep'
export default class Router {
constructor (comp, initialState) {
this.component = comp
this.initialState = clone(initialState)
this.state = clone(initialState) || {}
window.onpopstate = event => {
const state = event.state
if (state) {
if (state.toDispatch) {
this.component.dispatch(state.toDispatch)
}
}
}
}
reset () {
this.state = {
zoom: this.state.zoom,
sort: this.state.sort,
bounds: this.state.bounds,
rooms: {},
category: {}
}
this.update({})
}
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: [__dirname + "/src/index.js"],
output: {
path: __dirname + "/dist",
filename: "app.bundle.js",

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
lodash:
version "4.17.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"