Compare commits

...

169 Commits

Author SHA1 Message Date
Naida Vatric
11848cc0bb Added real estate ad edit ability 2020-03-25 16:33:21 +01:00
Naida Vatric
3fa9804ca6 Dropzone edit opction adadded. 2020-03-24 11:10:16 +01:00
Naida Vatric
477424caa1 Removing existing photos wip. 2020-03-23 12:26:08 +01:00
Naida Vatric
60f74c2cde Changed editing redirection. 2020-03-20 15:40:38 +01:00
Naida Vatric
55cb01c3c2 Started edit ad option. 2020-03-19 15:24:58 +01:00
Naida Vatric
f2d9369d5c Added delete ability. 2020-03-19 11:36:23 +01:00
Naida Vatric
ab50eb05af Started deleting of ads. 2020-03-18 21:29:11 +01:00
Naida Vatric
084766d0ea Added notification for new ad pusblish and new real estate. 2020-03-16 16:13:16 +01:00
Naida Vatric
981faeb610 For review view ad page. 2020-03-14 15:34:18 +01:00
Naida Vatric
a36fba09eb Merged finished kivi-input in view 2020-03-14 14:09:36 +01:00
Naida Vatric
9f1fe3641d For revision kivi ads input. 2020-03-12 22:46:47 +01:00
Naida Vatric
a7148ba6c3 Changed dropzone - url WiP. 2020-03-11 22:32:10 +01:00
Naida Vatric
5066c2fa70 Revert "Real estate input clean up."
This reverts commit 5f674230e1.
2020-03-10 22:03:32 +01:00
Naida Vatric
5f674230e1 Real estate input clean up. 2020-03-09 23:30:37 +01:00
Naida Vatric
96bc66ef7b Dropzone implemented. 2020-03-09 18:00:31 +01:00
Naida Vatric
6821f61e55 Flexslider fixed. 2020-03-03 22:03:41 +01:00
Naida Vatric
54d9822fc8 Flexslider vs Materilize debugg. 2020-03-03 17:02:20 +01:00
Naida Vatric
bbd9dab30d Upload image file to bucket success. 2020-02-26 14:56:00 +01:00
Naida Vatric
b80577ef6b WiP Ad preview page - flexslider problem. 2020-02-24 23:15:48 +01:00
Naida Vatric
d36d7f413d Initialized flex slider 2020-02-24 15:41:28 +01:00
Naida Vatric
9c63bdfbe2 Started photo gallery. 2020-02-23 23:05:28 +01:00
Naida Vatric
2218e6888a Added map to kivi ad preview. 2020-02-23 01:52:43 +01:00
Naida Vatric
b13b4bc7c2 CORS error tryouts. 2020-02-18 23:49:00 +01:00
Naida Vatric
c11248e100 Included npm cors. 2020-02-18 21:40:40 +01:00
Naida Vatric
17f0e6443c Changed CORS options. 2020-02-18 13:35:56 +01:00
Naida Vatric
57329b0311 CORS error still! 2020-02-16 12:11:18 +01:00
Naida Vatric
57df42dd05 WiP CORS Issue. 2020-02-16 01:06:53 +01:00
Naida Vatric
cbb3c1f954 Signed URL change. 2020-02-14 22:27:41 +01:00
Naida Vatric
ba07b9311f WiP Signed url error. 2020-02-14 15:34:33 +01:00
Naida Vatric
16d004c1ab WiP Started kivi original ad view. 2020-02-14 13:38:31 +01:00
Naida Vatric
f24abf62b2 Small css change. 2020-02-14 10:15:33 +01:00
Naida Vatric
34744613a7 WiP File upload started. 2020-02-14 00:37:11 +01:00
Naida Vatric
230ef60158 Started photo upload. 2020-02-13 17:08:49 +01:00
Naida Vatric
9bcadffe9c Changed id type to uuid. 2020-02-13 10:43:30 +01:00
Naida Vatric
9c234a85fd WiP Validation stil. 2020-02-12 23:43:59 +01:00
Naida Vatric
edb22266bd WiP Form validation 2020-02-12 11:44:56 +01:00
Naida Vatric
22c1982ef6 Added email input for KiviOriginals table. 2020-02-09 22:25:05 +01:00
Naida Vatric
86c7d23efd Merge branch 'master' into kivi-original-ads-input 2020-02-09 19:19:30 +01:00
Naida Vatric
ab6812889a Merge branch 'fixing-saljic-bugs' into 'master'
Fixing saljic bugs

See merge request saburly/marketalarm/web!92
2020-02-09 18:11:00 +00:00
Naida Vatric
b82134e280 Fixed saljic bug for heroku. 2020-02-09 19:09:00 +01:00
Naida Vatric
be378883c8 Just another fix try. 2020-02-08 00:47:00 +01:00
Naida Vatric
8a87b9e253 Another fix. 2020-02-08 00:27:26 +01:00
Naida Vatric
43bc23b164 Another fix. Defined more var. 2020-02-07 22:27:01 +01:00
Naida Vatric
fc6351af46 Added columns and logs for types. 2020-02-07 22:12:53 +01:00
Naida Vatric
9e06731c84 WiP Added location input. 2020-02-07 14:05:00 +01:00
Naida Vatric
7777081c99 WIP Form submit added. 2020-02-07 12:26:29 +01:00
Naida Vatric
6a957db183 WiP Added input fields. 2020-02-07 00:27:09 +01:00
Naida Vatric
f8349dae1f Merged master to kivi original input. 2020-02-06 23:05:26 +01:00
Naida Vatric
6267b2cab4 Merge branch 'staging-tag-to-checkup-email' into 'master'
Added staging tag to checkup email. Email footer bug fixed.

See merge request saburly/marketalarm/web!91
2020-02-06 21:43:56 +00:00
Naida Vatric
97724a47a1 Removed debugg logs. Smal fixes. 2020-02-06 22:40:56 +01:00
Naida Vatric
91a1c6a91e Added more logs for debugging. 2020-02-06 14:12:35 +01:00
Naida Vatric
05062201bf WiP Added field to input form. 2020-02-06 01:57:29 +01:00
Naida Vatric
eb4ab2e341 Changed misspeling. 2020-02-05 23:02:14 +01:00
Naida Vatric
2d0a00b967 Debugging- noOfRealEstates and staging tag. 2020-02-05 21:58:45 +01:00
Naida Vatric
74def9c059 Added staging tag to checkup email. Email footer bug fixed. 2020-02-05 21:35:18 +01:00
Naida Vatric
d29b3eb1b3 Merge branch 'crawler-saljicnekretnine' into 'master'
Crawler saljicnekretnine

See merge request saburly/marketalarm/web!90
2020-02-04 13:09:10 +00:00
Naida Vatric
41b59e8c7c Merge branch 'tag-staging-email' into 'master'
Tag staging email

See merge request saburly/marketalarm/web!85
2020-02-04 13:07:53 +00:00
Naida Vatric
b933fa96d4 Merge branch 'master' into 'tag-staging-email'
# Conflicts:
#   app/config/appConfig.js
2020-02-04 13:07:43 +00:00
Naida Vatric
6429bb30c2 WiP Added publish enums. 2020-02-04 01:02:32 +01:00
Naida Vatric
7b97835e8b WiP Started input form. 2020-02-03 14:57:36 +01:00
Naida Vatric
d45441f4be WiP Changed welcome and input form for ad type 2020-02-03 13:11:40 +01:00
Naida Vatric
824db4fbc3 Merge branch 'checkup-email' into 'master'
Added check up email that everything works.

See merge request saburly/marketalarm/web!89
2020-01-31 21:59:35 +00:00
Naida Vatric
0f91841c43 Merge branch 'master' into 'checkup-email'
# Conflicts:
#   app/config/appConfig.js
#   app/services/notificationService.js
2020-01-31 21:59:26 +00:00
Naida Vatric
12b4a8f6ec Merge branch 'ads-without-price' into 'master'
Ads without price

See merge request saburly/marketalarm/web!88
2020-01-31 21:53:36 +00:00
Naida Vatric
17621ad310 Merge branch 'price-history-log' into 'master'
Price history log

See merge request saburly/marketalarm/web!87
2020-01-31 21:53:18 +00:00
Naida Vatric
c461525959 Merge branch 'sliders-formating' into 'master'
Changed sliders for different real estate types.

See merge request saburly/marketalarm/web!86
2020-01-31 21:53:04 +00:00
Naida Vatric
aec9c1e1d5 Merge branch 'more-details-email' into 'master'
More details email

See merge request saburly/marketalarm/web!84
2020-01-31 21:52:33 +00:00
Naida Vatric
2d672f4660 Merge branch 'prostor-vip-ads' into 'master'
Prostor vip ads

See merge request saburly/marketalarm/web!83
2020-01-31 21:52:13 +00:00
Naida Vatric
bc15cf65a5 Merge branch 'results-link' into 'master'
Results link

See merge request saburly/marketalarm/web!82
2020-01-31 21:51:38 +00:00
Naida Vatric
ce11d57ab2 Merge branch 'no-all-results-email' into 'master'
No all results email

See merge request saburly/marketalarm/web!81
2020-01-31 21:51:20 +00:00
Naida Vatric
4a6bcf262e Merge branch 'add-even-more-filters' into 'master'
Add even more filters

See merge request saburly/marketalarm/web!77
2020-01-31 21:50:54 +00:00
Naida Vatric
712cde1632 Changed not to use NODE_ENV variable. 2020-01-31 22:47:47 +01:00
Naida Vatric
1ba7cf8531 Added crawler for Saljic nekretnine. 2020-01-31 22:03:39 +01:00
Naida Vatric
7a7aecb3ee WIP Scraped no of rooms, floors etc. 2020-01-31 00:55:24 +01:00
Naida Vatric
78c4054cde WIP Scraped title, price and location. 2020-01-30 16:24:34 +01:00
Naida Vatric
94ffc2d6d2 WIP Started saljic crawler. 2020-01-29 23:22:39 +01:00
Naida Vatric
b11f18696f Prepared config files. 2020-01-29 01:09:53 +01:00
Naida Vatric
fa46f75dd3 Changed default to switched on. 2020-01-28 22:28:52 +01:00
Naida Vatric
470f53d29b Changed to send email only to some users. 2020-01-24 16:04:28 +01:00
Naida Vatric
40509d2836 Changed logic for checkup.For testing. 2020-01-24 01:01:10 +01:00
Naida Vatric
b2c102bc1a Added check up email that everything works. 2020-01-23 15:48:48 +01:00
Naida Vatric
98263364c7 Added option to include-exclude ads without price 2020-01-23 11:13:53 +01:00
Naida Vatric
5b3491fdba Added migration and model change for searchReq table. 2020-01-23 10:12:56 +01:00
Naida Vatric
c6f0e039a5 Added price history log. 2020-01-21 23:12:04 +01:00
Naida Vatric
8d3f001678 WIP Added model, migration and bulk upsert fnc. 2020-01-21 16:28:47 +01:00
Naida Vatric
42eddb3aa5 WIP Bulk create not working. 2020-01-21 01:19:35 +01:00
Naida Vatric
0a181f742f WIP Added model and migration for new table priceHistory. 2020-01-20 23:09:02 +01:00
Naida Vatric
d117383802 Tested both ways for realestate and search req filters. 2020-01-17 22:58:22 +01:00
Naida Vatric
870b71a3c7 WIP Changed all logic for searchRequest. 2020-01-17 01:54:06 +01:00
Naida Vatric
e6725355a0 Changed sliders for different realestate types. 2020-01-15 01:33:37 +01:00
Naida Vatric
4fd4018bf6 Merge branch 'sliders-formating' of gitlab.com:saburly/marketalarm/web into add-even-more-filters 2020-01-14 23:19:51 +01:00
Naida Vatric
b9122f8f00 Added tag to email to denote staging from production. 2020-01-14 15:59:17 +01:00
Naida Vatric
fc33c1210a Add more detail to the email 2020-01-13 14:58:09 +01:00
Naida Vatric
511b290096 Login to prostor.ba befoure crawl. 2020-01-13 12:05:33 +01:00
Naida Vatric
ba43fa0713 WIP Changed cookies. 2020-01-13 11:02:26 +01:00
Naida Vatric
e70901d369 WIP Changed login to crawler. 2020-01-13 09:12:03 +01:00
Naida Vatric
8505282670 WiP Login of crawler prostor. 2020-01-12 01:22:50 +01:00
Naida Vatric
64e4835899 Changed redirecting for VIP ads. 2020-01-10 22:52:50 +01:00
Naida Vatric
1658325c4b WIP Fake vip ads. 2020-01-10 19:20:26 +01:00
Naida Vatric
49161c1b60 WIP Changed redirecting for VIP ads. 2020-01-09 12:19:19 +01:00
Naida Vatric
d23ddf849f Results title text made into link. 2020-01-07 01:06:22 +01:00
Naida Vatric
38bd0343f5 Merge branch 'results-link' of gitlab.com:saburly/marketalarm/web into no-all-results-email 2020-01-07 01:01:57 +01:00
Naida Vatric
259799144e Merge branch 'rental-crawler-fix' into 'master'
Rental crawler fix

See merge request saburly/marketalarm/web!80
2020-01-06 23:12:52 +00:00
Naida Vatric
bc73d4159d Merge branch 'master' into 'rental-crawler-fix'
# Conflicts:
#   .gitignore
2020-01-06 23:12:40 +00:00
Naida Vatric
37ad32fe76 Merge branch 'edit-location-start' into 'master'
Edit location start

See merge request saburly/marketalarm/web!79
2020-01-06 23:10:16 +00:00
Naida Vatric
94875a0fa3 Merge branch 'add-currency-to-price-filters' into 'master'
Add currency to price filters

See merge request saburly/marketalarm/web!78
2020-01-06 23:09:40 +00:00
Naida Vatric
fa4e0d64de Changed email content to show number of all matching real estates. 2020-01-06 23:59:56 +01:00
Naida Vatric
0c2d218d29 Changed floor numbers and basement-attic tag. 2020-01-02 00:10:31 +01:00
Naida Vatric
fed2dc00dc Changed number of rooms. 2019-12-29 23:42:39 +01:00
Naida Vatric
d5d3a1f306 Changed accesRoadType logic 2019-12-26 23:30:05 +01:00
Naida Vatric
42ff1f762f Changed to avoid falsy values and not defined realestate parametrs. 2019-12-21 02:20:26 +01:00
Naida Vatric
cc78e5acd5 Updated location to start from selected when edit. 2019-12-20 01:02:57 +01:00
Naida Vatric
55319a54e9 WIP Idea to implement bound map to be equal to selected 2019-12-19 02:12:23 +01:00
Naida Vatric
ef5de27c06 Add currency to price filters - added above input 2019-12-18 22:21:56 +01:00
Naida Vatric
bee390aa15 RealEstate included even is price is null. 2019-12-18 21:49:12 +01:00
Naida Vatric
251437f815 Changed searchRequest to include case of incomplete ads wanted. 2019-12-18 02:04:31 +01:00
Naida Vatric
4391aa5939 First review changes: applied prettier, ternary and changed accesRoadType filter 2019-12-17 11:28:00 +01:00
Naida Vatric
c672b3ab9f First review changes: applied prettier, ternary and changed accesRoadType filter 2019-12-17 11:18:58 +01:00
Bilal Catic
76f4ed0a30 apply prettier 2019-12-16 22:04:37 +01:00
Bilal Catic
73b3f0d22f Merge branch 'master' into 'add-even-more-filters'
# Conflicts:
#   app/config/appConfig.js
2019-12-16 20:52:40 +00:00
Bilal Catic
547411f189 Merge branch 'map-key-env-var' into 'master'
Map key env var

See merge request saburly/marketalarm/web!76
2019-12-16 20:49:58 +00:00
Bilal Catic
a45a0ec361 apply prettier 2019-12-16 21:40:21 +01:00
Naida Vatric
43074b6eb3 Finished map key to env 2019-12-16 21:40:21 +01:00
Naida Vatric
cb52c8592a Moved API Google Map key to env variables. 2019-12-16 21:40:21 +01:00
Naida Vatric
5a2fdb7291 Queries for db search changed. Needs testing. 2019-12-14 01:10:48 +01:00
Naida Vatric
e83712fb33 Changed acces road type check and include incomplete 2019-12-13 00:45:28 +01:00
Naida Vatric
0e585e74ae Merge branch 'add-even-more-filters' of gitlab.com:saburly/marketalarm/web into add-even-more-filters 2019-12-11 22:52:26 +01:00
Naida Vatric
e6e1688a49 Changed searchRequest helper 2019-12-11 22:44:26 +01:00
Naida Vatric
dee7c6000a WiP - changed db helpers 2019-12-11 01:24:18 +01:00
Naida Vatric
fbcda328b7 Merge branch 'update-dockerfile-readme-setup' into 'master'
Update docker file, readme and setup script

See merge request saburly/marketalarm/web!75
2019-12-10 20:44:53 +00:00
Naida Vatric
6f729b4135 "Taking over work in progress." 2019-12-10 11:07:31 +01:00
Naida Vatric
ef4fff4e70 Finished map key to env 2019-12-08 22:16:31 +01:00
Naida Vatric
ade28eb981 Moved API Google Map key to env variables. 2019-12-08 00:58:06 +01:00
Naida Vatric
5d792846ae Update docker file, readme and setup script 2019-12-05 22:46:50 +01:00
Bilal Catic
f8ea2f0f78 include new fields for search request 2019-11-18 22:46:18 +01:00
Bilal Catic
232221af9e improve css 2019-11-18 22:46:18 +01:00
Bilal Catic
271af35f0c add new enum value for access road type and heating type; add filter enums 2019-11-18 22:46:18 +01:00
Bilal Catic
6baa151ea2 add new fields to the search request table and model 2019-11-18 22:46:18 +01:00
Bilal Catic
e42531ff57 specify and use custom css class for checkbox labels 2019-11-18 22:46:18 +01:00
Bilal Catic
002a8e8572 add more filters to different tab on filters page; update css 2019-11-18 22:46:18 +01:00
Bilal Catic
fd8592c581 modify materialize tabs style to match Kivi color scheme 2019-11-18 22:46:18 +01:00
Bilal Catic
5cab9ee7c4 remove accordion files and import 2019-11-18 22:44:26 +01:00
Bilal Catic
1106f92560 add accordion for additional filters 2019-11-18 22:44:26 +01:00
Bilal Catic
ab8373651e update garage price slider options 2019-11-18 19:05:00 +01:00
Bilal Catic
ade09f6f15 change sale and rent action title 2019-11-18 18:56:34 +01:00
Bilal Catic
e4edc24cad Merge branch 'replace-front-page-next-button' into 'master'
select ad type on welcome page; update css

See merge request saburly/marketalarm/web!73
2019-11-18 14:49:07 +00:00
Bilal Catic
44565d2f89 select ad type on welcome page; update css 2019-11-18 10:48:41 +01:00
Bilal Catic
860014662a Merge branch 'add-more-real-estate-filters-to-crawler' into 'master'
Add more real estate filters to crawler

See merge request saburly/marketalarm/web!72
2019-11-14 13:58:57 +00:00
Bilal Catic
af42d2c448 improve OLX ad status detection 2019-11-14 08:47:48 +01:00
Bilal Catic
5148f88a62 improve Rental and Aktido ad status detection 2019-11-14 08:31:57 +01:00
Bilal Catic
a7cd75653d improve OLX ad status detection 2019-11-14 08:04:58 +01:00
Bilal Catic
168b2186e7 add more fields to the Prostor real estates crawler 2019-11-14 07:23:23 +01:00
Bilal Catic
1e68d640e2 add RENTED enum status 2019-11-14 07:22:54 +01:00
Bilal Catic
c13857bc09 add additional fields to the Prostor crawler 2019-11-14 02:09:42 +01:00
Bilal Catic
618dcd217e update ENV variables template file 2019-11-14 02:09:22 +01:00
Bilal Catic
3b3e2eda07 refactor Prostor crawler 2019-11-13 16:54:16 +01:00
Bilal Catic
ae93d2f03d update ENV variable description 2019-11-13 16:52:55 +01:00
Bilal Catic
a63671959b improve real estate properties detection for Rental 2019-11-12 22:53:16 +01:00
Bilal Catic
b6d68db3a3 improve real estate properties detection for aktido 2019-11-12 21:39:28 +01:00
Bilal Catic
c91e56c46e add additional real estate fields for Aktido crawler 2019-11-11 19:34:43 +01:00
Bilal Catic
e871550ba6 add two more heating types for Rental crawler 2019-11-11 18:46:01 +01:00
Bilal Catic
debdd01b28 add new fields to the Rental crawler 2019-11-11 17:15:46 +01:00
Bilal Catic
9e10800b02 add new heating type ENUM 2019-11-11 17:15:14 +01:00
Bilal Catic
cb9bb9e566 add rental scraper test script 2019-11-11 03:34:15 +01:00
Bilal Catic
b6024af2cb add new fields for OLX crawler 2019-11-08 17:05:51 +01:00
Bilal Catic
50514aaf03 add new ENUMS for real estate properties 2019-11-08 16:40:15 +01:00
Bilal Catic
9ba41dd7f7 add columns for update on duplicate real estate 2019-11-08 16:39:37 +01:00
Bilal Catic
02f5b97e80 add migration for new real estate fields; update real estate model 2019-11-08 16:27:55 +01:00
Bilal Catic
7242e233e3 Merge branch 'replace-frontend-arrow-functions-with-old-style-function' into 'master'
replace arrow functions on frontend with old style function

See merge request saburly/marketalarm/web!71
2019-11-08 13:11:56 +00:00
106 changed files with 17921 additions and 592 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
node_modules/
.env
.idea/
.eslintrc
.vscode/

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
*.ejs

View File

@@ -3,6 +3,7 @@ FROM postgres:11.3
ENV POSTGIS_MAJOR 2.4
RUN apt-get update \
&& apt-get --assume-yes install postgresql-11-postgis-2.5-scripts\
&& apt-get --assume-yes install software-properties-common postgis\
&& rm -rf /var/lib/apt/lists/

View File

@@ -4,6 +4,8 @@ The purpose of this project is to build a web application that enables subscribi
## Setup
* Before setup please confirm that Docker is installed `docker --version`. If not install it from official site.
### Setup with npm commands
1. Install packages
@@ -24,7 +26,7 @@ this will create and run postgres image and then execute migrations
`docker build -t marketalerts .`
2. Run postgres image with
`docker run --name pg_marketalerts -d -p 5432:5432 marketalerts`
`docker run -e POSTGRES_USER=docker -e POSTGRES_PASSWORD=docker -e POSTGRES_DB=marketalerts --name pg_marketalerts -d -p 5432:5432 marketalerts`
3. Install packages
`npm install`
@@ -41,3 +43,4 @@ this will create and run postgres image and then execute migrations
- AWS SES credentials are handled with env vratiables
- Notification emails are sent in batches of 50, by using SES templates
- Make sure that you are using different templates for different envirorments

View File

@@ -7,7 +7,42 @@ const PRICE_SLIDER_OPTIONS_SALE = {
step: 1000,
connect: true
};
const FLAT_PRICE_SLIDER_OPTIONS_SALE = {
start: [50000, 150000],
range: {
min: [0],
max: [800000]
},
step: 5000,
connect: true
};
const HOUSE_PRICE_SLIDER_OPTIONS_SALE = {
start: [50000, 150000],
range: {
min: [0],
max: [1500000]
},
step: 10000,
connect: true
};
const OFFICE_PRICE_SLIDER_OPTIONS_SALE = {
start: [15000, 50000],
range: {
min: [0],
max: [2000000]
},
step: 2000,
connect: true
};
const LAND_PRICE_SLIDER_OPTIONS_SALE = {
start: [40000, 80000],
range: {
min: [0],
max: [2000000]
},
step: 10000,
connect: true
};
const PRICE_SLIDER_OPTIONS_RENT = {
start: [300, 500],
range: {
@@ -17,18 +52,62 @@ const PRICE_SLIDER_OPTIONS_RENT = {
step: 50,
connect: true
};
const FLAT_PRICE_SLIDER_OPTIONS_RENT = {
start: [300, 600],
range: {
min: [0],
max: [4000]
},
step: 100,
connect: true
};
const HOUSE_PRICE_SLIDER_OPTIONS_RENT = {
start: [500, 1000],
range: {
min: [0],
max: [10000]
},
step: 100,
connect: true
};
const OFFICE_PRICE_SLIDER_OPTIONS_RENT = {
start: [200, 1000],
range: {
min: [0],
max: [20000]
},
step: 100,
connect: true
};
const LAND_PRICE_SLIDER_OPTIONS_RENT = {
start: [500, 1000],
range: {
min: [0],
max: [20000]
},
step: 100,
connect: true
};
//This will be used for Flats, Apartments, Houses
const HOME_SIZE_SLIDER_OPTIONS = {
start: [30, 75],
range: {
min: [0],
max: [400]
max: [500]
},
step: 5,
connect: true
};
const OFFICE_SIZE_SLIDER_OPTIONS = {
start: [30, 150],
range: {
min: [0],
max: [1200]
},
step: 10,
connect: true
};
const GARDEN_SIZE_SLIDER_OPTIONS = {
start: [100, 1000],
range: {
@@ -58,13 +137,23 @@ const GARAGE_SIZE_SLIDER_OPTIONS = {
connect: true
};
const GARAGE_PRICE_SLIDER_OPTIONS = {
const GARAGE_PRICE_SLIDER_OPTIONS_SALE = {
start: [2000, 10000],
range: {
min: [0],
max: [100000]
max: [60000]
},
step: 500,
step: 200,
connect: true
};
const GARAGE_PRICE_SLIDER_OPTIONS_RENT = {
start: [50, 150],
range: {
min: [0],
max: [1000]
},
step: 10,
connect: true
};
@@ -72,12 +161,12 @@ const AD_TYPE = {
AD_TYPE_SALE: {
id: 1,
stringId: "SALE",
title: "Prodaja"
title: "Kupi"
},
AD_TYPE_RENT: {
id: 2,
stringId: "RENT",
title: "Najam"
title: "Unajmi"
},
AD_TYPE_REQUEST: {
id: 3,
@@ -94,16 +183,30 @@ const AD_CATEGORY = {
id: "FLAT",
title: "Stan",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
hasAccesRoadType: true,
hasBalconyProp: true,
hasNewBuildingProp: true,
hasElevatorProp: true,
hasNumberOfRoom: true,
hasNumberOfFloors: false,
hasFloorProp: true,
priceSliderOptionsSale: FLAT_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: FLAT_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
HOUSE: {
id: "HOUSE",
title: "Kuća",
hasGardenSize: true,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
hasAccesRoadType: true,
hasBalconyProp: true,
hasNewBuildingProp: true,
hasElevatorProp: false,
hasNumberOfRoom: true,
hasNumberOfFloors: true,
hasFloorProp: false,
priceSliderOptionsSale: HOUSE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: HOUSE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
},
@@ -111,40 +214,75 @@ const AD_CATEGORY = {
id: "OFFICE",
title: "Kancelarija",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
hasAccesRoadType: true,
hasBalconyProp: false,
hasNewBuildingProp: true,
hasElevatorProp: true,
hasNumberOfRoom: true,
hasNumberOfFloors: false,
hasFloorProp: true,
priceSliderOptionsSale: OFFICE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: OFFICE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: OFFICE_SIZE_SLIDER_OPTIONS
},
LAND: {
id: "LAND",
title: "Zemljište",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
hasAccesRoadType: true,
hasBalconyProp: false,
hasNewBuildingProp: false,
hasElevatorProp: false,
hasNumberOfRoom: false,
hasNumberOfFloors: false,
hasFloorProp: false,
priceSliderOptionsSale: LAND_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: LAND_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: LAND_SIZE_SLIDER_OPTIONS
},
APARTMENT: {
id: "APARTMENT",
title: "Apartman",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
hasAccesRoadType: true,
hasBalconyProp: true,
hasNewBuildingProp: true,
hasElevatorProp: true,
hasNumberOfRoom: true,
hasNumberOfFloors: false,
hasFloorProp: true,
priceSliderOptionsSale: FLAT_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: FLAT_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
GARAGE: {
id: "GARAGE",
title: "Garaža",
hasGardenSize: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
hasAccesRoadType: true,
hasBalconyProp: false,
hasNewBuildingProp: false,
hasElevatorProp: false,
hasNumberOfRoom: false,
hasNumberOfFloors: false,
hasFloorProp: false,
priceSliderOptionsSale: GARAGE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: GARAGE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: GARAGE_SIZE_SLIDER_OPTIONS
},
COTTAGE: {
id: "COTTAGE",
title: "Vikendica",
hasGardenSize: true,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT,
hasAccesRoadType: true,
hasBalconyProp: true,
hasNewBuildingProp: true,
hasElevatorProp: false,
hasNumberOfRoom: true,
hasNumberOfFloors: true,
hasFloorProp: false,
priceSliderOptionsSale: HOUSE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: HOUSE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
}
@@ -156,14 +294,18 @@ const AD_STATUS = {
STATUS_SOLD: 3,
STATUS_DELETED: 4,
STATUS_URGENT: 5,
STATUS_DISCOUNTED: 6
STATUS_DISCOUNTED: 6,
STATUS_RENTED: 7,
STATUS_VIP: 8
};
const AD_AGENCY = {
OLX: "OLX",
RENTAL: "RENTAL",
PROSTOR: "PROSTOR",
AKTIDO: "AKTIDO"
AKTIDO: "AKTIDO",
KIVI: "KIVI",
SALJIC: "SALJIC"
};
const CRAWLER_AD_TYPE = {
@@ -187,11 +329,95 @@ const EMAIL_FREQUENCY = {
}
};
const HEATING_TYPE = {
ANY: {
id: "ANY",
title: "Svi"
},
NO_HEATING: {
id: "NO_HEATING",
title: "Nije uvedeno"
},
ELECTRICITY: {
id: "ELECTRICITY",
title: "Struja"
},
GAS: {
id: "GAS",
title: "Plin"
},
WOOD: {
id: "WOOD",
title: "Drva"
},
CENTRAL_CITY: {
id: "CENTRAL_CITY",
title: "Centralno (gradsko)"
},
CENTRAL_BOILER: {
id: "CENTRAL_BOILER",
title: "Centralno (kotlovnica)"
},
CENTRAL_GAS: {
id: "CENTRAL_GAS",
title: "Centralno (plin)"
},
HEAT_PUMP: {
id: "HEAT_PUMP",
title: "Toplotna pumpa"
},
OTHER: {
id: "OTHER",
title: "Drugo"
}
};
const ACCESS_ROAD_TYPE = {
ANY: {
id: "ANY",
title: "Svi"
},
ASPHALT: {
id: "ASPHALT",
title: "Asfalt"
},
CONCRETE: {
id: "CONCRETE",
title: "Beton"
},
MACADAM: {
id: "MACADAM",
title: "Makadam"
},
OTHER: {
id: "OTHER",
title: "Drugo"
}
};
const FURNISHING_TYPE = {
NOT_FURNISHED: {
id: "NOT_FURNISHED",
title: "Nenamješten"
},
HALF_FURNISHED: {
id: "HALF_FURNISHED",
title: "Polunamješten"
},
FURNISHED: {
id: "FURNISHED",
title: "Namješten"
}
};
module.exports = {
AD_TYPE,
AD_CATEGORY,
AD_STATUS,
AD_AGENCY,
CRAWLER_AD_TYPE,
EMAIL_FREQUENCY
EMAIL_FREQUENCY,
HEATING_TYPE,
ACCESS_ROAD_TYPE,
FURNISHING_TYPE
};

110
app/common/filterEnums.js Normal file
View File

@@ -0,0 +1,110 @@
const { AD_CATEGORY, ACCESS_ROAD_TYPE, HEATING_TYPE } = require("./enums");
const ADVANCED_BOOLEAN_FILTERS = [
{
dbField: "balcony",
title: "Balkon",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE
]
},
{
dbField: "elevator",
title: "Lift",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.OFFICE
]
},
{
dbField: "newBuilding",
title: "Novogradnja",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
}
];
const ADVANCED_SEGMENT_SELECT_FILTERS = [
{
dbField: "accessRoadType",
title: "Pristupni put",
values: Object.keys(ACCESS_ROAD_TYPE).map(key => ACCESS_ROAD_TYPE[key]),
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
]
}
// {
// dbField: "heatingType",
// title: "Vrsta grijanja",
// values: Object.keys(HEATING_TYPE).map(key => HEATING_TYPE[key]),
// categoriesToShow: [
// AD_CATEGORY.FLAT,
// AD_CATEGORY.HOUSE,
// AD_CATEGORY.APARTMENT,
// AD_CATEGORY.COTTAGE,
// AD_CATEGORY.OFFICE
// ]
// }
];
const ADVANCED_RANGE_FILTERS = [
{
id: "numberOfFloors",
title: "Broj spratova",
dbFieldMin: "numberOfFloorsMin",
dbFieldMax: "numberOfFloorsMax",
validValueMin: -1,
validValueMax: 50,
categoriesToShow: [AD_CATEGORY.HOUSE, AD_CATEGORY.COTTAGE]
},
{
id: "floor",
title: "Sprat",
dbFieldMin: "floorMin",
dbFieldMax: "floorMax",
validValueMin: -10,
validValueMax: 50,
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.OFFICE
]
},
{
id: "numberOfRooms",
title: "Broj soba",
dbFieldMin: "numberOfRoomsMin",
dbFieldMax: "numberOfRoomsMax",
decimalPlaces: 1,
validValueMin: 0,
validValueMax: 200,
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
}
];
module.exports = {
ADVANCED_BOOLEAN_FILTERS,
ADVANCED_SEGMENT_SELECT_FILTERS,
ADVANCED_RANGE_FILTERS
};

492
app/common/publishEnums.js Normal file
View File

@@ -0,0 +1,492 @@
const {
AD_CATEGORY,
ACCESS_ROAD_TYPE,
HEATING_TYPE,
FURNISHING_TYPE
} = require("./enums");
const BASIC_BOOLEAN_PUBLISH = [
{
dbField: "newBuilding",
title: "Novogradnja",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.GARAGE
]
},
{
dbField: "balcony",
title: "Balkon",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE
]
},
{
dbField: "elevator",
title: "Lift",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.OFFICE
]
},
{
dbField: "recentlyAdapted",
title: "Nedavno adaptirano",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
}
];
const BASIC_INPUT_PUBLISH = [
{
dbField: "title",
title: "Naslov",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
],
constraint: ["required"]
},
{
dbField: "shortDescription",
title: "Opis",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
],
constraint: []
},
{
dbField: "price",
title: "Cijena (KM)",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
],
constraint: ["numerical"]
},
{
dbField: "area",
title: "Površina (m\xB2)",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
],
constraint: ["numerical"]
},
{
dbField: "gardenSize",
title: "Površina okućnice (m\xB2)",
categoriesToShow: [AD_CATEGORY.HOUSE, AD_CATEGORY.COTTAGE],
constraint: ["numerical"]
},
{
dbField: "streetName",
title: "Adresa",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
],
constraint: []
},
{
dbField: "numberOfRooms",
title: "Broj soba",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
],
constraint: ["integer"]
},
{
dbField: "numberOfFloors",
title: "Broj spratova",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE
],
constraint: ["integer"]
},
{
dbField: "floor",
title: "Sprat",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.OFFICE
],
constraint: ["integer"]
}
];
const BASIC_SEGMENT_PUBLISH = [
{
dbField: "furnishingType",
title: "Namještaj",
values: Object.keys(FURNISHING_TYPE).map(key => FURNISHING_TYPE[key]),
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
}
];
const ADDITIONAL_BOOLEAN_PUBLISH = [
{
dbField: "water",
title: "Voda",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "electricity",
title: "Struja",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.GARAGE
]
},
{
dbField: "drainageSystem",
title: "Kanalizacija",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "registeredInZkBooks",
title: "Uknjiženo",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
]
},
{
dbField: "parking",
title: "Parking",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "garage",
title: "Garaža",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "gas",
title: "Plin",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "antiTheftDoor",
title: "Blindirana vrata",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "airCondition",
title: "Klimatizirano",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "phoneConnection",
title: "Telefon",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "cableTV",
title: "Kablovska",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "internet",
title: "Internet",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
},
{
dbField: "basementAttic",
title: "Podrum-Tavan",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE
]
},
{
dbField: "storeRoom",
title: "Ostava",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE
]
},
{
dbField: "videoSurveillance",
title: "Video nadzor",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.GARAGE
]
},
{
dbField: "alarm",
title: "Alarm",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.GARAGE
]
},
{
dbField: "suitableForStudents",
title: "Za studente",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE
]
},
{
dbField: "includingBills",
title: "Uključen trošak režija",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.GARAGE
]
},
{
dbField: "animalsAllowed",
title: "Kućni ljubimci dozvoljeni",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE
]
},
{
dbField: "pool",
title: "Bazen",
categoriesToShow: [AD_CATEGORY.HOUSE, AD_CATEGORY.COTTAGE]
},
{
dbField: "exchange",
title: "Zamjena",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
]
},
{
dbField: "urbanPlanPermit",
title: "Urbanistička dozvola",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
]
},
{
dbField: "buildingPermit",
title: "Građevinska dozvola",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
]
}
];
const ADDITIONAL_INPUT_PUBLISH = [
{
dbField: "longDescription",
title: "Detaljan opis",
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
]
}
];
const ADDITIONAL_SEGMENT_PUBLISH = [
{
dbField: "accessRoadType",
title: "Pristupni put",
values: Object.keys(ACCESS_ROAD_TYPE).map(key => ACCESS_ROAD_TYPE[key]),
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE,
AD_CATEGORY.LAND,
AD_CATEGORY.GARAGE
]
},
{
dbField: "heatingType",
title: "Grijanje",
values: Object.keys(HEATING_TYPE).map(key => HEATING_TYPE[key]),
categoriesToShow: [
AD_CATEGORY.FLAT,
AD_CATEGORY.HOUSE,
AD_CATEGORY.APARTMENT,
AD_CATEGORY.COTTAGE,
AD_CATEGORY.OFFICE
]
}
];
module.exports = {
BASIC_INPUT_PUBLISH,
BASIC_SEGMENT_PUBLISH,
BASIC_BOOLEAN_PUBLISH,
ADDITIONAL_BOOLEAN_PUBLISH,
ADDITIONAL_INPUT_PUBLISH,
ADDITIONAL_SEGMENT_PUBLISH
};

View File

@@ -9,11 +9,15 @@ const APP_URL =
? process.env.APP_URL || "http://market-alarm"
: process.env.APP_URL || `${APP_BASE_URL}:${APP_PORT}`;
const STAGING = process.env.SETTINGS !== "production";
const DEFAULT_TIMEZONE = "Europe/Sarajevo";
const CRAWLER_INTERVAL = parseInt(process.env.CRAWLER_INTERVAL) || 60;
const STOP_CRAWLER = !!parseInt(process.env.STOP_CRAWLER);
const CHECK_UP_DAYS = parseInt(process.env.CHECK_UP_DAYS) || 10;
const AWS_EMAIL_CONFIG = {
REGION: process.env.AWS_REGION || "",
CREDENTIALS: {
@@ -30,6 +34,13 @@ const MAX_REAL_ESTATES_IN_FIRST_EMAIL =
const PRINT_CRAWLER_DEBUG = process.env.PRINT_CRAWLER_DEBUG_INFO || 0;
const GOOGLE_MAP_KEY = process.env.GOOGLE_MAP_KEY || "";
const PROSTOR_LOGIN = {
EMAIL: process.env.PROSTOR_LOGIN_EMAIL,
PASSWORD: process.env.PROSTOR_LOGIN_PASS
};
module.exports = {
APP_PORT,
APP_URL,
@@ -39,5 +50,9 @@ module.exports = {
AWS_EMAIL_CONFIG,
MAX_REAL_ESTATES_IN_EMAIL,
MAX_REAL_ESTATES_IN_FIRST_EMAIL,
PRINT_CRAWLER_DEBUG
PRINT_CRAWLER_DEBUG,
GOOGLE_MAP_KEY,
STAGING,
CHECK_UP_DAYS,
PROSTOR_LOGIN
};

View File

@@ -0,0 +1,34 @@
const { currentKiviRealEstate } = require("../helpers/url");
const { findRealEstateByAgencyId } = require("../helpers/db/realEstate");
const getDeletePublishedAd = async (req, res) => {
const title = "Uspješno ste izbrisali svoj oglas iz baze.";
const kiviOriginal = await currentKiviRealEstate(req);
if (!kiviOriginal || !kiviOriginal.kiviAdId) {
res.render("notFound", { title: " " });
return;
}
const realEstate = await findRealEstateByAgencyId(kiviOriginal.kiviAdId);
if (!realEstate || !realEstate.dataValues) {
res.render("notFound", { title: " " });
return;
}
realEstate.deleted = true;
realEstate.deletedAt = Date.now();
kiviOriginal.email = "";
await realEstate.save();
await kiviOriginal.save();
res.render("deleteRealEstate", { nextStep: "/", title });
};
module.exports = {
getDeletePublishedAd
};

View File

@@ -0,0 +1,8 @@
const editSuccess = async (req, res) => {
const title = "Uspjeh!";
res.render("editSuccess", { title });
};
module.exports = {
editSuccess
};

View File

@@ -4,9 +4,37 @@ const getLocation = async (req, res) => {
const title = "Odaberite lokaciju";
const nextStep = req.query.nextStep || "/";
//Check if location data already exists (active request)
//If it does then get location is called through edit field query
//and map should show already selected location not initial map
let selectedLatLngBounds = {};
let boundsSelected = false;
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const selectedArea = searchRequest.areaToSearch;
const sw = selectedArea.coordinates[0][3];
const ne = selectedArea.coordinates[0][1];
if (sw[0] && ne[0]) {
selectedLatLngBounds = {
swLat: sw[1],
swLng: sw[0],
neLat: ne[1],
neLng: ne[0]
};
boundsSelected = true;
}
res.render("location", {
nextStep,
title
title,
boundsSelected,
selectedLatLngBounds
});
};

View File

@@ -0,0 +1,385 @@
const { findRealEstateByAgencyId } = require("../helpers/db/realEstate");
const {
bulkUpsertKiviPhotos,
findPhotosForKiviAd,
deleteUrlPhotosAfterUpdate
} = require("../helpers/db/kiviOriginalAdsPhotos");
const { currentKiviRealEstate } = require("../helpers/url");
const {
notifyForNewRealEstates,
notifyForNewAdPublish
} = require("../services/notificationService");
const validate = require("validate.js");
const {
AD_CATEGORY,
FURNISHING_TYPE,
ACCESS_ROAD_TYPE,
HEATING_TYPE
} = require("../common/enums");
const { APP_URL } = require("../config/appConfig");
const {
BASIC_BOOLEAN_PUBLISH,
BASIC_SEGMENT_PUBLISH,
ADDITIONAL_BOOLEAN_PUBLISH,
ADDITIONAL_SEGMENT_PUBLISH,
BASIC_INPUT_PUBLISH,
ADDITIONAL_INPUT_PUBLISH
} = require("../common/publishEnums");
const getPublishInputs = async (req, res) => {
const kiviOriginal = await currentKiviRealEstate(req);
const realEstate = await findRealEstateByAgencyId(
kiviOriginal.dataValues.kiviAdId
);
if (!realEstate || !realEstate.dataValues) {
res.render("notFound", { title: " " });
return;
}
const pageTitle = "Podaci o nekretnini";
const {
price,
area,
adType,
realEstateType,
locationLat,
locationLong,
accessRoadType,
heatingType,
balcony,
newBuilding,
elevator,
recentlyAdapted,
gardenSize,
numberOfRooms,
numberOfFloors,
floor,
water,
electricity,
drainageSystem,
registeredInZkBooks,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
exchange,
urbanPlanPermit,
buildingPermit,
furnishingType,
shortDescription,
streetName,
title,
longDescription
} = realEstate;
const email = kiviOriginal.dataValues.email;
//If email is not empty - has string value, then proces of publishing has been conducted alredy
//That means user is editing existing real estate ad not publishing new one
let editingRealEstate = false;
if (email) {
editingRealEstate = true;
}
//If we are editing real estate ad we need to fetch and show images on server
const urlGooglePrefix =
"https://storage.cloud.google.com/marketalarm-photos/";
let realEstatePhotosUrls = [];
if (editingRealEstate) {
const realEstatePhotosData = await findPhotosForKiviAd(
kiviOriginal.dataValues.kiviAdId
);
realEstatePhotosData.map(row => {
realEstatePhotosUrls.push(urlGooglePrefix + row.dataValues.photoUrl);
});
} else {
realEstatePhotosUrls.push(false);
}
const category = AD_CATEGORY[realEstateType] || AD_CATEGORY.FLAT;
// TODO: Maybe this is slow, pay attention to this
const filterInputs = filterObject => {
const filterCategories = filterObject.categoriesToShow;
return filterCategories.indexOf(category) !== -1;
};
//Boolean inputs to be shown on Basic Data tab
const basicBooleanPublishInputs = BASIC_BOOLEAN_PUBLISH.filter(filterInputs);
const basicBooleanPublishValues = {
balcony,
elevator,
newBuilding,
recentlyAdapted
};
//Boolean inputs to be shown on Additional Data tab
const additionalBooleanPublishInputs = ADDITIONAL_BOOLEAN_PUBLISH.filter(
filterInputs
);
const additionalBooleanPublishValues = {
water,
electricity,
drainageSystem,
registeredInZkBooks,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
exchange,
urbanPlanPermit,
buildingPermit
};
//Segment select inputs to be shown on Basic Data tab
const basicSegmentSelectInputs = BASIC_SEGMENT_PUBLISH.filter(filterInputs);
const basicSegmentSelectValues = {
furnishingType
};
//Segment select inputs to be shown on Additional Data tab
const additionalSegmentSelectInputs = ADDITIONAL_SEGMENT_PUBLISH.filter(
filterInputs
);
const additionalSegmentSelectValues = {
accessRoadType,
heatingType
};
//Input text type inputs to be shown on Basic Data tab
const basicInputInputs = BASIC_INPUT_PUBLISH.filter(filterInputs);
const basicInputValues = {
price,
area,
gardenSize,
numberOfRooms,
numberOfFloors,
floor,
title,
shortDescription,
streetName
};
//Input type textare to be shown on Additional Data
const additionalInputInputs = ADDITIONAL_INPUT_PUBLISH.filter(filterInputs);
const additionalInputValues = {
longDescription
};
res.render("publishRealEstate", {
title: pageTitle,
basicBooleanPublishInputs,
basicBooleanPublishValues,
additionalBooleanPublishInputs,
additionalBooleanPublishValues,
basicSegmentSelectInputs,
basicSegmentSelectValues,
additionalSegmentSelectInputs,
additionalSegmentSelectValues,
basicInputInputs,
basicInputValues,
additionalInputInputs,
additionalInputValues,
validate: validate,
email,
locationLat: locationLat || 0,
locationLong: locationLong || 0,
editingRealEstate,
realEstatePhotosUrls
});
};
const postPublishInputs = async (req, res) => {
const kiviOriginal = await currentKiviRealEstate(req);
if (!kiviOriginal || !kiviOriginal.kiviAdId) {
res.render("notFound", { title: " " });
return;
}
const realEstate = await findRealEstateByAgencyId(kiviOriginal.kiviAdId);
if (!realEstate || !realEstate.dataValues) {
res.render("notFound", { title: " " });
return;
}
const editingRealEstate = req.body.editingRealEstate === "true";
// console.log("Editing real estate:", editingRealEstate);
const nextStepPage = editingRealEstate
? req.query.nextStep || "/uspjesnaizmjena"
: req.query.nextStep || "/uspjesnaobjava";
//Request body
//console.log("Body:", req.body);
const balcony = req.body.balcony === "on";
const elevator = req.body.elevator === "on";
const newBuilding = req.body.newBuilding === "on";
const recentlyAdapted = req.body.recentlyAdapted === "on";
const water = req.body.water === "on";
const electricity = req.body.electricity === "on";
const drainageSystem = req.body.drainageSystem === "on";
const registeredInZkBooks = req.body.registeredInZkBooks === "on";
const parking = req.body.parking === "on";
const garage = req.body.garage === "on";
const gas = req.body.gas === "on";
const antiTheftDoor = req.body.antiTheftDoor === "on";
const airCondition = req.body.airCondition === "on";
const phoneConnection = req.body.phoneConnection === "on";
const cableTV = req.body.cableTV === "on";
const internet = req.body.internet === "on";
const basementAttic = req.body.basementAttic === "on";
const storeRoom = req.body.storeRoom === "on";
const videoSurveillance = req.body.videoSurveillance === "on";
const alarm = req.body.alarm === "on";
const suitableForStudents = req.body.suitableForStudents === "on";
const includingBills = req.body.includingBills === "on";
const animalsAllowed = req.body.animalsAllowed === "on";
const pool = req.body.pool === "on";
const exchange = req.body.exchange === "on";
const urbanPlanPermit = req.body.urbanPlanPermit === "on";
const buildingPermit = req.body.buildingPermit === "on";
const furnishingType = req.body.furnishingType;
//VALIDACIJA TAKO POTVRDITI DA JE ISPRAVNA VRIJEDNOST
/* if (!FURNISHING_TYPE[furnishingType]) {
res.render("notFound", { title: " Greška !" });
return;
} */
const accessRoadType = req.body.accessRoadType;
/*if (!ACCESS_ROAD_TYPE[accessRoadType]) {
res.render("notFound", { title: " Greška !" });
return;
} */
const heatingType = req.body.heatingType;
/*if (!HEATING_TYPE[heatingType]) {
res.render("notFound", { title: " Greška !" });
return;
}*/
const price = parseFloat(req.body.price) || null;
const area = parseFloat(req.body.area) || null;
const gardenSize = parseFloat(req.body.gardenSize) || null;
const numberOfRooms = parseInt(req.body.numberOfRooms) || null;
const numberOfFloors = parseInt(req.body.numberOfFloors) || null;
const floor = parseInt(req.body.floor) || null;
const title = req.body.title || "";
const shortDescription = req.body.shortDescription || "";
const streetName = req.body.streetName || "";
const longDescription = req.body.longDescription || "";
const locationLat = req.body.lat || null;
const locationLong = req.body.lng || null;
//Contact email saved in other table
const contactEmail = req.body.email || "";
//Image urls are stored in new table
const imageUrls =
req.body.imageUrls.split("|").filter(url => url !== "") || [];
//If we are in editing mode we need to "delete" photos that are not longer associated with real estate ad
if (editingRealEstate) {
await deleteUrlPhotosAfterUpdate(imageUrls);
}
const imageUrlsData = imageUrls.map(url => {
return {
kiviAdId: kiviOriginal.kiviAdId,
photoUrl: url
};
});
const savedImageUrls = await bulkUpsertKiviPhotos(imageUrlsData);
realEstate.balcony = balcony;
realEstate.elevator = elevator;
realEstate.newBuilding = newBuilding;
realEstate.recentlyAdapted = recentlyAdapted;
realEstate.water = water;
realEstate.electricity = electricity;
realEstate.drainageSystem = drainageSystem;
realEstate.registeredInZkBooks = registeredInZkBooks;
realEstate.parking = parking;
realEstate.garage = garage;
realEstate.gas = gas;
realEstate.antiTheftDoor = antiTheftDoor;
realEstate.airCondition = airCondition;
realEstate.phoneConnection = phoneConnection;
realEstate.cableTV = cableTV;
realEstate.internet = internet;
realEstate.basementAttic = basementAttic;
realEstate.storeRoom = storeRoom;
realEstate.videoSurveillance = videoSurveillance;
realEstate.alarm = alarm;
realEstate.suitableForStudents = suitableForStudents;
realEstate.includingBills = includingBills;
realEstate.animalsAllowed = animalsAllowed;
realEstate.pool = pool;
realEstate.exchange = exchange;
realEstate.urbanPlanPermit = urbanPlanPermit;
realEstate.buildingPermit = buildingPermit;
realEstate.furnishingType = furnishingType;
realEstate.accessRoadType = accessRoadType;
realEstate.heatingType = heatingType;
realEstate.price = price;
realEstate.area = area;
realEstate.gardenSize = gardenSize;
realEstate.numberOfRooms = numberOfRooms;
realEstate.numberOfFloors = numberOfFloors;
realEstate.floor = floor;
realEstate.title = title;
realEstate.shortDescription = shortDescription;
realEstate.streetName = streetName;
realEstate.longDescription = longDescription;
realEstate.locationLat = locationLat;
realEstate.locationLong = locationLong;
kiviOriginal.email = contactEmail;
//console.log("realEstate", realEstate);
await realEstate.save();
await kiviOriginal.save();
//Calling function to notify real estate owner that ads is published or edited on Kivi page after 1 sec
setTimeout(
notifyForNewAdPublish,
1000,
realEstate,
kiviOriginal,
editingRealEstate
);
//Calling function to notify users of new real estate (or edited realestate) after 2 min
setTimeout(notifyForNewRealEstates, 1000 * 60 * 2, [realEstate]);
res.redirect(nextStepPage);
};
module.exports = {
getPublishInputs,
postPublishInputs
};

View File

@@ -0,0 +1,105 @@
const { currentKiviRealEstate } = require("../helpers/url");
const {
createRealEstate,
findRealEstateByAgencyId
} = require("../helpers/db/realEstate");
const { createKiviOriginal } = require("../helpers/db/kiviOriginal");
const { AD_CATEGORY, AD_TYPE, AD_AGENCY } = require("../common/enums");
const { APP_URL } = require("../config/appConfig");
const getPublishTypes = async (req, res) => {
const kiviOriginal = await currentKiviRealEstate(req);
const realEstate = await findRealEstateByAgencyId(kiviOriginal.kiviAdId);
const title = "Koju nekretninu nudite?";
let selectedAdType = AD_TYPE.AD_TYPE_SALE.id;
const labelAdType = ["Prodaj", "Iznajmi"];
if (
realEstate &&
realEstate.adType &&
realEstate.adType === AD_TYPE.AD_TYPE_RENT.stringId
) {
selectedAdType = AD_TYPE.AD_TYPE_RENT.id;
}
const realEstateTypes = Object.keys(AD_CATEGORY)
.map(category => AD_CATEGORY[category])
.filter(category => category.title);
res.render("realEstateType", {
selectedAdType,
labelAdType,
realEstateTypes,
title,
AD_TYPE
});
};
const postPublishTypes = async (req, res) => {
const kiviOriginal = await currentKiviRealEstate(req);
const realEstate = await findRealEstateByAgencyId(kiviOriginal.kiviAdId);
const adType = parseInt(req.body.adType);
const adTypeStringIds = {
[AD_TYPE.AD_TYPE_SALE.id]: AD_TYPE.AD_TYPE_SALE.stringId,
[AD_TYPE.AD_TYPE_RENT.id]: AD_TYPE.AD_TYPE_RENT.stringId
};
const adTypeStringId =
adTypeStringIds[adType] || AD_TYPE.AD_TYPE_SALE.stringId;
const validRealEstateTypes = Object.keys(AD_CATEGORY).filter(
category => !!AD_CATEGORY[category].title
);
const selectedRealEstateType = req.body.realEstateType || null;
if (validRealEstateTypes.indexOf(selectedRealEstateType) === -1) {
res.render("notFound", { title: " " });
return;
}
const nextStepPage = req.query.nextStep || "podacionekretnini";
let nextStepUrl = "";
if (kiviOriginal && kiviOriginal.kiviAdId && realEstate && realEstate.id) {
//
nextStepUrl = `/${nextStepPage}/${kiviOriginal.kiviAdId}`;
realEstate.adType = adTypeStringId;
realEstate.realEstateType = selectedRealEstateType;
//Url override
realEstate.url = `${APP_URL}/preglednekretnine/${realEstate.id}`;
await realEstate.save();
} else {
try {
const newKiviOriginal = await createKiviOriginal({
email: ""
});
const newKiviAdViewUrl = `${APP_URL}/preglednekretnine/${realEstate.id}`;
const newRealEstate = await createRealEstate({
adType: adTypeStringId,
realEstateType: selectedRealEstateType,
url: newKiviAdViewUrl,
originAgencyName: AD_AGENCY.KIVI,
agencyObjectId: newKiviOriginal.kiviAdId
});
nextStepUrl = `/${nextStepPage}/${newKiviOriginal.kiviAdId}`;
} catch (error) {
console.log(error);
nextStepUrl = `/`;
}
}
res.redirect(nextStepUrl);
};
module.exports = {
getPublishTypes,
postPublishTypes
};

View File

@@ -0,0 +1,8 @@
const publishSuccess = async (req, res) => {
const title = "Uspjeh!";
res.render("publishSuccess", { title });
};
module.exports = {
publishSuccess
};

View File

@@ -1,5 +1,10 @@
const { currentSearchRequest } = require("../helpers/url");
const { AD_CATEGORY, AD_TYPE } = require("../common/enums");
const { AD_CATEGORY, AD_TYPE, ACCESS_ROAD_TYPE } = require("../common/enums");
const {
ADVANCED_BOOLEAN_FILTERS,
ADVANCED_SEGMENT_SELECT_FILTERS,
ADVANCED_RANGE_FILTERS
} = require("../common/filterEnums");
const getFilters = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
@@ -19,7 +24,19 @@ const getFilters = async (req, res) => {
sizeMin,
sizeMax,
gardenSizeMin,
gardenSizeMax
gardenSizeMax,
numberOfRoomsMin,
numberOfRoomsMax,
numberOfFloorsMin,
numberOfFloorsMax,
floorMin,
floorMax,
includeIncompleteAds,
balcony,
elevator,
newBuilding,
accessRoadType,
includeWithoutPrice
} = searchRequest;
const category = AD_CATEGORY[realEstateType] || AD_CATEGORY.FLAT;
@@ -41,6 +58,40 @@ const getFilters = async (req, res) => {
return;
}
// TODO: Maybe this is slow, pay attention to this
const filterFilters = filterObject => {
const filterCategories = filterObject.categoriesToShow;
return filterCategories.indexOf(category) !== -1;
};
const advancedBooleanFilterObjects = ADVANCED_BOOLEAN_FILTERS.filter(
filterFilters
);
const advancedSegmentSelectFilterObjects = ADVANCED_SEGMENT_SELECT_FILTERS.filter(
filterFilters
);
const advancedRangeFilterObjects = ADVANCED_RANGE_FILTERS.filter(
filterFilters
);
const advancedBooleanFilterValues = {
includeIncompleteAds,
balcony,
elevator,
newBuilding
};
const advancedSegmentSelectFilterValues = {
accessRoadType
};
const advancedRangeFilterValues = {
numberOfFloorsMin,
numberOfFloorsMax,
numberOfRoomsMin,
numberOfRoomsMax,
floorMin,
floorMax
};
if (priceMin || priceMax) {
priceSliderOptions.start = [priceMin, priceMax];
}
@@ -58,11 +109,21 @@ const getFilters = async (req, res) => {
hasGardenSize,
priceSliderOptions: JSON.stringify(priceSliderOptions),
sizeSliderOptions: JSON.stringify(sizeSliderOptions),
gardenSizeSliderOptions: JSON.stringify(gardenSizeSliderOptions)
gardenSizeSliderOptions: JSON.stringify(gardenSizeSliderOptions),
advancedBooleanFilterObjects,
advancedBooleanFilterValues,
advancedSegmentSelectFilterObjects,
advancedSegmentSelectFilterValues,
advancedRangeFilterObjects,
advancedRangeFilterValues,
includeIncompleteAds,
includeWithoutPrice
});
};
const postFilters = async (req, res) => {
//
console.log("postFilters");
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
@@ -78,13 +139,93 @@ const postFilters = async (req, res) => {
const sizeMin = parseInt(req.body.sizeMin) || 0;
const sizeMax = parseInt(req.body.sizeMax) || 0;
//TODO: Filter validation
const advancedRangeFilters = {};
ADVANCED_RANGE_FILTERS.forEach(filter => {
let parsingFunction = parseInt;
if (filter.decimalPlaces) {
parsingFunction = parseFloat;
}
advancedRangeFilters[filter.dbFieldMin] = parsingFunction(
req.body[filter.dbFieldMin]
);
advancedRangeFilters[filter.dbFieldMax] = parsingFunction(
req.body[filter.dbFieldMax]
);
advancedRangeFilters[filter.dbFieldMin] = isNaN(
advancedRangeFilters[filter.dbFieldMin]
)
? null
: advancedRangeFilters[filter.dbFieldMin];
advancedRangeFilters[filter.dbFieldMax] = isNaN(
advancedRangeFilters[filter.dbFieldMax]
)
? null
: advancedRangeFilters[filter.dbFieldMax];
try {
if (filter.decimalPlaces) {
advancedRangeFilters[filter.dbFieldMin] = advancedRangeFilters[
filter.dbFieldMin
].toFixed(filter.decimalPlaces);
advancedRangeFilters[filter.dbFieldMax] = advancedRangeFilters[
filter.dbFieldMax
].toFixed(filter.decimalPlaces);
}
} catch (e) {
advancedRangeFilters[filter.dbFieldMin] = null;
advancedRangeFilters[filter.dbFieldMax] = null;
}
if (
advancedRangeFilters[filter.dbFieldMin] < filter.validValueMin ||
advancedRangeFilters[filter.dbFieldMin] > filter.validValueMax
) {
advancedRangeFilters[filter.dbFieldMin] = filter.validValueMin;
}
if (
advancedRangeFilters[filter.dbFieldMax] < filter.validValueMin ||
advancedRangeFilters[filter.dbFieldMax] > filter.validValueMax
) {
advancedRangeFilters[filter.dbFieldMax] = filter.validValueMax;
}
});
const includeIncompleteAds = req.body.includeIncompleteAds === "on";
const includeWithoutPrice = req.body.includeWithoutPrice === "on";
const balcony = req.body.balcony === "on";
const elevator = req.body.elevator === "on";
const newBuilding = req.body.newBuilding === "on";
const accessRoadType = req.body.accessRoadType;
if (!ACCESS_ROAD_TYPE[accessRoadType]) {
res.render("notFound", { title: " Greška !" });
return;
}
//TODO: Filter validation
searchRequest.priceMin = priceMin;
searchRequest.priceMax = priceMax;
searchRequest.sizeMin = sizeMin;
searchRequest.sizeMax = sizeMax;
for (const filter of Object.keys(advancedRangeFilters)) {
searchRequest[filter] = advancedRangeFilters[filter];
}
searchRequest.balcony = balcony;
searchRequest.elevator = elevator;
searchRequest.newBuilding = newBuilding;
searchRequest.includeIncompleteAds = includeIncompleteAds;
searchRequest.includeWithoutPrice = includeWithoutPrice;
searchRequest.accessRoadType = accessRoadType;
if (
req.body.gardenSizeMin !== undefined &&
req.body.gardenSizeMax !== undefined
@@ -97,7 +238,6 @@ const postFilters = async (req, res) => {
searchRequest.gardenSizeMin = gardenSizeMin;
searchRequest.gardenSizeMax = gardenSizeMax;
}
await searchRequest.save();
res.redirect(nextStepUrl);

View File

@@ -8,6 +8,7 @@ const getRealEstateTypes = async (req, res) => {
const title = "Koju nekretninu tražite?";
let selectedAdType = AD_TYPE.AD_TYPE_SALE.id;
const labelAdType = [AD_TYPE.AD_TYPE_SALE.title, AD_TYPE.AD_TYPE_RENT.title];
if (
searchRequest &&
searchRequest.adType &&
@@ -21,6 +22,7 @@ const getRealEstateTypes = async (req, res) => {
res.render("realEstateType", {
selectedAdType,
labelAdType,
realEstateTypes,
title,
AD_TYPE

View File

@@ -2,13 +2,14 @@
const {
findRealEstatesForSearchRequest
} = require("../helpers/db/searchRequestMatch");
const { AD_STATUS } = require("../common/enums");
const getRealEstates = async (req, res) => {
const searchRequestId = req.params["searchRequestId"] || "";
const realEstates = await findRealEstatesForSearchRequest(searchRequestId);
const title = "Nekretnine koje odgovaraju Vašim uslovima pretrage";
res.render("realEstates", { realEstates, title });
res.render("realEstates", { realEstates, title, AD_STATUS });
};
module.exports = {

View File

@@ -1,9 +1,11 @@
const { getRealEstateById } = require("../helpers/db/realEstate");
const { AD_STATUS } = require("../common/enums");
const getRedirect = async (req, res) => {
const id = req.params.id || null;
let error = false;
let redirectUrl = undefined;
let vipAd = undefined;
if (!id) {
error = true;
} else {
@@ -13,6 +15,7 @@ const getRedirect = async (req, res) => {
error = true;
} else {
redirectUrl = realEstate.url;
vipAd = realEstate.adStatus === AD_STATUS.STATUS_VIP;
}
} catch (e) {
error = true;
@@ -24,7 +27,7 @@ const getRedirect = async (req, res) => {
res.render("notFound", { title });
} else {
const title = "Preusmjeravanje";
res.render("redirect", { title, redirectUrl });
res.render("redirect", { title, redirectUrl, vipAd });
}
};

View File

@@ -0,0 +1,202 @@
const { findRealEstateByAgencyId } = require("../helpers/db/realEstate");
const { findPhotosForKiviAd } = require("../helpers/db/kiviOriginalAdsPhotos");
const { currentKiviRealEstate, currentRealEstate } = require("../helpers/url");
const {
BASIC_BOOLEAN_PUBLISH,
BASIC_SEGMENT_PUBLISH,
ADDITIONAL_BOOLEAN_PUBLISH,
ADDITIONAL_SEGMENT_PUBLISH,
BASIC_INPUT_PUBLISH,
ADDITIONAL_INPUT_PUBLISH
} = require("../common/publishEnums");
const { AD_CATEGORY, AD_TYPE } = require("../common/enums");
const getViewRealEstate = async (req, res) => {
//Variation if we acces to real estate previews via kiviAdId
/*
const kiviOriginal = await currentKiviRealEstate(req);
if (!kiviOriginal || !kiviOriginal.kiviAdId) {
res.render("notFound", { title: " " });
return;
}
const realEstate = await findRealEstateByAgencyId(kiviOriginal.kiviAdId); */
const realEstate = await currentRealEstate(req);
if (!realEstate || !realEstate.dataValues) {
res.render("notFound", { title: " " });
return;
}
const pageTitle = "Pregled nekretnine";
const {
price,
area,
adType,
agencyObjectId,
realEstateType,
locationLat,
locationLong,
accessRoadType,
heatingType,
balcony,
newBuilding,
elevator,
recentlyAdapted,
gardenSize,
numberOfRooms,
numberOfFloors,
floor,
water,
electricity,
drainageSystem,
registeredInZkBooks,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
exchange,
urbanPlanPermit,
buildingPermit,
furnishingType,
shortDescription,
streetName,
title,
longDescription
} = realEstate;
//Categorize all database values by value type - input, boolean or segment selected
const allInputValues = {
price,
area,
gardenSize,
numberOfRooms,
numberOfFloors,
floor,
title,
shortDescription,
streetName,
longDescription
};
const allBooleanValues = {
balcony,
elevator,
newBuilding,
recentlyAdapted,
water,
electricity,
drainageSystem,
registeredInZkBooks,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
exchange,
urbanPlanPermit,
buildingPermit
};
const allSegmentSelectedValues = {
furnishingType,
accessRoadType,
heatingType
};
//We need titles of fields ex Balkon, Novogradnja
const ALL_BOOLEAN_FIELDS = [
...BASIC_BOOLEAN_PUBLISH,
...ADDITIONAL_BOOLEAN_PUBLISH
];
const ALL_INPUT_FIELDS = [
...BASIC_INPUT_PUBLISH,
...ADDITIONAL_INPUT_PUBLISH
];
const ALL_SEGMENT_FIELDS = [
...BASIC_SEGMENT_PUBLISH,
...ADDITIONAL_SEGMENT_PUBLISH
];
//On view add page we will show only values that are not - null, or "", or undefined
const forShowing = value => {
return value !== false && value !== null && value !== "";
};
//Filter all values to be shown on page or not
//For showing on page we also need title ex. "Balkon"
const booleanFields = ALL_BOOLEAN_FIELDS.filter(object => {
return forShowing(allBooleanValues[object.dbField]);
});
const inputFields = ALL_INPUT_FIELDS.filter(object => {
return forShowing(allInputValues[object.dbField]);
});
const segmentFields = ALL_SEGMENT_FIELDS.filter(object => {
return forShowing(allSegmentSelectedValues[object.dbField]);
});
//Photo urls from Google storage bucket
const kiviAdId = agencyObjectId;
const urlGooglePrefix =
"https://storage.cloud.google.com/marketalarm-photos/";
const realEstatePhotosData = await findPhotosForKiviAd(kiviAdId);
const realEstatePhotosUrls = realEstatePhotosData.map(row => {
return urlGooglePrefix + row.dataValues.photoUrl;
});
const showRealEstateType = AD_CATEGORY[realEstateType].title.toUpperCase();
let showAdType = "";
switch (adType) {
case AD_TYPE.AD_TYPE_SALE.stringId:
showAdType = AD_TYPE.AD_TYPE_SALE.title.toUpperCase();
break;
case AD_TYPE.AD_TYPE_RENT.stringId:
showAdType = AD_TYPE.AD_TYPE_RENT.title.toUpperCase();
break;
default:
showAdType = "-";
break;
}
res.render("viewRealEstate", {
title: pageTitle,
booleanFields,
inputFields,
allInputValues,
segmentFields,
allSegmentSelectedValues,
locationLat,
locationLong,
showAdType,
showRealEstateType,
realEstatePhotosUrls
});
};
module.exports = {
getViewRealEstate
};

View File

@@ -1,7 +1,79 @@
const { createSearchRequest } = require("../helpers/db/searchRequest");
const { createRealEstate } = require("../helpers/db/realEstate");
const { createKiviOriginal } = require("../helpers/db/kiviOriginal");
const { AD_TYPE, AD_CATEGORY, AD_AGENCY } = require("../common/enums");
const { APP_URL } = require("../config/appConfig");
const getWelcome = (req, res) => {
res.render("welcome", { nextStep: "/vrstanekretnine", title: false });
res.render("welcome", {
title: false,
AD_TYPE
});
};
const postWelcome = async (req, res) => {
const adType = parseInt(req.body.adType);
const publishAdType = parseInt(req.body.publishAdType);
let nextStepUrl = "";
if (adType) {
const adTypeStringId = getAdTypeString(adType);
try {
const newSearchRequest = await createSearchRequest({
adType: adTypeStringId,
realEstateType: AD_CATEGORY.FLAT.id
});
nextStepUrl = `/vrstanekretnine/${newSearchRequest.id}`;
} catch (error) {
console.log(error);
nextStepUrl = `/`;
}
} else if (publishAdType) {
const adTypeStringId = getAdTypeString(publishAdType);
try {
//First we create new Kivi Ad Original object in db then new Real Estate
//Problem with id-s
const newKiviOriginal = await createKiviOriginal({
email: ""
});
//Temporary url because we have cyclic id call - need to override for safety measures
const newKiviAdViewUrl = `${APP_URL}/preglednekretnine/${newKiviOriginal.kiviAdId}`;
const newRealEstate = await createRealEstate({
adType: adTypeStringId,
realEstateType: AD_CATEGORY.FLAT.id,
url: newKiviAdViewUrl,
originAgencyName: AD_AGENCY.KIVI,
agencyObjectId: newKiviOriginal.kiviAdId
});
nextStepUrl = `/objavinekretninu/${newKiviOriginal.kiviAdId}`;
} catch (error) {
console.log(error);
nextStepUrl = `/`;
}
}
res.redirect(nextStepUrl);
};
//--- Helper function
const getAdTypeString = adType => {
const adTypeStringIds = {
[AD_TYPE.AD_TYPE_SALE.id]: AD_TYPE.AD_TYPE_SALE.stringId,
[AD_TYPE.AD_TYPE_RENT.id]: AD_TYPE.AD_TYPE_RENT.stringId
};
const adTypeStringId =
adTypeStringIds[adType] || AD_TYPE.AD_TYPE_SALE.stringId;
return adTypeStringId;
};
module.exports = {
getWelcome
getWelcome,
postWelcome
};

View File

@@ -9,12 +9,14 @@ const OlxCrawler = require("./specificCrawlers/olx");
const RentalCrawler = require("./specificCrawlers/rental");
const ProstorCrawler = require("./specificCrawlers/prostor");
const AktidoCrawler = require("./specificCrawlers/aktido");
const SaljicCrawler = require("./specificCrawlers/saljic");
const {
OLX_CONFIG,
RENTAL_CONFIG,
PROSTOR_CONFIG,
AKTIDO_CONFIG
AKTIDO_CONFIG,
SALJIC_CONFIG
} = require("./crawlerConfig");
const PostgresSaver = require("./savers/postgres");
@@ -57,6 +59,15 @@ async function crawlAll() {
AKTIDO_CONFIG.AKTIDO_MAX_RESULTS_PER_PAGE,
AKTIDO_CONFIG.AKTIDO_IGNORED_USERNAMES,
AKTIDO_CONFIG.AKTIDO_DELAY_BETWEEN_PAGES
),
new SaljicCrawler(
[postgresSaver],
SALJIC_CONFIG.SALJIC_CRAWLER_AD_TYPE,
SALJIC_CONFIG.SALJIC_CRAWLER_AD_CATEGORIES,
SALJIC_CONFIG.SALJIC_MAX_PAGES,
SALJIC_CONFIG.SALJIC_MAX_RESULTS_PER_PAGE,
SALJIC_CONFIG.SALJIC_IGNORED_USERNAMES,
SALJIC_CONFIG.SALJIC_DELAY_BETWEEN_PAGES
)
];

View File

@@ -5,10 +5,12 @@ const OLX_CONFIG = require("./specificConfigs/olx");
const RENTAL_CONFIG = require("./specificConfigs/rental");
const PROSTOR_CONFIG = require("./specificConfigs/prostor");
const AKTIDO_CONFIG = require("./specificConfigs/aktido");
const SALJIC_CONFIG = require("./specificConfigs/saljic");
module.exports = {
OLX_CONFIG,
RENTAL_CONFIG,
PROSTOR_CONFIG,
AKTIDO_CONFIG
AKTIDO_CONFIG,
SALJIC_CONFIG
};

View File

@@ -1,6 +1,7 @@
const moment = require("moment");
const { bulkUpsertRealEstates } = require("../../helpers/db/realEstate");
const { bulkUpsertPriceHistory } = require("../../helpers/db/priceHistory");
class PostgresSaver {
connect() {
@@ -11,6 +12,21 @@ class PostgresSaver {
async save(results) {
const savedRecords = await bulkUpsertRealEstates(results);
//Extruding data for price history table
const resultPrices = savedRecords.map(realEstate => {
//Null values canot be recognized by ignore duplicates in sequalize
//Value price = 0 indicates 'cijena na upit'
const priceTmp =
realEstate.dataValues.price === null ? 0 : realEstate.dataValues.price;
return {
realEstateId: realEstate.dataValues.id,
price: priceTmp,
createdAt: realEstate.dataValues.createdAt,
updatedAt: realEstate.dataValues.updatedAt
};
});
const savedPrices = await bulkUpsertPriceHistory(resultPrices);
if (Array.isArray(savedRecords)) {
const newRealEstates = [];

View File

@@ -29,5 +29,6 @@ module.exports = {
PROSTOR_CRAWLER_AD_CATEGORIES: transformedProstorCrawlerAdCategories,
PROSTOR_IGNORED_USERNAMES: prostorIgnoredUsernames || [],
PROSTOR_DELAY_BETWEEN_PAGES:
parseInt(process.env.PROSTOR_DELAY_BETWEEN_PAGES) || 1000
parseInt(process.env.PROSTOR_DELAY_BETWEEN_PAGES) || 1000,
PROSTOR_FORCE_CRAWL: !!parseInt(process.env.PROSTOR_FORCE_CRAWL)
};

View File

@@ -0,0 +1,34 @@
"use strict";
const { CRAWLER_AD_TYPE, AD_CATEGORY } = require("../../common/enums");
const saljicCrawlerAdType =
process.env.SALJIC_CRAWLER_AD_TYPE !== undefined
? CRAWLER_AD_TYPE[process.env.SALJIC_CRAWLER_AD_TYPE]
: null;
const saljicParsedCrawlerAdCategories =
process.env.SALJIC_CRAWLER_AD_CATEGORIES !== undefined
? process.env.SALJIC_CRAWLER_AD_CATEGORIES.split(",").map(category =>
category.trim()
)
: ["FLAT", "HOUSE"];
const saljicIgnoredUsernames = [];
const transformedSaljicCrawlerAdCategories = saljicParsedCrawlerAdCategories
.map(categoryName =>
AD_CATEGORY[categoryName] ? AD_CATEGORY[categoryName].id : undefined
)
.filter(category => !!category);
module.exports = {
SALJIC_MAX_PAGES: parseInt(process.env.SALJIC_MAX_PAGES) || 100,
SALJIC_MAX_RESULTS_PER_PAGE:
parseInt(process.env.SALJIC_MAX_RESULTS_PER_PAGE) || 5000,
SALJIC_CRAWLER_AD_TYPE: saljicCrawlerAdType || CRAWLER_AD_TYPE.NONE,
SALJIC_CRAWLER_AD_CATEGORIES: transformedSaljicCrawlerAdCategories,
SALJIC_IGNORED_USERNAMES: saljicIgnoredUsernames || [],
SALJIC_DELAY_BETWEEN_PAGES:
parseInt(process.env.SALJIC_DELAY_BETWEEN_PAGES) || 1000,
SALJIC_FORCE_CRAWL: !!parseInt(process.env.SALJIC_FORCE_CRAWL)
};

View File

@@ -11,7 +11,10 @@ const {
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE
CRAWLER_AD_TYPE,
HEATING_TYPE,
ACCESS_ROAD_TYPE,
FURNISHING_TYPE
} = require("../../common/enums");
const {
@@ -219,6 +222,7 @@ class AktidoCrawler {
throw { message: "Can't find ad data JSON" };
}
let adStatus = AD_STATUS.STATUS_NORMAL;
const aktidoId = extractedData["re_realEstates_id"];
const adCategory = this.getKiviCategoryIdFromAktidoId(
parseInt(extractedData["re_types_id"])
@@ -237,6 +241,181 @@ class AktidoCrawler {
};
}
const descriptionIds = extractedData["re_descriptions_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(descriptionIds)) {
throw {
message:
'Expected array od descriptions but "re_descriptions_id" not found !'
};
}
const spaceIds = extractedData["re_spaces_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(spaceIds)) {
throw {
message: 'Expected array od spaces but "re_spaces_id" not found !'
};
}
const infrastructureIds = extractedData["re_infrastructure_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(infrastructureIds)) {
throw {
message:
'Expected array od infrastructures but "re_infrastructure_id" not found !'
};
}
const floorNoIds = extractedData["re_floorNO_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(floorNoIds)) {
throw {
message:
'Expected array od infrastructures but "re_floorNO_id" not found !'
};
}
// counting floor enums
// for (let i = 1; i < 10; i++) {
// const floorEnumsTitle = $(
// `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 > p:nth-child(${i}) > span:nth-child(1)`
// )
// .text()
// .trim();
// if (floorEnumsTitle === "Spratnost:") {
// const floorEnumsValue = $(
// `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 > p:nth-child(${i}) > span:nth-child(2)`
// )
// .text()
// .trim()
// .split(",");
//
// console.log("==========");
// floorNoIds.forEach((id, index) => {
// console.log("\t", id, " = ", floorEnumsValue[index]);
// });
// break;
// }
// }
// enumerating infrastructure - relation between id and infrastructure title
// let found = false;
// let infrastructureDescriptions = {};
// for (let i = 1; i < 5; i++) {
// found = false;
// for (let j = 1; j < 10; j++) {
// const infrastructureTitle = $(
// `#b2 > div > div:nth-child(${i}) > div > ul > li:nth-child(${j}) > strong`
// )
// .text()
// .trim();
// if (infrastructureTitle === "Osnovna infrastruktura:") {
// found = true;
//
// const infrastructureValues = $(
// `#b2 > div > div:nth-child(${i}) > div > ul > li:nth-child(${j}) > div`
// )
// .text()
// .trim()
// .split(",");
//
// infrastructureIds.forEach((id, index) => {
// infrastructureDescriptions[id] = infrastructureValues[index];
// });
// }
// }
// if (found) {
// break;
// }
// }
const realEstatePropertiesFromDescriptions = this.getPropertiesFromDescriptions(
descriptionIds
);
const realEstatePropertiesFromSpaces = this.getPropertiesFromSpaces(
spaceIds
);
const realEstatePropertiesFromInfrastructure = this.getPropertiesFromInfrastructure(
infrastructureIds
);
if (extractedData["adm_realEstates_discount"] === "1") {
adStatus = AD_STATUS.STATUS_DISCOUNTED;
}
let numberOfRooms =
parseInt(extractedData["re_realEstates_roomsNO"]) +
parseInt(extractedData["re_realEstates_bedroomNO"]) || null,
numberOfFloors =
parseInt(extractedData["re_realEstates_floorsNO"]) ||
this.getNumberOfFloorsFromFloorId(extractedData["re_floorNO_id"]),
floor =
parseInt(extractedData["re_realEstates_floorNO"]) ||
this.getFloorNumberFromFloorId(extractedData["re_floorNO_id"]),
accessRoadType = realEstatePropertiesFromDescriptions.accessRoadType,
heatingType =
this.getHeatingTypeId(extractedData["re_heating_id"]) || null,
furnishingType = realEstatePropertiesFromDescriptions.furnishingType,
balcony =
realEstatePropertiesFromDescriptions.balcony ||
realEstatePropertiesFromSpaces.balcony,
newBuilding = extractedData["op_realEstates_newBuilding"]
? extractedData["op_realEstates_newBuilding"] === "1"
: null,
elevator = realEstatePropertiesFromDescriptions.elevator,
water =
realEstatePropertiesFromDescriptions.water ||
realEstatePropertiesFromInfrastructure.water,
electricity =
realEstatePropertiesFromDescriptions.electricity ||
realEstatePropertiesFromInfrastructure.electricity,
drainageSystem =
realEstatePropertiesFromInfrastructure.drainageSystem,
registeredInZkBooks =
extractedData["op_realEstates_ownerPermit"] === 1 || null,
recentlyAdapted = null,
parking =
realEstatePropertiesFromDescriptions.parking ||
realEstatePropertiesFromSpaces.parking,
garage = realEstatePropertiesFromSpaces.garage,
gas = realEstatePropertiesFromInfrastructure.gas,
antiTheftDoor = realEstatePropertiesFromDescriptions.antiTheftDoor,
airCondition = realEstatePropertiesFromDescriptions.airCondition,
phoneConnection =
realEstatePropertiesFromInfrastructure.phoneConnection,
cableTV = realEstatePropertiesFromInfrastructure.cableTV,
internet = realEstatePropertiesFromInfrastructure.internet,
basementAttic = realEstatePropertiesFromSpaces.basementAttic,
storeRoom = realEstatePropertiesFromSpaces.storeRoom,
videoSurveillance =
realEstatePropertiesFromDescriptions.videoSurveillance ||
realEstatePropertiesFromInfrastructure.videoSurveillance,
alarm = realEstatePropertiesFromDescriptions.alarm,
suitableForStudents = null,
includingBills =
extractedData["op_realEstates_utilitiesIncluded"] === "1" || null,
animalsAllowed = null,
pool = realEstatePropertiesFromDescriptions.pool,
urbanPlanPermit =
extractedData["op_realEstates_locationPermit"] === "1" ||
realEstatePropertiesFromDescriptions.urbanPlanPermit,
buildingPermit =
extractedData["op_realEstates_buildingPermit"] === "1" || null,
utilityConnection =
realEstatePropertiesFromDescriptions.utilityConnection,
distanceToRiver = null,
numberOfViewsAgency = null;
const title = extractedData["re_realEstates_portalName"];
const extractedPrice = parseFloat(
extractedData["re_realEstates_price"]
@@ -277,8 +456,6 @@ class AktidoCrawler {
};
}
const adStatus = AD_STATUS.STATUS_NORMAL;
const data = {
url,
agencyObjectId: aktidoId,
@@ -303,7 +480,42 @@ class AktidoCrawler {
locationLong,
adStatus,
publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString()
renewedDate: renewedDateMoment.toISOString(),
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency
};
return data;
@@ -350,6 +562,270 @@ class AktidoCrawler {
}
}
getPropertiesFromDescriptions(descriptionIds) {
const result = {
accessRoadType: null,
furnishingType: null,
balcony: null,
elevator: null,
parking: null,
antiTheftDoor: null,
airCondition: null,
videoSurveillance: null,
alarm: null,
pool: null,
urbanPlanPermit: null,
utilityConnection: null,
water: null,
electricity: null
};
for (const descriptionId of descriptionIds) {
switch (descriptionId) {
case 16:
result.furnishingType = FURNISHING_TYPE.NOT_FURNISHED.id;
break;
case 17:
result.furnishingType = FURNISHING_TYPE.HALF_FURNISHED.id;
break;
case 1:
case 28:
result.furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case 14:
result.elevator = true;
break;
case 39:
result.electricity = true;
break;
case 40:
result.water = true;
break;
case 41:
case 58:
result.accessRoadType = ACCESS_ROAD_TYPE.ASPHALT.id;
break;
case 26:
result.balcony = true;
break;
case 62:
result.parking = true;
break;
case 3:
result.antiTheftDoor = true;
break;
case 2:
case 21:
result.airCondition = true;
break;
case 4:
result.alarm = true;
break;
case 55:
result.videoSurveillance = true;
break;
case 9:
result.pool = true;
break;
case 60:
result.urbanPlanPermit = true;
break;
case 38:
result.utilityConnection = true;
break;
}
}
return result;
}
getPropertiesFromSpaces(spaceIds) {
const result = {
balcony: null,
parking: null,
garage: null,
basementAttic: null,
storeRoom: null
};
for (const spaceId of spaceIds) {
switch (spaceId) {
case 36:
case 12:
result.parking = true;
break;
case 1:
case 2:
case 3:
result.balcony = true;
break;
case 4:
case 30:
result.garage = true;
break;
case 9:
case 10:
result.storeRoom = true;
break;
case 18:
case 34:
case 37:
case 27:
result.basementAttic = true;
break;
}
}
return result;
}
getHeatingTypeId(heatingRentalId) {
// heatingRentalId can have multiple values, like: "1, 2, 3", parseInt will take first integer value
const heatingId = parseInt(heatingRentalId);
switch (heatingId) {
case 27:
case 16:
return HEATING_TYPE.GAS.id;
case 4:
return HEATING_TYPE.CENTRAL_GAS.id;
case 3:
case 23:
case 6:
case 7:
case 8:
case 9:
case 10:
return HEATING_TYPE.CENTRAL_BOILER.id;
case 2:
case 13:
case 30:
case 17:
case 29:
case 31:
return HEATING_TYPE.ELECTRICITY.id;
case 24:
case 25:
case 12:
return HEATING_TYPE.CENTRAL_CITY.id;
case 26:
case 21:
case 20:
return HEATING_TYPE.WOOD.id;
case 28:
case 19:
return HEATING_TYPE.HEAT_PUMP.id;
case 14:
case 32:
return HEATING_TYPE.OTHER.id;
default:
return null;
}
}
getPropertiesFromInfrastructure(infrastructureIds) {
const result = {
electricity: null,
water: null,
gas: null,
drainageSystem: null,
phoneConnection: null,
internet: null,
videoSurveillance: null,
cableTV: null
};
for (const infrastructureId of infrastructureIds) {
switch (infrastructureId) {
case 1:
result.electricity = true;
break;
case 2:
result.water = true;
break;
case 4:
result.gas = true;
break;
case 5:
result.drainageSystem = true;
break;
case 7:
case 8:
result.phoneConnection = true;
break;
case 10:
result.internet = true;
break;
case 11:
result.cableTV = true;
break;
case 16:
case 17:
result.videoSurveillance = true;
break;
}
}
return result;
}
getFloorNumberFromFloorId(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
// just extracting floor number from first element
const floorsId = floorsIdText.split(",");
if (floorsId.length === 0) {
return null;
}
const firstFloorId = parseInt(floorsId[0]);
// 1 pod
// 2 sut
// 3 raz
// 4 pri
// 5 vpri
// 6 prv
// 7 dru
// 8 tre
// 9 čet
// 10 man
// 11
// 12 pot
// 13 vpot
// 14 tav
// 15 pet
const floorNumber = [
-1,
-1,
0,
0,
1,
1,
2,
3,
4,
null,
null,
null,
null,
null,
5
];
return floorNumber[firstFloorId - 1] || null;
}
getNumberOfFloorsFromFloorId(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
const floorIds = floorsIdText.split(",");
if (floorIds.length === 0) {
return null;
}
return floorIds.length;
}
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -362,7 +838,7 @@ class AktidoCrawler {
// }
//For now, we use only Postgres saver, so ...
return await savers[0].save(results);
return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}

View File

@@ -10,7 +10,10 @@ const {
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE
CRAWLER_AD_TYPE,
HEATING_TYPE,
FURNISHING_TYPE,
ACCESS_ROAD_TYPE
} = require("../../common/enums");
const {
@@ -271,6 +274,7 @@ class OlxCrawler {
//====== OTHER AD INFORMATION ===============
let adType = null;
let olxId = null;
let numberOfViewsAgency = null;
let otherInformationDivId;
//We need to locate DIV ID where other information are stored
@@ -293,6 +297,7 @@ class OlxCrawler {
const olxIdFieldSelector = `#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(4)`;
const publishedDateValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(3) > div.df2.neanimiraj > time`;
const numberOfViewsAgencyValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(6) > div.df2`;
const renewedDateFullValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div.op.ob.pop`;
const publishedDate = $(publishedDateValueSelector)
@@ -331,60 +336,7 @@ class OlxCrawler {
)
.text()
.trim();
const olxIdFieldTitle = $(`${olxIdFieldSelector} > div.df1`)
.text()
.trim();
olxId = $(`${olxIdFieldSelector} > div.df2`)
.text()
.trim();
if (olxIdFieldTitle !== "OLX ID") {
throw { message: "Cannot find correct OLX ID" };
}
//===========================================
//====== DETAIL INFORMATION FIELDS ==========
let area = null;
let gardenSize = null;
let fieldIndex = 1;
do {
const fieldSelector = `#dodatnapolja1 > div:nth-child(${fieldIndex})`;
const fieldTitleSelector = `${fieldSelector} > div.df1`;
const fieldValueSelector = `${fieldSelector} > div.df2`;
const fieldTitle = $(fieldTitleSelector)
.text()
.trim();
const fieldValue = $(fieldValueSelector)
.text()
.trim();
switch (fieldTitle) {
case "Kvadrata":
area = fieldValue;
break;
case "Okućnica (kvadratura)":
gardenSize = fieldValue;
break;
}
if (++fieldIndex === OLX_ENUMS.MAX_DETAIL_FIELDS || fieldTitle === "") {
break;
}
} while (true);
//===========================================
//====== UNUSED FIELDS FOR NOW ==============
const time = $("time").attr("datetime");
const numberOfViews = $(
"#artikal_glavni_div > div.artikal_lijevo > div:nth-child(18) > div:nth-child(6) > div.df2"
)
.text()
.trim();
//===========================================
//=========================================
const parsedCategory = this.getAdCategoryId(category);
if (!parsedCategory) {
throw { message: `Unknown ad category [${category}]` };
@@ -395,6 +347,221 @@ class OlxCrawler {
throw { message: "Unknown ad type" };
}
const olxIdFieldTitle = $(`${olxIdFieldSelector} > div.df1`)
.text()
.trim();
olxId = $(`${olxIdFieldSelector} > div.df2`)
.text()
.trim();
numberOfViewsAgency = parseInt(
$(numberOfViewsAgencyValueSelector)
.text()
.trim()
);
if (olxIdFieldTitle !== "OLX ID") {
throw { message: "Cannot find correct OLX ID" };
}
//===========================================
//====== DETAIL INFORMATION FIELDS ==========
let area,
gardenSize,
numberOfRooms = null,
numberOfFloors = null,
floor = null,
accessRoadType = null,
heatingType = null,
furnishingType = null,
balcony = null,
newBuilding = null,
elevator = null,
water = null,
electricity = null,
drainageSystem = null,
registeredInZkBooks = null,
recentlyAdapted = null,
parking = null,
garage = null,
gas = null,
antiTheftDoor = null,
airCondition = null,
phoneConnection = null,
cableTV = null,
internet = null,
basementAttic = null,
storeRoom = null,
videoSurveillance = null,
alarm = null,
suitableForStudents = null,
includingBills = null,
animalsAllowed = null,
pool = null,
urbanPlanPermit = null,
buildingPermit = null,
utilityConnection = null,
distanceToRiver = null;
let fieldIndex = 1;
do {
const fieldSelector = `#dodatnapolja1 > div:nth-child(${fieldIndex})`;
const fieldTitleSelector = `${fieldSelector} > div.df1`;
const fieldValueSelector = `${fieldSelector} > div.df2`;
const fieldTitle = $(fieldTitleSelector)
.text()
.trim()
.toLowerCase();
const fieldValue = $(fieldValueSelector)
.text()
.trim()
.toLowerCase();
switch (fieldTitle) {
case "kvadrata":
area = fieldValue;
break;
case "okućnica (kvadratura)":
gardenSize = fieldValue;
break;
case "broj soba":
numberOfRooms = this.parseNumberOfRooms(fieldValue, parsedCategory);
break;
case "broj prostorija":
numberOfRooms = this.parseNumberOfRooms(fieldValue, parsedCategory);
break;
case "broj spratova":
numberOfFloors = this.parseNumberOfFloors(
fieldValue,
parsedCategory
);
break;
case "sprat":
floor = this.parseFloorNumber(fieldValue, parsedCategory);
break;
case "vrsta grijanja":
heatingType = this.getHeatingTypeId(fieldValue);
break;
case "namješten?":
furnishingType = this.getFurnishingTypeId(fieldValue);
break;
case "namješten":
furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case "namještena":
furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case "voda":
water = true;
break;
case "struja":
electricity = true;
break;
case "kanalizacija":
drainageSystem = fieldValue !== "nema";
break;
case "godina izgradnje":
newBuilding = newBuilding || fieldValue === "novogradnja";
break;
case "kućni ljubimci":
animalsAllowed = fieldValue === "da";
break;
case "uknjiženo / zk":
registeredInZkBooks = true;
break;
case "uknjiženo (zk)":
registeredInZkBooks = true;
break;
case "novogradnja":
newBuilding = true;
break;
case "nedavno adaptiran":
recentlyAdapted = true;
break;
case "nedavno adaptirana":
recentlyAdapted = true;
break;
case "balkon":
balcony = true;
break;
case "lift":
elevator = true;
break;
case "parking":
parking = true;
break;
case "garaža":
garage = true;
break;
case "plin":
gas = true;
break;
case "blindirana vrata":
antiTheftDoor = true;
break;
case "klima":
airCondition = true;
break;
case "telefonski priključak":
phoneConnection = true;
break;
case "kablovska tv":
cableTV = true;
break;
case "internet":
internet = true;
break;
case "podrum/tavan":
basementAttic = true;
break;
case "ostava/špajz":
storeRoom = true;
break;
case "video nadzor":
videoSurveillance = true;
break;
case "alarm":
alarm = true;
break;
case "za studente":
suitableForStudents = true;
break;
case "uključen trošak režija":
includingBills = true;
break;
case "građevinska dozvola":
buildingPermit = true;
break;
case "komunalni priključak":
utilityConnection = true;
break;
case "urbanistička dozvola":
urbanPlanPermit = true;
break;
case "udaljenost od rijeke (m)":
distanceToRiver = parseInt(fieldValue) || null;
break;
case "prilaz":
accessRoadType = this.getAccessRoadTypeId(fieldValue);
break;
case "bazen":
pool = true;
break;
case "iznajmljeno":
status = AD_STATUS.STATUS_RENTED;
break;
default:
// console.log(fieldTitle, " = ", fieldValue);
break;
}
if (++fieldIndex === OLX_ENUMS.MAX_DETAIL_FIELDS || fieldTitle === "") {
break;
}
} while (true);
//===========================================
//=========================================
const parsedArea = this.parseArea(area) || null;
const parsedGardenSize = this.parseArea(gardenSize) || null;
const parsedPrice = this.parsePrice(price) || null;
@@ -409,6 +576,13 @@ class OlxCrawler {
locationLong = parseFloat(locationLatLngMatches[2]) || null;
}
if (
title.indexOf("[PRODANO]") !== -1 ||
title.indexOf("[ZAVRŠENO]") !== -1
) {
status = AD_STATUS.STATUS_SOLD;
}
const data = {
url,
agencyObjectId: olxId,
@@ -439,7 +613,42 @@ class OlxCrawler {
locationLong,
adStatus: status,
publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString()
renewedDate: renewedDateMoment.toISOString(),
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency
};
return data;
@@ -485,6 +694,64 @@ class OlxCrawler {
}
}
getHeatingTypeId(heatingTypeText) {
switch (heatingTypeText) {
case "struja":
return HEATING_TYPE.ELECTRICITY.id;
case "plin":
return HEATING_TYPE.GAS.id;
case "drva":
return HEATING_TYPE.WOOD.id;
case "centralno (gradsko)":
return HEATING_TYPE.CENTRAL_CITY.id;
case "centralno (kotlovnica)":
return HEATING_TYPE.CENTRAL_BOILER.id;
case "centralno (plin)":
return HEATING_TYPE.CENTRAL_GAS.id;
case "nije uvedeno":
return HEATING_TYPE.NO_HEATING.id;
case "ostalo":
return HEATING_TYPE.OTHER.id;
case "drugo":
return HEATING_TYPE.OTHER.id;
default:
console.log("grijanje = NEPOZNATO [", heatingTypeText, "]");
return null;
}
}
getFurnishingTypeId(furnishingTypeText) {
switch (furnishingTypeText) {
case "namješten":
return FURNISHING_TYPE.FURNISHED.id;
case "polunamješten":
return FURNISHING_TYPE.HALF_FURNISHED.id;
case "nenamješten":
return FURNISHING_TYPE.NOT_FURNISHED.id;
case "":
return FURNISHING_TYPE.FURNISHED.id;
default:
console.log("namješten = NEPOZNATO [", furnishingTypeText, "]");
return null;
}
}
getAccessRoadTypeId(accessRoadTypeText) {
switch (accessRoadTypeText) {
case "asfalt":
return ACCESS_ROAD_TYPE.ASPHALT.id;
case "beton":
return ACCESS_ROAD_TYPE.CONCRETE.id;
case "makadam":
return ACCESS_ROAD_TYPE.MACADAM.id;
case "ostalo":
return ACCESS_ROAD_TYPE.OTHER.id;
default:
console.log("pristup = NEPOZNATO [", accessRoadTypeText, "]");
return null;
}
}
parseArea(areaText) {
if (!areaText) {
return NaN;
@@ -505,56 +772,100 @@ class OlxCrawler {
return parseFloat(formattedPriceText);
}
parseRenewedDate(renewedDateText) {
const currentMoment = moment.tz(DEFAULT_TIMEZONE);
if (renewedDateText.includes("Prije mjesec dana")) {
return currentMoment.add(-1, "month");
}
if (renewedDateText.includes("Jučer")) {
return currentMoment.add(-1, "day");
}
if (renewedDateText.includes("Prije sat")) {
return currentMoment.add(-1, "hour");
}
if (renewedDateText.includes("dan")) {
// format for this case should be "Prije N dana" or "Prije N dan"
const dateParts = renewedDateText.split(" ");
if (dateParts[0] === "Prije") {
const numberOfDays = parseInt(dateParts[1]);
return currentMoment.add(-1 * numberOfDays, "days");
} else {
return undefined;
parseNumberOfRooms(numberOfRoomsText, categoryId) {
if (categoryId === AD_CATEGORY.FLAT.id) {
switch (numberOfRoomsText) {
case "garsonjera":
return 0;
case "jednosoban (1)":
return 1;
case "jednoiposoban (1.5)":
return 1.5;
case "dvosoban (2)":
return 2;
case "trosoban (3)":
return 3;
case "četverosoban (4)":
return 4;
case "petosoban i više":
return 5;
default:
console.log(
"broj soba [stan] = NEPOZNATO [",
numberOfRoomsText,
", ",
categoryId,
"]"
);
return null;
}
}
if (renewedDateText.includes("sat")) {
const dateParts = renewedDateText.split(" ");
const parsedHours =
dateParts && dateParts.length > 2 ? parseInt(dateParts[1]) : undefined;
if (!parsedHours) {
return undefined;
}
return currentMoment.add(-1 * parsedHours, "hours");
if (
categoryId === AD_CATEGORY.HOUSE.id ||
categoryId === AD_CATEGORY.COTTAGE.id ||
categoryId === AD_CATEGORY.APARTMENT.id ||
categoryId === AD_CATEGORY.OFFICE.id
) {
return parseInt(numberOfRoomsText) || null;
}
const todayVariations = ["min", "sekund", "maloprije"];
for (const todayVariation of todayVariations) {
if (renewedDateText.includes(todayVariation)) {
return currentMoment;
}
console.log("broj soba = NEPOZNATO [", numberOfRoomsText, "]");
return null;
}
parseNumberOfFloors(numberOfFloorsText, categoryId) {
if (
categoryId === AD_CATEGORY.HOUSE.id ||
categoryId === AD_CATEGORY.COTTAGE.id
) {
return parseInt(numberOfFloorsText) || null;
}
const renewedDateMoment = moment.tz(
renewedDateText,
OLX_ENUMS.OLX_RENEWED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
if (categoryId === AD_CATEGORY.OFFICE.id) {
if (
numberOfFloorsText === "suteren" ||
numberOfFloorsText === "prizemlje"
) {
return 0;
}
if (numberOfFloorsText === "6+") {
return 7;
}
return parseInt(numberOfFloorsText) || null;
}
return renewedDateMoment.isValid() ? renewedDateMoment : undefined;
console.log("broj spratova = NEPOZNATO [", numberOfFloorsText, "]");
return null;
}
parseFloorNumber(floorText, categoryId) {
if (
categoryId === AD_CATEGORY.FLAT.id ||
categoryId === AD_CATEGORY.APARTMENT.id
) {
if (
floorText === "suteren" ||
floorText === "prizemlje" ||
floorText === "visoko prizemlje"
) {
return 0;
}
return parseInt(floorText) || null;
}
if (categoryId === AD_CATEGORY.OFFICE.id) {
if (floorText === "zaseban objekat") {
return null;
}
if (floorText === "prizemlje" || floorText === "visoko prizemlje") {
return 0;
}
return parseInt(floorText) || null;
}
console.log("sprat = NEPOZNATO [", floorText, "]");
return null;
}
async sleep(ms) {
@@ -569,7 +880,7 @@ class OlxCrawler {
// }
//For now, we use only Postgres saver, so ...
return await savers[0].save(results);
return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}

View File

@@ -2,16 +2,25 @@
const fetch = require("node-fetch");
const cheerio = require("cheerio");
const moment = require("moment-timezone");
const FormData = require("form-data");
const {
AD_TYPE,
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE
CRAWLER_AD_TYPE,
FURNISHING_TYPE,
HEATING_TYPE
} = require("../../common/enums");
const { PRINT_CRAWLER_DEBUG } = require("../../config/appConfig");
const {
PRINT_CRAWLER_DEBUG,
DEFAULT_TIMEZONE,
PROSTOR_LOGIN
} = require("../../config/appConfig");
const { PROSTOR_FORCE_CRAWL } = require("../specificConfigs/prostor");
const PROSTOR_ENUMS = {
PROSTOR_AD_TYPE: {
@@ -48,44 +57,385 @@ class ProstorCrawler {
this.crawlerAdTypes = crawlerAdTypes;
this.crawlerAdCategories = crawlerAdCategories;
this.maxResultsPerPage = maxResultsPerPage;
this.delayBetweenPages = delayBetweenPages;
}
async crawl() {
const crawlAdCategories = this.crawlerAdCategories;
//We need session cookie to use login privileges
const prostorCookie = await this.getCookies();
//New tag to check if crawler loged in
const login = await this.loginForScraping(PROSTOR_LOGIN, prostorCookie);
const newRealEstates = [];
if (crawlAdCategories) {
//Crawl only if login was successful
if (crawlAdCategories && login) {
const indexGenerators = [];
for (const adCategory of crawlAdCategories) {
const urlAdTypePart =
PROSTOR_ENUMS.PROSTOR_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = PROSTOR_ENUMS.PROSTOR_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
const urlPageToCrawl = `${this.baseUrl}?remove_sold=1${urlAdTypePart}${urlCategoryPart}`;
const singleCategoryResults = await this.extractRealEstates(
urlPageToCrawl
);
indexGenerators.push(this.categoryIndexer(adCategory, prostorCookie));
}
const resultsSubset = singleCategoryResults.slice(
0,
this.maxResultsPerPage
);
const saveResults = await this.saveCrawledResults(resultsSubset);
const { newRecords } = saveResults;
newRealEstates.push(...newRecords);
let done = false;
while (!done) {
const categoryIndexerPromises = [];
const generatorsToRemove = [];
for (const indexGenerator of indexGenerators) {
categoryIndexerPromises.push(indexGenerator.next());
generatorsToRemove.push(false);
}
const singlePageResults = await Promise.all(categoryIndexerPromises);
const entries = singlePageResults.entries();
for (const [index, { value: singlePageResult }] of entries) {
if (singlePageResult) {
const saveResults = await this.saveCrawledResults(singlePageResult);
const { newRecords } = saveResults;
newRealEstates.push(...newRecords);
if (
Array.isArray(newRecords) &&
newRecords.length === 0 &&
!PROSTOR_FORCE_CRAWL
) {
generatorsToRemove[index] = true;
}
} else {
//Generator returned undefined, remove this generator from array
generatorsToRemove[index] = true;
// console.log("Generator ", index + 1, "has no more pages");
}
}
// console.log("Generators state : ", generatorsToRemove);
for (let i = generatorsToRemove.length - 1; i >= 0; i--) {
if (generatorsToRemove[i]) {
// console.log("\tRemove generator ", i + 1);
indexGenerators.splice(i, 1);
}
}
if (indexGenerators.length === 0) {
done = true;
}
await this.sleep(this.delayBetweenPages);
}
}
return newRealEstates;
}
async extractRealEstates(url) {
async *categoryIndexer(adCategory, prostorCookie) {
const urlAdTypePart = PROSTOR_ENUMS.PROSTOR_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = PROSTOR_ENUMS.PROSTOR_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
const urlPageToCrawl = `${this.baseUrl}?remove_sold=0${urlAdTypePart}${urlCategoryPart}`;
const listOfAllRealEstates = await this.extractRealEstates(
urlPageToCrawl,
prostorCookie
);
let elementToStartIndexFrom = 0;
while (true) {
const realEstatesForSinglePage = listOfAllRealEstates.slice(
elementToStartIndexFrom,
elementToStartIndexFrom + this.maxResultsPerPage
);
if (realEstatesForSinglePage.length > 0) {
elementToStartIndexFrom += realEstatesForSinglePage.length;
const singlePageResults = await this.indexSinglePage(
realEstatesForSinglePage,
prostorCookie
);
const filteredSinglePageResults = singlePageResults.filter(
singleResult => !!singleResult
);
if (
Array.isArray(filteredSinglePageResults) &&
filteredSinglePageResults.length > 0
) {
yield filteredSinglePageResults;
} else {
return undefined;
}
} else {
return undefined;
}
}
} else {
return undefined;
}
}
async indexSinglePage(realEstatesList, prostorCookie) {
const asyncActions = [];
for (const realEstate of realEstatesList) {
asyncActions.push(this.scrapeAd(realEstate, prostorCookie));
}
try {
return await Promise.all(asyncActions);
} catch (e) {
console.log(
"[PROSTOR] Error crawling ads : ",
e.message || "UNKNOWN ERROR"
);
return [];
}
}
async scrapeAd(realEstate, prostorCookie) {
const { lat, lng, property_name, price, size, link, status } = realEstate;
//Status information is given already in realestate list
//For VIP Ads status ='' canot be used, but no VIP ads are crawled
//We will make "fake" vip ad for RE that have size=55
//It is weird because yesterday it said 'VIP ponuda' ???
const adStatus =
size === "55"
? ProstorCrawler.getStatusId("VIP ponuda")
: ProstorCrawler.getStatusId(status);
const url = `https://prostor.ba${link}`;
// console.log("[PROSTOR] Scraping : ", url);
try {
const adPageSource = await fetch(url, {
headers: { Cookie: prostorCookie }
});
const body = await adPageSource.text();
const $ = cheerio.load(body);
// link contains part of the URL in the format of : /prodaja/stan/stup/9556
// general form is : /actionType/realEstateType/location/realEstateID
// linkParts contains : ['', 'actionType', 'realEstateType', 'location', 'realEstateID']
const linkParts = link.split("/");
const adType = ProstorCrawler.getAdTypeId(linkParts[1]);
const realEstateType = ProstorCrawler.getAdCategoryId(linkParts[2]);
const prostorId = linkParts[4];
if (!adType || !realEstateType || !prostorId) {
return null;
}
const allDataSelector =
"body > div > div.container-fluid > div > div.column-right > table > tbody";
const realEstateProperties = {};
$(allDataSelector)
.find("p")
.each((i, element) => {
const propertyElement = $(element)
.text()
.split(":")
.map(text => text.trim().toLowerCase());
const propertyTitle = propertyElement[0];
realEstateProperties[propertyTitle] = propertyElement[1];
});
$(allDataSelector)
.find("div.mb-2")
.each((i, element) => {
const propertyElement = $(element)
.text()
.trim()
.toLowerCase();
realEstateProperties[propertyElement] = true;
});
if (JSON.stringify(realEstateProperties) === JSON.stringify({})) {
return null;
}
let numberOfRooms =
parseFloat(realEstateProperties["broj soba"]) +
parseFloat(realEstateProperties["broj spavaćih soba"]) || null,
numberOfFloors = null,
floor = null,
accessRoadType = null,
heatingType = ProstorCrawler.getHeatingTypeId(realEstateProperties),
furnishingType = null,
balcony =
realEstateProperties["balkon"] ||
realEstateProperties["terasa"] ||
realEstateProperties["lođa"] ||
null,
newBuilding = linkParts[1] === "novogradnja",
elevator = realEstateProperties["lift"] || null,
water = realEstateProperties["voda"] || null,
electricity = realEstateProperties["električna energija"] || null,
drainageSystem = realEstateProperties["kanalizacija"] || null,
registeredInZkBooks = null,
recentlyAdapted = null,
parking = realEstateProperties["parking"] || null,
garage = realEstateProperties["garaža"] || null,
gas = realEstateProperties["plin"] || null,
antiTheftDoor = realEstateProperties["blindo vrata"] || null,
airCondition = realEstateProperties["klima"] || null,
phoneConnection = realEstateProperties["telefon"] || null,
cableTV = realEstateProperties["kablovksa tv"] || null,
internet =
realEstateProperties["internet"] ||
realEstateProperties["adsl"] ||
null,
basementAttic = realEstateProperties["podrum"] || null,
storeRoom = realEstateProperties["ostava"] || null,
videoSurveillance = realEstateProperties["video nadzor"],
alarm = realEstateProperties["alarm"] || null,
suitableForStudents = null,
includingBills = null,
animalsAllowed = null,
pool = realEstateProperties["bazen"] || null,
urbanPlanPermit = null,
buildingPermit = null,
utilityConnection = null,
distanceToRiver = null,
numberOfViewsAgency = null;
// Floor versions (there are possibly more versions) :
// Sprat: 3/3
// Sprat: 1 - 2/2
// Sprat: Pr - 7/7
// Sprat: -2/0
// If there are two parts, that represents more real estates are sold
// numberOfFloors is contained in second part, after / sign
const floorsArray = realEstateProperties["sprat"].split(" - ");
let floorText = "";
if (floorsArray.length === 1) {
const floorDescription = floorsArray[0].split("/");
numberOfFloors = parseInt(floorDescription[1]) || null;
floorText = floorDescription[0];
floor = Math.round(parseFloat(floorText));
} else if (floorsArray.length === 2) {
const floorDescription = floorsArray[1].split("/");
numberOfFloors = parseInt(floorDescription[1]) || null;
floorText = floorsArray[0];
floor = Math.round(parseFloat(floorText));
} else {
// This is something strange
}
if (isNaN(floor)) {
// It was textual representation of floor, like "Pr", "Su" or similar
switch (floorText) {
case "pr":
floor = 0;
break;
case "su":
floor = -1;
break;
default:
console.log(
"[PROSTOR] Unknown textual representation of floor : ",
floorText
);
floor = null;
}
}
if (realEstateProperties["namješteno"]) {
furnishingType = FURNISHING_TYPE.FURNISHED.id;
} else if (realEstateProperties["polunamješteno"]) {
furnishingType = FURNISHING_TYPE.HALF_FURNISHED.id;
} else {
furnishingType = FURNISHING_TYPE.NOT_FURNISHED.id;
}
const title = property_name;
const parsedPrice = parseFloat(price.replace(/\./g, "")) || null;
const parsedArea = parseFloat(size);
const gardenSize = null;
const longDescription = null;
const data = {
url,
agencyObjectId: prostorId,
originAgencyName: AD_AGENCY.PROSTOR,
realEstateType,
adType,
title,
price: parsedPrice,
area: parsedArea,
gardenSize,
shortDescription: "",
longDescription: longDescription,
streetNumber: 0,
streetName: realEstateProperties["adresa"],
locality: "",
municipality: "",
city: "",
region: "",
entity: "",
country: "",
locationLat: lat,
locationLong: lng,
adStatus,
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency
};
return data;
} catch (e) {
console.error(
"[PROSTOR] Exception caught: " + e.message,
"\r\nURL:",
url
);
return null;
}
}
async extractRealEstates(url, prostorCookie) {
if (PRINT_CRAWLER_DEBUG) {
console.log("[PROSTOR] Index page : ", url);
}
try {
const res = await fetch(url);
const res = await fetch(url, {
headers: { Cookie: prostorCookie }
});
const body = await res.text();
const $ = cheerio.load(body);
@@ -115,18 +465,19 @@ class ProstorCrawler {
const jsonData = scriptData.substring(23, jsonEndIndex) + "]";
const realEstates = JSON.parse(jsonData);
const transformedRealEstates = [];
for (const realEstate of realEstates) {
const transformedRealEstate = ProstorCrawler.transformRealEstateData(
realEstate
);
if (transformedRealEstate) {
transformedRealEstates.push(transformedRealEstate);
}
}
return transformedRealEstates;
// const transformedRealEstates = [];
//
// for (const realEstate of realEstates) {
// const transformedRealEstate = ProstorCrawler.transformRealEstateData(
// realEstate
// );
// if (transformedRealEstate) {
// transformedRealEstates.push(transformedRealEstate);
// }
// }
//
// return transformedRealEstates;
return realEstates;
} else {
throw {
message: "Something is wrong with JSON data or data is moved"
@@ -134,73 +485,15 @@ class ProstorCrawler {
}
} catch (e) {
console.log(e);
throw { message: "Can't find ad data JSON" };
throw e;
}
}
} catch (e) {
console.error("[PROSTOR] Exception caught:", e.message);
return [];
}
}
static transformRealEstateData(realEstateData) {
try {
const { lat, lng, property_name, price, size, link } = realEstateData;
// link contains part of the URL in the format of : /prodaja/stan/stup/9556
// general form is : /actionType/realEstateType/location/realEstateID
// linkParts contains : ['', 'actionType', 'realEstateType', 'location', 'realEstateID']
const linkParts = link.split("/");
const adType = ProstorCrawler.getAdTypeId(linkParts[1]);
const realEstateType = ProstorCrawler.getAdCategoryId(linkParts[2]);
const prostorId = linkParts[4];
const url = `https://prostor.ba${link}`;
if (!adType || !realEstateType || !prostorId) {
return null;
}
const adStatus = AD_STATUS.STATUS_NORMAL;
const parsedPrice = parseFloat(price.replace(/\./g, "")) || null;
const parsedArea = parseFloat(size);
const data = {
url,
agencyObjectId: prostorId,
originAgencyName: AD_AGENCY.PROSTOR,
realEstateType,
adType,
title: property_name,
price: parsedPrice,
area: parsedArea,
gardenSize: null,
shortDescription: "",
longDescription: "",
streetNumber: 0,
streetName: "",
locality: "",
municipality: "",
city: "",
region: "",
entity: "",
country: "",
locationLat: lat,
locationLong: lng,
adStatus,
publishedDate: null,
renewedDate: null
};
return data;
} catch (e) {
console.error(
"[PROSTOR] Exception caught: " + e.message,
"\r\nURL:",
url
"[PROSTOR] Exception caught:",
e.message || "UNKNOWN MESSAGE"
);
return null;
return [];
}
}
@@ -231,11 +524,63 @@ class ProstorCrawler {
return AD_TYPE.AD_TYPE_SALE.stringId;
case "najam":
return AD_TYPE.AD_TYPE_RENT.stringId;
case "novogradnja":
return AD_TYPE.AD_TYPE_SALE.stringId;
default:
return undefined;
}
}
static getHeatingTypeId(realEstateProperties) {
const realEstatePropertiesKeys = Object.keys(realEstateProperties);
for (const property of realEstatePropertiesKeys) {
switch (property) {
case "centralno toplane":
return HEATING_TYPE.CENTRAL_CITY.id;
case "etažno plinsko":
return HEATING_TYPE.CENTRAL_GAS.id;
case "termo blok":
case "podno grijanje":
return HEATING_TYPE.OTHER.id;
case "etažno električno":
case "konvektori":
return HEATING_TYPE.ELECTRICITY.id;
case "plinske peći":
return HEATING_TYPE.GAS.id;
case "vlastita kotlovnica":
return HEATING_TYPE.CENTRAL_BOILER.id;
case "toplotna pumpa":
return HEATING_TYPE.HEAT_PUMP.id;
case "kamin":
return HEATING_TYPE.WOOD.id;
default:
//console.log("[PROSTOR] Nepoznato >>> [", property, "]");
}
}
}
static getStatusId(statusText) {
switch (statusText) {
case "":
return AD_STATUS.STATUS_NORMAL;
case "Rezervisano":
return AD_STATUS.STATUS_RESERVED;
case "Prodano":
return AD_STATUS.STATUS_SOLD;
case "Iznajmljeno":
return AD_STATUS.STATUS_RENTED;
case "VIP ponuda":
return AD_STATUS.STATUS_VIP;
default:
console.log("[PROSTOR] Unknown AD_STATUS : [", statusText, "]");
return AD_STATUS.STATUS_NORMAL;
}
}
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async saveCrawledResults(results) {
const savers = this.savers;
@@ -244,9 +589,54 @@ class ProstorCrawler {
// }
//For now, we use only Postgres saver, so ...
return await savers[0].save(results);
return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
async loginForScraping(PROSTOR_LOGIN, prostorCookie) {
let formData = new FormData();
formData.append("email", PROSTOR_LOGIN.EMAIL);
formData.append("password", PROSTOR_LOGIN.PASSWORD);
return fetch("https://prostor.ba/moj-prostor/prijava", {
method: "POST",
body: formData,
headers: { Cookie: prostorCookie }
})
.then(page => {
return page.text();
})
.then(resp => {
const $ = cheerio.load(resp);
if (
$("h1")
.text()
.indexOf("Dobrodošli") !== -1
) {
console.log("[PROSTOR]: Crawler loged in!");
return true;
} else {
console.log("[PROSTOR]: Crawler login failed - wrong credentials!");
return false;
}
})
.catch(err => {
console.log("[PROSTOR]: Crawler login error ", err);
});
}
async getCookies() {
const getResponse = await fetch("https://prostor.ba/moj-prostor/prijava", {
headers: { Cookie: "" }
});
const raw = getResponse.headers.raw()["set-cookie"];
const cookie = raw
.map(datastring => {
const data = datastring.split(";");
const cookieData = data[0];
return cookieData;
})
.join(";");
return cookie;
}
}
module.exports = ProstorCrawler;

View File

@@ -11,7 +11,10 @@ const {
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE
CRAWLER_AD_TYPE,
HEATING_TYPE,
ACCESS_ROAD_TYPE,
FURNISHING_TYPE
} = require("../../common/enums");
const {
@@ -219,6 +222,7 @@ class RentalCrawler {
throw { message: "Can't find ad data JSON" };
}
let adStatus = AD_STATUS.STATUS_NORMAL;
const rentalId = extractedData["re_realEstates_id"];
const adCategory = this.getKiviCategoryIdFromRentalId(
parseInt(extractedData["re_types_id"])
@@ -237,6 +241,143 @@ class RentalCrawler {
};
}
const descriptionIds = extractedData["re_descriptions_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(descriptionIds)) {
throw {
message:
'Expected array od descriptions but "re_descriptions_id" not found !'
};
}
const spaceIds = extractedData["re_spaces_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(spaceIds)) {
throw {
message: 'Expected array od spaces but "re_spaces_id" not found !'
};
}
const infrastructureIds = extractedData["re_infrastructure_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(infrastructureIds)) {
throw {
message:
'Expected array od infrastructures but "re_infrastructure_id" not found !'
};
}
const floorNoIds = extractedData["re_floorNO_id"]
.split(",")
.map(stringNumber => parseInt(stringNumber));
if (!Array.isArray(floorNoIds)) {
throw {
message:
'Expected array od infrastructures but "re_floorNO_id" not found !'
};
}
const numberOfViewsAgencySelector = $(
"body > div > div.container > div.row.content-top > div.col-xs-12.col-sm-12.col-md-9 > div > div.box-viewcount"
);
// number of views is written as : "Broj pregledavanja: NNN"
const numberOfViewsAgencyFullText = numberOfViewsAgencySelector
.text()
.trim();
const numberOfViewsAgencyParts = numberOfViewsAgencyFullText.split(":");
const realEstatePropertiesFromDescriptions = this.getPropertiesFromDescriptions(
descriptionIds
);
const realEstatePropertiesFromSpaces = this.getPropertiesFromSpaces(
spaceIds
);
const realEstatePropertiesFromInfrastructure = this.getPropertiesFromInfrastructure(
infrastructureIds
);
if (extractedData["adm_realEstates_discount"] === "1") {
adStatus = AD_STATUS.STATUS_DISCOUNTED;
}
let numberOfRooms =
parseInt(extractedData["re_realEstates_roomsNO"]) +
parseInt(extractedData["re_realEstates_bedNO"]) || null,
numberOfFloors =
parseInt(extractedData["re_realEstates_floorsNO"]) ||
this.getNumberOfFloorsFromFloorId(extractedData["re_floorNO_id"]),
floor =
parseInt(extractedData["re_realEstates_floorNO"]) ||
this.getFloorNumberFromFloorId(extractedData["re_floorNO_id"]),
accessRoadType = realEstatePropertiesFromDescriptions.accessRoadType,
heatingType =
this.getHeatingTypeId(extractedData["re_heating_id"]) || null,
furnishingType = realEstatePropertiesFromDescriptions.furnishingType,
balcony =
realEstatePropertiesFromDescriptions.balcony ||
realEstatePropertiesFromSpaces.balcony,
newBuilding = extractedData["op_realEstates_newBuilding"]
? extractedData["op_realEstates_newBuilding"] === "1"
: null,
elevator = realEstatePropertiesFromDescriptions.elevator,
water =
realEstatePropertiesFromDescriptions.water ||
realEstatePropertiesFromInfrastructure.water,
electricity =
realEstatePropertiesFromDescriptions.electricity ||
realEstatePropertiesFromInfrastructure.electricity,
drainageSystem =
realEstatePropertiesFromInfrastructure.drainageSystem,
registeredInZkBooks =
extractedData["op_realEstates_ownerPermit"] === 1 || null,
recentlyAdapted = null,
parking =
realEstatePropertiesFromDescriptions.parking ||
realEstatePropertiesFromSpaces.parking,
garage = realEstatePropertiesFromSpaces.garage,
gas = realEstatePropertiesFromInfrastructure.gas,
antiTheftDoor = realEstatePropertiesFromDescriptions.antiTheftDoor,
airCondition = realEstatePropertiesFromDescriptions.airCondition,
phoneConnection =
realEstatePropertiesFromInfrastructure.phoneConnection,
cableTV = realEstatePropertiesFromInfrastructure.cableTV,
internet = realEstatePropertiesFromInfrastructure.internet,
basementAttic =
realEstatePropertiesFromSpaces.basementAttic ||
this.checkBasemAtticFromFloors(extractedData["re_floorNO_id"]),
storeRoom = realEstatePropertiesFromSpaces.storeRoom,
videoSurveillance =
realEstatePropertiesFromDescriptions.videoSurveillance ||
realEstatePropertiesFromInfrastructure.videoSurveillance,
alarm = realEstatePropertiesFromDescriptions.alarm,
suitableForStudents = null,
includingBills =
extractedData["op_realEstates_utilitiesIncluded"] === "1" || null,
animalsAllowed = null,
pool = realEstatePropertiesFromDescriptions.pool,
urbanPlanPermit =
extractedData["op_realEstates_locationPermit"] === "1" ||
realEstatePropertiesFromDescriptions.urbanPlanPermit,
buildingPermit =
extractedData["op_realEstates_buildingPermit"] === "1" || null,
utilityConnection =
realEstatePropertiesFromDescriptions.utilityConnection,
distanceToRiver = null,
numberOfViewsAgency =
numberOfViewsAgencyParts.length > 1
? parseInt(numberOfViewsAgencyParts[1])
: null;
const title = extractedData["re_realEstates_portalName"];
const extractedPrice = parseFloat(
extractedData["re_realEstates_price"]
@@ -258,9 +399,7 @@ class RentalCrawler {
);
if (!publishedDateMoment.isValid()) {
throw {
message: `Invalid published date : ${
extractedData["re_realEstates_inserted"]
}`
message: `Invalid published date : ${extractedData["re_realEstates_inserted"]}`
};
}
@@ -271,14 +410,10 @@ class RentalCrawler {
);
if (!renewedDateMoment.isValid()) {
throw {
message: `Invalid renewed date : ${
extractedData["re_realEstates_edited"]
}`
message: `Invalid renewed date : ${extractedData["re_realEstates_edited"]}`
};
}
const adStatus = AD_STATUS.STATUS_NORMAL;
const data = {
url,
agencyObjectId: rentalId,
@@ -303,7 +438,42 @@ class RentalCrawler {
locationLong,
adStatus,
publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString()
renewedDate: renewedDateMoment.toISOString(),
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency
};
return data;
@@ -350,6 +520,304 @@ class RentalCrawler {
}
}
getPropertiesFromDescriptions(descriptionIds) {
const result = {
accessRoadType: null,
furnishingType: null,
balcony: null,
elevator: null,
parking: null,
antiTheftDoor: null,
airCondition: null,
videoSurveillance: null,
alarm: null,
pool: null,
urbanPlanPermit: null,
utilityConnection: null,
water: null,
electricity: null
};
for (const descriptionId of descriptionIds) {
switch (descriptionId) {
case 16:
result.furnishingType = FURNISHING_TYPE.NOT_FURNISHED.id;
break;
case 17:
result.furnishingType = FURNISHING_TYPE.HALF_FURNISHED.id;
break;
case 1:
case 28:
result.furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case 14:
result.elevator = true;
break;
case 39:
result.electricity = true;
break;
case 40:
result.water = true;
break;
case 41:
case 58:
result.accessRoadType = ACCESS_ROAD_TYPE.ASPHALT.id;
break;
case 26:
result.balcony = true;
break;
case 62:
result.parking = true;
break;
case 3:
result.antiTheftDoor = true;
break;
case 2:
case 21:
result.airCondition = true;
break;
case 4:
result.alarm = true;
break;
case 55:
result.videoSurveillance = true;
break;
case 9:
result.pool = true;
break;
case 60:
result.urbanPlanPermit = true;
break;
case 38:
result.utilityConnection = true;
break;
}
}
return result;
}
getPropertiesFromSpaces(spaceIds) {
const result = {
balcony: null,
parking: null,
garage: null,
basementAttic: null,
storeRoom: null
};
for (const spaceId of spaceIds) {
switch (spaceId) {
case 36:
case 12:
result.parking = true;
break;
case 1:
case 2:
case 3:
result.balcony = true;
break;
case 4:
case 30:
result.garage = true;
break;
case 9:
case 10:
result.storeRoom = true;
break;
case 18:
case 34:
case 37:
case 27:
result.basementAttic = true;
break;
}
}
return result;
}
getHeatingTypeId(heatingRentalId) {
// heatingRentalId can have multiple values, like: "1, 2, 3", parseInt will take first integer value
const heatingId = parseInt(heatingRentalId);
switch (heatingId) {
case 27:
case 16:
return HEATING_TYPE.GAS.id;
case 4:
return HEATING_TYPE.CENTRAL_GAS.id;
case 3:
case 23:
case 6:
case 7:
case 8:
case 9:
case 10:
return HEATING_TYPE.CENTRAL_BOILER.id;
case 2:
case 13:
case 30:
case 17:
case 29:
case 31:
return HEATING_TYPE.ELECTRICITY.id;
case 24:
case 25:
case 12:
return HEATING_TYPE.CENTRAL_CITY.id;
case 26:
case 21:
case 20:
return HEATING_TYPE.WOOD.id;
case 28:
case 19:
return HEATING_TYPE.HEAT_PUMP.id;
case 14:
case 32:
return HEATING_TYPE.OTHER.id;
default:
return null;
}
}
getPropertiesFromInfrastructure(infrastructureIds) {
const result = {
electricity: null,
water: null,
gas: null,
drainageSystem: null,
phoneConnection: null,
internet: null,
videoSurveillance: null,
cableTV: null
};
for (const infrastructureId of infrastructureIds) {
switch (infrastructureId) {
case 1:
result.electricity = true;
break;
case 2:
result.water = true;
break;
case 4:
result.gas = true;
break;
case 5:
result.drainageSystem = true;
break;
case 7:
case 8:
result.phoneConnection = true;
break;
case 10:
result.internet = true;
break;
case 11:
result.cableTV = true;
break;
case 16:
case 17:
result.videoSurveillance = true;
break;
}
}
return result;
}
getFloorNumberFromFloorId(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
// just extracting floor number from first element
const floorsId = floorsIdText.split(",");
if (floorsId.length === 0) {
return null;
}
const firstFloorId = parseInt(floorsId[0]);
// 1 pod
// 2 sut
// 3 raz
// 4 pri
// 5 vpri
// 6 prv
// 7 dru
// 8 tre
// 9 čet
// 10 man
// 11
// 12 pot
// 13 vpot
// 14 tav
// 15 pet
const floorNumber = [
-1,
-1,
0,
0,
1,
1,
2,
3,
4,
null,
null,
null,
null,
null,
5
];
return floorNumber[firstFloorId - 1] || null;
}
getNumberOfFloorsFromFloorId(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
const floorIds = floorsIdText.split(",");
if (floorIds.length === 0) {
return null;
}
let noOfFloors = floorIds.length;
// Floors of 'suteren', 'podrum', 'tavan' and 'potkrovlje' are not counted
floorIds.forEach(id => {
if (
parseInt(id) === 1 ||
parseInt(id) === 2 ||
parseInt(id) === 12 ||
parseInt(id) === 14
) {
noOfFloors--;
}
});
return noOfFloors;
}
checkBasemAtticFromFloors(floorsIdText) {
// floorIdText can be array of numbers, separated by comma or number
const floorIds = floorsIdText.split(",");
let check = false;
if (floorIds.length === 0) {
check = false;
}
//If floors 'suteren', 'podrum', 'tavan' and 'potkrovlje' exists then tag for basement-attic is true
floorIds.forEach(id => {
if (
parseInt(id) === 1 ||
parseInt(id) === 2 ||
parseInt(id) === 12 ||
parseInt(id) === 14
) {
check = true;
}
});
return check;
}
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -362,7 +830,7 @@ class RentalCrawler {
// }
//For now, we use only Postgres saver, so ...
return await savers[0].save(results);
return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}

View File

@@ -0,0 +1,630 @@
"use strict";
const fetch = require("node-fetch");
const cheerio = require("cheerio");
const moment = require("moment-timezone");
const {
AD_TYPE,
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE,
FURNISHING_TYPE,
HEATING_TYPE
} = require("../../common/enums");
const {
PRINT_CRAWLER_DEBUG,
DEFAULT_TIMEZONE
} = require("../../config/appConfig");
const { SALJIC_FORCE_CRAWL } = require("../specificConfigs/saljic");
const SALJIC_ENUMS = {
SALJIC_AD_TYPE: {
[CRAWLER_AD_TYPE.ALL]: "&input_vrsta=",
[CRAWLER_AD_TYPE.ONLY_SELL]: "&input_vrsta=1",
[CRAWLER_AD_TYPE.ONLY_RENT]: "&input_vrsta=2"
},
SALJIC_AD_CATEGORY: {
[AD_CATEGORY.ALL.id]: "&input_kategorija=",
[AD_CATEGORY.FLAT.id]: "&input_kategorija=15",
[AD_CATEGORY.HOUSE.id]: "&input_kategorija=9",
[AD_CATEGORY.LAND.id]: "&input_kategorija=5", //3 and 4 also gradjevinsko
[AD_CATEGORY.OFFICE.id]: "&input_kategorija=8",
[AD_CATEGORY.APARTMENT.id]: "&input_kategorija=1",
[AD_CATEGORY.GARAGE.id]: "&input_kategorija=2"
//[AD_CATEGORY.COTTAGE.id]: ""
}
};
class SaljicCrawler {
constructor(
savers = [],
crawlerAdTypes = CRAWLER_AD_TYPE.ALL,
crawlerAdCategories = [AD_CATEGORY.FLAT, AD_CATEGORY.HOUSE],
maxPages = 5000,
maxResultsPerPage = 5000,
ignoredUsernames = [],
delayBetweenPages = 1000
) {
this.savers = savers;
this.baseUrl = "https://www.saljicnekretnine.ba/v2/nekretnine_search";
this.crawlerAdTypes = crawlerAdTypes;
this.crawlerAdCategories = crawlerAdCategories;
this.maxResultsPerPage = maxResultsPerPage;
this.delayBetweenPages = delayBetweenPages;
}
async crawl() {
const crawlAdCategories = this.crawlerAdCategories;
const newRealEstates = [];
if (crawlAdCategories) {
const indexGenerators = [];
for (const adCategory of crawlAdCategories) {
indexGenerators.push(this.categoryIndexer(adCategory));
}
//
//console.log(indexGenerators);
//
let done = false;
while (!done) {
const categoryIndexerPromises = [];
const generatorsToRemove = [];
for (const indexGenerator of indexGenerators) {
categoryIndexerPromises.push(indexGenerator.next());
generatorsToRemove.push(false);
}
const singlePageResults = await Promise.all(categoryIndexerPromises);
const entries = singlePageResults.entries();
for (const [index, { value: singlePageResult }] of entries) {
if (singlePageResult) {
const saveResults = await this.saveCrawledResults(singlePageResult);
const { newRecords } = saveResults;
newRealEstates.push(...newRecords);
if (
Array.isArray(newRecords) &&
newRecords.length === 0 &&
!SALJIC_FORCE_CRAWL
) {
generatorsToRemove[index] = true;
}
} else {
//Generator returned undefined, remove this generator from array
generatorsToRemove[index] = true;
// console.log("Generator ", index + 1, "has no more pages");
}
}
// console.log("Generators state : ", generatorsToRemove);
for (let i = generatorsToRemove.length - 1; i >= 0; i--) {
if (generatorsToRemove[i]) {
// console.log("\tRemove generator ", i + 1);
indexGenerators.splice(i, 1);
}
}
if (indexGenerators.length === 0) {
done = true;
}
await this.sleep(this.delayBetweenPages);
}
}
return newRealEstates;
}
async *categoryIndexer(adCategory) {
let pageToIndex = 1;
const urlAdTypePart = SALJIC_ENUMS.SALJIC_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = SALJIC_ENUMS.SALJIC_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
while (true) {
const urlPagePart = pageToIndex === 1 ? "" : (pageToIndex - 1) * 2 * 11;
const urlPageToCrawl = `${this.baseUrl}?order_by=${urlAdTypePart}${urlCategoryPart}&per_page=${urlPagePart}`;
const singlePageResults = await this.indexSinglePage(
urlPageToCrawl,
this.maxResultsPerPage
);
if (Array.isArray(singlePageResults) && singlePageResults.length > 0) {
yield singlePageResults;
} else {
return undefined;
}
++pageToIndex;
if (pageToIndex === this.maxPages) {
return undefined;
}
}
} else {
return undefined;
}
}
async indexSinglePage(url, maxResultsPerPage) {
if (PRINT_CRAWLER_DEBUG) {
console.log("[SALJIC] Index page : ", url);
}
try {
const res = await fetch(url);
const body = await res.text();
const $ = cheerio.load(body);
let hrefs = [];
$("#shop")
.find(".product")
.each((i, elem) => {
const href = $(elem)
.find("a")
.first()
.attr("href");
if (href) {
hrefs.push(href);
}
});
let adTypesTmp = [];
$("#shop")
.find(".product")
.each((i, elem) => {
const adType = $(elem)
.find(".trakica-search-page")
.text()
.trim();
if (adType) {
adTypesTmp.push(adType);
}
});
//Converting to AD_TYPE
const adTypes = adTypesTmp.map(adTypeText => {
return this.getAdTypeId(adTypeText);
});
//Converting to absolute URLs
const hrefsAbs = hrefs.map(link => {
return "https://www.saljicnekretnine.ba" + link;
});
let actualNoOfResults =
hrefsAbs.length <= maxResultsPerPage
? hrefsAbs.length
: maxResultsPerPage;
const asyncScraping = [];
for (let i = 0; i < actualNoOfResults; i++) {
asyncScraping.push(this.scrapeAd(hrefsAbs[i], adTypes[i]));
}
const scrapedData = await Promise.all(asyncScraping);
const filteredScrapedData = scrapedData.filter(adData => !!adData);
return filteredScrapedData;
} catch (e) {
console.error("[SALJIC] Exception caught:" + e);
return [];
}
}
async scrapeAd(url, adType) {
// console.log("[SALJIC] Scraping : ", url);
try {
const adPageSource = await fetch(url);
const body = await adPageSource.text();
const $ = cheerio.load(body);
// No information for status ex. PRODAN
const status = AD_STATUS.STATUS_NORMAL;
//Extracting agency ID from url
const agencyObjectId = parseInt(url.substring(46, url.length));
//Extracting main properties
const propertySelectors = {
title:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-title > h2",
price:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.topmargin-sm.single-product > div.product > div.product-price > ins",
streetName:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > p",
descriptions:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > div.toggle.toggle-bg > div.togglec >p:nth-child(1)",
latAndLong:
"div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > div.gmap.bottommargin > iframe"
};
const title = $(propertySelectors.title)
.text()
.replace(/(\r\n|\n|\r)/gm, "")
.replace(/ {1,}/g, " ")
.trim();
const priceText = $(propertySelectors.price)
.text()
.replace(/(\r\n|\n|\r)/gm, "")
.replace(/ {1,}/g, " ")
.trim();
const price =
priceText === "CIJENA NA UPIT"
? null
: parseFloat(
priceText.substring(8, priceText.length - 3).replace(",", "")
);
const streetName = $(propertySelectors.streetName)
.text()
.replace(/(\r\n|\n|\r)/gm, "")
.trim();
const descriptions = $(propertySelectors.descriptions)
.text()
.replace(/\"/g, "")
.trim();
const latAndLongSrc = $(propertySelectors.latAndLong).attr("src");
const latText = latAndLongSrc.substring(
latAndLongSrc.indexOf("marker=") + 7,
latAndLongSrc.indexOf("%2C", latAndLongSrc.indexOf("marker="))
);
const longText = latAndLongSrc.substring(
latAndLongSrc.indexOf("%2C", latAndLongSrc.indexOf("marker=")) + 3,
latAndLongSrc.length
);
const locationLat = parseFloat(latText) || null;
const locationLong = parseFloat(longText) || null;
//====== DETAIL INFORMATION FIELDS ==========
let area = null,
gardenSize = null,
numberOfRooms = null,
numberOfFloors = null,
floor = null,
accessRoadType = null,
heatingType = null,
furnishingType = null,
balcony = null,
newBuilding = null,
elevator = null,
water = null,
electricity = null,
drainageSystem = null,
registeredInZkBooks = null,
recentlyAdapted = null,
parking = null,
garage = null,
gas = null,
antiTheftDoor = null,
airCondition = null,
phoneConnection = null,
cableTV = null,
internet = null,
basementAttic = null,
storeRoom = null,
videoSurveillance = null,
alarm = null,
suitableForStudents = null,
includingBills = null,
animalsAllowed = null,
pool = null,
exchange = null,
urbanPlanPermit = null,
buildingPermit = null,
utilityConnection = null,
distanceToRiver = null;
let publishedDate = null;
let renewedDate = null;
let realEstateType;
let numberOfViewsAgency = null;
let numberOfViewsKivi = null;
let streetNumber = 0;
let adStatus = status;
let shortDescription = descriptions.substring(
0,
descriptions.indexOf(".")
);
let longDescription = descriptions;
//Extracting data - Glavne karakteristike
let mainFieldIndex = 1;
do {
const mainFieldSelector = `div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > div.col-md-12.bottommargin > ul > li.list-group-item:nth-child(${mainFieldIndex})`;
const mainField = $(mainFieldSelector)
.text()
.replace(/[\n\r\t]/gm, "")
.trim();
const mainFieldTitle = mainField.substring(0, mainField.indexOf(" "));
const mainFieldValue = mainField
.substring(mainField.indexOf(" "), mainField.length)
.trim();
switch (mainFieldTitle) {
case "Površina":
area = parseFloat(
mainFieldValue.substring(0, mainFieldValue.indexOf(" "))
);
break;
case "Okućnica":
gardenSize = parseFloat(
mainFieldValue.substring(0, mainFieldValue.indexOf(" "))
);
break;
case "Broj soba":
numberOfRooms = parseInt(mainFieldValue);
break;
case "Broj spratova":
numberOfFloors = parseInt(mainFieldValue);
break;
case "Sprat":
floor = parseInt(mainFieldValue);
break;
case "Godina renoviranja":
recentlyAdapted = true;
break;
case "Broj parking mjesta":
parking = true;
break;
case "Dostupno od":
const day = mainFieldValue.substring(0, 2);
const month = mainFieldValue.substring(3, 5);
const year = mainFieldValue.substring(6, mainFieldValue.length);
publishedDate = new Date(`${month}/${day}/${year}`);
break;
default:
break;
}
if (mainFieldTitle === "") {
break;
}
mainFieldIndex++;
} while (true);
//Extracting data - Sadrzaji
let additionalFieldIndex = 1;
do {
const additionalFieldSelector = `div.content-wrap > div.container > div.col-md-8.nobottommargin > div.single-post > div.entry > div.entry-content.topmargin > div.col-md-12.bottommargin > ul > li.border-color.col-md-5.col-md-offset-1.col-md-pull-1.list-group-item-bottom:nth-child(${additionalFieldIndex})`;
const additionalField = $(additionalFieldSelector)
.text()
.trim();
if (additionalFieldIndex === 1) {
//Extracting data of real estate type
const categoryTmp = additionalField
.replace(/[\n\r\t]/gm, "")
.substring(
additionalField.indexOf("Kategorija") + 10,
additionalField.length
)
.trim();
realEstateType = this.getAdCategoryId(categoryTmp);
} else {
switch (additionalField) {
case "Internet":
internet = true;
break;
case "Garaža":
garage = true;
break;
case "Klima":
airCondition = true;
break;
case "Balkon":
balcony = true;
break;
case "Ostava":
storeRoom = true;
break;
case "Podrum":
basementAttic = true;
break;
case "Blindirana vrata":
antiTheftDoor = true;
break;
case "Voda":
water = true;
break;
case "Kablovska":
cableTV = true;
break;
case "Uknjiženo":
registeredInZkBooks = true;
break;
case "Grijanje - centralno":
heatingType = HEATING_TYPE.CENTRAL_CITY.id;
break;
case "Grijanje - plin":
heatingType = HEATING_TYPE.GAS.id;
break;
case "Grijanje - struja":
heatingType = HEATING_TYPE.ELECTRICITY.id;
break;
case "Grijanje":
heatingType = HEATING_TYPE.OTHER.id;
break;
case "Plin":
gas = true;
break;
case "Namješten":
furnishingType = FURNISHING_TYPE.FURNISHED.id;
break;
case "Alarm":
alarm = true;
break;
case "Video nadzor":
videoSurveillance = true;
break;
case "Lift":
elevator = true;
break;
case "Novogradnja":
newBuilding = true;
break;
default:
break;
}
}
if (additionalField === "") {
break;
}
additionalFieldIndex++;
} while (true);
//If no published date it takes current date of crawling
if (publishedDate) {
renewedDate = new Date();
} else {
publishedDate = new Date();
renewedDate = new Date();
}
const originAgencyName = AD_AGENCY.SALJIC;
const locality = "";
const municipality = "";
const city = "";
const region = "";
const entity = "";
const country = "";
const data = {
url,
agencyObjectId,
originAgencyName,
realEstateType,
adType,
title,
price,
area,
gardenSize,
shortDescription,
longDescription,
streetNumber,
streetName,
locality,
municipality,
city,
region,
entity,
country,
locationLat,
locationLong,
adStatus,
publishedDate,
renewedDate,
numberOfRooms,
numberOfFloors,
floor,
accessRoadType,
heatingType,
furnishingType,
balcony,
newBuilding,
elevator,
water,
electricity,
drainageSystem,
registeredInZkBooks,
recentlyAdapted,
parking,
garage,
gas,
antiTheftDoor,
airCondition,
phoneConnection,
cableTV,
internet,
basementAttic,
storeRoom,
videoSurveillance,
alarm,
suitableForStudents,
includingBills,
animalsAllowed,
pool,
exchange,
urbanPlanPermit,
buildingPermit,
utilityConnection,
distanceToRiver,
numberOfViewsAgency,
numberOfViewsKivi
};
return data;
} catch (e) {
console.error("Exception caught: " + e.message, "\r\nURL:", url);
}
return null;
}
//======= HELPER FUNCTIONS =============
getAdCategoryId(categoryText) {
switch (categoryText) {
case "Stan":
return AD_CATEGORY.FLAT.id;
case "Građevinsko zemljiste":
return AD_CATEGORY.LAND.id;
case "Industrijsko zemljiste":
return AD_CATEGORY.LAND.id;
case "Poljoprivredno zemljiste":
return AD_CATEGORY.LAND.id;
case "Kuća":
return AD_CATEGORY.HOUSE.id;
case "Poslovni prostor":
return AD_CATEGORY.OFFICE.id;
case "Kancelarije":
return AD_CATEGORY.OFFICE.id;
case "Apartmani":
return AD_CATEGORY.APARTMENT.id;
case "Garaža":
return AD_CATEGORY.GARAGE.id;
case "Vikendica":
return AD_CATEGORY.COTTAGE.id;
default:
return undefined;
}
}
getAdTypeId(adTypeText) {
switch (adTypeText) {
case "PRODAJA":
return AD_TYPE.AD_TYPE_SALE.stringId;
case "NAJAM":
return AD_TYPE.AD_TYPE_RENT.stringId;
default:
return undefined;
}
}
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async saveCrawledResults(results) {
const savers = this.savers;
// for (const saver of savers) {
// await saver.save(results);
// }
//For now, we use only Postgres saver, so ...
return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}
module.exports = SaljicCrawler;

View File

@@ -0,0 +1,20 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const createKiviOriginal = async (kiviAdFields = {}) => {
return await db.KiviOriginal.create(kiviAdFields);
};
const getKiviOriginalById = async id => {
try {
return db.KiviOriginal.findByPk(id);
} catch (error) {
console.log("kiviOriginal.js", error);
return null;
}
};
module.exports = {
createKiviOriginal,
getKiviOriginalById
};

View File

@@ -0,0 +1,51 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const Op = sequelize.Op;
const bulkUpsertKiviPhotos = async kiviPhotosData => {
try {
return await db.KiviOriginalAdsPhotos.bulkCreate(kiviPhotosData, {
ignoreDuplicates: true
});
} catch (e) {
console.log("Error bulk upserting kiviOriginalAdsPhotos : ", e);
}
};
const findPhotosForKiviAd = async id => {
try {
return db.KiviOriginalAdsPhotos.findAll({
where: {
kiviAdId: id
}
});
} catch (error) {
console.log("kiviOriginalAdsPhotos.js", error);
return null;
}
};
const deleteUrlPhotosAfterUpdate = async photoUrlsToKeep => {
//We delete all urls that are not in "newly changed - edited" photo urls array
const deleteQuery = {
photoUrl: {
[Op.notIn]: photoUrlsToKeep
}
};
try {
return db.KiviOriginalAdsPhotos.destroy({
where: deleteQuery
});
} catch (error) {
console.log("kiviOriginalAdsPhotos.js", error);
return null;
}
};
module.exports = {
bulkUpsertKiviPhotos,
findPhotosForKiviAd,
deleteUrlPhotosAfterUpdate
};

View File

@@ -0,0 +1,20 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const bulkUpsertPriceHistory = async priceHistoryData => {
try {
const order = [["realEstateId", "desc"]];
return await db.PriceHistory.bulkCreate(priceHistoryData, {
order,
ignoreDuplicates: true
});
} catch (e) {
console.log("Error bulk upserting priceHistory : ", e);
}
};
module.exports = {
bulkUpsertPriceHistory
};

View File

@@ -2,6 +2,7 @@
const db = require("../../models/index");
const sequelize = require("sequelize");
const Op = sequelize.Op;
const { AD_CATEGORY } = require("../../common/enums");
const bulkUpsertRealEstates = async realEstateData => {
try {
@@ -26,7 +27,42 @@ const bulkUpsertRealEstates = async realEstateData => {
"gardenSize",
"adStatus",
"updatedAt",
"renewedDate"
"renewedDate",
"numberOfRooms",
"numberOfFloors",
"floor",
"accessRoadType",
"heatingType",
"furnishingType",
"balcony",
"newBuilding",
"elevator",
"water",
"electricity",
"drainageSystem",
"registeredInZkBooks",
"recentlyAdapted",
"parking",
"garage",
"gas",
"antiTheftDoor",
"airCondition",
"phoneConnection",
"cableTV",
"internet",
"basementAttic",
"storeRoom",
"videoSurveillance",
"alarm",
"suitableForStudents",
"includingBills",
"animalsAllowed",
"pool",
"urbanPlanPermit",
"buildingPermit",
"utilityConnection",
"distanceToRiver",
"numberOfViewsAgency"
];
const order = [["updatedAt", "desc"]];
@@ -41,9 +77,28 @@ const bulkUpsertRealEstates = async realEstateData => {
};
const getRealEstateById = async id => {
return db.RealEstate.findByPk(id);
try {
return db.RealEstate.findByPk(id);
} catch (error) {
console.log("realEstate.js", error);
return null;
}
};
const createRealEstate = async (realEstateFields = {}) => {
return await db.RealEstate.create(realEstateFields);
};
const findRealEstateByAgencyId = async kiviId => {
try {
return db.RealEstate.findOne({
where: { agencyObjectId: kiviId }
});
} catch (error) {
console.log("realEstate.js", error);
return null;
}
};
const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
const {
priceMin,
@@ -52,9 +107,26 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
sizeMax,
adType,
realEstateType,
areaToSearch
areaToSearch,
gardenSizeMin,
gardenSizeMax,
numberOfRoomsMin,
numberOfRoomsMax,
numberOfFloorsMin,
numberOfFloorsMax,
floorMin,
floorMax,
includeIncompleteAds,
includeWithoutPrice,
balcony,
elevator,
newBuilding,
accessRoadType
} = searchRequest;
//Needed for defining which attribute should exist or not
const realEstateTypeObject = AD_CATEGORY[realEstateType];
const longitudeColumn = sequelize.col("locationLong");
const latitudeColumn = sequelize.col("locationLat");
@@ -81,13 +153,12 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
const geoSearchQueryPart = sequelize.where(contains, true);
//General queries contain only attributes that are defined for every searchreq
//Query for case of complete ads
const query = {
adType,
realEstateType,
price: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
area: {
[Op.lte]: sizeMax,
[Op.gte]: sizeMin
@@ -95,10 +166,202 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
[Op.and]: geoSearchQueryPart
};
//Query for case of incomplete ads
const queryIncludeIncomplete = {
adType,
realEstateType,
area: {
[Op.or]: {
[Op.and]: {
[Op.lte]: sizeMax,
[Op.gte]: sizeMin
},
[Op.is]: null
}
},
[Op.and]: geoSearchQueryPart
};
//Is user unchecked includeWithoutPrice FALSE then it shouldn't return null values of price
//If not then null values are accepted (this is DEFAULT)
//includeIncpompleteAds does not have effect on price query
if (includeWithoutPrice) {
query.price = {
[Op.or]: {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
[Op.is]: null
}
};
queryIncludeIncomplete.price = {
[Op.or]: {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
[Op.is]: null
}
};
} else {
query.price = {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
}
};
queryIncludeIncomplete.price = {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
}
};
}
//Every other attribute is checked separately and included in query only if it is defined for real estate type
if (
realEstateTypeObject.hasGardenSize &&
gardenSizeMax != null &&
gardenSizeMin != null
) {
query.gardenSize = {
[Op.lte]: gardenSizeMax,
[Op.gte]: gardenSizeMin
};
queryIncludeIncomplete.gardenSize = {
[Op.or]: {
[Op.and]: {
[Op.lte]: gardenSizeMax,
[Op.gte]: gardenSizeMin
},
[Op.is]: null
}
};
}
if (
realEstateTypeObject.hasNumberOfRoom &&
numberOfRoomsMin != null &&
numberOfRoomsMax != null
) {
query.numberOfRooms = {
[Op.lte]: numberOfRoomsMax,
[Op.gte]: numberOfRoomsMin
};
queryIncludeIncomplete.numberOfRooms = {
[Op.or]: {
[Op.and]: {
[Op.lte]: numberOfRoomsMax,
[Op.gte]: numberOfRoomsMin
},
[Op.is]: null
}
};
}
if (
realEstateTypeObject.hasNumberOfFloors &&
numberOfFloorsMin != null &&
numberOfFloorsMax != null
) {
query.numberOfFloors = {
[Op.lte]: numberOfFloorsMax,
[Op.gte]: numberOfFloorsMin
};
queryIncludeIncomplete.numberOfFloors = {
[Op.or]: {
[Op.and]: {
[Op.lte]: numberOfFloorsMax,
[Op.gte]: numberOfFloorsMin
},
[Op.is]: null
}
};
}
if (
realEstateTypeObject.hasFloorProp &&
floorMin != null &&
floorMax != null
) {
query.floor = {
[Op.lte]: floorMax,
[Op.gte]: floorMin
};
queryIncludeIncomplete.floor = {
[Op.or]: {
[Op.and]: {
[Op.lte]: floorMax,
[Op.gte]: floorMin
},
[Op.is]: null
}
};
}
//Logic for balcony, newBuilding and elevator from users side
//If true is checked, then I want characteristic to be true but,
//if it is not checked, then I dont care - it can be null or false or true
if (realEstateTypeObject.hasBalconyProp && balcony === true) {
query.balcony = {
[Op.eq]: balcony
};
queryIncludeIncomplete.balcony = {
[Op.or]: {
[Op.eq]: balcony,
[Op.is]: null
}
};
}
if (realEstateTypeObject.hasNewBuildingProp && newBuilding === true) {
query.newBuilding = {
[Op.eq]: newBuilding
};
queryIncludeIncomplete.newBuilding = {
[Op.or]: {
[Op.eq]: newBuilding,
[Op.is]: null
}
};
}
if (realEstateTypeObject.hasElevatorProp && elevator === true) {
query.elevator = {
[Op.eq]: elevator
};
queryIncludeIncomplete.elevator = {
[Op.or]: {
[Op.eq]: elevator,
[Op.is]: null
}
};
}
//If user wants 'ANY' road type acces then it is not included in query -
//returns every road type and null values
if (accessRoadType !== "ANY") {
query.accessRoadType = {
[Op.eq]: accessRoadType
};
queryIncludeIncomplete.accessRoadType = {
[Op.or]: {
[Op.eq]: accessRoadType,
[Op.is]: null
}
};
}
//Query only for real estated that are not deleted
query.deleted = {
[Op.eq]: false
};
queryIncludeIncomplete.deleted = {
[Op.eq]: false
};
const order = [["updatedAt", "desc"]];
return await db.RealEstate.findAll({
where: query,
return db.RealEstate.findAll({
where: includeIncompleteAds ? queryIncludeIncomplete : query,
limit: maxResults,
order
});
@@ -107,5 +370,7 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
module.exports = {
bulkUpsertRealEstates,
getRealEstateById,
findRealEstatesForSearchRequest
createRealEstate,
findRealEstatesForSearchRequest,
findRealEstateByAgencyId
};

View File

@@ -2,11 +2,13 @@
const db = require("../../models/index");
const sequelize = require("sequelize");
const Op = sequelize.Op;
const { AD_CATEGORY } = require("../../common/enums");
const getSearchRequest = async searchRequestId => {
try {
return await db.SearchRequest.findByPk(searchRequestId);
} catch (error) {
console.log("searchrequest.js", error);
return null;
}
};
@@ -22,7 +24,15 @@ const findSearchRequestsForRealEstate = async realEstate => {
adType,
realEstateType,
locationLat,
locationLong
locationLong,
accessRoadType,
balcony,
newBuilding,
elevator,
gardenSize,
numberOfRooms,
numberOfFloors,
floor
} = realEstate;
if (!locationLat || !locationLong) {
@@ -39,32 +49,390 @@ const findSearchRequestsForRealEstate = async realEstate => {
const geoSearchQueryPart = sequelize.where(contains, true);
//Needed for defining which attribute should exist or not
const realEstateTypeObject = AD_CATEGORY[realEstateType];
// ?? Needed to decide on including incomplete RealEstates data
let checkForIncompleteWanted = false;
//Attributes are checked separately to make different query parts
//If real estate price is number then it searches for req that have priceMin and priceMax
//If real estate price is null it searches for req that accept ads without price
//User always defines price and area (sliders) - not null in search req
let priceQuery = {};
if (price != null) {
priceQuery = {
[Op.and]: [
{
priceMin: {
[Op.lte]: price
}
},
{
priceMax: {
[Op.gte]: price
}
}
]
};
} else {
priceQuery = {
includeWithoutPrice: {
[Op.eq]: true
}
};
}
let areaQuery = {};
if (area != null) {
areaQuery = {
[Op.and]: [
{
sizeMin: {
[Op.lte]: area
}
},
{
sizeMax: {
[Op.gte]: area
}
}
]
};
} else {
checkForIncompleteWanted = true;
}
//Other attributes can be defined or not depending on RealEstate type
//we check what to include in query based on real estate type object
let gardenSizeQuery = {};
if (realEstateTypeObject.hasGardenSize) {
if (gardenSize != null) {
gardenSizeQuery = {
[Op.and]: [
{
gardenSizeMin: {
[Op.lte]: gardenSize
}
},
{
gardenSizeMax: {
[Op.gte]: gardenSize
}
}
]
};
} else {
checkForIncompleteWanted = true;
}
}
let numberOfRoomsQuery = {};
if (realEstateTypeObject.hasNumberOfRoom) {
if (numberOfRooms != null) {
//If real estate has defined number of rooms ex. 3 it returns req
// that accepts 3 rooms or ones that don't have defined number - null
//Ex. they didnt choose advanced filters at all
numberOfRoomsQuery = {
[Op.and]: [
{
numberOfRoomsMin: {
[Op.or]: {
[Op.lte]: numberOfRooms,
[Op.is]: null
}
}
},
{
numberOfRoomsMax: {
[Op.or]: {
[Op.gte]: numberOfRooms,
[Op.is]: null
}
}
}
]
};
} else {
// If real estate dont have defined number of rooms ex. null
//It returns requests that didn't choose number of rooms - also null
//Or ones that picked some values but also picked to includeIncomplete ads
numberOfRoomsQuery = {
[Op.or]: [
{
[Op.and]: [
{
numberOfRoomsMin: {
[Op.is]: null
}
},
{
numberOfRoomsMax: {
[Op.is]: null
}
}
]
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
};
}
}
//Same logic for number of Floors and floors
let numberOfFloorsQuery = {};
if (realEstateTypeObject.hasNumberOfFloors) {
if (numberOfFloors != null) {
numberOfFloorsQuery = {
[Op.and]: [
{
numberOfFloorsMin: {
[Op.or]: {
[Op.lte]: numberOfFloors,
[Op.is]: null
}
}
},
{
numberOfFloorsMax: {
[Op.or]: {
[Op.gte]: numberOfFloors,
[Op.is]: null
}
}
}
]
};
} else {
numberOfFloorsQuery = {
[Op.or]: [
{
[Op.and]: [
{
numberOfFloorsMin: {
[Op.is]: null
}
},
{
numberOfFloorsMax: {
[Op.is]: null
}
}
]
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
};
}
}
let floorQuery = {};
if (realEstateTypeObject.hasFloorProp) {
if (floor != null) {
floorQuery = {
[Op.and]: [
{
floorMin: {
[Op.or]: {
[Op.lte]: floor,
[Op.is]: null
}
}
},
{
floorMax: {
[Op.or]: {
[Op.gte]: floor,
[Op.is]: null
}
}
}
]
};
} else {
floorQuery = {
[Op.or]: [
{
[Op.and]: [
{
floorMin: {
[Op.is]: null
}
},
{
floorMax: {
[Op.is]: null
}
}
]
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
};
}
}
//Logic for balcony, newBuilding and elevator
//If user dont check checkbox for ex. elevator it does not mean he only wants no elevator
//If real estate characteristic =true find all req, one that wants charachertistic or dont care - dont need query
//If real estate characteristic = false, find all req exept for ones that wants characteristic to be true
//If real estate characteristic = null, dont know if true or false, find req that dont care or want char and want incomplete ads
let balconyQuery = {};
if (realEstateTypeObject.hasBalconyProp && balcony !== true) {
if (balcony === false) {
balconyQuery = {
balcony: {
[Op.ne]: true
}
};
} else if (balcony === null) {
balconyQuery = {
[Op.or]: [
{
balcony: {
[Op.ne]: true
}
},
{
[Op.and]: [
{
balcony: {
[Op.eq]: true
}
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
}
]
};
}
}
let newBuildingQuery = {};
if (realEstateTypeObject.hasNewBuildingProp && newBuilding !== true) {
if (newBuilding === false) {
newBuildingQuery = {
newBuilding: {
[Op.ne]: true
}
};
} else if (newBuilding === null) {
newBuildingQuery = {
[Op.or]: [
{
newBuilding: {
[Op.ne]: true
}
},
{
[Op.and]: [
{
newBuilding: {
[Op.eq]: true
}
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
}
]
};
}
}
let elevatorQuery = {};
if (realEstateTypeObject.hasElevatorProp && elevator !== true) {
if (elevator === false) {
elevatorQuery = {
elevator: {
[Op.ne]: true
}
};
} else if (elevator === null) {
elevatorQuery = {
[Op.or]: [
{
elevator: {
[Op.ne]: true
}
},
{
[Op.and]: [
{
elevator: {
[Op.eq]: true
}
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
}
]
};
}
}
//General query consists of each individual query
const query = {
adType,
realEstateType,
subscribed: true,
[Op.and]: geoSearchQueryPart
[Op.and]: [
geoSearchQueryPart,
priceQuery,
areaQuery,
gardenSizeQuery,
numberOfRoomsQuery,
numberOfFloorsQuery,
floorQuery,
balconyQuery,
newBuildingQuery,
elevatorQuery
]
};
if (price) {
query.priceMin = {
[Op.lte]: price
//AccessRoadType is defined - should exists for each ad and estate type
if (accessRoadType != null) {
query.accessRoadType = {
[Op.or]: {
[Op.like]: "ANY",
[Op.eq]: accessRoadType
}
};
query.priceMax = {
[Op.gte]: price
} else {
//Null values are returned for user request that wanted ANY acces road type
query.accessRoadType = {
[Op.eq]: "ANY"
};
}
//Tag to check if incomplete ads are accepted in query
if (checkForIncompleteWanted) {
query.includeIncompleteAds = {
[Op.eq]: true
};
}
if (area) {
query.sizeMin = {
[Op.lte]: area
};
query.sizeMax = {
[Op.gte]: area
};
}
return await db.SearchRequest.findAll({ where: query });
return await db.SearchRequest.findAll({
where: query
});
};
module.exports = {

View File

@@ -1,5 +1,8 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const Op = sequelize.Op;
const { CHECK_UP_DAYS } = require("../../config/appConfig");
const findRealEstatesForSearchRequest = async searchRequestId => {
const query = {
@@ -40,6 +43,42 @@ const findNotNotifiedMatches = async () => {
return matchingRecords;
};
const findAllRequestsForCheckUp = async () => {
//First we find IDs of search request that don't need to be emailed for check up - to EXCLUDE
//The ones that received notification for real estate CHECK_UP_DAYS days from now
const date = new Date();
const checkUpDate = date.getDate() - CHECK_UP_DAYS;
date.setDate(checkUpDate);
const dateQuery = {
createdAt: {
[Op.gte]: date
}
};
const excludedMatches = await db.SearchRequestMatch.findAll({
attributes: ["searchRequestId"],
where: dateQuery,
order: [["searchRequestId", "ASC"]]
});
const excludedRequestsAll = excludedMatches.map(match => {
return match.dataValues.searchRequestId;
});
//Removing duplicate search request id-s for optimization
const excludedRequests = [...new Set(excludedRequestsAll)];
const query = {
subscribed: true,
id: {
[Op.notIn]: excludedRequests
}
};
const allRequestsForCheckUp = await db.SearchRequest.findAll({
where: query
});
return allRequestsForCheckUp;
};
const addMatches = async matchingRecords => {
return await db.SearchRequestMatch.bulkCreate(matchingRecords, {
@@ -50,5 +89,6 @@ const addMatches = async matchingRecords => {
module.exports = {
findRealEstatesForSearchRequest,
addMatches,
findNotNotifiedMatches
findNotNotifiedMatches,
findAllRequestsForCheckUp
};

View File

@@ -1,10 +1,18 @@
"use strict";
const { MAX_REAL_ESTATES_IN_EMAIL, APP_URL } = require("../config/appConfig");
const { AD_CATEGORY } = require("../common/enums");
const {
MAX_REAL_ESTATES_IN_EMAIL,
APP_URL,
STAGING
} = require("../config/appConfig");
const { AD_CATEGORY, AD_TYPE, EMAIL_FREQUENCY } = require("../common/enums");
const generateEmailFooter = searchRequestId => {
return `<div>Ako želite prestati dobijati obavještenja za ovu pretragu, <a href="${APP_URL}/odjava/${searchRequestId}">odjavite ovdje</a></div>
//Tag to recognize staging from development
const stagingTag = STAGING ? "[STAGING] " : "";
const generateEmailFooter = (searchRequestId, emailFrequencyTitle) => {
return ` <div>Trenutno ste prijavljeni da obavještenja o novim nekretninama primate <strong>${emailFrequencyTitle.toLowerCase()} </strong>.</div>
<div>Ako želite prestati dobijati obavještenja za ovu pretragu, <a href="${APP_URL}/odjava/${searchRequestId}">odjavite ovdje</a></div>
<div>Ako želite pogledati ili promijeniti uslove za ovu pretragu, <a href="${APP_URL}/pregled/${searchRequestId}">pogledajte ovdje</a></div>
<br/>
<strong>Vaš,<br/>Kivi tim</strong>`;
@@ -23,17 +31,24 @@ const generateRealEstateLinks = realEstates => {
const generateNotificationEmail = (
realEstates,
searchRequestId,
noAllRealEstates,
dailyNotification = false
) => {
const truncateList = realEstates.length > MAX_REAL_ESTATES_IN_EMAIL;
const realEstatesToShow = truncateList
? realEstates.slice(0, MAX_REAL_ESTATES_IN_EMAIL)
: realEstates;
const allRealEstatesLink = `${APP_URL}/nekretnine/${searchRequestId}`;
const emailFrequencyTitle = dailyNotification
? EMAIL_FREQUENCY.DAILY.title
: EMAIL_FREQUENCY.ASAP.title;
const realEstateLinks = generateRealEstateLinks(realEstatesToShow);
const moreRealEstates = `<div>Kompletan spisak nekretnina možete pogledati na <a href="${allRealEstatesLink}">listi nekretnina</a><div>`;
const emailFooter = generateEmailFooter(searchRequestId);
const moreRealEstates = `<div>Kompletan spisak nekretnina (${noAllRealEstates}) možete pogledati na <a href="${allRealEstatesLink}">listi nekretnina</a><div>`;
const emailFooter = generateEmailFooter(searchRequestId, emailFrequencyTitle);
const asapMessageBody =
realEstates.length > 1
? "Pronašli smo nekretnine koje odgovaraju Vašoj pretrazi"
@@ -46,7 +61,7 @@ const generateNotificationEmail = (
const messageBody = dailyNotification ? dailyMessageBody : asapMessageBody;
return `<h3>Zdravo</h3>
return `<h3>${stagingTag}Zdravo</h3>
<h4>${messageBody}</h4>
<div>
${realEstateLinks}
@@ -59,6 +74,28 @@ const generateNotificationEmail = (
const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
const realEstateType = AD_CATEGORY[searchRequest.realEstateType];
let adTypeTitle = "";
switch (searchRequest.adType) {
case AD_TYPE.AD_TYPE_SALE.stringId:
adTypeTitle = AD_TYPE.AD_TYPE_SALE.title;
break;
case AD_TYPE.AD_TYPE_RENT.stringId:
adTypeTitle = AD_TYPE.AD_TYPE_RENT.title;
break;
default:
adTypeTitle = "-";
break;
}
let emailFrequencyTitle;
switch (searchRequest.emailFrequency) {
case EMAIL_FREQUENCY.ASAP.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.ASAP.title;
break;
case EMAIL_FREQUENCY.DAILY.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.DAILY.title;
break;
}
const {
id,
gardenSizeMin,
@@ -70,6 +107,7 @@ const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
} = searchRequest;
const realEstateLinks = generateRealEstateLinks(matchingRealEstates);
const instantRealEstatesText = `<br/>
<div>
U međuvremenu pogledajte neke od nedavno objavljenih nekretnina koje odgovaraju Vašim uslovima pretrage :<br/>
@@ -80,13 +118,14 @@ const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
? `<div><strong>Kvadratura okućnice: Od ${gardenSizeMin} do ${gardenSizeMax} m2</strong></div>`
: ``;
const emailFooter = generateEmailFooter(id);
const emailFooter = generateEmailFooter(id, emailFrequencyTitle);
return `<h3>Zdravo</h3>
return `<h3>${stagingTag}Zdravo</h3>
<div>Naručili ste da Vam javimo ako se nekretnina sa navedenim uslovima pojavi u oglasima:</div>
<br/>
<div>
<div><strong>Tip nekretnine: </strong>${realEstateType.title}</div>
<div><strong>Vrsta oglasa: </strong>${adTypeTitle}</div>
<div><strong>Kvadratura nekretnine:</strong> Od ${sizeMin} do ${sizeMax} m2</div>
${gardenSize}
<div><strong>Cijena:</strong> ${priceMin} do ${priceMax} KM</div>
@@ -98,7 +137,7 @@ const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
const generateEmailSubject = (numberOfRealEstates, singleRealEstateTitle) => {
if (numberOfRealEstates === 1) {
return `Kivi: ${singleRealEstateTitle}`;
return `${stagingTag}Kivi: ${singleRealEstateTitle}`;
}
const leastSignificantDigit = numberOfRealEstates % 10;
@@ -106,7 +145,7 @@ const generateEmailSubject = (numberOfRealEstates, singleRealEstateTitle) => {
const secondLeastSignificantDigit = numberWithoutLastDigit % 10;
if (leastSignificantDigit === 1 && secondLeastSignificantDigit !== 1) {
return `Kivi : ${numberOfRealEstates} nova nekretnina`;
return `${stagingTag}Kivi : ${numberOfRealEstates} nova nekretnina`;
}
if (
@@ -114,14 +153,101 @@ const generateEmailSubject = (numberOfRealEstates, singleRealEstateTitle) => {
leastSignificantDigit <= 4 &&
secondLeastSignificantDigit !== 1
) {
return `Kivi: ${numberOfRealEstates} nove nekretnine`;
return `${stagingTag}Kivi: ${numberOfRealEstates} nove nekretnine`;
}
return `Kivi: ${numberOfRealEstates} novih nekretnina`;
return `${stagingTag}Kivi: ${numberOfRealEstates} novih nekretnina`;
};
const generateCheckUpEmail = searchRequest => {
const realEstateType = AD_CATEGORY[searchRequest.realEstateType];
const {
id,
gardenSizeMin,
gardenSizeMax,
sizeMin,
sizeMax,
priceMin,
priceMax
} = searchRequest;
let emailFrequencyTitle;
switch (searchRequest.emailFrequency) {
case EMAIL_FREQUENCY.ASAP.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.ASAP.title;
break;
case EMAIL_FREQUENCY.DAILY.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.DAILY.title;
break;
}
const gardenSize = realEstateType.hasGardenSize
? `<div><strong>Kvadratura okućnice: Od ${gardenSizeMin} do ${gardenSizeMax} m2</strong></div>`
: ``;
const emailFooter = generateEmailFooter(id, emailFrequencyTitle);
return `<h3>${stagingTag}Zdravo</h3>
<div><strong>Kivi tim traži nekretnine za Vas i kada to ne vidite.</strong></div>
<br />
<div>Vaša trenutno aktivna pretraga je:</div>
<br/>
<div>
<div><strong>Tip nekretnine: </strong>${realEstateType.title}</div>
<div><strong>Kvadratura nekretnine:</strong> Od ${sizeMin} do ${sizeMax} m2</div>
${gardenSize}
<div><strong>Cijena:</strong> ${priceMin} do ${priceMax} KM</div>
</div>
<br/>
${emailFooter}`;
};
const generateNewAdPublishEmail = (
realEstate,
kiviOriginal,
editingRealEstate,
numberOfMatchingRequests
) => {
let countingPrefix;
if (
numberOfMatchingRequests === 2 ||
numberOfMatchingRequests === 3 ||
numberOfMatchingRequests === 4
) {
countingPrefix = "postoje";
} else {
countingPrefix = "postoji";
}
let countingSufix;
if (numberOfMatchingRequests % 10 === 1 && numberOfMatchingRequests !== 11) {
countingSufix = "zahtjev";
} else {
countingSufix = "zahtjeva";
}
const successIntro = editingRealEstate
? `<div>Uspješno ste izmijenili oglas za Vašu nekretninu na <strong>Kivi.ba.</strong><div>`
: `<div>Uspješno ste objavili oglas za Vašu nekretninu na <strong>Kivi.ba.</strong><div>`;
return `<h3>${stagingTag}Zdravo</h3>
${successIntro}
<br/>
<div>U Kivi bazi trenutno ${countingPrefix} ${numberOfMatchingRequests} ${countingSufix} za nekretninom kao sto je Vaša.</div>
<br />
<div>Pregledajte Vaš oglas na sljedećem linku: <a href="${realEstate.url}" rel="noreferrer">${realEstate.title}</a><div>
<br/>
<div>Ako želite izmijeniti detalje oglasa, <a href="${APP_URL}/podacionekretnini/${kiviOriginal.kiviAdId}">izmjenite ovdje.</a></div>
<div>Ako želite izbrisati Vaš oglas iz Kivi baze, <a href="${APP_URL}/obrisioglas/${kiviOriginal.kiviAdId}">izbrišite ovdje.</a></div>
<br/>
<div>Hvala na ukazanom povjerenju!</div>
<br/>
<strong>Vaš,<br/>Kivi tim</strong>`;
};
module.exports = {
generateNotificationEmail,
generateNewSearchRequestEmail,
generateEmailSubject
generateEmailSubject,
generateCheckUpEmail,
generateNewAdPublishEmail
};

View File

@@ -1,4 +1,7 @@
const { getSearchRequest } = require("./db/searchRequest");
const { getRealEstateById } = require("./db/realEstate");
const { getKiviOriginalById } = require("./db/kiviOriginal");
const validator = require("validator");
const currentSearchRequest = async req => {
const searchRequestId =
@@ -7,6 +10,23 @@ const currentSearchRequest = async req => {
return await getSearchRequest(searchRequestId);
};
module.exports = {
currentSearchRequest
const currentRealEstate = async req => {
const realEstateId = req && req.params ? req.params["realEstateId"] : null;
if (!realEstateId) return null;
return await getRealEstateById(parseInt(realEstateId));
};
const currentKiviRealEstate = async req => {
const kiviRealEstateId =
req && req.params ? req.params["kiviRealEstateId"] : null;
if (!kiviRealEstateId || !validator.isUUID(kiviRealEstateId)) return null;
return await getKiviOriginalById(kiviRealEstateId);
};
module.exports = {
currentSearchRequest,
currentRealEstate,
currentKiviRealEstate
};

View File

@@ -0,0 +1,163 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("RealEstates", "numberOfRooms", {
type: Sequelize.REAL
}),
queryInterface.addColumn("RealEstates", "numberOfFloors", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("RealEstates", "floor", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("RealEstates", "accessRoadType", {
type: Sequelize.TEXT
}),
queryInterface.addColumn("RealEstates", "heatingType", {
type: Sequelize.TEXT
}),
queryInterface.addColumn("RealEstates", "furnishingType", {
type: Sequelize.TEXT
}),
queryInterface.addColumn("RealEstates", "balcony", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "newBuilding", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "elevator", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "water", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "electricity", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "drainageSystem", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "registeredInZkBooks", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "recentlyAdapted", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "parking", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "garage", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "gas", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "antiTheftDoor", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "airCondition", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "phoneConnection", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "cableTV", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "internet", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "basementAttic", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "storeRoom", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "videoSurveillance", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "alarm", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "suitableForStudents", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "includingBills", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "animalsAllowed", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "pool", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "exchange", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "urbanPlanPermit", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "buildingPermit", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "utilityConnection", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("RealEstates", "distanceToRiver", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("RealEstates", "numberOfViewsAgency", {
type: Sequelize.INTEGER,
defaultValue: 0
}),
queryInterface.addColumn("RealEstates", "numberOfViewsKivi", {
type: Sequelize.INTEGER,
defaultValue: 0
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("RealEstates", "numberOfRooms"),
queryInterface.removeColumn("RealEstates", "numberOfFloors"),
queryInterface.removeColumn("RealEstates", "floor"),
queryInterface.removeColumn("RealEstates", "accessRoadType"),
queryInterface.removeColumn("RealEstates", "heatingType"),
queryInterface.removeColumn("RealEstates", "furnishingType"),
queryInterface.removeColumn("RealEstates", "balcony"),
queryInterface.removeColumn("RealEstates", "newBuilding"),
queryInterface.removeColumn("RealEstates", "elevator"),
queryInterface.removeColumn("RealEstates", "water"),
queryInterface.removeColumn("RealEstates", "electricity"),
queryInterface.removeColumn("RealEstates", "drainageSystem"),
queryInterface.removeColumn("RealEstates", "registeredInZkBooks"),
queryInterface.removeColumn("RealEstates", "recentlyAdapted"),
queryInterface.removeColumn("RealEstates", "parking"),
queryInterface.removeColumn("RealEstates", "garage"),
queryInterface.removeColumn("RealEstates", "gas"),
queryInterface.removeColumn("RealEstates", "antiTheftDoor"),
queryInterface.removeColumn("RealEstates", "airCondition"),
queryInterface.removeColumn("RealEstates", "phoneConnection"),
queryInterface.removeColumn("RealEstates", "cableTV"),
queryInterface.removeColumn("RealEstates", "internet"),
queryInterface.removeColumn("RealEstates", "basementAttic"),
queryInterface.removeColumn("RealEstates", "storeRoom"),
queryInterface.removeColumn("RealEstates", "videoSurveillance"),
queryInterface.removeColumn("RealEstates", "alarm"),
queryInterface.removeColumn("RealEstates", "suitableForStudents"),
queryInterface.removeColumn("RealEstates", "includingBills"),
queryInterface.removeColumn("RealEstates", "animalsAllowed"),
queryInterface.removeColumn("RealEstates", "pool"),
queryInterface.removeColumn("RealEstates", "exchange"),
queryInterface.removeColumn("RealEstates", "urbanPlanPermit"),
queryInterface.removeColumn("RealEstates", "buildingPermit"),
queryInterface.removeColumn("RealEstates", "utilityConnection"),
queryInterface.removeColumn("RealEstates", "distanceToRiver"),
queryInterface.removeColumn("RealEstates", "numberOfViewsAgency"),
queryInterface.removeColumn("RealEstates", "numberOfViewsKivi")
]);
}
};

View File

@@ -0,0 +1,64 @@
"use strict";
const { ACCESS_ROAD_TYPE, HEATING_TYPE } = require("../common/enums");
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("SearchRequests", "includeIncompleteAds", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("SearchRequests", "balcony", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("SearchRequests", "newBuilding", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("SearchRequests", "elevator", {
type: Sequelize.BOOLEAN
}),
queryInterface.addColumn("SearchRequests", "numberOfRoomsMin", {
type: Sequelize.REAL
}),
queryInterface.addColumn("SearchRequests", "numberOfRoomsMax", {
type: Sequelize.REAL
}),
queryInterface.addColumn("SearchRequests", "numberOfFloorsMin", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("SearchRequests", "numberOfFloorsMax", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("SearchRequests", "floorMin", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("SearchRequests", "floorMax", {
type: Sequelize.INTEGER
}),
queryInterface.addColumn("SearchRequests", "accessRoadType", {
type: Sequelize.TEXT,
defaultValue: ACCESS_ROAD_TYPE.ANY.id
}),
queryInterface.addColumn("SearchRequests", "heatingType", {
type: Sequelize.TEXT,
defaultValue: HEATING_TYPE.ANY.id
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("SearchRequests", "includeIncompleteAds"),
queryInterface.removeColumn("SearchRequests", "balcony"),
queryInterface.removeColumn("SearchRequests", "newBuilding"),
queryInterface.removeColumn("SearchRequests", "elevator"),
queryInterface.removeColumn("SearchRequests", "numberOfRoomsMin"),
queryInterface.removeColumn("SearchRequests", "numberOfRoomsMax"),
queryInterface.removeColumn("SearchRequests", "numberOfFloorsMin"),
queryInterface.removeColumn("SearchRequests", "numberOfFloorsMax"),
queryInterface.removeColumn("SearchRequests", "floorMin"),
queryInterface.removeColumn("SearchRequests", "floorMax"),
queryInterface.removeColumn("SearchRequests", "accessRoadType"),
queryInterface.removeColumn("SearchRequests", "heatingType")
]);
}
};

View File

@@ -0,0 +1,42 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
const tableFields = {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
allowNull: false,
primaryKey: true
},
realEstateId: {
type: Sequelize.BIGINT,
allowNull: false,
unique: "uniquePriceRealEstate",
references: {
model: "RealEstates",
key: "id"
},
onUpdate: "CASCADE",
onDelete: "SET NULL"
},
price: {
type: Sequelize.REAL,
unique: "uniquePriceRealEstate"
},
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
}
};
return queryInterface.createTable("PriceHistory", tableFields);
},
down: queryInterface => {
return queryInterface.dropTable("PriceHistory", {});
}
};

View File

@@ -0,0 +1,10 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) =>
queryInterface.addConstraint("PriceHistory", ["realEstateId", "price"], {
type: "unique",
name: "uniquePriceRealEstate"
}),
down: queryInterface =>
queryInterface.removeConstraint("PriceHistory", "uniquePriceRealEstate")
};

View File

@@ -0,0 +1,14 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("SearchRequests", "includeWithoutPrice", {
type: Sequelize.BOOLEAN,
defaultValue: true
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("SearchRequests", "includeWithoutPrice");
}
};

View File

@@ -0,0 +1,28 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
const tableFields = {
kiviAdId: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true
},
email: Sequelize.TEXT,
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
}
};
return queryInterface.createTable("KiviOriginal", tableFields);
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable("KiviOriginal", {});
}
};

View File

@@ -0,0 +1,39 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
const tableFields = {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
kiviAdId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "KiviOriginal",
key: "kiviAdId"
}
},
photoUrl: {
type: Sequelize.TEXT,
allowNull: false
},
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
}
};
return queryInterface.createTable("KiviOriginalAdsPhotos", tableFields);
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable("KiviOriginalAdsPhotos", {});
}
};

View File

@@ -0,0 +1,22 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("RealEstates", "deleted", {
type: Sequelize.BOOLEAN,
defaultValue: false
}),
queryInterface.addColumn("RealEstates", "deletedAt", {
type: Sequelize.DATE
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("RealEstates", "deleted"),
queryInterface.removeColumn("RealEstates", "deletedAt")
]);
}
};

View File

@@ -0,0 +1,18 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) =>
queryInterface.addConstraint(
"KiviOriginalAdsPhotos",
["kiviAdId", "photoUrl"],
{
type: "unique",
name: "uniqueKiviAdIdPhoto"
}
),
down: queryInterface =>
queryInterface.removeConstraint(
"KiviOriginalAdsPhotos",
"uniqueKiviAdIdPhoto"
)
};

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = (sequalize, DataTypes) => {
const KiviOriginal = sequalize.define(
"KiviOriginal",
{
kiviAdId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false,
primaryKey: true
},
email: DataTypes.TEXT
},
{
freezeTableName: true
}
);
return KiviOriginal;
};

View File

@@ -0,0 +1,43 @@
"use strict";
module.exports = (sequalize, DataTypes) => {
const KiviOriginalAdsPhotos = sequalize.define(
"KiviOriginalAdsPhotos",
{
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
kiviAdId: {
type: DataTypes.UUID,
allowNull: false,
unique: "uniqueKiviAdIdPhoto",
references: {
model: "KiviOriginal",
key: "kiviAdId"
}
},
photoUrl: {
type: DataTypes.TEXT,
allowNull: false,
unique: "uniqueKiviAdIdPhoto"
}
},
{
freezeTableName: true
}
);
KiviOriginalAdsPhotos.associate = models => {
KiviOriginalAdsPhotos.hasMany(models.KiviOriginal, {
foreignKey: "kiviAdId",
sourceKey: "kiviAdId",
targetKey: "kiviAdId",
as: "kiviOriginal"
});
};
return KiviOriginalAdsPhotos;
};

View File

@@ -0,0 +1,44 @@
"use strict";
module.exports = (sequalize, DataTypes) => {
const PriceHistory = sequalize.define(
"PriceHistory",
{
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
realEstateId: {
type: DataTypes.BIGINT,
allowNull: false,
unique: "uniquePriceRealEstate",
references: {
model: "RealEstates",
key: "id"
},
onUpdate: "CASCADE",
onDelete: "SET NULL"
},
price: {
type: DataTypes.REAL,
unique: "uniquePriceRealEstate"
}
},
{
freezeTableName: true
}
);
PriceHistory.associate = models => {
PriceHistory.hasMany(models.RealEstate, {
foreignKey: "id",
sourceKey: "realEstateId",
targetKey: "id",
as: "realEstates"
});
};
return PriceHistory;
};

View File

@@ -48,7 +48,46 @@ module.exports = (sequelize, DataTypes) => {
longDescription: DataTypes.TEXT,
adStatus: DataTypes.INTEGER,
publishedDate: DataTypes.DATE,
renewedDate: DataTypes.DATE
renewedDate: DataTypes.DATE,
numberOfRooms: DataTypes.INTEGER,
numberOfFloors: DataTypes.INTEGER,
floor: DataTypes.INTEGER,
accessRoadType: DataTypes.TEXT,
heatingType: DataTypes.TEXT,
furnishingType: DataTypes.TEXT,
balcony: DataTypes.BOOLEAN,
newBuilding: DataTypes.BOOLEAN,
elevator: DataTypes.BOOLEAN,
water: DataTypes.BOOLEAN,
electricity: DataTypes.BOOLEAN,
drainageSystem: DataTypes.BOOLEAN,
registeredInZkBooks: DataTypes.BOOLEAN,
recentlyAdapted: DataTypes.BOOLEAN,
parking: DataTypes.BOOLEAN,
garage: DataTypes.BOOLEAN,
gas: DataTypes.BOOLEAN,
antiTheftDoor: DataTypes.BOOLEAN,
airCondition: DataTypes.BOOLEAN,
phoneConnection: DataTypes.BOOLEAN,
cableTV: DataTypes.BOOLEAN,
internet: DataTypes.BOOLEAN,
basementAttic: DataTypes.BOOLEAN,
storeRoom: DataTypes.BOOLEAN,
videoSurveillance: DataTypes.BOOLEAN,
alarm: DataTypes.BOOLEAN,
suitableForStudents: DataTypes.BOOLEAN,
includingBills: DataTypes.BOOLEAN,
animalsAllowed: DataTypes.BOOLEAN,
pool: DataTypes.BOOLEAN,
exchange: DataTypes.BOOLEAN,
urbanPlanPermit: DataTypes.BOOLEAN,
buildingPermit: DataTypes.BOOLEAN,
utilityConnection: DataTypes.BOOLEAN,
distanceToRiver: DataTypes.INTEGER,
numberOfViewsAgency: DataTypes.INTEGER,
numberOfViewsKivi: DataTypes.INTEGER,
deleted: DataTypes.BOOLEAN,
deletedAt: DataTypes.DATE
});
return RealEstate;

View File

@@ -15,7 +15,15 @@ module.exports = (sequelize, DataTypes) => {
allowNull: false,
defaultValue: {
type: "Polygon",
coordinates: [[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]],
coordinates: [
[
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
]
],
crs: { type: "name", properties: { name: "EPSG:4326" } }
}
},
@@ -69,7 +77,20 @@ module.exports = (sequelize, DataTypes) => {
},
deletedEmail: {
type: DataTypes.TEXT
}
},
includeIncompleteAds: DataTypes.BOOLEAN,
includeWithoutPrice: DataTypes.BOOLEAN,
balcony: DataTypes.BOOLEAN,
elevator: DataTypes.BOOLEAN,
newBuilding: DataTypes.BOOLEAN,
numberOfRoomsMin: DataTypes.REAL,
numberOfRoomsMax: DataTypes.REAL,
numberOfFloorsMin: DataTypes.INTEGER,
numberOfFloorsMax: DataTypes.INTEGER,
floorMin: DataTypes.INTEGER,
floorMax: DataTypes.INTEGER,
accessRoadType: DataTypes.TEXT,
heatingType: DataTypes.TEXT
});
return SearchRequest;

View File

@@ -0,0 +1,6 @@
"use strict";
const { checkUpNotify } = require("../services/notificationService");
//For testing pursposes
(async () => {
await checkUpNotify();
})();

7
app/public/dropzone-5.7.0/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
build
components
node_modules
.DS_Store
.sass-cache
_site
_config.yaml

View File

@@ -0,0 +1,52 @@
{
"files": [
{
"name": "src/dropzone.coffee",
"regexs": [
"Dropzone.version = \"###\""
]
},
{
"name": "dist/dropzone.js",
"regexs": [
"version = \"###\""
]
},
{
"name": "dist/min/dropzone.min.js",
"regexs": [
"version=\"###\""
]
},
{
"name": "dist/dropzone-amd-module.js",
"regexs": [
"version = \"###\""
]
},
{
"name": "dist/min/dropzone-amd-module.min.js",
"regexs": [
"version=\"###\""
]
},
{
"name": "package.json",
"regexs": [
"\"version\": \"###\""
]
},
{
"name": "component.json",
"regexs": [
"\"version\": \"###\""
]
},
{
"name": "bower.json",
"regexs": [
"\"version\": \"###\""
]
}
]
}

View File

@@ -0,0 +1,6 @@
Contribute
==========
DO NOT CREATE PULL REQUESTS ON GITHUB!
I will simply close them. If you want to contribute, please use [gitlab.com](https://gitlab.com/meno/dropzone) instead.

View File

@@ -0,0 +1,12 @@
License
(The MIT License)
Copyright (c) 2012 Matias Meno <m@tias.me>
Logo & Website Design (c) 2015 "1910" www.weare1910.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,36 @@
<img alt="Dropzone.js" src="http://www.dropzonejs.com/images/new-logo.svg" />
Dropzone.js is a light weight JavaScript library that turns an HTML element into a dropzone.
This means that a user can drag and drop a file onto it, and the file gets uploaded to the server via AJAX.
* * *
_If you want support, please use [stackoverflow](http://stackoverflow.com/) with the `dropzone.js` tag and not the
GitHub issues tracker. Only post an issue here if you think you discovered a bug or have a feature request._
* * *
**Please read the [contributing guidelines](CONTRIBUTING.md) before you start working on Dropzone!**
<br>
<div align="center">
<a href="https://gitlab.com/meno/dropzone/builds/artifacts/master/download?job=release"><strong>&gt;&gt; Download &lt;&lt;</strong></a>
</div>
<br>
<br>
This is no longer the official repository for Dropzone. I have switched to [gitlab.com](https://gitlab.com/meno/dropzone)
as the primary location to continue development.
There are multiple reasons why I am switching from GitHub to GitLab, but a few of the reasons are the
issue tracker that GitHub is providing, *drowning* me in issues that I am unable to categorise or prioritize properly,
the lack of proper continuous integration, and build files. I don't want the compiled `.js` files in my repository, and
people regularly commit changes to the compiled files and create pull requests with them.
I will write a blog post soon, that goes into detail about why I am doing the switch.
This repository will still remain, and always host the most up to date versions of dropzone, but only the distribution
files!
MIT License
-----------

View File

@@ -0,0 +1,16 @@
{
"name": "dropzone",
"location": "enyo/dropzone",
"version": "5.7.0",
"description": "Dropzone is an easy to use drag'n'drop library. It supports image previews and shows nice progress bars.",
"homepage": "http://www.dropzonejs.com",
"main": [
"dist/min/dropzone.min.css",
"dist/min/dropzone.min.js"
],
"ignore": [
"*",
"!dist",
"!dist/**/*"
]
}

View File

@@ -0,0 +1,10 @@
{
"name": "dropzone",
"repo": "enyo/dropzone",
"version": "5.7.0",
"description": "Handles drag and drop of files for you.",
"scripts": [ "index.js", "dist/dropzone.js" ],
"styles": [ "dist/basic.css" ],
"dependencies": { },
"license": "MIT"
}

View File

@@ -0,0 +1,18 @@
{
"name": "enyo/dropzone",
"description": "Handles drag and drop of files for you.",
"homepage": "http://www.dropzonejs.com",
"keywords": [
"dragndrop",
"drag and drop",
"file upload",
"upload"
],
"authors": [{
"name": "Matias Meno",
"email": "m@tias.me",
"homepage": "http://www.matiasmeno.com"
}],
"license": "MIT",
"minimum-stability": "dev"
}

View File

@@ -0,0 +1,39 @@
/*
* The MIT License
* Copyright (c) 2012 Matias Meno <m@tias.me>
*/
.dropzone, .dropzone * {
box-sizing: border-box; }
.dropzone {
position: relative; }
.dropzone .dz-preview {
position: relative;
display: inline-block;
width: 120px;
margin: 0.5em; }
.dropzone .dz-preview .dz-progress {
display: block;
height: 15px;
border: 1px solid #aaa; }
.dropzone .dz-preview .dz-progress .dz-upload {
display: block;
height: 100%;
width: 0;
background: green; }
.dropzone .dz-preview .dz-error-message {
color: red;
display: none; }
.dropzone .dz-preview.dz-error .dz-error-message, .dropzone .dz-preview.dz-error .dz-error-mark {
display: block; }
.dropzone .dz-preview.dz-success .dz-success-mark {
display: block; }
.dropzone .dz-preview .dz-error-mark, .dropzone .dz-preview .dz-success-mark {
position: absolute;
display: none;
left: 30px;
top: 30px;
width: 54px;
height: 58px;
left: 50%;
margin-left: -27px; }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,396 @@
/*
* The MIT License
* Copyright (c) 2012 Matias Meno <m@tias.me>
*/
@-webkit-keyframes passing-through {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30%, 70% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); }
100% {
opacity: 0;
-webkit-transform: translateY(-40px);
-moz-transform: translateY(-40px);
-ms-transform: translateY(-40px);
-o-transform: translateY(-40px);
transform: translateY(-40px); } }
@-moz-keyframes passing-through {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30%, 70% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); }
100% {
opacity: 0;
-webkit-transform: translateY(-40px);
-moz-transform: translateY(-40px);
-ms-transform: translateY(-40px);
-o-transform: translateY(-40px);
transform: translateY(-40px); } }
@keyframes passing-through {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30%, 70% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); }
100% {
opacity: 0;
-webkit-transform: translateY(-40px);
-moz-transform: translateY(-40px);
-ms-transform: translateY(-40px);
-o-transform: translateY(-40px);
transform: translateY(-40px); } }
@-webkit-keyframes slide-in {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); } }
@-moz-keyframes slide-in {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); } }
@keyframes slide-in {
0% {
opacity: 0;
-webkit-transform: translateY(40px);
-moz-transform: translateY(40px);
-ms-transform: translateY(40px);
-o-transform: translateY(40px);
transform: translateY(40px); }
30% {
opacity: 1;
-webkit-transform: translateY(0px);
-moz-transform: translateY(0px);
-ms-transform: translateY(0px);
-o-transform: translateY(0px);
transform: translateY(0px); } }
@-webkit-keyframes pulse {
0% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); }
10% {
-webkit-transform: scale(1.1);
-moz-transform: scale(1.1);
-ms-transform: scale(1.1);
-o-transform: scale(1.1);
transform: scale(1.1); }
20% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); } }
@-moz-keyframes pulse {
0% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); }
10% {
-webkit-transform: scale(1.1);
-moz-transform: scale(1.1);
-ms-transform: scale(1.1);
-o-transform: scale(1.1);
transform: scale(1.1); }
20% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); } }
@keyframes pulse {
0% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); }
10% {
-webkit-transform: scale(1.1);
-moz-transform: scale(1.1);
-ms-transform: scale(1.1);
-o-transform: scale(1.1);
transform: scale(1.1); }
20% {
-webkit-transform: scale(1);
-moz-transform: scale(1);
-ms-transform: scale(1);
-o-transform: scale(1);
transform: scale(1); } }
.dropzone, .dropzone * {
box-sizing: border-box; }
.dropzone {
min-height: 150px;
border: 2px solid rgba(0, 0, 0, 0.3);
background: white;
padding: 20px 20px; }
.dropzone.dz-clickable {
cursor: pointer; }
.dropzone.dz-clickable * {
cursor: default; }
.dropzone.dz-clickable .dz-message, .dropzone.dz-clickable .dz-message * {
cursor: pointer; }
.dropzone.dz-started .dz-message {
display: none; }
.dropzone.dz-drag-hover {
border-style: solid; }
.dropzone.dz-drag-hover .dz-message {
opacity: 0.5; }
.dropzone .dz-message {
text-align: center;
margin: 2em 0; }
.dropzone .dz-message .dz-button {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit; }
.dropzone .dz-preview {
position: relative;
display: inline-block;
vertical-align: top;
margin: 16px;
min-height: 100px; }
.dropzone .dz-preview:hover {
z-index: 1000; }
.dropzone .dz-preview:hover .dz-details {
opacity: 1; }
.dropzone .dz-preview.dz-file-preview .dz-image {
border-radius: 20px;
background: #999;
background: linear-gradient(to bottom, #eee, #ddd); }
.dropzone .dz-preview.dz-file-preview .dz-details {
opacity: 1; }
.dropzone .dz-preview.dz-image-preview {
background: white; }
.dropzone .dz-preview.dz-image-preview .dz-details {
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
-ms-transition: opacity 0.2s linear;
-o-transition: opacity 0.2s linear;
transition: opacity 0.2s linear; }
.dropzone .dz-preview .dz-remove {
font-size: 14px;
text-align: center;
display: block;
cursor: pointer;
border: none; }
.dropzone .dz-preview .dz-remove:hover {
text-decoration: underline; }
.dropzone .dz-preview:hover .dz-details {
opacity: 1; }
.dropzone .dz-preview .dz-details {
z-index: 20;
position: absolute;
top: 0;
left: 0;
opacity: 0;
font-size: 13px;
min-width: 100%;
max-width: 100%;
padding: 2em 1em;
text-align: center;
color: rgba(0, 0, 0, 0.9);
line-height: 150%; }
.dropzone .dz-preview .dz-details .dz-size {
margin-bottom: 1em;
font-size: 16px; }
.dropzone .dz-preview .dz-details .dz-filename {
white-space: nowrap; }
.dropzone .dz-preview .dz-details .dz-filename:hover span {
border: 1px solid rgba(200, 200, 200, 0.8);
background-color: rgba(255, 255, 255, 0.8); }
.dropzone .dz-preview .dz-details .dz-filename:not(:hover) {
overflow: hidden;
text-overflow: ellipsis; }
.dropzone .dz-preview .dz-details .dz-filename:not(:hover) span {
border: 1px solid transparent; }
.dropzone .dz-preview .dz-details .dz-filename span, .dropzone .dz-preview .dz-details .dz-size span {
background-color: rgba(255, 255, 255, 0.4);
padding: 0 0.4em;
border-radius: 3px; }
.dropzone .dz-preview:hover .dz-image img {
-webkit-transform: scale(1.05, 1.05);
-moz-transform: scale(1.05, 1.05);
-ms-transform: scale(1.05, 1.05);
-o-transform: scale(1.05, 1.05);
transform: scale(1.05, 1.05);
-webkit-filter: blur(8px);
filter: blur(8px); }
.dropzone .dz-preview .dz-image {
border-radius: 20px;
overflow: hidden;
width: 120px;
height: 120px;
position: relative;
display: block;
z-index: 10; }
.dropzone .dz-preview .dz-image img {
display: block; }
.dropzone .dz-preview.dz-success .dz-success-mark {
-webkit-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
-moz-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
-ms-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
-o-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); }
.dropzone .dz-preview.dz-error .dz-error-mark {
opacity: 1;
-webkit-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
-moz-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
-ms-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
-o-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); }
.dropzone .dz-preview .dz-success-mark, .dropzone .dz-preview .dz-error-mark {
pointer-events: none;
opacity: 0;
z-index: 500;
position: absolute;
display: block;
top: 50%;
left: 50%;
margin-left: -27px;
margin-top: -27px; }
.dropzone .dz-preview .dz-success-mark svg, .dropzone .dz-preview .dz-error-mark svg {
display: block;
width: 54px;
height: 54px; }
.dropzone .dz-preview.dz-processing .dz-progress {
opacity: 1;
-webkit-transition: all 0.2s linear;
-moz-transition: all 0.2s linear;
-ms-transition: all 0.2s linear;
-o-transition: all 0.2s linear;
transition: all 0.2s linear; }
.dropzone .dz-preview.dz-complete .dz-progress {
opacity: 0;
-webkit-transition: opacity 0.4s ease-in;
-moz-transition: opacity 0.4s ease-in;
-ms-transition: opacity 0.4s ease-in;
-o-transition: opacity 0.4s ease-in;
transition: opacity 0.4s ease-in; }
.dropzone .dz-preview:not(.dz-processing) .dz-progress {
-webkit-animation: pulse 6s ease infinite;
-moz-animation: pulse 6s ease infinite;
-ms-animation: pulse 6s ease infinite;
-o-animation: pulse 6s ease infinite;
animation: pulse 6s ease infinite; }
.dropzone .dz-preview .dz-progress {
opacity: 1;
z-index: 1000;
pointer-events: none;
position: absolute;
height: 16px;
left: 50%;
top: 50%;
margin-top: -8px;
width: 80px;
margin-left: -40px;
background: rgba(255, 255, 255, 0.9);
-webkit-transform: scale(1);
border-radius: 8px;
overflow: hidden; }
.dropzone .dz-preview .dz-progress .dz-upload {
background: #333;
background: linear-gradient(to bottom, #666, #444);
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 0;
-webkit-transition: width 300ms ease-in-out;
-moz-transition: width 300ms ease-in-out;
-ms-transition: width 300ms ease-in-out;
-o-transition: width 300ms ease-in-out;
transition: width 300ms ease-in-out; }
.dropzone .dz-preview.dz-error .dz-error-message {
display: block; }
.dropzone .dz-preview.dz-error:hover .dz-error-message {
opacity: 1;
pointer-events: auto; }
.dropzone .dz-preview .dz-error-message {
pointer-events: none;
z-index: 1000;
position: absolute;
display: block;
display: none;
opacity: 0;
-webkit-transition: opacity 0.3s ease;
-moz-transition: opacity 0.3s ease;
-ms-transition: opacity 0.3s ease;
-o-transition: opacity 0.3s ease;
transition: opacity 0.3s ease;
border-radius: 8px;
font-size: 13px;
top: 130px;
left: -10px;
width: 140px;
background: #be2626;
background: linear-gradient(to bottom, #be2626, #a92222);
padding: 0.5em 1.2em;
color: white; }
.dropzone .dz-preview .dz-error-message:after {
content: '';
position: absolute;
top: -6px;
left: 64px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #be2626; }

4697
app/public/dropzone-5.7.0/dist/dropzone.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.dropzone,.dropzone *{box-sizing:border-box}.dropzone{position:relative}.dropzone .dz-preview{position:relative;display:inline-block;width:120px;margin:0.5em}.dropzone .dz-preview .dz-progress{display:block;height:15px;border:1px solid #aaa}.dropzone .dz-preview .dz-progress .dz-upload{display:block;height:100%;width:0;background:green}.dropzone .dz-preview .dz-error-message{color:red;display:none}.dropzone .dz-preview.dz-error .dz-error-message,.dropzone .dz-preview.dz-error .dz-error-mark{display:block}.dropzone .dz-preview.dz-success .dz-success-mark{display:block}.dropzone .dz-preview .dz-error-mark,.dropzone .dz-preview .dz-success-mark{position:absolute;display:none;left:30px;top:30px;width:54px;height:58px;left:50%;margin-left:-27px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
module.exports = require("./dist/dropzone.js"); // Exposing dropzone

View File

@@ -0,0 +1,40 @@
{
"name": "dropzone",
"version": "5.7.0",
"description": "Handles drag and drop of files for you.",
"keywords": [
"dragndrop",
"drag and drop",
"file upload",
"upload"
],
"homepage": "http://www.dropzonejs.com",
"main": "./dist/dropzone.js",
"maintainers": [
{
"name": "Matias Meno",
"email": "m@tias.me",
"web": "http://www.colorglare.com"
}
],
"contributors": [
{
"name": "Matias Meno",
"email": "m@tias.me",
"web": "http://www.colorglare.com"
}
],
"scripts": {
"test": "grunt && npm run test-prebuilt",
"test-prebuilt": "mocha-headless-chrome -f test/test-prebuilt.html -a no-sandbox -a disable-setuid-sandbox"
},
"bugs": {
"email": "m@tias.me",
"url": "https://gitlab.com/meno/dropzone/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://gitlab.com/meno/dropzone.git"
}
}

View File

@@ -102,3 +102,109 @@ h3 {
border-radius: 4px !important;
text-align: center;
}
.collection a.collection-item {
color: #02adba;
}
.collection a.collection-item:not(.active):hover {
background-color: rgba(2, 173, 186, 0.2);
}
.tabs .tab a {
color: #02adba;
-webkit-transition: color 0.28s ease, background-color 0.28s ease;
transition: color 0.28s ease, background-color 0.28s ease;
}
.tabs .tab a:focus,
.tabs .tab a:focus.active {
background-color: rgba(2, 173, 186, 0.2);
}
.tabs .tab a:hover,
.tabs .tab a.active {
color: #02adba;
}
.tabs .indicator {
background-color: #02adba;
}
[type="checkbox"].filled-in:checked + span:not(.lever):after {
border: 2px solid #02adba;
background-color: #02adba;
}
[type="checkbox"].filled-in:not(:checked) + span:not(.lever):after {
background-color: transparent;
border: 2px solid #02adba;
}
.distinguished {
border: 2px solid #02adba;
border-radius: 4px;
padding: 5px 5px 3px 5px;
margin-left: -5px;
}
.checkbox-label {
color: black;
font-size: 14px;
}
.column-label {
position: relative;
margin-top: 2rem;
margin-bottom: 1rem;
}
.estates-link {
color: rgba(0, 0, 0, 0.87);
}
.error {
color: #cc0033;
}
.custom-col {
margin-left: auto;
left: auto;
right: auto;
}
.dont-break-out {
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
word-break: break-all;
word-break: break-word;
-ms-hyphens: auto;
-moz-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}
.flex-direction-nav li a {
height: 50px;
}
.slider .slides li {
opacity: 1;
position: relative;
}
.dropzone {
background: white;
border-radius: 10px;
border: 4px dashed #02adba;
border-image: none;
max-width: 80%;
margin-left: auto;
margin-right: auto;
}
.dz-progress {
display: none;
}
.dz-preview .dz-image img {
width: 100%;
height: 100%;
object-fit: cover;
}

View File

@@ -25,6 +25,12 @@
border-right: 1px solid #02adba;
}
.segmented.small [type="radio"]:not(:checked) + span,
.segmented.small [type="radio"]:checked + span {
padding-left: 7px;
padding-right: 7px;
}
.segmented :last-child .label {
border-right: none;
}

View File

@@ -2,31 +2,54 @@
const express = require("express");
const welcome = require("../controllers/welcome").getWelcome;
const { getWelcome, postWelcome } = require("../controllers/welcome");
const {
getRealEstateTypes,
postRealEstateTypes
} = require("../controllers/realEstateTypes");
const {
getPublishTypes,
postPublishTypes
} = require("../controllers/publishRealEstateTypes");
const {
getPublishInputs,
postPublishInputs
} = require("../controllers/publishRealEstate");
const { getViewRealEstate } = require("../controllers/viewRealEstate");
const {
getQueryReview,
postQueryReview
} = require("../controllers/queryReview");
const { getGoAgain } = require("../controllers/goAgain");
const { publishSuccess } = require("../controllers/publishSuccess");
const { editSuccess } = require("../controllers/editSuccess");
const { getLocation, postLocation } = require("../controllers/location");
const { getUnsubscribe } = require("../controllers/unsubscribe");
const { getDeletePublishedAd } = require("../controllers/deleteRealEstate");
const { getRealEstates } = require("../controllers/realEstates");
const { getRedirect } = require("../controllers/redirect");
const { getFilters, postFilters } = require("../controllers/realEstateFilters");
const router = express.Router();
router.get("/", welcome);
router.get("/", getWelcome);
router.post("/", postWelcome);
router.get("/vrstanekretnine/:searchRequestId", getRealEstateTypes);
router.get("/vrstanekretnine", getRealEstateTypes);
router.post("/vrstanekretnine/:searchRequestId", postRealEstateTypes);
router.post("/vrstanekretnine", postRealEstateTypes);
router.get("/objavinekretninu/:kiviRealEstateId", getPublishTypes);
router.get("/objavinekretninu", getPublishTypes);
router.post("/objavinekretninu/:kiviRealEstateId", postPublishTypes);
router.post("/objavinekretninu", postPublishTypes);
router.get("/podacionekretnini/:kiviRealEstateId", getPublishInputs);
router.post("/podacionekretnini/:kiviRealEstateId", postPublishInputs);
router.get("/preglednekretnine/:realEstateId", getViewRealEstate);
router.get("/lokacija/:searchRequestId", getLocation);
router.post("/lokacija/:searchRequestId", postLocation);
@@ -38,8 +61,14 @@ router.post("/pregled/:searchRequestId", postQueryReview);
router.get("/odjava/:searchRequestId", getUnsubscribe);
router.get("/obrisioglas/:kiviRealEstateId", getDeletePublishedAd);
router.get("/ponovo", getGoAgain);
router.get("/uspjesnaobjava", publishSuccess);
router.get("/uspjesnaizmjena", editSuccess);
router.get("/nekretnine/:searchRequestId", getRealEstates);
router.get("/redirect/:id", getRedirect);

View File

@@ -0,0 +1,34 @@
"use strict";
const { RealEstate } = require("../models");
module.exports = {
async up(queryInterface, Sequelize) {
//Reading initial data from RealEstate table in db
const realEstateInitialData = await RealEstate.findAll();
//Extruding data for table PriceHistory
const priceHistoryInitialData = realEstateInitialData.map(realEstate => {
//Null values canot be recognized by ignore duplicates in sequalize
//Value price = 0 indicates 'cijena na upit'
const priceTmp =
realEstate.dataValues.price === null ? 0 : realEstate.dataValues.price;
return {
realEstateId: realEstate.dataValues.id,
price: priceTmp,
createdAt: realEstate.dataValues.createdAt,
updatedAt: realEstate.dataValues.updatedAt
};
});
return queryInterface.bulkInsert(
"PriceHistory",
priceHistoryInitialData,
{}
);
},
async down(queryInterface, Sequelize) {
return queryInterface.bulkDelete("PriceHistory", null, {});
}
};

View File

@@ -1,4 +1,8 @@
"use strict";
const { STAGING } = require("../config/appConfig");
const stagingTag = STAGING ? "[STAGING] " : "";
const {
matchRealEstates,
matchSearchRequest
@@ -6,9 +10,15 @@ const {
const {
generateNotificationEmail,
generateNewSearchRequestEmail,
generateEmailSubject
generateEmailSubject,
generateCheckUpEmail,
generateNewAdPublishEmail
} = require("../helpers/emailContentGenerator");
const { findNotNotifiedMatches } = require("../helpers/db/searchRequestMatch");
const {
findNotNotifiedMatches,
findAllRequestsForCheckUp,
findRealEstatesForSearchRequest
} = require("../helpers/db/searchRequestMatch");
const { sendEmail } = require("../services/emailService");
const notifyForNewRealEstates = async newRealEstates => {
@@ -21,13 +31,17 @@ const notifyForNewSearchRequest = async searchRequest => {
const searchRequestId = searchRequest.id;
const matchingRealEstates = matches[searchRequestId].realEstates;
const emailContent = generateNewSearchRequestEmail(
searchRequest,
matchingRealEstates
);
const { email } = searchRequest;
await sendEmail(email, "Kivi - novi zahtjev za pretragu", emailContent);
await sendEmail(
email,
`${stagingTag} Kivi - novi zahtjev za pretragu`,
emailContent
);
};
const notifyMatches = async (matches, dailyNotification = false) => {
@@ -39,10 +53,18 @@ const notifyMatches = async (matches, dailyNotification = false) => {
const { email, subscribed } = searchRequest;
if (notifyNow && subscribed) {
const allMatchingRealEstates = matches[id].realEstates || [];
//Variable allMatchingRealEstates are real estates that are "new" on the market
//the ones that we notify user in this moment, not all that already exists in db
//New variable allRealEstates are all real estates that exists in db for search req
const allRealEstates = await findRealEstatesForSearchRequest(id);
const noAllRealEstates = allRealEstates.length;
if (allMatchingRealEstates.length > 0) {
const emailContent = generateNotificationEmail(
allMatchingRealEstates,
id,
noAllRealEstates,
dailyNotification
);
const emailSubject = generateEmailSubject(
@@ -109,8 +131,55 @@ const notifyRequestsWithDailyOption = async () => {
await notifyMatches(matches, true);
};
const checkUpNotify = async () => {
const searchRequestsForCheckUp = await findAllRequestsForCheckUp();
const asyncSendEmailActions = [];
for (const searchRequest of searchRequestsForCheckUp) {
const { email } = searchRequest.dataValues;
const emailSubject = `${stagingTag}Kivi: Mi tražimo nekretnine za vas!`;
const emailContent = generateCheckUpEmail(searchRequest.dataValues);
const sendEmailPromise = sendEmail(email, emailSubject, emailContent);
asyncSendEmailActions.push(sendEmailPromise);
sendEmailPromise.catch(err => console.log("[Email Sending Failed]", err));
}
await Promise.all(asyncSendEmailActions);
};
const notifyForNewAdPublish = async (
realEstate,
kiviOriginal,
editingRealEstate
) => {
// console.log("Real estate:", realEstate);
// console.log("Kivi original:", kiviOriginal);
const email = kiviOriginal.dataValues.email;
const emailSubject = editingRealEstate
? `${stagingTag}Kivi - Uspješno ste izmijenili oglas!`
: `${stagingTag}Kivi - Uspješno ste objavili oglas!`;
const matches = await matchRealEstates([realEstate]);
//Counting number of matching req
let numberOfMatchingRequests = 0;
for (const match in matches) {
numberOfMatchingRequests++;
}
const emailContent = generateNewAdPublishEmail(
realEstate,
kiviOriginal,
editingRealEstate,
numberOfMatchingRequests
);
await sendEmail(email, emailSubject, emailContent);
};
module.exports = {
notifyForNewRealEstates,
notifyForNewSearchRequest,
notifyRequestsWithDailyOption
notifyRequestsWithDailyOption,
notifyForNewAdPublish,
checkUpNotify
};

View File

@@ -9,11 +9,16 @@ const { MAX_REAL_ESTATES_IN_FIRST_EMAIL } = require("../config/appConfig");
const { EMAIL_FREQUENCY } = require("../common/enums");
const matchRealEstates = async realEstates => {
if (Array.isArray(realEstates)) {
//Filter deleted real estates
const filteredRealEstates = realEstates.filter(
realEstate => realEstate.deleted === false
);
if (Array.isArray(filteredRealEstates)) {
const asyncMatchActions = [];
const matches = {};
const matchingRecords = [];
for (const realEstate of realEstates) {
for (const realEstate of filteredRealEstates) {
const searchRequestsPromise = findSearchRequestsForRealEstate(realEstate);
asyncMatchActions.push(searchRequestsPromise);

View File

@@ -0,0 +1,69 @@
<br>
<% for (const filter of advancedBooleanFilterObjects){ %>
<p>
<label class="checkbox-label">
<input type="checkbox" class="filled-in" name="<%= filter.dbField %>"
<% if (advancedBooleanFilterValues[filter.dbField]) { %>
checked
<% } %>>
<span><%= filter.title %></span>
</label>
</p>
<% } %>
<br>
<% for (const filter of advancedRangeFilterObjects){ %>
<div class="row">
<p class="column-label col s5 m3 l2">
<%= filter.title %>
</p>
<div class="input-field col s3 m4 l5">
<input
id="<%= filter.dbFieldMin %>"
name="<%= filter.dbFieldMin %>"
type="number"
value="<%= advancedRangeFilterValues[filter.dbFieldMin] !== undefined ? advancedRangeFilterValues[filter.dbFieldMin] : ""%>"
>
<label for="<%= filter.dbFieldMin %>">Od</label>
</div>
<div class="input-field col s3 m4 l5">
<input
id="<%= filter.dbFieldMax %>"
name="<%= filter.dbFieldMax %>"
type="number"
value="<%= advancedRangeFilterValues[filter.dbFieldMax] !== undefined ? advancedRangeFilterValues[filter.dbFieldMax] : ""%>"
>
<label for="<%= filter.dbFieldMax %>">Do</label>
</div>
</div>
<% } %>
<br>
<% for (const filter of advancedSegmentSelectFilterObjects){ %>
<div>
<label class="checkbox-label"><%= filter.title %>: </label><br><br>
<span class="segmented small">
<% for (const segmentObject of filter.values) { %>
<label>
<input type="radio" name="<%= filter.dbField %>" value="<%= segmentObject.id %>"
<% if (advancedSegmentSelectFilterValues[filter.dbField] === segmentObject.id) { %>
checked
<% } %>>
<span class="label"><%= segmentObject.title %></span>
</label>
<% } %>
</span>
</div>
<% } %>
<br>
<p class="distinguished">
<label class="checkbox-label">
<input type="checkbox" class="filled-in" name="includeIncompleteAds"
<% if (includeIncompleteAds) { %>
checked
<% } %>>
<span>Uključi i oglase bez potpunih informacija</span>
</label>
</p>

View File

@@ -0,0 +1,12 @@
<!-- -->
<br><br>
<div class="row center-align">
<img src="../assets/images/logo.svg" alt="kivi logo" width="160">
</div>
<div class="row">
<div class="col s6 push-s3">
<a href="<%= nextStep %>" class="welcome-center-button waves-effect waves-light btn">
Početna stranica
</a>
</div>
</div>

11
app/views/editSuccess.ejs Normal file
View File

@@ -0,0 +1,11 @@
<br>
<div class="row center-align">
<p>Vaš oglas je izmijenjen u Kivi bazi.</p>
<br>
<div class="row center-align">
<img src="../assets/images/logo.svg" alt="kivi logo" width="160">
</div>
<br>
<p>Poslali smo potvrdni email sa izmijenjenim detaljima oglasa na Vašu email adresu.</p>
<a href="/" class="">Nova pretraga</a>
</div>

View File

@@ -9,16 +9,28 @@
gtag('js', new Date());
gtag('config', '<%= process.env.GA_ID %>');
</script>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/13.1.5/nouislider.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.7.0/dropzone.css">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
<script src="https://code.jquery.com/jquery-2.1.1.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/validate.js/0.13.1/validate.min.js"></script>
<script src="/assets/dropzone-5.7.0/dist/dropzone.js"></script>
<script type="text/javascript">
Dropzone.autoDiscover = false;
</script>
<meta charset="UTF-8" />
<link rel="stylesheet" href="/assets/main.css">
<link rel="stylesheet" href="/assets/segment.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flexslider/2.7.2/flexslider.css" type="text/css" media="screen" />
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/flexslider/2.7.2/jquery.flexslider.js"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png">
@@ -47,6 +59,9 @@
<% } else { %>
<title>Kivi.ba</title>
<% } %>
</head>
<body>

View File

@@ -1,13 +1,17 @@
<div class="row center-align">
<h3>
Područje na mapi će biti uključeno u pretragu. Namjestite mapu na ulice
koje želite da budu vidljive.
Područje na mapi će biti uključeno u pretragu. Namjestite mapu na ulice koje
želite da budu vidljive.
</h3>
</div>
<div class="row center-align">
<div class="col s12 m12 l12 xl12">
<input id="autocompleteInput" placeholder="Unesite grad, naselje ili ulicu..." type="text" />
<input
id="autocompleteInput"
placeholder="Unesite grad, naselje ili ulicu..."
type="text"
/>
</div>
</div>
@@ -17,12 +21,17 @@
</div>
</div>
<br>
<br />
<form method="POST" id="form-map-output">
<div class="row center-align">
<div class="col s6 push-s3">
<a id="submit" href="#" class="welcome-center-button waves-effect waves-light btn">Dalje</a>
<a
id="submit"
href="#"
class="welcome-center-button waves-effect waves-light btn"
>Dalje</a
>
</div>
</div>
<input type="hidden" name="north" id="north" />
@@ -41,15 +50,15 @@
function locateMe() {
if (navigator.geolocation) {
function onLocationSuccess (position) {
const coordinates = position && position.coords ? position.coords : null;
if (coordinates){
function onLocationSuccess(position) {
const coordinates =
position && position.coords ? position.coords : null;
if (coordinates) {
const longitude = coordinates.longitude || null;
const latitude = coordinates.latitude || null;
if (longitude && latitude && map){
map.setCenter({lat: latitude, lng: longitude});
if (longitude && latitude && map) {
map.setCenter({ lat: latitude, lng: longitude });
map.setZoom(16);
}
}
@@ -61,20 +70,20 @@
function initMap() {
const BOSNIA_BOUNDS = {
north: 45.70,
north: 45.7,
south: 41.69,
west: 15.55,
east: 20.77,
east: 20.77
};
const SARAJEVO_COORDINATES = {
lat: 43.85,
lng: 18.41,
lng: 18.41
};
const mapElement = document.getElementById('map');
const mapElement = document.getElementById("map");
const restrictMapPanningToBosniaOnly = {
latLngBounds: BOSNIA_BOUNDS,
strictBounds: true,
strictBounds: true
};
const initialMapParams = {
center: SARAJEVO_COORDINATES,
@@ -87,38 +96,50 @@
};
map = new google.maps.Map(mapElement, initialMapParams);
const inputElement = document.getElementById('autocompleteInput');
const restrictAutocompleteResultsToBosniaOnly = {'country': 'ba'};
const inputElement = document.getElementById("autocompleteInput");
const restrictAutocompleteResultsToBosniaOnly = { country: "ba" };
const initialAutocompleteParams = {
types: ['geocode'],
types: ["geocode"],
componentRestrictions: restrictAutocompleteResultsToBosniaOnly,
fields: ['geometry', 'types', 'address_components']
fields: ["geometry", "types", "address_components"]
};
autocomplete = new google.maps.places.Autocomplete(inputElement, initialAutocompleteParams);
autocomplete.bindTo('bounds', map);
autocomplete.addListener('place_changed', onPlaceChanged);
autocomplete = new google.maps.places.Autocomplete(
inputElement,
initialAutocompleteParams
);
autocomplete.bindTo("bounds", map);
autocomplete.addListener("place_changed", onPlaceChanged);
pacSelectFirst(inputElement);
addLocateMeButton(map);
//After map initialization we check if area is already selected
//If yes we bound map to show already selected area
const boundsSelected = <%- boundsSelected %>;
const selectedLatLngBounds = <%- JSON.stringify(selectedLatLngBounds) %>;
if (boundsSelected) {
boundMapToSelected(map, selectedLatLngBounds);
}
}
function addLocateMeButton(map) {
var parent = document.createElement('div');
var parent = document.createElement("div");
parent.className = "locate-me-container";
var a = document.createElement('a');
var a = document.createElement("a");
a.id = "locateMe";
a.className = "btn-floating";
var i = document.createElement('i');
var i = document.createElement("i");
i.innerText = "gps_fixed";
i.className = "material-icons right";
a.appendChild(i)
a.appendChild(i);
a.addEventListener("click", locateMe);
parent.appendChild(a)
parent.appendChild(a);
map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(parent)
map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(parent);
}
function onPlaceChanged() {
@@ -133,33 +154,49 @@
function pacSelectFirst(input) {
// store the original event binding function
const _addEventListener = input.addEventListener
? input.addEventListener
: input.attachEvent
? input.addEventListener
: input.attachEvent;
function addEventListenerWrapper (type, listener) {
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') {
const originalListener = listener
listener = function (event) {
const suggestionSelected = $('.pac-item-selected').length > 0
if (event.key == 'Enter' && !suggestionSelected) {
const simulatedDownArrow = $.Event('keydown', {
if (type == "keydown") {
const originalListener = listener;
listener = function(event) {
const suggestionSelected = $(".pac-item-selected").length > 0;
if (event.key == "Enter" && !suggestionSelected) {
const simulatedDownArrow = $.Event("keydown", {
keyCode: 40,
which: 40
})
originalListener.apply(input, [simulatedDownArrow])
});
originalListener.apply(input, [simulatedDownArrow]);
}
originalListener.apply(input, [event])
}
originalListener.apply(input, [event]);
};
}
_addEventListener.apply(input, [type, listener])
_addEventListener.apply(input, [type, listener]);
}
input.addEventListener = addEventListenerWrapper
input.attachEvent = addEventListenerWrapper
input.addEventListener = addEventListenerWrapper;
input.attachEvent = addEventListenerWrapper;
}
function boundMapToSelected(map, selectedLatLngBounds) {
const swBound = new google.maps.LatLng(
selectedLatLngBounds.swLat,
selectedLatLngBounds.swLng
);
const neBound = new google.maps.LatLng(
selectedLatLngBounds.neLat,
selectedLatLngBounds.neLng
);
let bounds = new google.maps.LatLngBounds();
bounds.extend(swBound);
bounds.extend(neBound);
map.fitBounds(bounds);
}
$(document).ready(function() {
@@ -171,11 +208,16 @@
$("#east").val(mapBounds.getNorthEast().lng());
$("#west").val(mapBounds.getSouthWest().lng());
$("#locationInput").val(document.getElementById('autocompleteInput').value);
$("#locationInput").val(
document.getElementById("autocompleteInput").value
);
$("#form-map-output").submit();
});
});
</script>
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyAna8ohfV2HBMcxGk_29vqxU5Z_bDickqg&language=bs&libraries=places&callback=initMap" async
defer></script>
<script
src="https://maps.googleapis.com/maps/api/js?key=<%= process.env.GOOGLE_MAP_KEY %>&language=bs&libraries=places&callback=initMap"
async
defer
></script>

View File

@@ -0,0 +1,50 @@
<br>
<div class="row">
<% for (const input of additionalInputInputs){ %>
<div class="input-field col s12">
<textarea
id="<%= input.dbField %>"
form="publishForm"
name="<%= input.dbField %>"
cols="80" rows="15"
value="<%= additionalInputValues[input.dbField] !== undefined ? additionalInputValues[input.dbField] : ""%>"
></textarea>
<label for="<%= input.dbField %>"><%= input.title %></label>
</div>
<% } %>
</div>
<br>
<div class="row">
<% for (const input of additionalBooleanPublishInputs){ %>
<p class="col s6 m4 l4">
<label class="checkbox-label">
<input type="checkbox" class="filled-in" name="<%= input.dbField %>"
<% if (additionalBooleanPublishValues[input.dbField]) { %>
checked
<% } %>>
<span><%= input.title %></span>
</label>
</p>
<% } %>
</div>
<br>
<% for (const input of additionalSegmentSelectInputs){ %>
<div>
<label class="checkbox-label"><%= input.title %>: </label><br><br>
<span class="segmented small">
<% for (const segmentObject of input.values) { %>
<% if (segmentObject.id!=="ANY") { %>
<label>
<input type="radio" name="<%= input.dbField %>" value="<%= segmentObject.id %>"
<% if (additionalSegmentSelectValues[input.dbField] === segmentObject.id) { %>
checked
<% } %>>
<span class="label"><%= segmentObject.title %></span>
</label>
<% } %>
<% } %>
</span>
</div>
<% } %>

View File

@@ -0,0 +1,49 @@
<br>
<div class="row" id="basic-inputs">
<% for (const input of basicInputInputs){ %>
<div class="input-field col s10 m5 l4">
<input
id="<%= input.dbField %>"
name="<%= input.dbField %>"
type="text"
value="<%= basicInputValues[input.dbField] !== undefined ? basicInputValues[input.dbField] : ""%>"
>
<label for="<%= input.dbField %>"><%= input.title %></label>
</div>
<% } %>
</div>
<br>
<div class="row">
<% for (const input of basicBooleanPublishInputs){ %>
<p>
<label class="checkbox-label">
<input type="checkbox" class="filled-in" name="<%= input.dbField %>"
<% if (basicBooleanPublishValues[input.dbField]) { %>
checked
<% } %>>
<span><%= input.title %></span>
</label>
</p>
<% } %>
</div>
<br>
<% for (const input of basicSegmentSelectInputs){ %>
<div>
<label class="checkbox-label"><%= input.title %>: </label><br><br>
<span class="segmented small">
<% for (const segmentObject of input.values) { %>
<label>
<input type="radio" name="<%= input.dbField %>" value="<%= segmentObject.id %>"
<% if (basicSegmentSelectValues[input.dbField] === segmentObject.id) { %>
checked
<% } %>>
<span class="label"><%= segmentObject.title %></span>
</label>
<% } %>
</span>
</div>
<% } %>

32
app/views/publishEnd.ejs Normal file
View File

@@ -0,0 +1,32 @@
<br>
<div class="row center-align">
<h3>Vaš oglas je spreman!</h3>
Unesite kontakt email i objavite oglas.
<br>
<div class="row center-align input-field col s3 m4 l5 form-group">
<input
id="email"
name="email"
type="email"
value="<%= email !== undefined ? email : ""%>"
>
<div class="messages"></div>
<label for="email">Email</label>
</div>
</div>
<br>
<div class="row center-align">
<div class="col s6 push-s3">
<a id="submit" href="#" form="publishForm" class="welcome-center-button waves-effect waves-light btn">
<% if(editingRealEstate) { %>
Snimi izmjene
<% } else { %>
Objavi oglas
<% } %>
</a>
</div>
</div>
<br>

View File

@@ -0,0 +1,224 @@
<div class="row center-align">
<h3>
Izaberite lokaciju nekretnine na mapi.
</h3>
</div>
<div class="row center-align">
<div class="col s12 m12 l12 xl12">
<input
id="autocompleteInput"
placeholder="Unesite grad, naselje ili ulicu..."
type="text"
/>
</div>
</div>
<div class="row center-align">
<div class="col s12">
<div id="map"></div>
</div>
</div>
<input type="hidden" name="north" id="north" />
<input type="hidden" name="south" id="south" />
<input type="hidden" name="east" id="east" />
<input type="hidden" name="west" id="west" />
<input type="hidden" name="lat" id="lat" />
<input type="hidden" name="lng" id="lng" />
<input type="hidden" name="locationInput" id="locationInput" />
<input type="hidden" name="locationInputData" id="locationInputData" />
<script>
let autocomplete;
let map;
let places;
let geocoder;
let marker =false; //Initialy no marker on map
const editingRealEstate = <%- editingRealEstate %>;
function locateMe() {
if (navigator.geolocation) {
function onLocationSuccess(position) {
const coordinates =
position && position.coords ? position.coords : null;
if (coordinates) {
const longitude = coordinates.longitude || null;
const latitude = coordinates.latitude || null;
if (longitude && latitude && map) {
map.setCenter({ lat: latitude, lng: longitude });
map.setZoom(16);
}
}
}
navigator.geolocation.getCurrentPosition(onLocationSuccess);
}
}
function initMap() {
const BOSNIA_BOUNDS = {
north: 45.7,
south: 41.69,
west: 15.55,
east: 20.77
};
const SARAJEVO_COORDINATES = {
lat: 43.85,
lng: 18.41
};
const mapElement = document.getElementById("map");
const restrictMapPanningToBosniaOnly = {
latLngBounds: BOSNIA_BOUNDS,
strictBounds: true
};
const initialMapParams = {
center: SARAJEVO_COORDINATES,
zoom: 12,
restriction: restrictMapPanningToBosniaOnly,
mapTypeControl: false,
panControl: false,
zoomControl: true,
streetViewControl: false
};
map = new google.maps.Map(mapElement, initialMapParams);
const inputElement = document.getElementById("autocompleteInput");
const restrictAutocompleteResultsToBosniaOnly = { country: "ba" };
const initialAutocompleteParams = {
types: ["geocode"],
componentRestrictions: restrictAutocompleteResultsToBosniaOnly,
fields: ["geometry", "types", "address_components"]
};
autocomplete = new google.maps.places.Autocomplete(
inputElement,
initialAutocompleteParams
);
autocomplete.bindTo("bounds", map);
autocomplete.addListener("place_changed", onPlaceChanged);
pacSelectFirst(inputElement);
addLocateMeButton(map);
//Move map and marker to already selected position if in editing mode
if( editingRealEstate===true) {
//console.log('Editujem mapu!');
setMarkerToLocation(map, editingRealEstate);
}
//Add event listener to position marker on map
google.maps.event.addListener(map, 'click', positionMarker);
}
function positionMarker(event) {
let clickedLocation = event.latLng;
if(marker === false){
marker = new google.maps.Marker({
position: clickedLocation,
map: map,
draggable: true
});
//google.maps.event.addListener(marker, 'dragend', function(event){
// markerLocation();
// });
} else{
marker.setPosition(clickedLocation);
}
}
function setMarkerToLocation(map) {
const ESTATE_COORDINATES = ( <%= locationLat %> !==0 && <%= locationLong %> !== 0 ) ?
{
lat: <%= locationLat %>,
lng: <%= locationLong %>
}:
{ lat: 43.85, //Set to Sarajevo if coordinates are not picked
lng: 18.41
};
marker = new google.maps.Marker({
position: ESTATE_COORDINATES,
map: map,
draggable: true
});
// google.maps.event.addListener(marker, 'dragend', function(event){
// markerLocation();
// });
//Zooming map to current location
map.setCenter(ESTATE_COORDINATES);
map.setZoom(13);
}
function addLocateMeButton(map) {
var parent = document.createElement("div");
parent.className = "locate-me-container";
var a = document.createElement("a");
a.id = "locateMe";
a.className = "btn-floating";
var i = document.createElement("i");
i.innerText = "gps_fixed";
i.className = "material-icons right";
a.appendChild(i);
a.addEventListener("click", locateMe);
parent.appendChild(a);
map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(parent);
}
function onPlaceChanged() {
const place = autocomplete.getPlace();
if (place.geometry) {
map.fitBounds(place.geometry.viewport);
map.setZoom(map.getZoom() + 1);
$("#locationInputData").val(JSON.stringify(place));
}
}
function pacSelectFirst(input) {
// store the original event binding function
const _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") {
const originalListener = listener;
listener = function(event) {
const suggestionSelected = $(".pac-item-selected").length > 0;
if (event.key == "Enter" && !suggestionSelected) {
const simulatedDownArrow = $.Event("keydown", {
keyCode: 40,
which: 40
});
originalListener.apply(input, [simulatedDownArrow]);
}
originalListener.apply(input, [event]);
};
}
_addEventListener.apply(input, [type, listener]);
}
input.addEventListener = addEventListenerWrapper;
input.attachEvent = addEventListenerWrapper;
}
</script>
<script
src="https://maps.googleapis.com/maps/api/js?key=<%= process.env.GOOGLE_MAP_KEY %>&language=bs&libraries=places&callback=initMap"
async
defer
></script>

View File

@@ -0,0 +1,12 @@
<br>
<div action="/photos-upload" class="dropzone" id="photos-upload">
<div class="fallback">
<input name="file" type="file" multiple />
</div>
</div>
<br>
<input type="hidden" name="imageUrls" id="imageUrls" value="">

View File

@@ -0,0 +1,349 @@
<br>
<form id="publishForm" method="POST" novalidate >
<div class="row">
<div class="col s12">
<ul class="tabs">
<li class="tab col s3"><a href="#publishBasicData">Osnovni podaci</a></li>
<li class="tab col s3"><a href="#publishAdditionalData">Dodatni podaci</a></li>
<li class="tab col s2"><a href="#publishLocation">Lokacija</a></li>
<li class="tab col s2"><a href="#publishPhotos">Fotografije</a></li>
<li class="tab col s2"><a href="#publishEnd">Kraj</a></li>
</ul>
</div>
<div id="publishBasicData" class="col s12">
<%- include("./publishBasicData.ejs") %>
</div>
<div id="publishAdditionalData" class="col s12">
<%- include("./publishAdditionalData.ejs") %>
</div>
<div id="publishLocation" class="col s12">
<%- include("./publishLocation.ejs") %>
</div>
<div id="publishPhotos" class="col s12">
<%- include("./publishPhotos.ejs") %>
</div>
<div id="publishEnd" class="col s12">
<%- include("./publishEnd.ejs") %>
</div>
</div>
<input type="hidden" name="editingRealEstate" id="editingRealEstate" value="">
</form>
<script>
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function getFileName(fileName) {
const encodedFileName = (uuidv4() + fileName).replace(/\s+/g, '');
return encodedFileName;
}
function upload() {
var file = $('#selector')[0].files[0];
uploadFile(file)
}
async function generateSignedURL(file) {
const fileName = getFileName(file.name);
const response = await fetch('/generateSignedURL?filename=' + fileName);
if (!response.ok) {
throw new Error('Network response for fetch was not ok.');
}
let signedUrl = await response.text();
signedUrl = signedUrl.replace(/\"/g, "")
await uploadFile(file, fileName, signedUrl);
return fileName;
}
function uploadFile(file, fileName, url) {
return fetch(url, {
method: 'PUT',
headers: new Headers({'content-type': 'image/*'}),
mode: 'cors',
body: file
})
.then(response => response.text())
.then (response => {
return response;
}
)
.catch(error => $("#status").html(error)
)
.then(response => {
$("#imageUrls").val($("#imageUrls").val()+ fileName+"|");
});
}
$(document).ready(function(){
$('.tabs').tabs();
const editingRealEstate = <%- JSON.stringify(editingRealEstate) %>;
$("#editingRealEstate").val(editingRealEstate);
const currentRealEstatePhotosUrls = <%-JSON.stringify(realEstatePhotosUrls)%>;
// Manual dropzone init
const dropzoneOptions = {
url: "/photos-upload", //can be a function that returns url ?
autoProcessQueue:false, //not to upload files automaticly
method: "put", //or post
parallelUploads: 1,
uploadMultiple: false,
addRemoveLinks: true,
maxFilesize: 2, //MB,
resizeWidth: 600,
maxFiles: 10,
acceptedFiles: "image/*",
dictDefaultMessage: `<span class="text-center">
<h3>Prevuci fotografije ili klikni za dodavanje!</h3>
(Maksimalno 10 fotografija.)
</span>`,
dictResponseError: 'Error uploading file!',
dictRemoveFile: 'Izbriši ',
dictFileTooBig: 'Fajl je prevelik!',
dictInvalidFileType: 'Iabrani fajl nije fotografija!',
dictMaxFilesExceeded: 'Dostigli ste maksimalan broj fotografija!',
init: function () {
let fileCountOnServer = currentRealEstatePhotosUrls.length; // The number of files already uploaded
this.options.maxFiles = this.options.maxFiles - fileCountOnServer;
if (editingRealEstate) {
for (let i=0; i<currentRealEstatePhotosUrls.length; i++) {
let fileName = currentRealEstatePhotosUrls[i].replace("https://storage.cloud.google.com/marketalarm-photos/", "");
var mockFile = { name: fileName, size: 12345, type: 'image/jpeg', accepted: true, status: Dropzone.ACCEPTED };
this.options.addedfile.call(this, mockFile);
this.options.thumbnail.call(this, mockFile, currentRealEstatePhotosUrls[i]);
this.files.push(mockFile);
mockFile.previewElement.classList.add('dz-success');
mockFile.previewElement.classList.add('dz-complete');
}
};
this.on("addedfile", function(event) {
while (this.files.length > this.options.maxFiles) {
this.removeFile(this.files[0]); //automaticly removing if more then 10 files
}
});
this.on("removedfile", function(event) {
let fileCountOnPreview = this.files.length;
this.options.maxFiles = 10; //resets allowed number of files
this._updateMaxFilesReachedClass();
});
}
}
var photosUploader = new Dropzone('#photos-upload', dropzoneOptions);
//VALIDATION - WiP
//Helper validation functions
const isValidEmail = $email => {
const simpleEmailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return $email && $email.length < 250 && simpleEmailRegex.test($email);
};
const isPresent = $input => {
return $input && $input!=="" && $input != null;
};
const isNumber = $input => {
const simpleNumberRegex = /([0-9]+[.|,][0-9])|([0-9][.|,][0-9]+)|([0-9]+)/;
return $input && $input.length <250 && simpleNumberRegex.test($input) && !isNaN($input);
};
const isInteger = $input => {
const simpleIntegerRegex = /^([+-]?[1-9]\d*|0)$/;
return $input && $input.length <250 && simpleIntegerRegex.test($input);
};
const form = document.querySelector("#publishForm");
function showErrors(form, errors) {
// We loop through all the inputs and show the errors for that input
//_.each(form.querySelectorAll("input[name], select[name]"), function(input) {
// Since the errors can be null if no errors were found we need to handle
// that
showErrorsForInput(input, errors && errors[input.name]);
// });
}
// Shows the errors for a specific input
function showErrorsForInput(input, errors) {
// This is the root of the input
var formGroup = closestParent(input.parentNode, "form-group"),
// Find where the error messages will be insert into
messages = formGroup.querySelector(".messages");
// First we remove any old messages and resets the classes
resetFormGroup(formGroup);
// If we have errors
if (errors) {
// we first mark the group has having errors
formGroup.classList.add("has-error");
// then we append all the errors
$.each(errors, function(error) {
addError(messages, errors[error]);
});
} else {
// otherwise we simply mark it as success
formGroup.classList.add("has-success");
}
}
// Recusively finds the closest parent that has the specified class
function closestParent(child, className) {
if (!child || child == document) {
return null;
}
if (child.classList.contains(className)) {
return child;
} else {
return closestParent(child.parentNode, className);
}
}
function resetFormGroup(formGroup) {
formGroup.classList.remove("has-error");
formGroup.classList.remove("has-success");
$.each(formGroup.querySelectorAll(".help-block.error"), function(el) {
el.parentNode.removeChild(el);
});
}
// Adds the specified error with the following markup
// <p class="help-block error">[message]</p>
function addError(messages, error) {
var block = document.createElement("p");
block.classList.add("help-block");
block.classList.add("error");
block.innerText = error;
messages.appendChild(block);
}
const validate = (input) => {
let valid=true;
let errorMsg =[];
let constraint = input.constraint[0];
switch (constraint) {
case "required":
valid = isPresent ($(`#${input.dbField}`).val());
errorMsg = ["Ovo je obavezno polje."];
break;
case "numerical":
valid = isNumber ($(`#${input.dbField}`).val());
errorMsg = ["Unesite brojcanu vrijednost."];
break;
case "integer":
valid = isInteger ($(`#${input.dbField}`).val());
errorMsg = ["Unesite cijeli broj."];
break;
default :
valid = true;
}
if (!valid) {
const inputField = document.querySelector(`#${input.dbField}`);
showErrorsForInput( inputField, errorMsg);
return false;
} else {
return true;
}
}
$("#submit").click( async function (e) {
e.preventDefault();
if (marker) {
const currentLocation = marker.getPosition();
$("#lat").val(currentLocation.lat());
$("#lng").val(currentLocation.lng());
$("#locationInput").val(
document.getElementById("autocompleteInput").value
);
} else {
$("#lat").val(0);
$("#lng").val(0);
}
//Tag for checking of error presence
let hasErrors = false;
//Check if email is valid
const validEmail = isValidEmail($("#email").val());
//Show messeges for invalid email is present
if (!validEmail) {
const errorMsgs = ["Unesite validan email."];
const email = document.querySelector("#email");
showErrorsForInput( email, errorMsgs)
hasErrors = true;
};
//Check if other input fields are valid - vratiti se na ovo!!
//const basicInputInputs= document.getElementById("basic-inputs").getElementsByTagName("input");
//alert(JSON.stringify(""));
/*
$.each(basicInputInputs, function (input) {
alert(input);
validate (input);
})
for (const input of basicInputInputs ) {
alert(input.getAttribute(name));
validate (input);
} */
//Filter all files to exclude ones that are already uploaded during the ad publish
//But keep them stored to know that they are not deleted from ad
let filesForUpload =[];
if(editingRealEstate) {
let currentPhotosFilenames = currentRealEstatePhotosUrls.map( url => {
return url.replace("https://storage.cloud.google.com/marketalarm-photos/", "");
})
photosUploader.files.map( file => {
if ( currentPhotosFilenames.includes(file.name) ) {
return $("#imageUrls").val($("#imageUrls").val()+ file.name+"|");
} else if (file.status!=="error") {
return filesForUpload.push(file);
}
});
} else {
photosUploader.files.map( file => {
if (file.status!=="error") {
return filesForUpload.push(file);
}
})
}
const asyncUpload =[];
filesForUpload.forEach( file => {
asyncUpload.push(generateSignedURL(file));
})
if (!hasErrors) {
await Promise.all(asyncUpload);
$("#publishForm").submit();
};
});
});
</script>

View File

@@ -0,0 +1,11 @@
<br>
<div class="row center-align">
<p>Vaš oglas je spašen u Kivi bazu.</p>
<br>
<div class="row center-align">
<img src="../assets/images/logo.svg" alt="kivi logo" width="160">
</div>
<br>
<p>Poslali smo potvrdni email sa detaljima oglasa na Vašu email adresu.</p>
<a href="/" class="">Nova pretraga</a>
</div>

View File

@@ -1,63 +1,20 @@
<br>
<form id="filtersForm" method="POST">
<br>
<div class="row center-align">
<h5>Cijena</h5>
<br><br>
<div class="center-align no-ui-slider" id="priceFilter"></div>
</div>
<br>
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input class="sliderInputBox" type="number" id="priceMin" name="priceMin">
<div class="col s12">
<ul class="tabs">
<li class="tab col s6"><a href="#standardFilters">Filteri</a></li>
<li class="tab col s6"><a href="#advancedFilters">Napredni filteri</a></li>
</ul>
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input class="sliderInputBox" type="number" id="priceMax" name="priceMax">
<div id="standardFilters" class="col s12">
<%- include("./standardFilters.ejs") %>
</div>
<div id="advancedFilters" class="col s12">
<%- include("./advancedFilters.ejs") %>
</div>
</div>
<br>
<div class="row center-align">
<h5>Površina</h5>
<br><br>
<div class="center-align no-ui-slider" id="sizeFilter"></div>
</div>
<br>
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input class="sliderInputBox" type="number" id="sizeMin" name="sizeMin">
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input class="sliderInputBox" type="number" id="sizeMax" name="sizeMax">
</div>
</div>
<br>
<% if(hasGardenSize) { %>
<div class="row center-align">
<h5>Površina okućnice</h5>
<br><br>
<div class="center-align no-ui-slider" id="gardenSizeFilter"></div>
</div>
<br>
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input class="sliderInputBox" type="number" id="gardenSizeMin" name="gardenSizeMin">
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input class="sliderInputBox" type="number" id="gardenSizeMax" name="gardenSizeMax">
</div>
</div>
<% } %>
<div class="row">
<div class="col s6 push-s3">
<a id="submit" href="#" class="welcome-center-button waves-effect waves-light btn">Dalje</a>
@@ -66,125 +23,7 @@
</form>
<script>
$(document).ready(function() {
const priceSliderOptions = <%- priceSliderOptions %>;
const sizeSliderOptions = <%- sizeSliderOptions %>;
const priceStep = priceSliderOptions.step;
const sizeStep = sizeSliderOptions.step;
delete priceSliderOptions.step;
delete sizeSliderOptions.step;
function updatePriceInputs(values, handle, unencoded) {
$("#priceMin").val(Math.round(unencoded[0]/priceStep)*priceStep);
$("#priceMax").val(Math.round(unencoded[1]/priceStep)*priceStep);
}
function updateSizeInputs(values, handle, unencoded) {
$("#sizeMin").val(Math.round(unencoded[0]/sizeStep)*sizeStep);
$("#sizeMax").val(Math.round(unencoded[1]/sizeStep)*sizeStep);
}
const priceSlider = document.getElementById("priceFilter");
const sizeSlider = document.getElementById("sizeFilter");
const priceSliderObject = noUiSlider.create(priceSlider, priceSliderOptions);
const sizeSliderObject = noUiSlider.create(sizeSlider, sizeSliderOptions);
priceSliderObject.on('slide', updatePriceInputs);
sizeSliderObject.on('slide', updateSizeInputs);
function priceMinChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = priceSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
priceSliderObject.set([fixedNewValue, null]);
$("#priceMin").val(Math.round(priceSliderObject.get()[0]));
}
}
function priceMaxChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
priceSliderObject.set([null, newValue]);
$("#priceMax").val(Math.round(priceSliderObject.get()[1]));
}
}
$("#priceMin").val(priceSliderOptions.start[0]);
$("#priceMax").val(priceSliderOptions.start[1]);
$("#priceMin").change(priceMinChangeHandler);
$("#priceMax").change(priceMaxChangeHandler);
function sizeMinChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = sizeSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
sizeSliderObject.set([fixedNewValue, null]);
$("#sizeMin").val(Math.round(sizeSliderObject.get()[0]));
}
}
function sizeMaxChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
sizeSliderObject.set([null, newValue]);
$("#sizeMax").val(Math.round(sizeSliderObject.get()[1]));
}
}
$("#sizeMin").val(sizeSliderOptions.start[0]);
$("#sizeMax").val(sizeSliderOptions.start[1]);
$("#sizeMin").change(sizeMinChangeHandler);
$("#sizeMax").change(sizeMaxChangeHandler);
<% if(hasGardenSize) { %>
const gardenSizeSliderOptions = <%- gardenSizeSliderOptions %>;
const gardenSizeStep = gardenSizeSliderOptions.step;
delete gardenSizeSliderOptions.step;
function updateGardenSizeInputs(values, handle, unencoded) {
$("#gardenSizeMin").val(Math.round(unencoded[0]/gardenSizeStep)*gardenSizeStep);
$("#gardenSizeMax").val(Math.round(unencoded[1]/gardenSizeStep)*gardenSizeStep);
}
const gardenSizeSlider = document.getElementById("gardenSizeFilter");
const gardenSizeSliderObject = noUiSlider.create(gardenSizeSlider, gardenSizeSliderOptions);
gardenSizeSliderObject.on('slide', updateGardenSizeInputs);
function gardenSizeMinChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = gardenSizeSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
gardenSizeSliderObject.set([fixedNewValue, null]);
$("#gardenSizeMin").val(Math.round(gardenSizeSliderObject.get()[0]));
}
}
function gardenSizeMaxChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
gardenSizeSliderObject.set([null, newValue]);
$("#gardenSizeMin").val(Math.round(gardenSizeSliderObject.get()[0]));
}
}
$("#gardenSizeMin").val(gardenSizeSliderOptions.start[0]);
$("#gardenSizeMax").val(gardenSizeSliderOptions.start[1]);
$("#gardenSizeMin").change("step", gardenSizeMinChangeHandler);
$("#gardenSizeMax").change("step", gardenSizeMaxChangeHandler);
<% } %>
$("#submit").click(function() {
const priceFilterValues = priceSlider.noUiSlider.get();
$("#priceFilterMin").val(priceFilterValues[0]);
$("#priceFilterMax").val(priceFilterValues[1]);
const sizeFilterValues = sizeSlider.noUiSlider.get();
$("#sizeFilterMin").val(sizeFilterValues[0]);
$("#sizeFilterMax").val(sizeFilterValues[1]);
<% if (hasGardenSize) { %>
const gardenSizeFilterValues = gardenSizeSlider.noUiSlider.get();
$("#gardenSizeFilterMin").val(gardenSizeFilterValues[0]);
$("#gardenSizeFilterMax").val(gardenSizeFilterValues[1]);
<% } %>
$("#filtersForm").submit();
});
$(document).ready(function(){
$('.tabs').tabs();
});
</script>

View File

@@ -9,7 +9,7 @@
<% if (selectedAdType === AD_TYPE.AD_TYPE_SALE.id) { %>
checked
<% } %>>
<span class="label"><%= AD_TYPE.AD_TYPE_SALE.title %></span>
<span class="label"><%= labelAdType[0] %></span>
</label>
<label>
@@ -17,7 +17,7 @@
<% if (selectedAdType === AD_TYPE.AD_TYPE_RENT.id) { %>
checked
<% } %>>
<span class="label"><%= AD_TYPE.AD_TYPE_RENT.title %></span>
<span class="label"><%= labelAdType[1] %></span>
</label>
</span>
</div>

View File

@@ -1,13 +1,29 @@
<div class="row center-align">
<ul class="collection with-header">
<% for(const realEstate of realEstates) { %>
<li class="collection-item">
<div><%= realEstate.title %>
<a href="<%= realEstate.url %>" class="kivi-color secondary-content">
<ul class="collection with-header">
<% for(const realEstate of realEstates) { %>
<li class="collection-item">
<% if(realEstate.adStatus === AD_STATUS.STATUS_VIP) {%>
<div>
<% //This needs to do redirecting instead of direct link to realestate
%>
<a href="/redirect/<%= realEstate.id %>" class="estates-link">
<%= realEstate.title %>
<div class="kivi-color secondary-content">
<i class="material-icons">send</i>
</a>
</div>
</li>
<% } %>
</ul>
</div>
</div>
</a>
</div>
<%} else { %>
<div>
<a href="<%= realEstate.url %>" class="estates-link">
<%= realEstate.title %>
<div class="kivi-color secondary-content">
<i class="material-icons">send</i>
</div>
</a>
</div>
<% }%>
</li>
<% } %>
</ul>
</div>

View File

@@ -1,26 +1,49 @@
<br><br>
<br /><br />
<div class="center">
<div class="preloader-wrapper big active center">
<div class="kivi-spinner-color spinner-layer spinner-green-only">
<div class="circle-clipper left">
<div class="circle"></div>
</div><div class="gap-patch">
<div class="circle"></div>
</div><div class="circle-clipper right">
<div class="circle"></div>
</div>
</div>
<div class="preloader-wrapper big active center">
<div class="kivi-spinner-color spinner-layer spinner-green-only">
<div class="circle-clipper left">
<div class="circle"></div>
</div>
<div class="gap-patch">
<div class="circle"></div>
</div>
<div class="circle-clipper right">
<div class="circle"></div>
</div>
</div>
</div>
</div>
<br>
<br />
<% if(vipAd) { %>
<div class="center">
<h6>
<a href="<%= redirectUrl %>" rel="noreferrer" id="realEstateUrl">Kliknite ovdje ako Vas web preglednik ne preusmjeri automatski</a>
</h6>
<h6>
Ovaj oglas zahtijeva da budete član
<a href="https://prostor.ba/" rel="noreferrer">Prostor.ba</a>.
<br />
<br />
<a href="https://prostor.ba/moj-prostor/prijava" rel="noreferrer"
>Ulogujte se</a
>
ili napravite
<a href="https://prostor.ba/moj-prostor/registracija" rel="noreferrer"
>novi račun</a
>, a potom otvorite <a href="<%= redirectUrl %>" rel="noreferrer">oglas</a>.
</h6>
</div>
<% } else { %>
<div class="center">
<h6>
<a href="<%= redirectUrl %>" rel="noreferrer" id="realEstateUrl"
>Kliknite ovdje ako Vas web preglednik ne preusmjeri automatski</a
>
</h6>
</div>
<% }%>
<script>
window.onload = function() {
document.getElementById('realEstateUrl').click();
}
window.onload = function() {
document.getElementById("realEstateUrl").click();
};
</script>

View File

@@ -0,0 +1,204 @@
<br />
<div class="row center-align">
<h5>Cijena (KM)</h5>
<br /><br />
<div class="col s12">
<div class="center-align no-ui-slider" id="priceFilter"></div>
</div>
</div>
<br />
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input class="sliderInputBox" type="number" id="priceMin" name="priceMin" />
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input class="sliderInputBox" type="number" id="priceMax" name="priceMax" />
</div>
</div>
<br />
<p class="distinguished">
<label class="checkbox-label">
<input type="checkbox" class="filled-in" name="includeWithoutPrice"
checked
>
<span>Uključi i oglase bez cijene</span>
</label>
</p>
<br />
<div class="row center-align">
<h5>Površina (m<sup>2</sup>)</h5>
<br /><br />
<div class="col s12">
<div class="center-align no-ui-slider" id="sizeFilter"></div>
</div>
</div>
<br />
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input class="sliderInputBox" type="number" id="sizeMin" name="sizeMin" />
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input class="sliderInputBox" type="number" id="sizeMax" name="sizeMax" />
</div>
</div>
<br />
<% if(hasGardenSize) { %>
<div class="row center-align">
<h5>Površina okućnice (m<sup>2</sup>)</h5>
<br /><br />
<div class="col s12">
<div class="center-align no-ui-slider" id="gardenSizeFilter"></div>
</div>
</div>
<br />
<div class="row">
<div class="col s5 m3 l3 push-m1 push-l2">
<input
class="sliderInputBox"
type="number"
id="gardenSizeMin"
name="gardenSizeMin"
/>
</div>
<div class="col s5 m3 l3 push-s1 push-m4 push-l4">
<input
class="sliderInputBox"
type="number"
id="gardenSizeMax"
name="gardenSizeMax"
/>
</div>
</div>
<% } %>
<script>
$(document).ready(function() {
const priceSliderOptions = <%- priceSliderOptions %>;
const sizeSliderOptions = <%- sizeSliderOptions %>;
const priceStep = priceSliderOptions.step;
const sizeStep = sizeSliderOptions.step;
delete priceSliderOptions.step;
delete sizeSliderOptions.step;
function updatePriceInputs(values, handle, unencoded) {
$("#priceMin").val(Math.round(unencoded[0]/priceStep)*priceStep);
$("#priceMax").val(Math.round(unencoded[1]/priceStep)*priceStep);
}
function updateSizeInputs(values, handle, unencoded) {
$("#sizeMin").val(Math.round(unencoded[0]/sizeStep)*sizeStep);
$("#sizeMax").val(Math.round(unencoded[1]/sizeStep)*sizeStep);
}
const priceSlider = document.getElementById("priceFilter");
const sizeSlider = document.getElementById("sizeFilter");
const priceSliderObject = noUiSlider.create(priceSlider, priceSliderOptions);
const sizeSliderObject = noUiSlider.create(sizeSlider, sizeSliderOptions);
priceSliderObject.on('slide', updatePriceInputs);
sizeSliderObject.on('slide', updateSizeInputs);
function priceMinChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = priceSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
priceSliderObject.set([fixedNewValue, null]);
$("#priceMin").val(Math.round(priceSliderObject.get()[0]));
}
}
function priceMaxChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
priceSliderObject.set([null, newValue]);
$("#priceMax").val(Math.round(priceSliderObject.get()[1]));
}
}
$("#priceMin").val(priceSliderOptions.start[0]);
$("#priceMax").val(priceSliderOptions.start[1]);
$("#priceMin").change(priceMinChangeHandler);
$("#priceMax").change(priceMaxChangeHandler);
function sizeMinChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = sizeSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
sizeSliderObject.set([fixedNewValue, null]);
$("#sizeMin").val(Math.round(sizeSliderObject.get()[0]));
}
}
function sizeMaxChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
sizeSliderObject.set([null, newValue]);
$("#sizeMax").val(Math.round(sizeSliderObject.get()[1]));
}
}
$("#sizeMin").val(sizeSliderOptions.start[0]);
$("#sizeMax").val(sizeSliderOptions.start[1]);
$("#sizeMin").change(sizeMinChangeHandler);
$("#sizeMax").change(sizeMaxChangeHandler);
<% if(hasGardenSize) { %>
const gardenSizeSliderOptions = <%- gardenSizeSliderOptions %>;
const gardenSizeStep = gardenSizeSliderOptions.step;
delete gardenSizeSliderOptions.step;
function updateGardenSizeInputs(values, handle, unencoded) {
$("#gardenSizeMin").val(Math.round(unencoded[0]/gardenSizeStep)*gardenSizeStep);
$("#gardenSizeMax").val(Math.round(unencoded[1]/gardenSizeStep)*gardenSizeStep);
}
const gardenSizeSlider = document.getElementById("gardenSizeFilter");
const gardenSizeSliderObject = noUiSlider.create(gardenSizeSlider, gardenSizeSliderOptions);
gardenSizeSliderObject.on('slide', updateGardenSizeInputs);
function gardenSizeMinChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const currentValues = gardenSizeSliderObject.get();
const newValue = element.currentTarget.value;
const fixedNewValue = newValue > currentValues[1] ? currentValues[1] : newValue;
gardenSizeSliderObject.set([fixedNewValue, null]);
$("#gardenSizeMin").val(Math.round(gardenSizeSliderObject.get()[0]));
}
}
function gardenSizeMaxChangeHandler(element) {
if (element && element.currentTarget && element.currentTarget.value){
const newValue = element.currentTarget.value;
gardenSizeSliderObject.set([null, newValue]);
$("#gardenSizeMin").val(Math.round(gardenSizeSliderObject.get()[0]));
}
}
$("#gardenSizeMin").val(gardenSizeSliderOptions.start[0]);
$("#gardenSizeMax").val(gardenSizeSliderOptions.start[1]);
$("#gardenSizeMin").change("step", gardenSizeMinChangeHandler);
$("#gardenSizeMax").change("step", gardenSizeMaxChangeHandler);
<% } %>
$("#submit").click(function() {
const priceFilterValues = priceSlider.noUiSlider.get();
$("#priceFilterMin").val(priceFilterValues[0]);
$("#priceFilterMax").val(priceFilterValues[1]);
const sizeFilterValues = sizeSlider.noUiSlider.get();
$("#sizeFilterMin").val(sizeFilterValues[0]);
$("#sizeFilterMax").val(sizeFilterValues[1]);
<% if (hasGardenSize) { %>
const gardenSizeFilterValues = gardenSizeSlider.noUiSlider.get();
$("#gardenSizeFilterMin").val(gardenSizeFilterValues[0]);
$("#gardenSizeFilterMax").val(gardenSizeFilterValues[1]);
<% } %>
$("#filtersForm").submit();
});
});
</script>

View File

@@ -0,0 +1,129 @@
<br/>
<div class="row col s12 center-align">
<div class="col s6 center-align distinguished">
<div><%= showAdType %> </div>
</div>
<div class="col s6 center-align distinguished">
<%= showRealEstateType %>
</div>
</div>
<section class="slider">
<div class="flexslider" >
<ul class="slides">
<% for (const photoUrl of realEstatePhotosUrls) { %>
<li class="flex-li">
<img src=<%= photoUrl %> alt=""/>
</li>
<% } %>
</ul>
</div>
</section>
<br/>
<br>
<div class="row col s12">
<% for (const field of inputFields){ %>
<p>
<span class="col s4"><%= field.title %></span>
<span class="col s8 distinguished dont-break-out"><%= allInputValues[field.dbField] %></span>
</p>
<br>
<% } %>
</div>
<br>
<div class="row">
<% for (const field of segmentFields){ %>
<p>
<span class="col s4"><%= field.title.replace(/>/g,'') %></span>
<% for (const segmentObject of field.values) { %>
<% if (allSegmentSelectedValues[field.dbField] === segmentObject.id) { %>
<span class="col s8 distinguished"><%= segmentObject.title.replace(/>/g,'') %></span>
<% } %>
<% } %>
</p>
<br>
<% } %>
</div>
<br>
<div class="row col s12">
<% for (const field of booleanFields){ %>
<p class="col s4">
<span>&#10003;</span>
<span><%= field.title %></span>
</p>
<% } %>
</div>
<div class="row center-align ">
<div class="distinguished">
<span>Lokacija nekretnine</span>
</div>
<br>
<br>
<div class="col s12">
<div id="map"></div>
</div>
</div>
<script type="text/javascript">
$(window).load(function() {
$('.flexslider').flexslider({
animation: "slide",
smoothHeight: true
});
});
</script>
<script>
//Setting up image gallery - carousel
//Setting up location map
let map;
function initMap() {
const BOSNIA_BOUNDS = {
north: 45.7,
south: 41.69,
west: 15.55,
east: 20.77
};
const ESTATE_COORDINATES = {
lat: <%= locationLat %>,
lng: <%= locationLong %>
};
const mapElement = document.getElementById("map");
const restrictMapPanningToBosniaOnly = {
latLngBounds: BOSNIA_BOUNDS,
strictBounds: true
};
const initialMapParams = {
center: ESTATE_COORDINATES,
zoom: 13,
restriction: restrictMapPanningToBosniaOnly,
mapTypeControl: false,
panControl: false,
zoomControl: true,
streetViewControl: false
};
map = new google.maps.Map(mapElement, initialMapParams);
marker = new google.maps.Marker({
position: ESTATE_COORDINATES,
map: map,
});
}
</script>
<script
src="https://maps.googleapis.com/maps/api/js?key=<%= process.env.GOOGLE_MAP_KEY %>&language=bs&libraries=places&callback=initMap"
async
defer
></script>

View File

@@ -1,4 +1,3 @@
<!-- -->
<br><br>
<div class="row center-align">
<img src="assets/images/logo.svg" alt="kivi logo" width="160">
@@ -8,8 +7,48 @@
<div> Na vaš email. </div>
<div> BESPLATNO </div>
</div>
<div class="row center-align">
<div class="col s6 push-s3">
<a href="<%= nextStep %>" class="welcome-center-button btn">Javi mi</a>
<form method="POST" name="welcomeForm">
<div class="row center-align">
<div class="col s5 m4 l3 push-s1 push-m2 push-l3">
<a href="#" onclick="saleClick()" class="welcome-center-button btn">Kupi</a>
</div>
<div class="col s5 m4 l3 push-s1 push-m2 push-l3">
<a href="#" onclick="rentClick()" class="welcome-center-button btn">Unajmi</a>
</div>
</div>
<input type="hidden" id="adType" name="adType">
</form>
<div class="row center-align">
<div>Objavite svoj oglas.</div>
</div>
<form method="POST" name="welcomePublishForm">
<div class="row center-align">
<div class="col s5 m4 l3 push-s1 push-m2 push-l3">
<a href="#" onclick="publishSaleClick()" class="welcome-center-button btn">Prodaj</a>
</div>
<div class="col s5 m4 l3 push-s1 push-m2 push-l3">
<a href="#" onclick="publishRentClick()" class="welcome-center-button btn">Iznajmi</a>
</div>
</div>
<input type="hidden" id="publishAdType" name="publishAdType">
</form>
<script>
function saleClick(){
$("#adType").val("<%= AD_TYPE.AD_TYPE_SALE.id %>");
document.welcomeForm.submit();
}
function rentClick(){
$("#adType").val("<%= AD_TYPE.AD_TYPE_RENT.id %>");
document.welcomeForm.submit();
}
function publishSaleClick(){
$("#publishAdType").val("<%= AD_TYPE.AD_TYPE_SALE.id %>");
document.welcomePublishForm.submit();
}
function publishRentClick(){
$("#publishAdType").val("<%= AD_TYPE.AD_TYPE_RENT.id %>");
document.welcomePublishForm.submit();
}
</script>

View File

@@ -8,12 +8,22 @@ SEQUELIZE_LOGGING=0- no sequelize logging, 1- log to the console
PORT=Port for the app, defaults to 5000
APP_BASE_URL=base url for the app
SETTINGS=Variable to denote development, staging and production
MAX_REAL_ESTATES_IN_EMAIL=Max number of real estates that will be shown in email, others will be truncated and URL with full list will be shwon
MAX_REAL_ESTATES_IN_FIRST_EMAIL=Max number of real estates that will be shown in first (welcome) email
CHECK_UP_DAYS=Check up email is sent after this number of days without notification
#=============== GOOGLE ANALYTICS =============#
GA_ID=Google Analytics ID
#=============== GOOGLE MAPS =============#
GOOGLE_MAP_KEY=(your-key-here)
#=============== GOOGLE STORAGE =============#
GOOGLE_APPLICATION_CREDENTIALS="Path to json key file"
#=============== AWS SDK EMAIL SETTINGS =======#
AWS_KEY_ID=(your-key-here)
AWS_SECRET_ACCESS_KEY=(your-key-here)
@@ -42,12 +52,14 @@ RENTAL_DELAY_BETWEEN_PAGES=time in miliseconds to wait before indexing next page
RENTAL_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found
#==PROSTOR==
PROSTOR_MAX_PAGES=!!! This is not used for prostor crawler !!!
PROSTOR_MAX_RESULTS_PER_PAGE=For Prostor crawler, this represents MAX RESULTS in total
PROSTOR_MAX_RESULTS_PER_PAGE=For Prostor crawler, this represents how many ads are crawled at once
PROSTOR_CRAWLER_AD_TYPE=enum name of what type of ads should be crawled, check common/enums.js file for valid values
PROSTOR_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values
PROSTOR_IGNORED_USERNAMES=!!! This is not used for prostor crawler !!!
PROSTOR_DELAY_BETWEEN_PAGES=!!! This is not used for prostor crawler !!!
PROSTOR_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found
PROSTOR_LOGIN_EMAIL=Email of valid Prostor.ba account for crawling purposes
PROSTOR_LOGIN_PASS=Password of valid Prostor.ba account for crawling purposes
#==AKTIDO==
AKTIDO_MAX_PAGES=Restrict crawler to this number of pages
AKTIDO_MAX_RESULTS_PER_PAGE=Only this number or less results from one page will be scraped and saved
@@ -56,3 +68,8 @@ AKTIDO_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to
AKTIDO_IGNORED_USERNAMES=!!! This is not used for aktido crawler !!!
AKTIDO_DELAY_BETWEEN_PAGES=time in miliseconds to wait before indexing next page
AKTIDO_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found
#==SALJIC NEKRETNINE==
SALJIC_MAX_RESULTS_PER_PAGE=For Saljic crawler, this represents how many ads are crawled at once
SALJIC_CRAWLER_AD_TYPE=enum name of what type of ads should be crawled, check common/enums.js file for valid values
SALJIC_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories to be included, check common/enums.js file for valid values
SALJIC_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found

184
help.js Normal file
View File

@@ -0,0 +1,184 @@
let autocomplete;
let map;
let places;
let geocoder;
let marker =false; //Initialy no marker on map
const editingRealEstate = <%- editingRealEstate %>;
function locateMe() {
if (navigator.geolocation) {
function onLocationSuccess(position) {
const coordinates =
position && position.coords ? position.coords : null;
if (coordinates) {
const longitude = coordinates.longitude || null;
const latitude = coordinates.latitude || null;
if (longitude && latitude && map) {
map.setCenter({ lat: latitude, lng: longitude });
map.setZoom(16);
}
}
}
navigator.geolocation.getCurrentPosition(onLocationSuccess);
}
}
function initMap() {
const BOSNIA_BOUNDS = {
north: 45.7,
south: 41.69,
west: 15.55,
east: 20.77
};
const SARAJEVO_COORDINATES = {
lat: 43.85,
lng: 18.41
};
const mapElement = document.getElementById("map");
const restrictMapPanningToBosniaOnly = {
latLngBounds: BOSNIA_BOUNDS,
strictBounds: true
};
const initialMapParams = {
center: SARAJEVO_COORDINATES,
zoom: 12,
restriction: restrictMapPanningToBosniaOnly,
mapTypeControl: false,
panControl: false,
zoomControl: true,
streetViewControl: false
};
map = new google.maps.Map(mapElement, initialMapParams);
const inputElement = document.getElementById("autocompleteInput");
const restrictAutocompleteResultsToBosniaOnly = { country: "ba" };
const initialAutocompleteParams = {
types: ["geocode"],
componentRestrictions: restrictAutocompleteResultsToBosniaOnly,
fields: ["geometry", "types", "address_components"]
};
autocomplete = new google.maps.places.Autocomplete(
inputElement,
initialAutocompleteParams
);
autocomplete.bindTo("bounds", map);
autocomplete.addListener("place_changed", onPlaceChanged);
pacSelectFirst(inputElement);
addLocateMeButton(map);
//Move map and marker to already selected position if in editing mode
if( editingRealEstate===true) {
console.log('Editujem mapu!');
setMarkerToLocation(map, editingRealEstate);
}
//Add event listener to position marker on map
google.maps.event.addListener(map, 'click', positionMarker);
}
function positionMarker(event) {
let clickedLocation = event.latLng;
if(marker === false){
marker = new google.maps.Marker({
position: clickedLocation,
map: map,
draggable: true
});
//google.maps.event.addListener(marker, 'dragend', function(event){
// markerLocation();
// });
} else{
marker.setPosition(clickedLocation);
}
}
function setMarkerToLocation(map) {
const ESTATE_COORDINATES = ( <%= locationLat %> !==0 && <%= locationLong %> !== 0 ) ?
{
lat: <%= locationLat %>,
lng: <%= locationLong %>
}:
{ lat: 43.85, //Set to Sarajevo if coordinates are not picked
lng: 18.41
};
marker = new google.maps.Marker({
position: ESTATE_COORDINATES,
map: map,
draggable: true
});
// google.maps.event.addListener(marker, 'dragend', function(event){
// markerLocation();
// });
//Zooming map to current location
map.setCenter(ESTATE_COORDINATES);
map.setZoom(13);
}
function addLocateMeButton(map) {
var parent = document.createElement("div");
parent.className = "locate-me-container";
var a = document.createElement("a");
a.id = "locateMe";
a.className = "btn-floating";
var i = document.createElement("i");
i.innerText = "gps_fixed";
i.className = "material-icons right";
a.appendChild(i);
a.addEventListener("click", locateMe);
parent.appendChild(a);
map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(parent);
}
function onPlaceChanged() {
const place = autocomplete.getPlace();
if (place.geometry) {
map.fitBounds(place.geometry.viewport);
map.setZoom(map.getZoom() + 1);
$("#locationInputData").val(JSON.stringify(place));
}
}
function pacSelectFirst(input) {
// store the original event binding function
const _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") {
const originalListener = listener;
listener = function(event) {
const suggestionSelected = $(".pac-item-selected").length > 0;
if (event.key == "Enter" && !suggestionSelected) {
const simulatedDownArrow = $.Event("keydown", {
keyCode: 40,
which: 40
});
originalListener.apply(input, [simulatedDownArrow]);
}
originalListener.apply(input, [event]);
};
}
_addEventListener.apply(input, [type, listener]);
}
input.addEventListener = addEventListenerWrapper;
input.attachEvent = addEventListenerWrapper;
}

112
help2.js Normal file
View File

@@ -0,0 +1,112 @@
const validatorFunction = () => {
// These are the constraints used to validate the form --just email for now!
const constraints = {
email: {
email: {
message: "Proba"
},
// Email is required
presence: true,
// and must be an email (duh)
email: true
}
};
// Hook up the inputs to validate on the fly
const inputs = document.querySelectorAll("#email");
// inputs.on("change", ev => {
// const errors = validate(form, constraints) || {};
// showErrorsForInput(this, errors[this.name]);
// });
// var inputs = document.querySelectorAll("input, textarea, select");
for (var i = 0; i < inputs.length; ++i) {
inputs.item(i).addEventListener("change", function(ev) {
var errors = validate(form, constraints) || {};
showErrorsForInput(this, errors[this.name]);
});
}
const handleFormSubmit = (form, input) => {
// validate the form against the constraints
const errors = validate(form, constraints);
//
console.log("handleFormSubmit error:", errors);
// then we update the form to reflect the results
showErrors(form, errors || {});
if (!errors) {
showSuccess();
}
};
// Updates the inputs with the validation errors
const showErrors = (form, errors) => {
// We loop through all the inputs and show the errors for that input
$.each(form.querySelectorAll("input[name], select[name]"), input => {
// Since the errors can be null if no errors were found we need to handle
// that
showErrorsForInput(input, errors && errors[input.name]);
});
//showErrorsForInput(email, errors && errors[email]);
};
// Shows the errors for a specific input
const showErrorsForInput = (input, errors) => {
// This is the root of the input
const formGroup = closestParent(input.parentNode, "form-group"),
// Find where the error messages will be insert into
messages = formGroup.querySelector(".messages");
// First we remove any old messages and resets the classes
resetFormGroup(formGroup);
// If we have errors
if (errors) {
//
console.log("errors:", errors);
// we first mark the group has having errors
formGroup.classList.add("has-error");
// then we append all the errors
$.each(errors, error => {
addError(messages, errors[error]);
});
} else {
// otherwise we simply mark it as success
formGroup.classList.add("has-success");
}
};
// Recusively finds the closest parent that has the specified class
const closestParent = (child, className) => {
if (!child || child == document) {
return null;
}
if (child.classList.contains(className)) {
return child;
} else {
return closestParent(child.parentNode, className);
}
};
const resetFormGroup = formGroup => {
// Remove the success and error classes
formGroup.classList.remove("has-error");
formGroup.classList.remove("has-success");
// and remove any old messages
$.each(formGroup.querySelectorAll(".help-block.error"), el => {
el.parentNode.removeChild(el);
});
};
// Adds the specified error with the following markup
// <p class="help-block error">[message]</p>
const addError = (messages, error) => {
const block = document.createElement("p");
block.classList.add("help-block");
block.classList.add("error");
block.innerText = error;
messages.appendChild(block);
};
const showSuccess = () => {
// We made it \:D/
alert("Success!");
};
};

Some files were not shown because too many files have changed in this diff Show More