Compare commits
40 Commits
router
...
crawler-lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7567b27bb8 | ||
|
|
755973e42d | ||
|
|
9d9110bf63 | ||
|
|
50b737d5ac | ||
|
|
02410a8c3c | ||
|
|
1c25fe6502 | ||
|
|
7f3f08c65c | ||
|
|
2974480b83 | ||
|
|
2e2551f3c3 | ||
|
|
ec9a29e04d | ||
|
|
7a5f7242ac | ||
|
|
a63c108259 | ||
|
|
039e34237d | ||
|
|
5d90e5efcb | ||
|
|
1743171cfd | ||
|
|
b2787ebda5 | ||
|
|
aea928fdef | ||
|
|
84db719521 | ||
|
|
7a231a1b04 | ||
|
|
8f57f91d32 | ||
|
|
344877beda | ||
|
|
c556f52b1c | ||
|
|
a9664dbcc0 | ||
|
|
9de077df32 | ||
|
|
746d28d0fd | ||
|
|
880f7a3f65 | ||
|
|
0b2ddaef9e | ||
|
|
04a4283371 | ||
|
|
cfa14ed590 | ||
|
|
7c40035f6f | ||
|
|
1c133d21bc | ||
|
|
233437e1c1 | ||
|
|
6ef680a7d3 | ||
|
|
6a4c02d01a | ||
|
|
8792abcd9f | ||
|
|
e65a6a48e8 | ||
|
|
4a8740fb35 | ||
|
|
a1151150db | ||
|
|
738720aa13 | ||
|
|
5f4e3a01d3 |
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
crawler/build
|
crawler/build
|
||||||
|
backend/build
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|||||||
55
README.md
@@ -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
|
`cd kivi`
|
||||||
2. npm run dev
|
|
||||||
3. node build/crawler.js
|
|
||||||
4. profit!
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": ["es2015", "es2017"],
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +1,219 @@
|
|||||||
import express from 'express'
|
import express from 'express';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
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;
|
import {STATUS_NORMAL, STATUS_RESERVED, STATUS_SOLD} from '../common/enums';
|
||||||
var ObjectID = require('mongodb').ObjectID;
|
|
||||||
|
var hr = require ('date-fns/locale/hr');
|
||||||
|
|
||||||
|
var MongoClient = require ('mongodb').MongoClient;
|
||||||
|
var ObjectID = require ('mongodb').ObjectID;
|
||||||
|
|
||||||
var url = 'mongodb://localhost:27017/kivi';
|
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 PORT = process.env.PORT || 3001;
|
||||||
const AGENTURA_KEY = process.env.AGENTURA_KEY || '1somethingverysecret';
|
const AGENTURA_KEY = process.env.AGENTURA_KEY || '1somethingverysecret';
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
router.get('/search/listings/:id', async (req, res, next) => {
|
router.post ('/contact/:listingId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id;
|
const listingId = req.params.listingId;
|
||||||
|
const body = req.body;
|
||||||
|
|
||||||
const listings = db.collection('listings');
|
const contactRequests = db.collection ('contact_requests');
|
||||||
const listing = await listings.findOne({_id: new ObjectID(id)});
|
|
||||||
if (listing) {
|
if (!body.email) {
|
||||||
res.json(listing);
|
res.status (422);
|
||||||
} else {
|
res.end ('Email is required');
|
||||||
res.status(404);
|
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) {
|
} catch (e) {
|
||||||
console.log('error:', e);
|
console.log ('error:', e);
|
||||||
next(e);
|
next (e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/search/listings', async (req, res, next) => {
|
router.get ('/search/listings/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const bounds = req.query.bounds || '';
|
const id = req.params.id;
|
||||||
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');
|
const listings = db.collection ('listings');
|
||||||
let query = {};
|
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, {
|
||||||
|
hasMap: 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) {
|
if (bounds) {
|
||||||
const [lat1, lng1, lat2, lng2] = bounds.split(',').map(parseFloat)
|
const [lat1, lng1, lat2, lng2] = bounds.split (',').map (parseFloat);
|
||||||
const box = [[lat1, lng1], [lat2, lng2]];
|
const box = [[lat1, lng1], [lat2, lng2]];
|
||||||
|
|
||||||
query = Object.assign(query, {
|
query = Object.assign (query, {
|
||||||
loc: {
|
loc: {
|
||||||
"$geoWithin": {
|
$geoWithin: {
|
||||||
"$box": box
|
$box: box,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//AND
|
||||||
|
|
||||||
|
//Show only selected type of ads (selling or renting)
|
||||||
if (adType) {
|
if (adType) {
|
||||||
query = Object.assign(query, {
|
query = Object.assign (query, {
|
||||||
adType: parseInt(adType)
|
adType: parseInt (adType),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//AND
|
||||||
|
|
||||||
|
//Match price
|
||||||
if (minPrice || maxPrice) {
|
if (minPrice || maxPrice) {
|
||||||
const price = {}
|
const price = {};
|
||||||
if (minPrice) {
|
if (minPrice) {
|
||||||
price["$gte"] = parseFloat(minPrice);
|
price['$gte'] = parseFloat (minPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxPrice) {
|
if (maxPrice) {
|
||||||
price["$lte"] = parseFloat(maxPrice);
|
price['$lte'] = parseFloat (maxPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
query = Object.assign(query, {
|
query = Object.assign (query, {
|
||||||
price
|
price,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const and = [];
|
//AND
|
||||||
|
|
||||||
|
//Match number of rooms
|
||||||
if (rooms) {
|
if (rooms) {
|
||||||
const allRooms = rooms.split(',');
|
const roomCount = [];
|
||||||
const or = allRooms.map(val => {
|
let fourPlus = false;
|
||||||
if (val === '4+') {
|
|
||||||
return {
|
const allRooms = rooms.split (',');
|
||||||
rooms: {
|
allRooms.map (val => {
|
||||||
"$gte": 4
|
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) {
|
if (minSize || maxSize) {
|
||||||
const size = {}
|
const size = {};
|
||||||
if (minSize) {
|
if (minSize) {
|
||||||
size["$gte"] = parseFloat(minSize);
|
size['$gte'] = parseFloat (minSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxSize) {
|
if (maxSize) {
|
||||||
size["$lte"] = parseFloat(maxSize);
|
size['$lte'] = parseFloat (maxSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
query = Object.assign(query, {
|
query = Object.assign (query, {
|
||||||
size
|
size,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//AND
|
||||||
|
|
||||||
|
//Match category
|
||||||
if (category) {
|
if (category) {
|
||||||
const allCategories = category.split(',');
|
const categoryCount = [];
|
||||||
const or = allCategories.map(val => {
|
|
||||||
return {
|
const allCategories = category.split (',').map (val => {
|
||||||
category: parseInt(val)
|
categoryCount.push (parseInt (val));
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
and.push({ "$or": or });
|
query = Object.assign (query, {
|
||||||
}
|
category: {$in: categoryCount},
|
||||||
|
|
||||||
if (and.length > 0) {
|
|
||||||
query = Object.assign(query, {
|
|
||||||
"$and": and
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('QUERY: ', query);
|
console.log ('QUERY: ', query);
|
||||||
const cnt = await properties.find(query).count();
|
const cnt = await properties.find (query).count ();
|
||||||
|
|
||||||
res.header('X-Total-Count', cnt);
|
res.header ('X-Total-Count', cnt);
|
||||||
|
|
||||||
const getSort = () => {
|
const getSort = () => {
|
||||||
if (sort === 'price-min') {
|
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": [['field1','asc'], ['field2','desc']]
|
||||||
"sort": getSort()
|
sort: getSort (),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isPins = pins === "true";
|
const isPins = pins === 'true';
|
||||||
|
|
||||||
if (!isPins) {
|
if (!isPins) {
|
||||||
all = await all.skip(20 * page).limit(20).toArray();
|
all = await all.skip (20 * page).limit (20).toArray ();
|
||||||
} else {
|
} else {
|
||||||
all = await all.toArray();
|
all = await all.toArray ();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (all.length > 0) {
|
if (all.length > 0) {
|
||||||
res.header('X-Last-Record-Id', [...all].pop()._id);
|
res.header ('X-Last-Record-Id', [...all].pop ()._id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPins) {
|
if (isPins) {
|
||||||
res.json(all.map(val => {
|
res.json (
|
||||||
return {
|
all.map (val => {
|
||||||
_id: val._id,
|
return {
|
||||||
loc: val.loc
|
_id: val._id,
|
||||||
}
|
loc: val.loc,
|
||||||
}));
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
res.json(all.map(({_id,
|
res.json (
|
||||||
address,
|
all.map (({_id, address, images, price, rooms, size, time}) => ({
|
||||||
images,
|
_id,
|
||||||
price,
|
address,
|
||||||
rooms,
|
images: [images[0]],
|
||||||
size,
|
price,
|
||||||
time
|
rooms,
|
||||||
}) => ({
|
size,
|
||||||
_id,
|
time: distanceInWordsToNow (moment (time, 'DD.MM.YYYY'), {
|
||||||
address,
|
locale: hr,
|
||||||
images: [images[0]],
|
}),
|
||||||
price,
|
realTime: time,
|
||||||
rooms,
|
}))
|
||||||
size,
|
);
|
||||||
time: distanceInWordsToNow(
|
|
||||||
new Date(time),
|
|
||||||
{locale: hr}
|
|
||||||
)
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.end();
|
res.end ();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('error:', e);
|
console.log ('error:', e);
|
||||||
next(e);
|
next (e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const app = express ();
|
||||||
|
app.use (bodyParser.json ());
|
||||||
|
|
||||||
const app = express()
|
app.use (function (req, res, next) {
|
||||||
app.use(bodyParser.json());
|
res.header ('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header (
|
||||||
app.use(function(req, res, next) {
|
'Access-Control-Allow-Headers',
|
||||||
res.header("Access-Control-Allow-Origin", "*");
|
'Origin, X-Requested-With, Content-Type, Accept, X-Last-Record-Id, X-Total-Count'
|
||||||
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 (
|
||||||
res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
'Access-Control-Expose-Headers',
|
||||||
res.header('Access-Control-Allow-Credentials', 'true');
|
'X-Last-Record-Id, X-Total-Count'
|
||||||
next();
|
);
|
||||||
|
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 = database;
|
||||||
db.collection('listings').createIndex({loc: "2d"});
|
db.collection ('listings').createIndex ({loc: '2d'});
|
||||||
app.listen(PORT, () => console.log('Express server running at localhost: ' + PORT));
|
app.listen (PORT, () =>
|
||||||
|
console.log ('Express server running at localhost: ' + PORT)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module.exports = {
|
|||||||
filename: 'build/server.js'
|
filename: 'build/server.js'
|
||||||
},
|
},
|
||||||
target: 'node',
|
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
|
ext[mod] = 'commonjs ' + mod
|
||||||
return ext
|
return ext
|
||||||
}, {}),
|
}, {}),
|
||||||
|
|||||||
1124
backend/yarn.lock
3
common/.babelrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"presets": ["es2015", "react", "stage-3"]
|
||||||
|
}
|
||||||
1
common/config.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const BASE_URL = '138.68.67.31';
|
||||||
@@ -7,4 +7,10 @@ export const CATEGORY_FLAT = 0;
|
|||||||
export const CATEGORY_HOUSE = 1;
|
export const CATEGORY_HOUSE = 1;
|
||||||
export const CATEGORY_OFFICE = 2;
|
export const CATEGORY_OFFICE = 2;
|
||||||
export const CATEGORY_LAND = 3;
|
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;
|
||||||
|
|
||||||
1
crawler/automatic_crawler.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
RENTAL_FROM_PAGE=1 RENTAL_TO_PAGE=45 PROSTOR_FROM_PAGE=1 PROSTOR_TO_PAGE=26 MONGO_URL=mongodb://localhost:27017/kivi node /home/bilal/kivi/crawler/build/crawler.js > /home/bilal/crawler.log
|
||||||
@@ -13,13 +13,18 @@ import {
|
|||||||
} from 'source-map-support';
|
} from 'source-map-support';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import OlxCrawler from './specific/olx';
|
import OlxCrawler from './specific/olx';
|
||||||
|
import ProstorCrawler from './specific/prostor';
|
||||||
|
import RentalCrawler from './specific/rental';
|
||||||
import MongoSaver from './savers/mongo'
|
import MongoSaver from './savers/mongo'
|
||||||
|
|
||||||
install(); // for source maps to work
|
install(); // for source maps to work
|
||||||
|
|
||||||
let crawlers = [
|
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 = [
|
let savers = [
|
||||||
new MongoSaver(process.env.MONGO_URL)
|
new MongoSaver(process.env.MONGO_URL)
|
||||||
];
|
];
|
||||||
@@ -34,11 +39,11 @@ async function crawlAll() {
|
|||||||
await saver.connect();
|
await saver.connect();
|
||||||
await saver.save(results);
|
await saver.save(results);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Error saving. Trying next saver! ", e);
|
console.log("[E] Error saving. Trying next saver! ", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Error crawling. Trying next crawler! ", e);
|
console.log("[E] Error crawling. Trying next crawler! ", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
crawler/detalji
Normal 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"
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,10 @@ export default class MongoSaver {
|
|||||||
if (!saver.ready) {
|
if (!saver.ready) {
|
||||||
MongoClient.connect(saver.url, (err, db) => {
|
MongoClient.connect(saver.url, (err, db) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.log('Unable to connect to the mongoDB server. Error:', err);
|
console.log('[E] Unable to connect to the mongoDB server. Error:', err);
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
console.log('Connection established to', this.url);
|
//console.log('[I] Connection established to', this.url);
|
||||||
saver.db = db;
|
saver.db = db;
|
||||||
saver.collection = db.collection('listings');
|
saver.collection = db.collection('listings');
|
||||||
saver.ready = true;
|
saver.ready = true;
|
||||||
@@ -33,8 +33,9 @@ export default class MongoSaver {
|
|||||||
return results[key]
|
return results[key]
|
||||||
});
|
});
|
||||||
|
|
||||||
this.collection.update({ url: results.url }, resultsForMongo, { upsert: true });
|
for(const doc of resultsForMongo) {
|
||||||
// this.collection.insert(resultsForMongo);
|
this.collection.update({ url: doc.url }, doc, { upsert: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
@@ -42,7 +43,7 @@ export default class MongoSaver {
|
|||||||
//Close connection
|
//Close connection
|
||||||
await this.disconnect();
|
await this.disconnect();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("error closing", e);
|
console.log("[E] Error closing connection", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
let fetch = require('node-fetch');
|
let fetch = require('node-fetch');
|
||||||
let cheerio = require('cheerio');
|
let cheerio = require('cheerio');
|
||||||
let fs = require('fs');
|
let fs = require('fs');
|
||||||
|
let cloudinary = require('cloudinary');
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AD_TYPE_SALE,
|
AD_TYPE_SALE,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
CATEGORY_HOUSE,
|
CATEGORY_HOUSE,
|
||||||
CATEGORY_OFFICE,
|
CATEGORY_OFFICE,
|
||||||
CATEGORY_LAND
|
CATEGORY_LAND
|
||||||
} from '../enums';
|
} from '../../common/enums';
|
||||||
|
|
||||||
export default class OlxCrawler {
|
export default class OlxCrawler {
|
||||||
|
|
||||||
@@ -68,6 +69,14 @@ export default class OlxCrawler {
|
|||||||
images.push(img);
|
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) {
|
if (matches && matches.length >= 3) {
|
||||||
lat = matches[1];
|
lat = matches[1];
|
||||||
lng = matches[2];
|
lng = matches[2];
|
||||||
@@ -99,12 +108,12 @@ export default class OlxCrawler {
|
|||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
loc: [parseFloat(lat), parseFloat(lng)],
|
loc: [parseFloat(lat), parseFloat(lng)],
|
||||||
images
|
images: cloudinaryImages
|
||||||
};
|
};
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Exception caught: ' + e);
|
console.error('[E] Exception caught: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -112,7 +121,7 @@ export default class OlxCrawler {
|
|||||||
|
|
||||||
async indexPage(pageNr, maxResults = 1000) {
|
async indexPage(pageNr, maxResults = 1000) {
|
||||||
try {
|
try {
|
||||||
console.log('Starting to index page: ' + pageNr);
|
//console.log('Starting to index page: ' + pageNr);
|
||||||
const url = `http://www.olx.ba/pretraga?vrsta=samoprodaja&sort_order=desc&kategorija=23&sort_po=datum&kanton=9&stranica=${pageNr}`;
|
const url = `http://www.olx.ba/pretraga?vrsta=samoprodaja&sort_order=desc&kategorija=23&sort_po=datum&kanton=9&stranica=${pageNr}`;
|
||||||
|
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
@@ -129,7 +138,7 @@ export default class OlxCrawler {
|
|||||||
let actualNoOfResults = (hrefs.length <= maxResults) ? hrefs.length : maxResults;
|
let actualNoOfResults = (hrefs.length <= maxResults) ? hrefs.length : maxResults;
|
||||||
|
|
||||||
for (let i = 0; i < hrefs.length; i++) {
|
for (let i = 0; i < hrefs.length; i++) {
|
||||||
console.log(`indexing: ${hrefs[i]}`);
|
//console.log(`indexing: ${hrefs[i]}`);
|
||||||
|
|
||||||
const singleData = await this.indexSingle(hrefs[i]);
|
const singleData = await this.indexSingle(hrefs[i]);
|
||||||
|
|
||||||
@@ -141,7 +150,7 @@ export default class OlxCrawler {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Exception caught:' + e);
|
console.error('[E] Exception caught:' + e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
262
crawler/specific/prostor.js
Normal 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 ('[E] 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 ('[E] 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
422
crawler/specific/rental.js
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
'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) {
|
||||||
|
|
||||||
|
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) {
|
||||||
|
//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 ('[E] 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 ('[E] 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,17 +4,14 @@ module.exports = {
|
|||||||
|
|
||||||
output: {
|
output: {
|
||||||
path: __dirname + "/build",
|
path: __dirname + "/build",
|
||||||
filename: "crawler.js",
|
filename: "crawler.js"
|
||||||
devtool: 'source-map'
|
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
|
|
||||||
loaders: [{
|
loaders: [{
|
||||||
test: /.js?$/,
|
test: /.js?$/,
|
||||||
loader: 'babel-loader',
|
loader: 'babel-loader',
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/
|
||||||
presets: ['es2015'],
|
|
||||||
plugins: ['transform-async-to-generator']
|
|
||||||
}, {
|
}, {
|
||||||
test: /.json?$/,
|
test: /.json?$/,
|
||||||
loader: 'json-loader',
|
loader: 'json-loader',
|
||||||
|
|||||||
2372
crawler/yarn.lock
5276
package-lock.json
generated
Normal file
53
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
48823
web/dist/app.bundle.js
vendored
5
web/dist/index.html
vendored
@@ -3,12 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>KIVI - Najbolji način da pronađeš svoj dom</title>
|
<title>KIVI - Najbolji način da pronađeš svoj dom</title>
|
||||||
<meta charset="UTF-8">
|
<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 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=Arimo" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="main.css">
|
<link rel="stylesheet" href="main.css">
|
||||||
|
<link rel="stylesheet" href="welcome.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root">
|
<div id="root">
|
||||||
|
|||||||
314
web/dist/main.css
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -72,7 +73,9 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.right-content {
|
.right-content {
|
||||||
overflow-y: auto;
|
/*overflow-y: auto;*/
|
||||||
|
overflow-y: scroll; /* has to be scroll, not auto */
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 10px 10px 0;
|
padding: 10px 10px 0;
|
||||||
}
|
}
|
||||||
@@ -167,18 +170,17 @@ html {
|
|||||||
border-radius: 5px 0 0 5px;
|
border-radius: 5px 0 0 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-type-right.selected,
|
||||||
|
.view-type-left.selected {
|
||||||
|
background-color: #b6d53b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.view-type-right {
|
.view-type-right {
|
||||||
border-radius: 0px 5px 5px 0;
|
border-radius: 0px 5px 5px 0;
|
||||||
margin-left: -1px;
|
margin-left: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bottom {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-title {
|
.filter-title {
|
||||||
width: 40%;
|
width: 40%;
|
||||||
@@ -253,7 +255,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn.more-filters {
|
.filter-btn.more-filters {
|
||||||
float: right;
|
/*float: right;*/
|
||||||
width: 145px;
|
width: 145px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +386,8 @@ html {
|
|||||||
.address {
|
.address {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
height: 50px;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.street {
|
.street {
|
||||||
@@ -456,29 +460,100 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listings-filter {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bottom {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width : 768px) {
|
@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 {
|
.listings-count {
|
||||||
display: none;
|
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 {
|
#right {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
float: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters-btn-toggle {
|
.filters-btn-toggle {
|
||||||
margin-top: 90px;
|
margin-top: 70px;
|
||||||
|
margin-right: 10px;
|
||||||
display: block;
|
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 {
|
.filters-close-button {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-hidden {
|
|
||||||
position: absolute;
|
|
||||||
left: -100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-shown {
|
.right-shown {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -487,8 +562,14 @@ html {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.left-absolute {
|
||||||
|
position:absolute;
|
||||||
|
}
|
||||||
|
|
||||||
.left-base {
|
.left-base {
|
||||||
display: block;
|
display: block;
|
||||||
|
position: static;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header .title {
|
#header .title {
|
||||||
@@ -508,7 +589,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.where-to:focus {
|
.where-to:focus {
|
||||||
/*width: 85%;*/
|
width: 85%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-types {
|
.view-types {
|
||||||
@@ -517,7 +598,7 @@ html {
|
|||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 11px 0;
|
padding: 5px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +622,7 @@ html {
|
|||||||
.filters {
|
.filters {
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters-close {
|
.filters-close {
|
||||||
@@ -559,6 +641,23 @@ html {
|
|||||||
.value-between-box {
|
.value-between-box {
|
||||||
justify-content: space-around;
|
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 {
|
.ld-image-container {
|
||||||
height: 375px;
|
max-height: 335px;
|
||||||
width: 500px;
|
width: 500px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: rgba(0,0,0,0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ld-image-container .prev-button {
|
.ld-image-container .prev-button {
|
||||||
@@ -652,6 +753,7 @@ html {
|
|||||||
left: 0px;
|
left: 0px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ld-image-container .next-button {
|
.ld-image-container .next-button {
|
||||||
@@ -660,6 +762,7 @@ html {
|
|||||||
right: 0px;
|
right: 0px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ld-image-container .prev-button div,
|
.ld-image-container .prev-button div,
|
||||||
@@ -676,7 +779,7 @@ html {
|
|||||||
.ld-image-container img {
|
.ld-image-container img {
|
||||||
/*width: 100%;*/
|
/*width: 100%;*/
|
||||||
/*height: 100%;*/
|
/*height: 100%;*/
|
||||||
max-height: 375px;
|
max-height: 335px;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,7 +859,8 @@ html {
|
|||||||
padding: 15px 0;
|
padding: 15px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ld-check-availability button {
|
.ld-check-availability button,
|
||||||
|
.contact-form button {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
padding: 15px 0;
|
padding: 15px 0;
|
||||||
background-color: #51bc6a;
|
background-color: #51bc6a;
|
||||||
@@ -780,7 +884,8 @@ html {
|
|||||||
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.ld-image-container {
|
.ld-image-container,
|
||||||
|
.ld-image-container img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -828,3 +933,170 @@ h5 {
|
|||||||
color: #2d3138;
|
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
|
After Width: | Height: | Size: 12 KiB |
BIN
web/dist/static/images/mo-bg1.jpg
vendored
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
web/dist/static/images/pins_sprite.png
vendored
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 35 KiB |
BIN
web/dist/static/images/rent_0.png
vendored
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
web/dist/static/images/rent_0_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
web/dist/static/images/rent_1.png
vendored
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
web/dist/static/images/rent_1_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
web/dist/static/images/sa-bg.jpg
vendored
Normal file
|
After Width: | Height: | Size: 530 KiB |
BIN
web/dist/static/images/sa-bg2.jpg
vendored
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
web/dist/static/images/sa-bg3.jpg
vendored
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
web/dist/static/images/sale_0.png
vendored
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
web/dist/static/images/sale_0_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
web/dist/static/images/sale_1.png
vendored
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
web/dist/static/images/sale_1_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
web/dist/static/map.jpg
vendored
Normal file
|
After Width: | Height: | Size: 154 KiB |
193
web/dist/welcome.css
vendored
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
|
||||||
|
|
||||||
|
/*.welcome-container div {*/
|
||||||
|
/*border: 1px solid red;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
.welcome-container h1 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-container h2 {
|
||||||
|
|
||||||
|
/*padding-bottom: 25px;*/
|
||||||
|
color: #2d3138;
|
||||||
|
font-size: 1em;
|
||||||
|
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-size: auto 100%;
|
||||||
|
/*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: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top:0;
|
||||||
|
bottom:0;
|
||||||
|
z-index: 0;
|
||||||
|
/*margin-left: 20px;
|
||||||
|
margin-right: 20px;*/
|
||||||
|
height: 100%;
|
||||||
|
/*padding: 100px;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
/*height: 100%;*/
|
||||||
|
/*margin: 0 auto;*/
|
||||||
|
width: 240px;
|
||||||
|
background-color: hsla(0,0%,100%,.95);
|
||||||
|
box-shadow: 0 2px 4px 0 rgba(73,73,73,.1);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
margin-top:40%;
|
||||||
|
/*padding: 50px;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-button-active {
|
||||||
|
height: 80px;
|
||||||
|
width: 80px;
|
||||||
|
background:url('static/images/sale_1_mobile.png') no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
border: none;
|
||||||
|
margin-left: 10%;
|
||||||
|
margin-right: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-button-inactive {
|
||||||
|
height: 80px;
|
||||||
|
width: 80px;
|
||||||
|
background:url('static/images/sale_0_mobile.png') no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
border: none;
|
||||||
|
margin-left: 10%;
|
||||||
|
margin-right: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rent-button-active{
|
||||||
|
height: 80px;
|
||||||
|
width: 80px;
|
||||||
|
background:url('static/images/rent_1_mobile.png') no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
border: none;
|
||||||
|
margin-left: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rent-button-inactive{
|
||||||
|
height: 80px;
|
||||||
|
width: 80px;
|
||||||
|
background:url('static/images/rent_0_mobile.png') no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
border: none;
|
||||||
|
margin-left: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button{
|
||||||
|
background-color: #b6d53b;
|
||||||
|
margin: 10px;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1em;
|
||||||
|
width: 90%;
|
||||||
|
margin-left:5%;
|
||||||
|
margin-right: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 550px) {
|
||||||
|
|
||||||
|
.welcome-container h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-container h2 {
|
||||||
|
|
||||||
|
/*padding-bottom: 25px;*/
|
||||||
|
color: #2d3138;
|
||||||
|
font-size: 1.4em;
|
||||||
|
font-weight: 200;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: .59px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
/*height: 100%;*/
|
||||||
|
/*margin: 0 auto;*/
|
||||||
|
width: 500px;
|
||||||
|
background-color: hsla(0,0%,100%,.95);
|
||||||
|
box-shadow: 0 2px 4px 0 rgba(73,73,73,.1);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
margin-top:10%;
|
||||||
|
/*padding: 50px;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-button-active {
|
||||||
|
height: 150px;
|
||||||
|
width: 150px;
|
||||||
|
background:url('static/images/sale_1_mobile.png') no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
border: none;
|
||||||
|
margin-left: 15%;
|
||||||
|
margin-right: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-button-inactive {
|
||||||
|
height: 150px;
|
||||||
|
width: 150px;
|
||||||
|
background:url('static/images/sale_0_mobile.png') no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
border: none;
|
||||||
|
margin-left: 15%;
|
||||||
|
margin-right: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rent-button-active{
|
||||||
|
height: 150px;
|
||||||
|
width: 150px;
|
||||||
|
background:url('static/images/rent_1_mobile.png') no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
border: none;
|
||||||
|
margin-left: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rent-button-inactive{
|
||||||
|
height: 150px;
|
||||||
|
width: 150px;
|
||||||
|
background:url('static/images/rent_0_mobile.png') no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
border: none;
|
||||||
|
margin-left: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button{
|
||||||
|
background-color: #b6d53b;
|
||||||
|
margin: 10px;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.4em;
|
||||||
|
width: 90%;
|
||||||
|
margin-left:5%;
|
||||||
|
margin-right: 5%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
57
web/index.js
@@ -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'));
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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("&")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
144
web/src/components/ContactModal.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
CATEGORY_HOUSE,
|
CATEGORY_HOUSE,
|
||||||
CATEGORY_OFFICE,
|
CATEGORY_OFFICE,
|
||||||
CATEGORY_LAND
|
CATEGORY_LAND
|
||||||
} from '../../crawler/enums';
|
} from "../../../common/enums";
|
||||||
|
|
||||||
export default class Filters extends React.Component {
|
export default class Filters extends React.Component {
|
||||||
onCloseClick(e) {
|
onCloseClick(e) {
|
||||||
@@ -15,16 +15,20 @@ export default class Filters extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMaxPriceChange(e) {
|
onMaxPriceChange(e) {
|
||||||
|
const maxPrice = e.target.value;
|
||||||
|
|
||||||
this.props.dispatch({
|
this.props.dispatch({
|
||||||
type: "SET_MAX_PRICE",
|
type: "SET_MAX_PRICE",
|
||||||
action: { maxPrice: e.target.value }
|
action: { maxPrice }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMinPriceChange(e) {
|
onMinPriceChange(e) {
|
||||||
|
const minPrice = e.target.value;
|
||||||
|
|
||||||
this.props.dispatch({
|
this.props.dispatch({
|
||||||
type: "SET_MIN_PRICE",
|
type: "SET_MIN_PRICE",
|
||||||
action: { minPrice: e.target.value }
|
action: { minPrice }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,40 +47,70 @@ export default class Filters extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onRoomsClick(rooms) {
|
onRoomsClick(rooms) {
|
||||||
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
|
this.props.dispatch({
|
||||||
params: {rooms}
|
type: "UPDATE_ROUTE",
|
||||||
}});
|
action: {
|
||||||
|
params: { rooms }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.props.dispatch({type: 'SET_ROOMS', action: {rooms}});
|
this.props.dispatch({ type: "SET_ROOMS", action: { rooms } });
|
||||||
}
|
}
|
||||||
|
|
||||||
onCategoryClick(category) {
|
onCategoryClick(category) {
|
||||||
|
this.props.dispatch({
|
||||||
|
type: "UPDATE_ROUTE",
|
||||||
|
action: {
|
||||||
|
params: { category }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.props.dispatch({type: 'UPDATE_ROUTE', action: {
|
this.props.dispatch({ type: "SET_CATEGORY", action: { category } });
|
||||||
params: {category}
|
|
||||||
}});
|
|
||||||
|
|
||||||
this.props.dispatch({type: 'SET_CATEGORY', action: {category}});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onRefreshClick() {
|
onRefreshClick(closeFilters) {
|
||||||
this.updateSearch();
|
this.updateSearch();
|
||||||
|
|
||||||
|
if (closeFilters) {
|
||||||
|
this.props.dispatch({
|
||||||
|
type: "CLOSE_FILTERS"
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyPress (e) {
|
onKeyPress(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
this.updateSearch();
|
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" });
|
this.props.dispatch({ type: "UPDATE_SEARCH" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onResetSearch(e) {
|
||||||
|
this.props.dispatch({
|
||||||
|
type: "RESET_FILTERS"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { filters } = this.props;
|
const { filters } = this.props;
|
||||||
const selectedRooms = val => filters.rooms[val] ? "selected" : "";
|
const selectedRooms = val => filters.rooms[val] ? "selected" : "";
|
||||||
const selectedCategory = val => filters.category[val] ? "selected": "";
|
const selectedCategory = val => filters.category[val] ? "selected" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="filters">
|
<div className="filters">
|
||||||
@@ -110,13 +144,11 @@ export default class Filters extends React.Component {
|
|||||||
value={formatFilterNumber(filters.maxPrice)}
|
value={formatFilterNumber(filters.maxPrice)}
|
||||||
/>
|
/>
|
||||||
{this.props.filters.priceDirty
|
{this.props.filters.priceDirty
|
||||||
?
|
? <i
|
||||||
<i
|
|
||||||
onClick={this.onRefreshClick.bind(this)}
|
onClick={this.onRefreshClick.bind(this)}
|
||||||
className="fa fa-refresh fa-refresh-custom"
|
className="fa fa-refresh fa-refresh-custom"
|
||||||
aria-hidden="true">
|
aria-hidden="true"
|
||||||
</i>
|
/>
|
||||||
|
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,24 +159,36 @@ export default class Filters extends React.Component {
|
|||||||
<div className="filter-content">
|
<div className="filter-content">
|
||||||
<div
|
<div
|
||||||
onClick={this.onCategoryClick.bind(this, CATEGORY_FLAT)}
|
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
|
Stan
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={this.onCategoryClick.bind(this, CATEGORY_HOUSE)}
|
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
|
Kuća
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="filter-content">
|
<div className="filter-content">
|
||||||
<div
|
<div
|
||||||
onClick={this.onCategoryClick.bind(this, CATEGORY_LAND)}
|
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
|
Zemljište
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={this.onCategoryClick.bind(this, CATEGORY_OFFICE)}
|
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
|
Poslovni prostor
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,8 +216,8 @@ export default class Filters extends React.Component {
|
|||||||
? <i
|
? <i
|
||||||
onClick={this.onRefreshClick.bind(this)}
|
onClick={this.onRefreshClick.bind(this)}
|
||||||
className="fa fa-refresh fa-refresh-custom"
|
className="fa fa-refresh fa-refresh-custom"
|
||||||
aria-hidden="true">
|
aria-hidden="true"
|
||||||
</i>
|
/>
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +261,18 @@ export default class Filters extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="clear-both" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
87
web/src/components/Gallery.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
116
web/src/components/ListingDetails.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
214
web/src/components/Listings.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
658
web/src/components/Main.js
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
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 : {},
|
||||||
|
adType: 0
|
||||||
|
},
|
||||||
|
mobileView: 'MAP',
|
||||||
|
contact: {
|
||||||
|
message: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
valid: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Props : ");
|
||||||
|
console.log(props.initialState);
|
||||||
|
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
|
||||||
|
state.filters.adType = props.initialState.adType
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
adType
|
||||||
|
} = this.state.filters
|
||||||
|
|
||||||
|
const bounds = map.getBounds()
|
||||||
|
const properties = loadProperties({
|
||||||
|
bounds: bounds.toUrlValue(),
|
||||||
|
rooms,
|
||||||
|
minSize,
|
||||||
|
maxSize,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
category,
|
||||||
|
adType,
|
||||||
|
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,
|
||||||
|
adType
|
||||||
|
} = this.state.filters
|
||||||
|
|
||||||
|
const bounds = map.getBounds()
|
||||||
|
const properties = loadProperties({
|
||||||
|
bounds: bounds.toUrlValue(),
|
||||||
|
rooms,
|
||||||
|
minSize,
|
||||||
|
maxSize,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
category,
|
||||||
|
adType,
|
||||||
|
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
|
||||||
47
web/src/components/Welcome.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {AD_TYPE_SALE, AD_TYPE_RENT} from '../../../common/enums';
|
||||||
|
|
||||||
|
export default class Welcome extends React.Component {
|
||||||
|
|
||||||
|
constructor (props) {
|
||||||
|
super(props)
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
type: AD_TYPE_SALE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaleClick () {
|
||||||
|
this.setState({
|
||||||
|
type: AD_TYPE_SALE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onRentClick () {
|
||||||
|
this.setState({
|
||||||
|
type: AD_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>
|
||||||
|
<div>
|
||||||
|
<button className={this.state.type===AD_TYPE_SALE?'buy-button-active':'buy-button-inactive'} onClick={this.onSaleClick.bind(this)}></button>
|
||||||
|
<button className={this.state.type===AD_TYPE_RENT?'rent-button-active':'rent-button-inactive'} onClick={this.onRentClick.bind(this)}></button>
|
||||||
|
</div>
|
||||||
|
<button className='search-button' onClick={()=>this.props.onSearch({adType: this.state.type})} >TRAŽI</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
web/src/helpers/googleMaps.js
Normal 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
@@ -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 === 'adType') {
|
||||||
|
initialState.adType = 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 = ({adType}) => {
|
||||||
|
|
||||||
|
console.log("onSearch()");
|
||||||
|
//window.location = `/?adType=${adType}`;
|
||||||
|
renderMain({adType})
|
||||||
|
};
|
||||||
|
const welcome = <Welcome onSearch={onSearch} />;
|
||||||
|
render (welcome, root);
|
||||||
|
} else {
|
||||||
|
renderMain ();
|
||||||
|
}
|
||||||
73
web/src/lib/api.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import fetch from 'isomorphic-fetch'
|
||||||
|
import {BASE_URL} from '../../../common/config'
|
||||||
|
|
||||||
|
export const saveContactRequest = (listingId, params) => {
|
||||||
|
|
||||||
|
let url = `http://${BASE_URL}/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}/api/search/listings/${id}`
|
||||||
|
|
||||||
|
return fetch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
//credentials: 'include'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadProperties = (
|
||||||
|
{
|
||||||
|
bounds,
|
||||||
|
minPrice = '',
|
||||||
|
maxPrice = '',
|
||||||
|
minSize = '',
|
||||||
|
maxSize = '',
|
||||||
|
rooms = {},
|
||||||
|
category = {},
|
||||||
|
adType=1,
|
||||||
|
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}/api/search/listings?bounds=${bounds}&minPrice=${minPrice}&maxPrice=${maxPrice}&rooms=${allRooms}&minSize=${minSize}&maxSize=${maxSize}&adType=${adType}&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
@@ -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)
|
||||||
|
}
|
||||||
69
web/src/lib/helpers.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {BASE_URL} from '../../../common/config'
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
return `http://${BASE_URL}/?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`
|
||||||
|
}
|
||||||
109
web/src/lib/router.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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,
|
||||||
|
adType
|
||||||
|
} = this.state
|
||||||
|
|
||||||
|
if (listingId) {
|
||||||
|
params.push(`listingId=${listingId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(`adType=${adType}`);
|
||||||
|
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('&')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
entry: ["./index.js"],
|
entry: [__dirname + "/src/index.js"],
|
||||||
output: {
|
output: {
|
||||||
path: __dirname + "/dist",
|
path: __dirname + "/dist",
|
||||||
filename: "app.bundle.js",
|
filename: "app.bundle.js",
|
||||||
publicPath: "http://0.0.0.0:8080/"
|
publicPath: "http://138.68.67.31:8080/"
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
// .. rest of devserver options
|
||||||
|
|
||||||
|
host: '0.0.0.0',
|
||||||
|
disableHostCheck: true
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
loaders: [
|
loaders: [
|
||||||
|
|||||||