Compare commits

...

322 Commits

Author SHA1 Message Date
=
689a3cfadf Run prettier 2019-10-18 05:33:03 -07:00
=
41acb75b81 Fix problems with button color 2019-10-18 05:31:13 -07:00
=
9b830bdd01 Fixed a couple of things like (locate me, location edit) 2019-10-18 03:29:15 -07:00
Bilal Catic
bfb510252b Merge branch 'remove-obsolete-files' into 'master'
Remove obsolete files

See merge request saburly/marketalarm/web!52
2019-10-18 08:19:10 +00:00
Bilal Catic
67a7223a3a add OLX scraper test NPM script 2019-10-18 09:14:08 +02:00
Bilal Catic
2baf26db1c delete obsolete files 2019-10-18 09:13:39 +02:00
Edin
8654f0be58 Merge branch 'minor-fixes-and-favicon' into 'master'
Added favicon and other CSS fixes (color, header padding)

See merge request saburly/marketalarm/web!51
2019-10-17 13:15:52 +00:00
=
5c8fe05e36 Added favicon and other CSS fixes (color, header padding) 2019-10-17 06:05:18 -07:00
Bilal Catic
9341b72f6f Merge branch 'improve-slider-ranges' into 'master'
Improve slider ranges

See merge request saburly/marketalarm/web!50
2019-10-16 13:35:19 +00:00
Bilal Catic
8e5bc199a5 increase garage max size 2019-10-16 15:33:25 +02:00
Bilal Catic
8fca523bb5 change slider ranges for land and garage 2019-10-16 15:09:05 +02:00
Bilal Catic
a9109835fb hide forceSSL logs 2019-10-16 15:08:45 +02:00
Bilal Catic
d2513bca9f Merge branch 'force-ssl' into 'master'
redirect http to https

See merge request saburly/marketalarm/web!49
2019-10-16 10:54:38 +00:00
Bilal Catic
0fd6e88496 redirect http to https 2019-10-16 12:49:58 +02:00
Bilal Catic
69ed16af95 Merge branch 'add-logo-with-name' into 'master'
use logo with text

See merge request saburly/marketalarm/web!48
2019-10-16 09:07:29 +00:00
Bilal Catic
06d678dda5 use logo with text 2019-10-16 11:05:14 +02:00
Bilal Catic
62ef705341 Merge branch 'remove-location-placeholder' into 'master'
remove location placeholder; fix typo in email notification

See merge request saburly/marketalarm/web!47
2019-10-15 21:36:07 +00:00
Bilal Catic
e17f62dd6f remove location placeholder; fix typo in email notification 2019-10-15 23:35:41 +02:00
Bilal Catic
792ada2380 Merge branch 'change-logo' into 'master'
Change logo

See merge request saburly/marketalarm/web!46
2019-10-15 20:09:37 +00:00
Bilal Catic
384b4a356b fix redirect and unsubscribe for invalid id 2019-10-15 21:50:45 +02:00
Bilal Catic
247fe4d35c remove unused css rule 2019-10-15 21:50:45 +02:00
Bilal Catic
c949dfac21 change logo and real estate type selection color 2019-10-15 21:50:45 +02:00
Bilal Catic
0a32e0998b Merge branch 'improve-clickable-items' into 'master'
Improve clickable items

See merge request saburly/marketalarm/web!45
2019-10-15 19:38:47 +00:00
Bilal Catic
205892112d add waves effect to the real estate type selection items 2019-10-15 18:56:52 +02:00
Bilal Catic
acdeca94df redirect to Not Found page when search request param is missing from URL 2019-10-15 18:51:19 +02:00
Bilal Catic
566f9c4345 fix null value for garden size when real estate type is changed 2019-10-15 18:46:11 +02:00
Bilal Catic
2a1361733f change real estate type selection design 2019-10-15 18:34:34 +02:00
Bilal Catic
e0a1444b55 show error page when no search request is found 2019-10-14 11:30:52 +02:00
Bilal Catic
25ee3bc16c Merge branch 'modify-css-to-accomodate-KIVI-design' into 'master'
Modify css to accomodate kivi design

See merge request saburly/marketalarm/web!44
2019-10-14 09:18:21 +00:00
Bilal Catic
362ee0dbb2 fix query review empty email bug 2019-10-14 10:56:01 +02:00
Bilal Catic
418b8be746 apply kivi design 2019-10-14 10:52:32 +02:00
Bilal Catic
8f0b18a4d7 Merge branch 'add-other-olx-real-estate-categories' into 'master'
Add other olx real estate categories

See merge request saburly/marketalarm/web!43
2019-10-14 07:28:10 +00:00
Bilal Catic
dde56ef139 show email on query review page if it is already saved 2019-10-14 09:27:32 +02:00
Bilal Catic
60395078e6 fix how filters are loaded and shown on the filter and query review page 2019-10-14 09:27:32 +02:00
Bilal Catic
88e7cac420 allow olx crawler to recognize other OLX categories 2019-10-14 09:27:32 +02:00
Bilal Catic
9fc5072632 include other olx real estate categories in enums and configs 2019-10-14 09:27:32 +02:00
Bilal Catic
722d549393 Merge branch 'select-first-result-from-geolocation-dropdown-with-enter' into 'master'
Select first result from geolocation dropdown with enter

See merge request saburly/marketalarm/web!42
2019-10-14 07:26:47 +00:00
Bilal Catic
093571e8c5 Merge branch 'modify-notification-email-subject' into 'master'
modify notification email subject

See merge request saburly/marketalarm/web!41
2019-10-14 07:26:31 +00:00
Bilal Catic
3f72ffba89 change range filters from non-linear to linear 2019-10-13 23:32:09 +02:00
Bilal Catic
105ed47276 select first option from google autocomplete for location input 2019-10-13 22:47:07 +02:00
Bilal Catic
0a7aaec4c6 modify notification email subject 2019-10-11 23:10:28 +02:00
Bilal Catic
c0cbaf5b73 Merge branch 'combine-filters-on-one-page' into 'master'
Combine filters on one page

See merge request saburly/marketalarm/web!40
2019-10-11 15:25:57 +00:00
Bilal Catic
1dbe1da802 fix default range filter values 2019-10-11 15:45:50 +02:00
Bilal Catic
164510c8fc fix next button width 2019-10-11 15:45:29 +02:00
Bilal Catic
3251aca4e7 move all filters to one page 2019-10-11 15:37:47 +02:00
Bilal Catic
ef3d97612b remove .idea directory 2019-10-10 19:50:44 +02:00
Bilal Catic
73a39bcd0a control Google Analytics ID with ENV variable 2019-10-10 17:39:13 +02:00
Bilal Catic
549ab4e5e4 Merge branch 'fix-nav-bar-width' into 'master'
move navbar outside of the container

See merge request saburly/marketalarm/web!39
2019-10-10 12:32:36 +00:00
Bilal Catic
e2b72628a8 move navbar outside of the container 2019-10-10 13:54:18 +02:00
Bilal Catic
19a5c914aa Merge branch 'add-google-analytics' into 'master'
Add google analytics

See merge request saburly/marketalarm/web!38
2019-10-10 08:35:16 +00:00
Bilal Catic
73a792862b remove email sending logs 2019-10-10 00:59:12 +02:00
Bilal Catic
0818fcecd2 remove crawler and saver logging 2019-10-10 00:59:12 +02:00
Bilal Catic
40fb1e6ad7 create custom view for redirect and custom view for not found 2019-10-10 00:59:12 +02:00
Bilal Catic
20a93b7fdc add google analytics to the wizard steps 2019-10-10 00:59:12 +02:00
Bilal Catic
b17d97b40e Merge branch 'move-email-entry-to-the-review-page' into 'master'
Move email entry to the review page

See merge request saburly/marketalarm/web!37
2019-10-09 13:42:33 +00:00
Bilal Catic
778197b32f Merge branch 'add-notification-on-new-search-request' into 'master'
find matching real estates for new search request and notify

See merge request saburly/marketalarm/web!36
2019-10-09 13:41:57 +00:00
Bilal Catic
72864f3a37 move email input to the review step 2019-10-08 20:42:54 +02:00
Bilal Catic
615aed65b6 find matching real estates for new search request and notify 2019-10-07 20:49:54 +02:00
Bilal Catic
502c7c3e35 fix error on email validation failure 2019-09-30 19:33:01 +02:00
Bilal Catic
3e5371f780 Merge branch 'notification-service' into 'master'
Notification service

See merge request saburly/marketalarm/web!35
2019-09-30 14:23:39 +00:00
Bilal Catic
bbff526ea0 skip real estates without location in search 2019-09-30 14:27:26 +02:00
Bilal Catic
5e8e13a984 fix enums 2019-09-30 14:27:01 +02:00
Bilal Catic
37ba7e2c8c pass new real estates to the notification service 2019-09-30 13:53:35 +02:00
Bilal Catic
99a6cefd89 fix real estates list 2019-09-30 13:24:07 +02:00
Bilal Catic
0fdef956a7 use sequelize include option when querying real estates from matches 2019-09-30 13:20:38 +02:00
Bilal Catic
4b5548aa96 ad AS to DB query 2019-09-30 13:20:11 +02:00
Bilal Catic
b703d55c06 fix model associations 2019-09-30 13:17:27 +02:00
Bilal Catic
4713e78d51 remove obsolete DB helper functions 2019-09-30 12:08:37 +02:00
Bilal Catic
6272ec0f47 use matching service to find matches 2019-09-30 12:08:16 +02:00
Bilal Catic
dac7043a56 add service to find real estate - search request matches 2019-09-30 12:07:47 +02:00
Bilal Catic
12f826ef55 add methods to add and find real estate - search request matches 2019-09-30 12:07:21 +02:00
Bilal Catic
5a7b9826c2 refactor email content 2019-09-30 12:06:35 +02:00
Bilal Catic
b3ea2398d4 add more real estates to test 2019-09-30 10:31:50 +02:00
Bilal Catic
af36653c1a refactor redirect - use new method to find real estate by id 2019-09-30 10:31:07 +02:00
Bilal Catic
31ffc3253f add method to find search requests for real estate 2019-09-30 10:30:13 +02:00
Bilal Catic
54f7e4ba53 add method to find real estate by id 2019-09-30 10:29:27 +02:00
Bilal Catic
87a72be8fe use notificationService to send email for new search request 2019-09-30 10:29:05 +02:00
Bilal Catic
a738a371ae add notificationService to handle when, what and to whom to send email 2019-09-30 10:28:29 +02:00
Bilal Catic
9c0104a57c refactor crawler - adapt to use new ENUM objects 2019-09-30 10:27:12 +02:00
Bilal Catic
e1dfd6a4eb add helper to generate email; install html-to-text package 2019-09-30 09:51:10 +02:00
Bilal Catic
68ef31f867 add emailService to handle email sending 2019-09-30 09:49:47 +02:00
Bilal Catic
e3e47345bc load AWS config through app config; fix ENV path 2019-09-30 09:44:19 +02:00
Bilal Catic
c57d3f8457 add npm command to test matching and notification services 2019-09-29 19:28:47 +02:00
Bilal Catic
6d7366f2c2 use single ENUMS; change old realEstateType enums 2019-09-27 19:36:20 +02:00
Bilal Catic
d8d801d314 use single ENUMS; change old realEstateType enums 2019-09-27 18:20:16 +02:00
Bilal Catic
f31355dd84 use AD_TYPE enums for SearchRequest adType field 2019-09-27 16:49:10 +02:00
Bilal Catic
ad873d61e7 Merge branch 'start-crawler-loop-with-server' into 'master'
start crawler loop when server is started

See merge request saburly/marketalarm/web!34
2019-09-27 09:22:17 +00:00
Bilal Catic
c5a720484a add ENV variable to control crawler execution 2019-09-26 23:55:34 +02:00
Bilal Catic
2e92f961ff start crawler loop when server is started 2019-09-26 17:30:06 +02:00
Bilal Catic
0b083a02e2 Merge branch 'make-crawler-smarter' into 'master'
Make crawler smarter

See merge request saburly/marketalarm/web!33
2019-09-25 17:15:14 +00:00
Bilal Catic
3d203df988 remove comment from delay between indexing pages 2019-09-25 10:00:42 +00:00
Bilal Catic
c9a959f8be stop crawling when existing, not renewed ad is found 2019-09-25 08:55:00 +02:00
Bilal Catic
b3fcc6ba9a return new and existing real estates when saving results 2019-09-25 08:55:00 +02:00
Bilal Catic
f93d0e738f add delay between pages config variable 2019-09-25 08:55:00 +02:00
Bilal Catic
90bc57edb6 stop crawling when existing, non-renewed ad is found 2019-09-25 08:55:00 +02:00
Bilal Catic
746732f30b remove deleted column from RealEstate model 2019-09-25 08:55:00 +02:00
Bilal Catic
06d35fcb4b move ignored usernames config to crawler specific config 2019-09-25 08:55:00 +02:00
Bilal Catic
63eb64b0f6 parse and save published and renewed dates 2019-09-25 08:55:00 +02:00
Bilal Catic
c7184be5fc install moment and moment-timezone packages 2019-09-25 08:55:00 +02:00
Bilal Catic
18db554ea8 add published and renewed date columns to the RealEstates table 2019-09-25 08:55:00 +02:00
Bilal Catic
3140fdf0c0 use function generator to index pages; crawl in parallel 2019-09-25 08:55:00 +02:00
Bilal Catic
c4f6c6e1c3 construct crawling url before indexing single page 2019-09-25 08:55:00 +02:00
Edin Dazdarevic
5f1697f6ae Set viewport correctly 2019-09-21 19:44:22 +02:00
Bilal Catic
7845587fa7 Merge branch 'go_again_copy' into 'master'
Change final screen to explain to user what they can expect next

See merge request saburly/marketalarm/web!32
2019-09-21 15:58:20 +00:00
Bilal Catic
c2d1725173 Merge branch 'map_screen_copy' into 'master'
Add copy that explains the map screen better.

See merge request saburly/marketalarm/web!31
2019-09-21 14:42:32 +00:00
Bilal Catic
e3636451b7 fix typo 2019-09-21 14:41:20 +00:00
Senad Uka
0f01a070ca Change final screen to explain to user what they can expect next 2019-09-21 08:14:04 +02:00
Senad Uka
bfcc0a09d9 Add copy that explains the map screen better. 2019-09-21 06:06:39 +02:00
Bilal Catic
51411a4109 Merge branch 'switch-to-new-crawler' into 'master'
Switch to new crawler

See merge request saburly/marketalarm/web!30
2019-09-18 14:06:16 +00:00
Bilal Catic
f9cd26fca8 refactor enums 2019-09-18 15:33:58 +02:00
Bilal Catic
e374cac60e update RealEstates model, add DB helper functions for RealEstates model 2019-09-18 15:33:42 +02:00
Bilal Catic
d3b2f95719 add new ENV variables to control crawler 2019-09-18 15:33:09 +02:00
Bilal Catic
3d46c82d3d create new crawler and Postgres saver 2019-09-18 15:32:48 +02:00
Bilal Catic
2e13763939 update Sequelize 2019-09-18 06:35:39 +02:00
Bilal Catic
2702259eb1 change title, short and long description columns type to Text 2019-09-18 06:35:26 +02:00
Bilal Catic
17461b2e57 remove deleted and sold columns, adStatus is replacement column 2019-09-17 10:27:25 +02:00
Bilal Catic
5cb12b1714 remove lastTimeCrawled column - use updatedAt for this 2019-09-17 10:12:49 +02:00
Bilal Catic
b96ce17dbf add unique constraint - composite key to the RealEstates table 2019-09-17 09:07:14 +02:00
Bilal Catic
88ee2ee326 add adStatus column to the RealEstates table 2019-09-16 23:20:18 +02:00
Bilal Catic
33b91c8161 add short and long description columns to the RealEstates table 2019-09-16 23:09:29 +02:00
Bilal Catic
25faf75d6f add title column to the RealEstates table 2019-09-16 16:08:29 +02:00
Bilal Catic
76a989fa37 replace old crawler, without specific crawler and saver implementation 2019-09-16 15:59:53 +02:00
Bilal Catic
b28b160852 fix locate me button position 2019-09-16 11:56:55 +02:00
Bilal Catic
dbb9807e63 Merge branch 'redesign-DB-and-adapt-search-request' into 'master'
Redesign db and adapt search request

See merge request saburly/marketalarm/web!29
2019-09-13 16:55:11 +00:00
Bilal Catic
5dfe363adc adapt unsubscribe step for new DB design 2019-09-13 14:54:21 +02:00
Bilal Catic
2b22cd04fd fix unsubscribe and review links in email notification 2019-09-13 14:50:23 +02:00
Bilal Catic
c01cb03762 adapt query submit size step for new DB design 2019-09-13 14:40:10 +02:00
Bilal Catic
b4c71b93de adapt garden size step for new DB design 2019-09-13 14:30:54 +02:00
Bilal Catic
e26c2b6e8d adapt price and query review steps for new DB design 2019-09-13 14:17:46 +02:00
Bilal Catic
ff68e96f4f adapt size step for new DB design 2019-09-13 13:58:58 +02:00
Bilal Catic
db58d1e98b adapt location step for new DB design 2019-09-13 12:37:53 +02:00
Bilal Catic
fbf7aabe93 use sequelize findByPk method for getting SearchRequest object 2019-09-13 12:35:23 +02:00
Bilal Catic
f258800fd8 change defaultValue for subscribed to false 2019-09-13 12:33:16 +02:00
Bilal Catic
8f09d4f227 change url param case from snake_case to camelCase 2019-09-13 11:11:28 +02:00
Bilal Catic
1a874d4d88 adapt first step of search request to new DB design 2019-09-13 11:08:45 +02:00
Bilal Catic
3cbd79dcc9 create SearchRequest db helper model 2019-09-13 11:06:58 +02:00
Bilal Catic
77d9669ad6 modify URL helper for getting searchRequestId 2019-09-13 11:06:03 +02:00
Bilal Catic
b7d147b0a6 fix SearchRequest model, add default values for not null fields 2019-09-13 10:57:43 +02:00
Bilal Catic
e32e98537c add createdAt, updatedAt fields; add default values for not null fields 2019-09-13 10:56:46 +02:00
Bilal Catic
81ecb37493 add models for new DB design 2019-09-13 09:47:49 +02:00
Bilal Catic
75daf55fdf add migrations for new DB design 2019-09-13 09:46:59 +02:00
Bilal Catic
05f8cbd816 move old models to oldModels folder 2019-09-13 08:57:41 +02:00
Bilal Catic
a61816c5c2 move routes to separated file; stop running crawler in main loop 2019-09-12 06:23:03 +02:00
Bilal Catic
f781dc1cd0 create separate file for routes 2019-09-12 06:21:49 +02:00
Bilal Catic
510c7917dd fix APP_URL variable 2019-09-11 11:27:45 +02:00
Bilal Catic
202c4e441e use process.env.PORT so it can be deployed to Heroku 2019-09-11 11:23:05 +02:00
Bilal Catic
0b78254573 Merge branch 'restrict-email-results-to-10-only' into 'master'
limit number of real estate results in email to 10

See merge request saburly/marketalarm/web!28
2019-09-10 15:25:29 +00:00
Bilal Catic
1f79e1d555 limit number of real estate results in email to 10 2019-09-10 17:16:25 +02:00
Bilal Catic
de3ea71342 Merge branch 'simplify-location-search' into 'master'
Simplify location search

See merge request saburly/marketalarm/web!27
2019-09-10 15:15:09 +00:00
Bilal Catic
74125d761a improve zoom level by incrementing by one 2019-09-10 17:08:24 +02:00
Bilal Catic
9ec7e4be14 add locate me button; fix zoom level 2019-09-10 16:59:59 +02:00
Bilal Catic
aea221f0c3 Change email template; remove region, municipality values 2019-09-10 12:11:44 +02:00
Bilal Catic
4d245f3127 add zoom controls on the location map 2019-09-10 11:49:42 +02:00
Bilal Catic
fbbc0952e3 fix undefined split function exception 2019-09-10 11:49:26 +02:00
Bilal Catic
93fe5ee870 remove region and municipality params when searching olx 2019-09-10 11:19:42 +02:00
Bilal Catic
7fc932350e change realEstateType id property to olxid 2019-09-10 10:58:57 +02:00
Bilal Catic
17a6250b84 change column name for database query 2019-09-10 10:56:16 +02:00
Bilal Catic
78a4fd8eef add placeholder for location input and change map/autocomplete language 2019-09-10 08:04:54 +02:00
Bilal Catic
13c462f328 remove old request parameters and add location parameter to the review 2019-09-10 07:44:43 +02:00
Bilal Catic
af908759bf go to the location view after real estate type selection 2019-09-10 07:43:43 +02:00
Bilal Catic
7a6e1d5cfe add new view and controller for location 2019-09-10 07:43:18 +02:00
Bilal Catic
854984029c remove obsolete controllers and views 2019-09-10 07:42:59 +02:00
Bilal Catic
7a18a89131 return request directly 2019-09-10 05:46:52 +02:00
Bilal Catic
65c9ece073 change bounding box column name from snake case to camel case 2019-09-10 05:45:58 +02:00
Bilal Catic
d529ef64ae add migration 2019-09-09 13:08:32 +02:00
Bilal Catic
f84fed0a01 Merge branch 'project-fixes' into 'master'
Project fixes

See merge request saburly/marketalarm/web!26
2019-09-09 10:49:56 +00:00
Bilal Catic
87b79f3938 fix region and municipality OLX ids 2019-09-07 00:36:47 +02:00
Bilal Catic
68c69144a2 fix scheduler execution rule 2019-09-06 12:25:34 +02:00
Bilal Catic
b0f9c2c47b remove all logging noise 2019-09-06 12:01:25 +02:00
Bilal Catic
1d29f6c8ac change how APP_URL is used, use JS templating string 2019-09-05 14:24:29 +02:00
Bilal Catic
f311404968 allow control over sequelize logging using ENV variable 2019-09-05 13:50:13 +02:00
Bilal Catic
08f73445e9 add ENV variables for DB credentials to override sequelize config 2019-09-05 12:51:01 +02:00
Bilal Catic
60e618fd22 apply prettier on all files 2019-09-05 11:14:54 +02:00
=
719cf4d8f9 Email links now point to our URL and then redirect to the original property URL, ran prettier on the affected files 2019-09-04 07:00:27 -07:00
Nedim Uka
09792db21c Merge branch 'marketalerts-page' into 'master'
Real Estate Page

See merge request saburly/marketalarm/web!24
2019-07-15 09:48:08 +00:00
Nedim Uka
1999d45cb2 Changed template name 2019-07-15 11:47:41 +02:00
Nedim Uka
778b5ff411 Fixed bug, for duplicate results for 2 similar re reqests of one user 2019-07-15 11:40:28 +02:00
Nedim Uka
81c30c36ec Added realestate link to bulk email 2019-07-12 18:00:02 +02:00
Nedim Uka
753a09aa36 Fixed crawler not reading and comparing all RERequest results 2019-07-12 16:13:03 +02:00
Bilal Catic
4517624fa8 Merge branch 'nav-bar' into 'master'
Handle nav-bar

See merge request saburly/marketalarm/web!23
2019-07-12 13:22:16 +00:00
Nedim Uka
f9abf48f61 Removed unecessary comments 2019-07-12 10:53:23 +02:00
Nedim Uka
afeffe8c71 Added roboto font 2019-07-11 14:33:59 +02:00
Nedim Uka
a6bd63b7b8 Handle nav-bar 2019-07-11 14:25:38 +02:00
Bilal Catic
e305c547e1 Merge branch 'fetch-optimisation' into 'master'
Fetch optimization

See merge request saburly/marketalarm/web!22
2019-07-10 14:35:37 +00:00
Nedim Uka
33f9e37d93 Filter data by geolocation now sets hasLocation boolean instead of excluding results 2019-07-10 15:21:46 +02:00
Nedim Uka
5829de64e0 Added hrefs to global varialbe 2019-07-10 12:39:32 +02:00
Bilal Catic
efea857889 Merge branch 'services-scheduler' into 'master'
Added node schedule to run crawler and notification service

See merge request saburly/marketalarm/web!21
2019-07-09 21:51:15 +00:00
Nedim Uka
a43723485c Added node schedule to run crawler and notification service 2019-07-09 16:33:00 +02:00
Nedim Uka
1b098f181c Reduced pager to 5 pages at a time 2019-07-08 13:02:28 +02:00
Bilal Catic
2dd1eaa5fd Merge branch 'crawler-optimisation' into 'master'
Crawler optimisation

See merge request saburly/marketalarm/web!20
2019-07-08 08:01:48 +00:00
Nedim Uka
039b1a6376 Optimiset crawlers , and pagingation 2019-07-05 17:18:47 +02:00
Nedim Uka
222a134bbf Optimised crawler speed by using promises 2019-07-04 17:28:09 +02:00
Nedim Uka
0672f3c019 Changed template name 2019-07-04 09:51:04 +02:00
Bilal Catic
e4b3e3961d Merge branch 'notification-email-subject' into 'master'
Notification email subject

See merge request saburly/marketalarm/web!19
2019-07-04 07:44:49 +00:00
Nedim Uka
a807cb5bf2 Bulk emali subject 2019-07-03 16:01:55 +02:00
Nedim Uka
b79a274f96 Added formated subject to bulk email 2019-07-02 21:49:56 +02:00
Bilal Catic
7f0b2d299e Merge branch 'send-notification' into 'master'
Send notification

See merge request saburly/marketalarm/web!18
2019-07-02 10:32:11 +00:00
Nedim Uka
8b20f0e170 Formated title 2019-07-02 12:25:22 +02:00
Nedim Uka
93c147e73b Looged amazon send bulk email response, fixed some emails not sent bug 2019-07-02 11:54:33 +02:00
Nedim Uka
96e9da1fb1 Send templated bulk email, and remember notifed marketalerts 2019-06-28 18:06:19 +02:00
Nedim Uka
b3baffe174 Send notification email 2019-06-27 17:29:57 +02:00
Nedim Uka
208faa08df Added send notification service, and queried unsent marketalerts, fixed some issues with crawler, and added proper logging 2019-06-25 17:07:02 +02:00
Bilal Catic
5ffdaef1bf Merge branch 'crawler-service' into 'master'
Crawler service

See merge request saburly/marketalarm/web!17
2019-06-24 14:09:47 +00:00
Nedim Uka
1aa91fb4e2 Fixed gardenSize 2019-06-24 15:34:59 +02:00
Nedim Uka
2cf6f6f1ff Code refactoring, fixed bug with price parsing: 2019-06-24 14:20:31 +02:00
Nedim Uka
6eba5c2a97 gardenSize nan 2019-06-24 11:49:13 +02:00
Nedim Uka
2f474619ca Compare crawler results with db, and only save new if necessary 2019-06-21 16:48:19 +02:00
Nedim Uka
80ff9bcb6b saving additional fields, improved async functions with promises 2019-06-21 15:14:43 +02:00
Nedim Uka
3c59292f23 refactoring 2019-06-20 21:27:51 +02:00
Nedim Uka
1bcc5e8e5d Preparing to save results to db 2019-06-20 14:51:14 +02:00
Nedim Uka
c8ee848f0e Improved results filtering by lat lng 2019-06-20 10:57:37 +02:00
Nedim Uka
0f630e9ea4 Olix crawling, filter crawling result by lat, lng 2019-06-19 17:12:22 +02:00
Nedim Uka
9a8a27d1d9 Scheduler 2019-06-18 15:05:40 +02:00
Nedim Uka
b17b6862ba Added migrations, expanded maketalert table 2019-06-18 14:01:09 +02:00
Nedim Uka
6aaaea1612 working on crawler 2019-06-13 15:49:31 +02:00
Nedim Uka
fdd0124924 Added crawler service 2019-06-13 13:31:35 +02:00
Nedim Uka
c15f45e8f4 Fixed map not loding bug 2019-06-12 15:20:58 +02:00
Nedim Uka
371eac900e Merge branch 'range-slider' into 'master'
Range slider

See merge request saburly/marketalarm/web!16
2019-06-12 11:37:15 +00:00
Nedim Uka
5d6e7f3938 fixed slider css overlaping 2019-06-12 13:36:49 +02:00
Nedim Uka
efda7fdccd Added nouiRange slider 2019-06-12 13:32:28 +02:00
Nedim Uka
8bb0908c45 Slider thumb fix 2019-06-11 16:22:17 +02:00
Bilal Catic
5c75d690b0 Merge branch 'slider-bug' into 'master'
Sliders bug

See merge request saburly/marketalarm/web!15
2019-06-11 11:21:33 +00:00
Nedim Uka
f0e8a72756 Sliders now returning to correct range if they go beyond allowed value 2019-06-11 11:44:59 +02:00
Bilal Catic
62bf3380cd Merge branch 'confirmation-email' into 'master'
Geocoding restricttions, added values for range finders, added confirmation email, and .env file

See merge request saburly/marketalarm/web!14
2019-06-11 08:42:06 +00:00
Nedim Uka
caa1871939 deleted env file 2019-06-11 10:34:48 +02:00
Nedim Uka
506ac67956 Fixed garden size email issues 2019-06-11 10:26:48 +02:00
Nedim Uka
8f9e3ae46a Geocoding restricttions, added values for range finders, added confirmation email, and .env file 2019-06-10 17:29:31 +02:00
Bilal Catic
d6e999fcf1 Merge branch 'double-email' into 'master'
Double email

See merge request saburly/marketalarm/web!13
2019-05-30 12:08:25 +00:00
Nedim Uka
08a94ca4f8 Fixed google maps bug, changed size, gardenSize, and price colum names, fixed bug with query review not showing default values 2019-05-30 10:43:47 +02:00
Nedim Uka
a0f2b044b2 Added validation to email confirmation 2019-05-29 17:10:41 +02:00
Nedim Uka
7db74acad7 Set range fileds to be integer instead of strings 2019-05-29 17:10:41 +02:00
Bilal Catic
56865b4670 Merge branch 'realestate-size-slider' into 'master'
Real Estate Slider

See merge request saburly/marketalarm/web!12
2019-05-29 10:18:17 +00:00
Nedim Uka
1a8ac3fba4 Added range slider to gardensize and price 2019-05-29 11:03:01 +02:00
Nedim Uka
de3c76315e Real Estate Slider 2019-05-28 16:46:38 +02:00
Bilal Catic
e969a8dc8b Merge branch 'edit-bug-fix' into 'master'
Fixed bug related to map region edit

See merge request saburly/marketalarm/web!11
2019-05-27 15:52:11 +00:00
Nedim Uka
f4baec23cf Fixed bug related to map region edit 2019-05-27 15:17:41 +02:00
Bilal Catic
5bf95e0594 Merge branch 'google-maps' into 'master'
Added google maps step

See merge request saburly/marketalarm/web!10
2019-05-27 08:35:04 +00:00
Nedim Uka
6fbacb326f Fixed bounding box bug, and removed unecesary params 2019-05-27 09:18:54 +02:00
Nedim Uka
be416ffc0c Added google maps step 2019-05-24 16:16:47 +02:00
Nedim Uka
a3d9a82fee Merge branch 'fix-minor-bugs' into 'master'
Fix minor bugs

See merge request saburly/marketalarm/web!9
2019-05-24 13:54:40 +00:00
Nedim Uka
6772f8a953 Merge branch 'enable-return-to-query-review-directly' into 'master'
Enable return to query review directly

See merge request saburly/marketalarm/web!8
2019-05-24 13:53:27 +00:00
Nedim Uka
89a3c9e355 Merge branch 'change-migrations-use-string-instead-of-enums' into 'master'
Change migrations - use string instead of enum

See merge request saburly/marketalarm/web!7
2019-05-24 13:50:55 +00:00
Bilal Catic
dd38602c5a add simple email validation 2019-05-22 16:57:08 +02:00
Bilal Catic
a3f76d20fe fix URL on send icon 2019-05-22 15:58:42 +02:00
Bilal Catic
fc1275566e handle undefined realEstateType 2019-05-22 13:19:27 +02:00
Bilal Catic
c64ee42914 Skip and prevent saving garden size if not needed 2019-05-22 11:36:01 +02:00
Bilal Catic
aa3c965d5c skip to query review directly when editing data 2019-05-21 15:32:47 +02:00
Bilal Catic
126da48852 modify realEstateRequest model to use String instead of enum 2019-05-21 15:26:53 +02:00
Bilal Catic
58ae430564 modify migrations - use string instead of enum 2019-05-21 15:26:53 +02:00
Senad Uka
315a29749c Update database configuration 2019-05-21 12:11:30 +02:00
Nedim Uka
02bee9cf2c Merge branch 'add-steps-to-wizard' into 'master'
Add steps to wizard

See merge request saburly/marketalarm/web!6
2019-05-21 09:39:43 +00:00
Bilal Catic
1c2847509a add final page 2019-05-19 19:45:19 +02:00
Bilal Catic
87dc742e41 add query submit page 2019-05-19 13:34:44 +02:00
Bilal Catic
70ddc1f734 add query review page 2019-05-19 12:29:55 +02:00
Bilal Catic
2c415bbd79 use enums from enum file 2019-05-19 10:03:52 +02:00
Bilal Catic
b07eb5bbeb fix available value input for size 2019-05-19 10:03:36 +02:00
Bilal Catic
53585d3ae1 use enums from enum file 2019-05-19 02:14:20 +02:00
Bilal Catic
c652a306db add price screen 2019-05-17 11:32:41 +02:00
Bilal Catic
b15295bfe6 add garden size screen 2019-05-17 11:12:24 +02:00
Bilal Catic
7ad1117cae add size screen 2019-05-17 11:06:32 +02:00
Nedim Uka
393f6731e6 Merge branch 'refactor' into 'master'
Refactor

See merge request saburly/marketalarm/web!5
2019-05-17 07:46:44 +00:00
Bilal Catic
93faa7c9e3 update readme 2019-05-17 09:14:16 +02:00
Bilal Catic
93f5d8071e add npm commands for docker and setup 2019-05-17 09:10:03 +02:00
Bilal Catic
7192c28c07 update readme 2019-05-17 08:55:36 +02:00
Bilal Catic
76f9457d4f add nodemon and migrate scripts 2019-05-17 08:49:01 +02:00
Bilal Catic
68172951ed change region and municipality property names to english 2019-05-17 00:52:43 +02:00
Bilal Catic
dbf40b199e Merge branch 'refactor' of https://gitlab.com/saburly/marketalarm/web into refactor 2019-05-17 00:34:13 +02:00
Bilal Catic
4309bc709d change column name from 'city' to 'region' 2019-05-17 00:33:10 +02:00
Bilal Catic
4323017d02 fix Readme 2019-05-16 21:34:32 +00:00
Bilal Catic
42505a7089 change column name from 'place' to 'municipality' 2019-05-16 23:32:18 +02:00
Bilal Catic
1542310a81 clean code 2019-05-16 19:58:48 +02:00
Bilal Catic
c505062770 change controller file name to plural 2019-05-16 19:42:15 +02:00
Bilal Catic
ab681e5eeb change file names to CamelCase 2019-05-16 19:40:26 +02:00
Bilal Catic
616eddbb19 improve Readme 2019-05-16 17:12:17 +02:00
Bilal Catic
e5eb6b99a2 Merge branch 'rename' into 'master'
Refactoring

See merge request saburly/marketalarm/web!4
2019-05-16 12:49:52 +00:00
Nedim Uka
27fa721627 Renaming to english 2019-05-16 13:00:08 +02:00
Senad Uka
9fdfce49ed Merge branch 'dockerize-database' into 'master'
Dockerize database

See merge request saburly/marketalarm/web!2
2019-04-30 12:07:09 +00:00
MirnaM
59723410b6 Update README 2019-04-30 09:47:50 +02:00
MirnaM
51ed3551c7 Use default postgres port 2019-04-30 09:19:52 +02:00
MirnaM
58177a8cce Add dockerfile 2019-04-30 09:06:46 +02:00
Senad Uka
864b917b4f Make place selection possible 2019-04-30 06:48:41 +02:00
Senad Uka
a2f6f033bf Places almost finished 2019-04-28 11:13:46 +02:00
Senad Uka
64f2cb82a8 Scrape kantoni into html file 2019-04-28 09:02:46 +02:00
Senad Uka
17492eb52c City is now saved 2019-04-27 07:08:36 +02:00
Senad Uka
298c901759 Added migrations and saving real estate type correctly 2019-04-20 05:26:14 +02:00
Senad Uka
c534c1ee34 Added a new model - does not work yet 2019-04-16 06:27:11 +02:00
Senad Uka
2380c85122 Now posting the type of real estate 2019-04-15 06:56:03 +02:00
Senad Uka
0f7e9f9285 Type of real estate 2019-04-14 06:01:37 +02:00
Senad Uka
dee4df9bd8 Added compression 2019-04-13 10:38:25 +02:00
Senad Uka
4248e6304a Poruka 2019-04-13 10:27:35 +02:00
Senad Uka
9fd9fe8b82 Fixed css 2019-04-12 06:47:51 +02:00
Senad Uka
467d551857 Logo set up 2019-04-12 05:40:52 +02:00
Senad Uka
28f95b9c05 Logo and button - logo unfinished 2019-04-11 05:27:55 +02:00
Senad Uka
9aba66c273 Logo and button - logo unfinished 2019-04-11 05:27:34 +02:00
Senad Uka
d03e85a0dc Switched to materializecss + jquery 2019-04-10 05:17:39 +02:00
Senad Uka
9d49e72bb4 Fix mixed content 2019-04-10 05:01:19 +02:00
Senad Uka
efe2dd66a3 Heroku fix fix 2019-04-09 06:11:41 +02:00
Senad Uka
5f2fee504a Heroku postbuild fix 2019-04-09 06:08:22 +02:00
Senad Uka
add905c793 Saved what was unsaved 2019-04-09 06:04:17 +02:00
Senad Uka
5b25068009 Add first two pages (placeholders), and navigation mechanism 2019-04-09 06:01:21 +02:00
Senad Uka
262f71164c Removed react version 2019-03-26 05:10:21 +01:00
Senad Uka
a6fdf259a0 Hello world is now in a view 2019-03-26 05:09:53 +01:00
Senad Uka
da241c8200 Index now works 2019-03-26 05:06:15 +01:00
Senad Uka
8c7f26b099 Refactored backend 2019-03-25 05:16:58 +01:00
Senad Uka
0d7c154958 Express / EJS based app 2019-03-23 05:08:21 +01:00
Senad Uka
2ceb6805ce Configured wizard 2019-03-22 06:34:15 +01:00
Senad Uka
d240fcbcda Added the wizard component 2019-03-22 06:22:50 +01:00
Senad Uka
f53d60ab0f Untested version 2019-03-20 05:59:23 +01:00
Senad Uka
6821054494 Added aws sdk to node modules 2019-03-18 05:11:47 +01:00
Senad Uka
cb34890583 Connect to heroku's mysql 2019-03-15 10:31:56 +01:00
Senad Uka
2669aaa9eb Now serving whole app 2019-03-14 16:50:25 +01:00
Senad Uka
13a9886292 Fix the path 2019-03-14 05:57:17 +01:00
Senad Uka
a6df827cd1 Updated Procfile 2019-03-14 05:53:02 +01:00
Senad Uka
b67b15c4b9 Package version bump 2019-03-14 05:45:58 +01:00
Senad Uka
a4ed76e29b Updated to newest node, refactored for heroku 2019-03-14 05:41:06 +01:00
Senad Uka
7fc24add1f Package json 2019-03-13 05:43:48 +01:00
Senad Uka
b1a08a7a57 Started porting to heroku 2019-03-12 05:12:02 +01:00
=
1f7063f94e Assume email when creating notifications 2019-03-08 07:33:19 -08:00
=
8a1e406f43 Don't show actual results just the counts 2019-03-07 12:49:19 -08:00
Edin
01b864d75b Merge branch 'remove-payment-step' into 'master'
Remove payment step

See merge request saburly/marketalarm/web!1
2019-03-06 19:32:34 +00:00
=
b7cb61b53b Don't contact TCO api 2019-03-06 11:31:10 -08:00
=
a957293029 Removed card info inputs
Left to do:
  - Remove TCO integration calls
  - Actually save notification
  - Display a nice message
2019-03-05 09:31:04 -08:00
181 changed files with 10282 additions and 21171 deletions

View File

@@ -1 +1,3 @@
node_modules/
.env
.idea/

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM postgres:11.3
ENV POSTGIS_MAJOR 2.4
RUN apt-get update \
&& apt-get --assume-yes install software-properties-common postgis\
&& rm -rf /var/lib/apt/lists/
RUN mkdir -p /docker-entrypoint-initdb.d
CMD ["postgres"]

1
Procfile Normal file
View File

@@ -0,0 +1 @@
web: node ./index.js

View File

@@ -1,4 +1,43 @@
# web
# MarketAlert
The purpose of this project is to build a web application that enables subscribing to notifications when new products are published on various ad based marketplaces. The MVP will be only based on OLX.ba
## Setup
### Setup with npm commands
1. Install packages
`npm install`
2. Run setup script
`npm run setup`
this will create and run postgres image and then execute migrations
3. Run app
`npm start` to run app without restart on changes or
`npm run start-mon` to run app with automatic restart on code change
The purpose of this project is to build a web application that enables subscribing to notifications when new products are published on various ad based marketplaces. The MVP will be only based on OLX.ba
### Manual setup
1. Create postgres docker image
`docker build -t marketalerts .`
2. Run postgres image with
`docker run --name pg_marketalerts -d -p 5432:5432 marketalerts`
3. Install packages
`npm install`
4. Run migrations from `app` folder
`npm run migrate` or `npx sequelize db:migrate`
5. Run app
`npm start` or `npm run start-mon` to run app with automatic restart on code change
### AWS SES
- 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

152
app/common/enums.js Normal file
View File

@@ -0,0 +1,152 @@
const PRICE_SLIDER_OPTIONS = {
start: [50000, 85000],
range: {
min: [0],
max: [300000]
},
step: 1000,
connect: true,
tooltips: true
};
//This will be used for Flats, Apartments, Houses
const HOME_SIZE_SLIDER_OPTIONS = {
start: [30, 75],
range: {
min: [0],
max: [400]
},
step: 5,
connect: true,
tooltips: true
};
const GARDEN_SIZE_SLIDER_OPTIONS = {
start: [100, 1000],
range: {
min: [0],
max: [10000]
},
step: 100,
connect: true,
tooltips: true
};
const LAND_SIZE_SLIDER_OPTIONS = {
start: [5000, 15000],
range: {
min: [0],
max: [100000]
},
step: 100,
connect: true,
tooltips: true
};
const GARAGE_SIZE_SLIDER_OPTIONS = {
start: [10, 20],
range: {
min: [0],
max: [150]
},
step: 2,
connect: true,
tooltips: true
};
const GARAGE_PRICE_SLIDER_OPTIONS = {
start: [2000, 10000],
range: {
min: [0],
max: [100000]
},
step: 500,
connect: true,
tooltips: true
};
const AD_TYPE = {
AD_TYPE_SALE: "SALE",
AD_TYPE_RENT: "RENT"
};
const AD_CATEGORY = {
FLAT: {
id: "FLAT",
title: "Stan",
hasGardenSize: false,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
HOUSE: {
id: "HOUSE",
title: "Kuća",
hasGardenSize: true,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
},
OFFICE: {
id: "OFFICE",
title: "Kancelarija",
hasGardenSize: false,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
LAND: {
id: "LAND",
title: "Zemljište",
hasGardenSize: false,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: LAND_SIZE_SLIDER_OPTIONS
},
APARTMENT: {
id: "APARTMENT",
title: "Apartman",
hasGardenSize: false,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
},
GARAGE: {
id: "GARAGE",
title: "Garaža",
hasGardenSize: false,
priceSliderOptions: GARAGE_PRICE_SLIDER_OPTIONS,
sizeSliderOptions: GARAGE_SIZE_SLIDER_OPTIONS
},
COTTAGE: {
id: "COTTAGE",
title: "Vikendica",
hasGardenSize: true,
priceSliderOptions: PRICE_SLIDER_OPTIONS,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
}
};
const AD_STATUS = {
STATUS_NORMAL: 1,
STATUS_RESERVED: 2,
STATUS_SOLD: 3,
STATUS_DELETED: 4,
STATUS_URGENT: 5,
STATUS_DISCOUNTED: 6
};
const AD_AGENCY = {
OLX: "OLX"
};
const CRAWLER_AD_TYPE = {
NONE: 0,
ALL: 1,
ONLY_SELL: 2,
ONLY_RENT: 3
};
module.exports = {
AD_TYPE,
AD_CATEGORY,
AD_STATUS,
AD_AGENCY,
CRAWLER_AD_TYPE
};

40
app/config/appConfig.js Normal file
View File

@@ -0,0 +1,40 @@
"use strict";
require("dotenv").config({ path: __dirname + "/./../../.env" });
const APP_PORT = process.env.PORT || 5000;
const APP_BASE_URL = process.env.APP_BASE_URL || "http://localhost";
const APP_URL =
process.env.NODE_ENV && process.env.NODE_ENV === "production"
? process.env.APP_URL || "http://market-alarm"
: process.env.APP_URL || `${APP_BASE_URL}:${APP_PORT}`;
const DEFAULT_TIMEZONE = "Europe/Sarajevo";
const CRAWLER_INTERVAL = parseInt(process.env.CRAWLER_INTERVAL) || 60;
const STOP_CRAWLER = !!parseInt(process.env.STOP_CRAWLER);
const AWS_EMAIL_CONFIG = {
REGION: process.env.AWS_REGION || "",
CREDENTIALS: {
ACCESS_KEY_ID: process.env.AWS_KEY_ID || "",
SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY || ""
},
SOURCE_EMAIL: process.env.SOURCE_EMAIL || ""
};
const MAX_REAL_ESTATES_IN_EMAIL =
parseInt(process.env.MAX_REAL_ESTATES_IN_EMAIL) || 10;
const MAX_REAL_ESTATES_IN_FIRST_EMAIL =
parseInt(process.env.MAX_REAL_ESTATES_IN_FIRST_EMAIL) || 5;
module.exports = {
APP_PORT,
APP_URL,
DEFAULT_TIMEZONE,
CRAWLER_INTERVAL,
STOP_CRAWLER,
AWS_EMAIL_CONFIG,
MAX_REAL_ESTATES_IN_EMAIL,
MAX_REAL_ESTATES_IN_FIRST_EMAIL
};

15
app/config/config.json Normal file
View File

@@ -0,0 +1,15 @@
{
"development": {
"username": "docker",
"password": "docker",
"database": "marketalerts",
"port": "5432",
"dialect": "postgres"
},
"test": {
"use_env_variable": "DATABASE_URL"
},
"production": {
"use_env_variable": "DATABASE_URL"
}
}

View File

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

View File

@@ -0,0 +1,57 @@
const { currentSearchRequest } = require("../helpers/url");
const getLocation = async (req, res) => {
const title = "Odaberite lokaciju";
const nextStep = req.query.nextStep || "/";
res.render("location", {
nextStep,
title
});
};
const postLocation = async (req, res) => {
let searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const northWest = [req.body.west, req.body.north];
const northEast = [req.body.east, req.body.north];
const southEast = [req.body.east, req.body.south];
const southWest = [req.body.west, req.body.south];
const locationInputValue =
req.body.locationInput && req.body.locationInput.length > 0
? req.body.locationInput
: null;
searchRequest.areaToSearch = {
type: "Polygon",
coordinates: [[northWest, northEast, southEast, southWest, northWest]],
crs: { type: "name", properties: { name: "EPSG:4326" } }
};
let locationInputData;
if (req.body.locationInputData) {
try {
locationInputData = JSON.parse(req.body.locationInputData);
} catch (e) {
locationInputData = null;
}
}
await searchRequest.save();
const nextStepPage = req.query.nextStep || "filteri";
const nextStepUrl = `/${nextStepPage}/${searchRequest.id}`;
res.redirect(nextStepUrl);
};
module.exports = {
getLocation,
postLocation
};

View File

@@ -0,0 +1,135 @@
const { currentSearchRequest } = require("../helpers/url");
const { isValidEmail } = require("../helpers/email");
const {
notifyForNewSearchRequest
} = require("../services/notificationService");
const { AD_CATEGORY } = require("../common/enums");
const getQueryReviewData = searchRequest => {
const {
id,
realEstateType,
sizeMin,
sizeMax,
gardenSizeMin,
gardenSizeMax,
priceMin,
priceMax
} = searchRequest.dataValues;
const realEstateTypeObject = AD_CATEGORY[realEstateType];
const enableGardenSizeEdit = realEstateTypeObject
? realEstateTypeObject.hasGardenSize
: false;
const realEstateTypeTitle = realEstateTypeObject
? realEstateTypeObject.title
: "-";
const locationTitle = "Promjenite lokaciju";
const sizeTitle = `${sizeMin} - ${sizeMax} m2`;
const gardenSizeTitle = enableGardenSizeEdit
? `${gardenSizeMin} - ${gardenSizeMax} m2`
: "-";
const priceTitle = `${priceMin} - ${priceMax} KM`;
return [
{
id: "realEstateType",
title: realEstateTypeTitle,
url: `/vrstanekretnine/${id}?nextStep=filteri`
},
{
id: "location",
title: locationTitle,
url: `/lokacija/${id}?nextStep=pregled`
},
{
id: "size",
title: sizeTitle,
url: `/filteri/${id}?nextStep=pregled`
},
{
id: "gardenSize",
title: gardenSizeTitle,
url: enableGardenSizeEdit ? `/filteri/${id}?nextStep=pregled` : ""
},
{
id: "price",
title: priceTitle,
url: `/filteri/${id}?nextStep=pregled`
}
].filter(data => data.title != "-");
};
const getQueryReview = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const title = "Da li je ovo to što ste tražili ?";
const nextStep = req.query.nextStep;
const error = req.query.error;
const queryReviewData = getQueryReviewData(searchRequest);
const email = searchRequest.email;
res.render("queryReview", {
nextStep,
queryReviewData,
title,
email,
error
});
};
const postQueryReview = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
return null;
}
const nextStep = req.query.nextStep || "/ponovo";
const emailInput = req.body.email;
const emailConfirmInput = req.body.confirmEmail;
const title = "Da li je ovo to što ste tražili ?";
const queryReviewData = getQueryReviewData(searchRequest);
if (emailInput !== emailConfirmInput) {
const error = "Greška ! Unešeni emailovi nisu isti";
res.render("queryReview", {
error,
title,
queryReviewData,
email: ""
});
return;
}
if (!isValidEmail(emailInput)) {
const error = "Greška ! Unesite validan email";
res.render("queryReview", {
error,
title,
queryReviewData,
email: ""
});
return;
}
searchRequest.email = emailInput;
searchRequest.subscribed = true;
await searchRequest.save();
await notifyForNewSearchRequest(searchRequest);
res.redirect(nextStep);
};
module.exports = {
getQueryReview,
postQueryReview
};

View File

@@ -0,0 +1,97 @@
const { currentSearchRequest } = require("../helpers/url");
const { AD_CATEGORY } = require("../common/enums");
const getFilters = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const title = "Filteri za pretraživanje";
const {
realEstateType,
priceMin,
priceMax,
sizeMin,
sizeMax,
gardenSizeMin,
gardenSizeMax
} = searchRequest;
const category = AD_CATEGORY[realEstateType] || AD_CATEGORY.FLAT;
const {
hasGardenSize,
priceSliderOptions,
sizeSliderOptions,
gardenSizeSliderOptions
} = category;
if (priceMin || priceMax) {
priceSliderOptions.start = [priceMin, priceMax];
}
if (sizeMin || sizeMax) {
sizeSliderOptions.start = [sizeMin, sizeMax];
}
if (gardenSizeSliderOptions && (gardenSizeMin || gardenSizeMax)) {
gardenSizeSliderOptions.start = [gardenSizeMin, gardenSizeMax];
}
res.render("realEstateFilters", {
title,
hasGardenSize,
priceSliderOptions: JSON.stringify(priceSliderOptions),
sizeSliderOptions: JSON.stringify(sizeSliderOptions),
gardenSizeSliderOptions: JSON.stringify(gardenSizeSliderOptions)
});
};
const postFilters = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
const nextStepPage = req.query.nextStep || "pregled";
const nextStepUrl = `/${nextStepPage}/${searchRequest.id}`;
const priceMin = parseInt(req.body.priceFilterMin) || 0;
const priceMax = parseInt(req.body.priceFilterMax) || 0;
const sizeMin = parseInt(req.body.sizeFilterMin) || 0;
const sizeMax = parseInt(req.body.sizeFilterMax) || 0;
//TODO: Filter validation
searchRequest.priceMin = priceMin;
searchRequest.priceMax = priceMax;
searchRequest.sizeMin = sizeMin;
searchRequest.sizeMax = sizeMax;
if (
req.body.gardenSizeFilterMin !== undefined &&
req.body.gardenSizeFilterMax !== undefined
) {
const gardenSizeMin = parseInt(req.body.gardenSizeFilterMin);
const gardenSizeMax = parseInt(req.body.gardenSizeFilterMax);
//TODO: Filter validation
searchRequest.gardenSizeMin = gardenSizeMin;
searchRequest.gardenSizeMax = gardenSizeMax;
}
await searchRequest.save();
res.redirect(nextStepUrl);
};
module.exports = {
getFilters,
postFilters
};

View File

@@ -0,0 +1,46 @@
const { currentSearchRequest } = require("../helpers/url");
const { createSearchRequest } = require("../helpers/db/searchRequest");
const { AD_CATEGORY } = require("../common/enums");
const getRealEstateTypes = (req, res) => {
const title = "Koju nekretninu tražite?";
const realEstateTypes = Object.keys(AD_CATEGORY).map(
category => AD_CATEGORY[category]
);
res.render("realEstateType", { realEstateTypes, title });
};
const postRealEstateTypes = async (req, res) => {
const searchRequest = await currentSearchRequest(req);
//TODO: check if selected real estate type is valid
const selectedRealEstateType = req.body.realEstateType || null;
const nextStepPage = req.query.nextStep || "lokacija";
let nextStepUrl = "";
if (searchRequest && searchRequest.id) {
nextStepUrl = `/${nextStepPage}/${searchRequest.id}`;
searchRequest.realEstateType = selectedRealEstateType;
await searchRequest.save();
} else {
try {
const newSearchRequest = await createSearchRequest({
realEstateType: selectedRealEstateType
});
nextStepUrl = `/${nextStepPage}/${newSearchRequest.id}`;
} catch (error) {
console.log(error);
nextStepUrl = `/`;
}
}
res.redirect(nextStepUrl);
};
module.exports = {
getRealEstateTypes,
postRealEstateTypes
};

View File

@@ -0,0 +1,16 @@
"use strict";
const {
findRealEstatesForSearchRequest
} = require("../helpers/db/searchRequestMatch");
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 });
};
module.exports = {
getRealEstates
};

View File

@@ -0,0 +1,33 @@
const { getRealEstateById } = require("../helpers/db/realEstate");
const getRedirect = async (req, res) => {
const id = req.params.id || null;
let error = false;
let redirectUrl = undefined;
if (!id) {
error = true;
} else {
try {
const realEstate = await getRealEstateById(id);
if (!realEstate) {
error = true;
} else {
redirectUrl = realEstate.url;
}
} catch (e) {
error = true;
}
}
if (error) {
const title = "";
res.render("notFound", { title });
} else {
const title = "Preusmjeravanje";
res.render("redirect", { title, redirectUrl });
}
};
module.exports = {
getRedirect
};

View File

@@ -0,0 +1,20 @@
const { currentSearchRequest } = require("../helpers/url");
const getUnsubscribe = async (req, res) => {
const title = "Uspješno ste se odjavili";
const searchRequest = await currentSearchRequest(req);
if (!searchRequest || !searchRequest.dataValues) {
res.render("notFound", { title: " " });
return;
}
searchRequest.subscribed = false;
await searchRequest.save();
res.render("unsubscribe", { nextStep: "/vrstanekretnine", title });
};
module.exports = {
getUnsubscribe
};

View File

@@ -0,0 +1,7 @@
const getWelcome = (req, res) => {
res.render("welcome", { nextStep: "/vrstanekretnine", title: false });
};
module.exports = {
getWelcome
};

37
app/crawler/crawl.js Normal file
View File

@@ -0,0 +1,37 @@
"use strict";
/*
Entry point for crawling functionality
All communication between crawlers and savers is here
All environment specific configuration is read here and
passed to the crawlers and savers.
*/
const OlxCrawler = require("./specific/olx");
const { OLX_CONFIG } = require("./crawlerConfig");
const PostgresSaver = require("./savers/postgres");
const crawlers = [
new OlxCrawler(
[new PostgresSaver()],
OLX_CONFIG.OLX_CRAWLER_AD_TYPE,
OLX_CONFIG.OLX_CRAWLER_AD_CATEGORIES,
OLX_CONFIG.OLX_MAX_PAGES,
OLX_CONFIG.OLX_MAX_RESULTS_PER_PAGE,
OLX_CONFIG.OLX_IGNORED_USERNAMES,
OLX_CONFIG.OLX_DELAY_BETWEEN_PAGES
)
];
async function crawlAll() {
for (let crawler of crawlers) {
try {
return await crawler.crawl();
} catch (e) {
console.log("Error crawling. Trying next crawler! ", e);
return [];
}
}
}
module.exports = {
crawlAll
};

View File

@@ -0,0 +1,42 @@
"use strict";
require("dotenv").config({ path: __dirname + "/./../../.env" });
const { CRAWLER_AD_TYPE, AD_CATEGORY } = require("../common/enums");
const olxCrawlerAdType =
process.env.OLX_CRAWLER_AD_TYPE !== undefined
? CRAWLER_AD_TYPE[process.env.OLX_CRAWLER_AD_TYPE]
: null;
const olxParsedCrawlerAdCategories =
process.env.OLX_CRAWLER_AD_CATEGORIES !== undefined
? process.env.OLX_CRAWLER_AD_CATEGORIES.split(",").map(category =>
category.trim()
)
: ["FLAT", "HOUSE"];
const olxIgnoredUsernames =
process.env.OLX_IGNORED_USERNAMES !== undefined
? process.env.OLX_IGNORED_USERNAMES.split(",").map(username =>
username.trim()
)
: [];
const transformedCrawlerAdCategories = olxParsedCrawlerAdCategories
.map(categoryName =>
AD_CATEGORY[categoryName] ? AD_CATEGORY[categoryName].id : undefined
)
.filter(category => !!category);
const OLX_CONFIG = {
OLX_MAX_PAGES: parseInt(process.env.OLX_MAX_PAGES) || 500,
OLX_MAX_RESULTS_PER_PAGE:
parseInt(process.env.OLX_MAX_RESULTS_PER_PAGE) || 50,
OLX_CRAWLER_AD_TYPE: olxCrawlerAdType || CRAWLER_AD_TYPE.NONE,
OLX_CRAWLER_AD_CATEGORIES: transformedCrawlerAdCategories,
OLX_IGNORED_USERNAMES: olxIgnoredUsernames || [],
OLX_DELAY_BETWEEN_PAGES: parseInt(process.env.OLX_DELAY_BETWEEN_PAGES) || 1000
};
module.exports = {
OLX_CONFIG
};

5
app/crawler/npmCrawl.js Normal file
View File

@@ -0,0 +1,5 @@
const { crawlAll } = require("./crawl");
(async () => {
await crawlAll();
})();

View File

@@ -0,0 +1,47 @@
const moment = require("moment");
const { bulkUpsertRealEstates } = require("../../helpers/db/realEstate");
class PostgresSaver {
connect() {
//TODO: It seems we never worry about open/close connection with Sequelize ?
//TODO: Check if postgres is ready
return true;
}
async save(results) {
const savedRecords = await bulkUpsertRealEstates(results);
if (Array.isArray(savedRecords)) {
const newRealEstates = [];
const existingRealEstates = [];
for (const savedRecord of savedRecords) {
const { createdAt, updatedAt } = savedRecord;
const createdAtMoment = moment.utc(createdAt);
const updatedAtMoment = moment.utc(updatedAt);
if (createdAtMoment.isSame(updatedAtMoment, "second")) {
newRealEstates.push(savedRecord);
} else {
existingRealEstates.push(savedRecord);
}
}
return {
newRecords: newRealEstates,
existingRecords: existingRealEstates
};
} else {
throw { message: "[POSTGRES] Failed to save records" };
}
}
close() {
//TODO: It seems we never worry about open/close connection with Sequelize ?
return true;
}
}
module.exports = PostgresSaver;

565
app/crawler/specific/olx.js Normal file
View File

@@ -0,0 +1,565 @@
"use strict";
const fetch = require("node-fetch");
const cheerio = require("cheerio");
const Promise = require("bluebird");
const moment = require("moment-timezone");
const {
AD_TYPE,
AD_CATEGORY,
AD_AGENCY,
AD_STATUS,
CRAWLER_AD_TYPE
} = require("../../common/enums");
const { DEFAULT_TIMEZONE } = require("../../config/appConfig");
const OLX_ENUMS = {
OLX_AD_TYPE: {
[CRAWLER_AD_TYPE.ALL]: "",
[CRAWLER_AD_TYPE.ONLY_SELL]: "&vrsta=samoprodaja",
[CRAWLER_AD_TYPE.ONLY_RENT]: "&vrsta=samoizdavanje"
},
OLX_AD_CATEGORY: {
[AD_CATEGORY.FLAT.id]: "&kategorija=23",
[AD_CATEGORY.HOUSE.id]: "&kategorija=24",
[AD_CATEGORY.LAND.id]: "&kategorija=29",
[AD_CATEGORY.OFFICE.id]: "&kategorija=25",
[AD_CATEGORY.APARTMENT.id]: "&kategorija=27",
[AD_CATEGORY.GARAGE.id]: "&kategorija=30",
[AD_CATEGORY.COTTAGE.id]: "&kategorija=26"
},
MAX_DETAIL_FIELDS: 30,
OLX_PUBLISHED_DATE_FORMAT: "DD.MM.YYYY. u HH:mm",
OLX_RENEWED_DATE_FORMAT: "DD.MM.YYYY. u HH:mm"
};
class OlxCrawler {
constructor(
savers = [],
crawlerAdTypes = CRAWLER_AD_TYPE.ALL,
crawlerAdCategories = [AD_CATEGORY.FLAT, AD_CATEGORY.HOUSE],
maxPages = 1000,
maxResultsPerPage = 100,
ignoredUsernames = [],
delayBetweenPages = 1000
) {
this.savers = savers;
this.baseUrl = "https://www.olx.ba/pretraga?sort_order=desc&sort_po=datum";
this.crawlerAdTypes = crawlerAdTypes;
this.crawlerAdCategories = crawlerAdCategories;
this.maxPages = maxPages;
this.maxResultsPerPage = maxResultsPerPage;
this.ignoredUsernames = ignoredUsernames;
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));
}
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, existingRecords } = saveResults;
newRealEstates.push(...newRecords);
for (const existingRecord of existingRecords) {
const { publishedDate, renewedDate } = existingRecord;
const publishedDateMoment = moment.utc(publishedDate);
const renewedDateMoment = moment.utc(renewedDate);
const stopCrawlingThisCategory = publishedDateMoment.isSame(
renewedDateMoment,
"minute"
);
if (stopCrawlingThisCategory) {
generatorsToRemove[index] = true;
// console.log("\tGenerator ", index + 1, "has no more new ads");
break;
}
}
} 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 = OLX_ENUMS.OLX_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = OLX_ENUMS.OLX_AD_CATEGORY[adCategory];
if (urlAdTypePart && urlCategoryPart) {
while (true) {
const urlPageToCrawl = `${this.baseUrl}${urlAdTypePart}${urlCategoryPart}&stranica=${pageToIndex}`;
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) {
try {
const res = await fetch(url);
const body = await res.text();
const $ = cheerio.load(body);
let hrefs = [];
$("#rezultatipretrage")
.find(".listitem")
.each((i, elem) => {
const href = $(elem)
.find("a")
.first()
.attr("href");
if (href) {
hrefs.push(href);
}
});
let actualNoOfResults =
hrefs.length <= maxResultsPerPage ? hrefs.length : maxResultsPerPage;
const asyncScraping = [];
for (let i = 0; i < actualNoOfResults; i++) {
asyncScraping.push(this.scrapeAd(hrefs[i]));
}
const scrapedData = await Promise.all(asyncScraping);
const filteredScrapedData = scrapedData.filter(adData => !!adData);
return filteredScrapedData;
} catch (e) {
console.error("Exception caught:" + e);
return [];
}
}
async scrapeAd(url) {
// console.log("Scraping : ", url);
try {
const adPageSource = await fetch(url);
const body = await adPageSource.text();
const $ = cheerio.load(body);
let status = AD_STATUS.STATUS_NORMAL;
const propertySelectors = {
username:
"#lg > div.desno2.profil > div:nth-child(2) > div.vrsta1.vrsta_desno > a > div.username > span",
title: "#naslovartikla",
descriptions: ".artikal_detaljniopis_tekst",
category:
"#artikal_glavni_div > div.artikal_lijevo > div:nth-child(3) > div > span:nth-child(3) > a > span"
};
const username = $(propertySelectors.username)
.text()
.trim();
if (this.ignoredUsernames.includes((username || "").toLowerCase())) {
return null;
}
const title = $(propertySelectors.title)
.text()
.trim();
const descriptions = $(propertySelectors.descriptions);
const category = $(propertySelectors.category)
.text()
.trim();
//====== PRICE DETECTION AND EXTRACTION =====
let price = null;
const normalPriceValue = $("#pc > p:nth-child(2)").text();
const urgentPriceValue = $(
"#artikal_glavni_div > div.artikal_lijevo > div:nth-child(5) > p"
)
.text()
.trim();
if (normalPriceValue && normalPriceValue.length > 0) {
price = normalPriceValue;
if (
$("#pc > p.n")
.text()
.indexOf("Hitna") !== -1
) {
status = AD_STATUS.STATUS_URGENT;
} else {
status = AD_STATUS.STATUS_NORMAL;
}
} else if (urgentPriceValue && urgentPriceValue.length > 0) {
const priceValues = urgentPriceValue.split("KM");
//priceValues will contain values like ["100000", "90000", ...], second element is urgent price
if (priceValues.length > 1) {
price = priceValues[1].trim();
status = AD_STATUS.STATUS_DISCOUNTED;
} else {
throw { message: "Can't find urgent price" };
}
} else {
throw {
message: "Can't find price (it is not normal nor urgent price ?)"
};
}
//====== OTHER AD INFORMATION ===============
let adType = null;
let olxId = null;
let otherInformationDivId;
//We need to locate DIV ID where other information are stored
for (let possibleId = 10; possibleId <= 20; possibleId++) {
const adTypeFieldTitle = $(
`#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${possibleId}) > div:nth-child(2) > div.df1`
)
.text()
.trim();
if (adTypeFieldTitle === "Vrsta oglasa") {
otherInformationDivId = possibleId;
break;
}
}
if (!otherInformationDivId) {
throw { message: "Other information DIV could not be found" };
}
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 renewedDateFullValueSelector = `#artikal_glavni_div > div.artikal_lijevo > div.op.ob.pop`;
const publishedDate = $(publishedDateValueSelector)
.text()
.trim();
const publishedDateMoment = moment.tz(
publishedDate,
OLX_ENUMS.OLX_PUBLISHED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
if (!publishedDateMoment.isValid()) {
throw { message: "Invalid published date ! Check parsing format" };
}
const renewedDate = $(renewedDateFullValueSelector)
.data("content")
.trim();
const renewedDateMoment = moment.tz(
renewedDate,
OLX_ENUMS.OLX_RENEWED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
if (!renewedDateMoment) {
throw {
message:
"Invalid renewed date ! Check how parser parsed renewed date text"
};
}
adType = $(
`#artikal_glavni_div > div.artikal_lijevo > div:nth-child(${otherInformationDivId}) > div:nth-child(2) > div.df2`
)
.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" };
}
const parsedAdType = this.getAdTypeId(adType);
if (!parsedAdType) {
throw { message: "Unknown ad type" };
}
const parsedArea = this.parseArea(area) || null;
const parsedGardenSize = this.parseArea(gardenSize) || null;
const parsedPrice = this.parsePrice(price) || null;
const latLngRegex = /LatLng\(([0-9]+\.[0-9]+)\,\s+([0-9]+\.[0-9]+)\)/g;
const locationLatLngMatches = latLngRegex.exec(body);
let locationLat = null;
let locationLong = null;
if (locationLatLngMatches && locationLatLngMatches.length >= 3) {
locationLat = parseFloat(locationLatLngMatches[1]) || null;
locationLong = parseFloat(locationLatLngMatches[2]) || null;
}
const data = {
url,
agencyObjectId: olxId,
originAgencyName: AD_AGENCY.OLX,
realEstateType: parsedCategory,
adType: parsedAdType,
title,
price: parsedPrice,
area: parsedArea,
gardenSize: parsedGardenSize,
shortDescription: descriptions
.first()
.text()
.trim(),
longDescription: descriptions
.last()
.text()
.trim(),
streetNumber: 0,
streetName: "",
locality: "",
municipality: "",
city: "",
region: "",
entity: "",
country: "",
locationLat,
locationLong,
adStatus: status,
publishedDate: publishedDateMoment.toISOString(),
renewedDate: renewedDateMoment.toISOString()
};
return data;
} catch (e) {
console.error("Exception caught: " + e.message, "\r\nURL:", url);
}
return null;
}
//======= HELPER FUNCTIONS =============
getAdCategoryId(categoryText) {
switch (categoryText) {
case "Stanovi":
return AD_CATEGORY.FLAT.id;
case "Zemljišta":
return AD_CATEGORY.LAND.id;
case "Kuće":
return AD_CATEGORY.HOUSE.id;
case "Poslovni prostori":
return AD_CATEGORY.OFFICE.id;
case "Apartmani":
return AD_CATEGORY.APARTMENT.id;
case "Garaže":
return AD_CATEGORY.GARAGE.id;
case "Vikendice":
return AD_CATEGORY.COTTAGE.id;
default:
return undefined;
}
}
getAdTypeId(adTypeText) {
switch (adTypeText) {
case "Prodaja":
return AD_TYPE.AD_TYPE_SALE;
case "Izdavanje":
return AD_TYPE.AD_TYPE_RENT;
default:
return undefined;
}
}
parseArea(areaText) {
if (!areaText) {
return NaN;
}
const removeDotsExceptLastOneRegex = /[.](?=.*[.])/g;
const textWithOnlyOneDecimalDot = areaText
.replace(",", ".")
.replace(removeDotsExceptLastOneRegex, "");
return parseFloat(textWithOnlyOneDecimalDot);
}
parsePrice(priceText) {
if (!priceText) {
return NaN;
}
const formattedPriceText = priceText.replace(".", "").replace(",", ".");
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;
}
}
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");
}
const todayVariations = ["min", "sekund", "maloprije"];
for (const todayVariation of todayVariations) {
if (renewedDateText.includes(todayVariation)) {
return currentMoment;
}
}
const renewedDateMoment = moment.tz(
renewedDateText,
OLX_ENUMS.OLX_RENEWED_DATE_FORMAT,
DEFAULT_TIMEZONE
);
return renewedDateMoment.isValid() ? renewedDateMoment : 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 await savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted
}
}
module.exports = OlxCrawler;

931
app/helpers/codes.js Normal file
View File

@@ -0,0 +1,931 @@
const regions = [
{
name: " Sarajevo",
id: "sarajevo",
olxid: "9",
municipalities: [
{
name: "Hadžići",
id: "hadii",
olxid: "3817"
},
{
name: "Ilidža",
id: "ilida",
olxid: "3879"
},
{
name: "Ilijaš",
id: "ilija",
olxid: "3892"
},
{
name: "Sarajevo - Centar",
id: "sarajevocentar",
olxid: "3812"
},
{
name: "Sarajevo-Novi Grad",
id: "sarajevonovigrad",
olxid: "3969"
},
{
name: "Sarajevo-Novo Sarajevo",
id: "sarajevonovosarajevo",
olxid: "5896"
},
{
name: "Sarajevo-Stari Grad",
id: "sarajevostarigrad",
olxid: "4048"
},
{
name: "Trnovo",
id: "trnovo",
olxid: "4063"
},
{
name: "Vogošća",
id: "vogoa",
olxid: "4126"
}
]
},
{
name: " Unsko-sanski",
id: "unskosanski",
olxid: "1",
municipalities: [
{
name: "Bihać",
id: "biha",
olxid: "75"
},
{
name: "Bosanska Krupa",
id: "bosanskakrupa",
olxid: "373"
},
{
name: "Bosanski Petrovac",
id: "bosanskipetrovac",
olxid: "504"
},
{
name: "Bužim",
id: "buim",
olxid: "374"
},
{
name: "Cazin",
id: "cazin",
olxid: "857"
},
{
name: "Ključ",
id: "klju",
olxid: "2362"
},
{
name: "Sanski Most",
id: "sanskimost",
olxid: "3738"
},
{
name: "Velika Kladuša",
id: "velikakladua",
olxid: "5122"
}
]
},
{
name: " Posavski",
id: "posavski",
olxid: "2",
municipalities: [
{
name: "Domaljevac",
id: "domaljevac",
olxid: "6144"
},
{
name: "Odžak",
id: "odak",
olxid: "424"
},
{
name: "Orašje",
id: "oraje",
olxid: "3252"
},
{
name: "Šamac",
id: "amac",
olxid: "540"
}
]
},
{
name: " Tuzlanski",
id: "tuzlanski",
olxid: "3",
municipalities: [
{
name: "Banovići",
id: "banovii",
olxid: "2"
},
{
name: "Doboj-Istok",
id: "dobojistok",
olxid: "1090"
},
{
name: "Gradačac",
id: "gradaac",
olxid: "1854"
},
{
name: "Gračanica",
id: "graanica",
olxid: "1826"
},
{
name: "Kalesija",
id: "kalesija",
olxid: "2129"
},
{
name: "Kladanj",
id: "kladanj",
olxid: "2319"
},
{
name: "Lukavac",
id: "lukavac",
olxid: "2840"
},
{
name: "Sapna",
id: "sapna",
olxid: "5699"
},
{
name: "Srebrenik",
id: "srebrenik",
olxid: "4391"
},
{
name: "Teočak",
id: "teoak",
olxid: "5010"
},
{
name: "Tuzla",
id: "tuzla",
olxid: "4944"
},
{
name: "Čelić",
id: "eli",
olxid: "2801"
},
{
name: "Živinice",
id: "ivinice",
olxid: "5774"
}
]
},
{
name: " Zeničko-dobojski",
id: "zenickodobojski",
olxid: "4",
municipalities: [
{
name: "Breza",
id: "breza",
olxid: "704"
},
{
name: "Doboj-Jug",
id: "dobojjug",
olxid: "1122"
},
{
name: "Kakanj",
id: "kakanj",
olxid: "2022"
},
{
name: "Maglaj",
id: "maglaj",
olxid: "2941"
},
{
name: "Olovo",
id: "olovo",
olxid: "1925"
},
{
name: "Tešanj",
id: "teanj",
olxid: "4594"
},
{
name: "Usora",
id: "usora",
olxid: "1087"
},
{
name: "Vareš",
id: "vare",
olxid: "5037"
},
{
name: "Visoko",
id: "visoko",
olxid: "5171"
},
{
name: "Zavidovići",
id: "zavidovii",
olxid: "5548"
},
{
name: "Zenica",
id: "zenica",
olxid: "4571"
},
{
name: "Žepče",
id: "epe",
olxid: "2940"
}
]
},
{
name: " Bosansko-podrinjski",
id: "bosanskopodrinjski",
olxid: "5",
municipalities: [
{
name: "Foča",
id: "foa",
olxid: "1289"
},
{
name: "Goražde",
id: "gorade",
olxid: "1588"
},
{
name: "Pale",
id: "pale",
olxid: "3546"
}
]
},
{
name: " Srednjobosanski",
id: "srednjobosanski",
olxid: "6",
municipalities: [
{
name: "Bugojno",
id: "bugojno",
olxid: "732"
},
{
name: "Busovača",
id: "busovaa",
olxid: "810"
},
{
name: "Dobretići",
id: "dobretii",
olxid: "4151"
},
{
name: "Donji Vakuf",
id: "donjivakuf",
olxid: "1160"
},
{
name: "Fojnica",
id: "fojnica",
olxid: "1407"
},
{
name: "Gornji Vakuf - Uskoplje",
id: "gornjivakufuskoplje",
olxid: "1775"
},
{
name: "Jajce",
id: "jajce",
olxid: "1960"
},
{
name: "Kiseljak",
id: "kiseljak",
olxid: "2237"
},
{
name: "Kreševo",
id: "kreevo",
olxid: "2608"
},
{
name: "Novi Travnik",
id: "novitravnik",
olxid: "3477"
},
{
name: "Travnik",
id: "travnik",
olxid: "4678"
},
{
name: "Vitez",
id: "vitez",
olxid: "5422"
}
]
},
{
name: " Hercegovačko-neretvanski",
id: "hercegovackoneretvanski",
olxid: "7",
municipalities: [
{
name: "Grad Mostar",
id: "gradmostar",
olxid: "3017"
},
{
name: "Jablanica",
id: "jablanica",
olxid: "1930"
},
{
name: "Konjic",
id: "konjic",
olxid: "2169"
},
{
name: "Neum",
id: "neum",
olxid: "3111"
},
{
name: "Prozor",
id: "prozor",
olxid: "3421"
},
{
name: "Ravno",
id: "ravno",
olxid: "4769"
},
{
name: "Stolac",
id: "stolac",
olxid: "4439"
},
{
name: "Čapljina",
id: "apljina",
olxid: "947"
},
{
name: "Čitluk",
id: "itluk",
olxid: "1009"
}
]
},
{
name: " Zapadno-hercegovački",
id: "zapadnohercegovacki",
olxid: "8",
municipalities: [
{
name: "Grude",
id: "grude",
olxid: "1892"
},
{
name: "Ljubuški",
id: "ljubuki",
olxid: "2905"
},
{
name: "Posušje",
id: "posuje",
olxid: "3268"
},
{
name: "Široki Brijeg",
id: "irokibrijeg",
olxid: "2708"
}
]
},
{
name: " Livanjski",
id: "livanjski",
olxid: "10",
municipalities: [
{
name: "Bosansko Grahovo",
id: "bosanskograhovo",
olxid: "560"
},
{
name: "Drvar",
id: "drvar",
olxid: "4640"
},
{
name: "Glamoč",
id: "glamo",
olxid: "1533"
},
{
name: "Kupres",
id: "kupres",
olxid: "2635"
},
{
name: "Livno",
id: "livno",
olxid: "2741"
},
{
name: "Tomislavgrad",
id: "tomislavgrad",
olxid: "1228"
}
]
},
{
name: " Banjalučka",
id: "banjalučka",
olxid: "14",
municipalities: [
{
name: "Banja Luka",
id: "banjaluka",
olxid: "21"
},
{
name: "Gradiška",
id: "gradika",
olxid: "305"
},
{
name: "Istočni Drvar",
id: "istonidrvar",
olxid: "4662"
},
{
name: "Jezero",
id: "jezero",
olxid: "1965"
},
{
name: "Kneževo",
id: "kneevo",
olxid: "4147"
},
{
name: "Kostajnica",
id: "kostajnica",
olxid: "6142"
},
{
name: "Kotor Varoš",
id: "kotorvaro",
olxid: "2574"
},
{
name: "Kozarska Dubica",
id: "kozarskadubica",
olxid: "244"
},
{
name: "Krupa na uni",
id: "krupanauni",
olxid: "382"
},
{
name: "Kupres ",
id: "kupres",
olxid: "2654"
},
{
name: "Laktaši",
id: "laktai",
olxid: "2671"
},
{
name: "Mrkonjić Grad",
id: "mrkonjigrad",
olxid: "3073"
},
{
name: "Novi Grad",
id: "novigrad",
olxid: "444"
},
{
name: "Oštra Luka",
id: "otraluka",
olxid: "3737"
},
{
name: "Petrovac",
id: "petrovac",
olxid: "515"
},
{
name: "Prijedor",
id: "prijedor",
olxid: "3287"
},
{
name: "Prnjavor",
id: "prnjavor",
olxid: "3358"
},
{
name: "Ribnik",
id: "ribnik",
olxid: "2365"
},
{
name: "Srbac",
id: "srbac",
olxid: "4271"
},
{
name: "Čelinac",
id: "elinac",
olxid: "979"
},
{
name: "Šipovo",
id: "ipovo",
olxid: "4509"
}
]
},
{
name: " Dobojsko-Bijeljinska",
id: "dobojskobijeljinska",
olxid: "15",
municipalities: [
{
name: "Bijeljina",
id: "bijeljina",
olxid: "123"
},
{
name: "Bosanski Brod",
id: "bosanskibrod",
olxid: "421"
},
{
name: "Derventa",
id: "derventa",
olxid: "1030"
},
{
name: "Doboj",
id: "doboj",
olxid: "1088"
},
{
name: "Donji Žabar",
id: "donjiabar",
olxid: "3254"
},
{
name: "Lopare",
id: "lopare",
olxid: "2800"
},
{
name: "Lukavac",
id: "lukavac",
olxid: "6029"
},
{
name: "Modriča",
id: "modria",
olxid: "2996"
},
{
name: "Pelagićevo",
id: "pelagievo",
olxid: "1856"
},
{
name: "Petrovo",
id: "petrovo",
olxid: "1827"
},
{
name: "Stanari",
id: "stanari",
olxid: "1148"
},
{
name: "Teslić",
id: "tesli",
olxid: "4549"
},
{
name: "Tešanj",
id: "teanj",
olxid: "4636"
},
{
name: "Travnik",
id: "travnik",
olxid: "4692"
},
{
name: "Tuzla",
id: "tuzla",
olxid: "4966"
},
{
name: "Ugljevik",
id: "ugljevik",
olxid: "5009"
},
{
name: "Vukosavlje",
id: "vukosavlje",
olxid: "3197"
},
{
name: "Šamac",
id: "amac",
olxid: "539"
}
]
},
{
name: " Sarajevsko-Zvornička",
id: "sarajevskozvornicka",
olxid: "16",
municipalities: [
{
name: "Bratunac",
id: "bratunac",
olxid: "595"
},
{
name: "Han Pijesak",
id: "hanpijesak",
olxid: "1904"
},
{
name: "Ilijaš",
id: "ilija",
olxid: "3947"
},
{
name: "Istočni Stari Grad",
id: "istonistarigrad",
olxid: "4049"
},
{
name: "Kasindo",
id: "kasindo",
olxid: "3880"
},
{
name: "Kladanj",
id: "kladanj",
olxid: "2325"
},
{
name: "Lukavica",
id: "lukavica",
olxid: "3971"
},
{
name: "Milići",
id: "milii",
olxid: "6143"
},
{
name: "Olovo",
id: "olovo",
olxid: "3221"
},
{
name: "Osmaci",
id: "osmaci",
olxid: "2128"
},
{
name: "Pale",
id: "pale",
olxid: "3978"
},
{
name: "Rogatica",
id: "rogatica",
olxid: "3529"
},
{
name: "Rudo",
id: "rudo",
olxid: "3648"
},
{
name: "Sarajevo-Novi Grad",
id: "sarajevonovigrad",
olxid: "6069"
},
{
name: "Sokolac",
id: "sokolac",
olxid: "4183"
},
{
name: "Srebrenica",
id: "srebrenica",
olxid: "4310"
},
{
name: "Trnovo",
id: "trnovo",
olxid: "4067"
},
{
name: "Ustiprača",
id: "ustipraa",
olxid: "1593"
},
{
name: "Višegrad",
id: "viegrad",
olxid: "5259"
},
{
name: "Vlasenica",
id: "vlasenica",
olxid: "5456"
},
{
name: "Zvornik",
id: "zvornik",
olxid: "5684"
},
{
name: "Šekovići",
id: "ekovii",
olxid: "4475"
},
{
name: "Žepa",
id: "epa",
olxid: "1906"
}
]
},
{
name: " Trebinjsko-Fočanska",
id: "trebinjskofocanska",
olxid: "17",
municipalities: [
{
name: "Berkovići",
id: "berkovii",
olxid: "4441"
},
{
name: "Bileća",
id: "bilea",
olxid: "183"
},
{
name: "Foča",
id: "foa",
olxid: "1287"
},
{
name: "Gacko",
id: "gacko",
olxid: "1462"
},
{
name: "Istočni Mostar",
id: "istonimostar",
olxid: "3038"
},
{
name: "Kalinovik",
id: "kalinovik",
olxid: "2164"
},
{
name: "Ljubinje",
id: "ljubinje",
olxid: "2884"
},
{
name: "Nevesinje",
id: "nevesinje",
olxid: "3138"
},
{
name: "Trebinje",
id: "trebinje",
olxid: "4766"
},
{
name: "Čajniče",
id: "ajnie",
olxid: "911"
}
]
},
{
name: "Distrikt Brčko",
id: "distriktbrcko",
olxid: "12",
municipalities: [
{
name: "Brčko",
id: "brko",
olxid: "645"
}
]
}
];
const getRegions = () => {
return regions.map(g => ({ name: g.name, id: g.id, olxid: g.olxid }));
};
const getRegion = regionId => {
return regions.find(region => region.id === regionId);
};
const getRegionName = regionId => {
const region = getRegion(regionId);
return region && region.name ? region.name : null;
};
const getMunicipalitiesForRegion = regionId => {
const region = getRegion(regionId);
return region && region.municipalities ? region.municipalities : null;
};
const getMunicipality = (regionId, municipalityId) => {
const region = getRegion(regionId);
if (!region) {
return null;
}
const municipality = region.municipalities.find(
municipality => municipality.id === municipalityId
);
if (!municipality) {
return null;
}
return municipality;
};
const getMunicipalityName = (regionId, municipalityId) => {
const region = getRegion(regionId);
if (!region) {
return null;
}
const municipality = region.municipalities.find(
municipality => municipality.id === municipalityId
);
if (!municipality) {
return null;
}
return municipality.name;
};
module.exports = {
getRegion,
getRegions,
getRegionName,
getMunicipalitiesForRegion,
getMunicipalityName,
getMunicipality
};

View File

@@ -0,0 +1,111 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const Op = sequelize.Op;
const bulkUpsertRealEstates = async realEstateData => {
try {
const fieldsToUpdateIfDuplicate = [
"realEstateType",
"adType",
"price",
"area",
"streetNumber",
"streetName",
"locality",
"municipality",
"city",
"region",
"entity",
"country",
"locationLat",
"locationLong",
"title",
"shortDescription",
"longDescription",
"gardenSize",
"adStatus",
"updatedAt",
"renewedDate"
];
const order = [["updatedAt", "desc"]];
return await db.RealEstate.bulkCreate(realEstateData, {
updateOnDuplicate: fieldsToUpdateIfDuplicate,
returning: true,
order
});
} catch (e) {
console.log("Error bulk upserting realEstates : ", e);
}
};
const getRealEstateById = async id => {
return db.RealEstate.findByPk(id);
};
const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
const {
priceMin,
priceMax,
sizeMin,
sizeMax,
adType,
realEstateType,
areaToSearch
} = searchRequest;
const longitudeColumn = sequelize.col("locationLong");
const latitudeColumn = sequelize.col("locationLat");
const pointGeometry = sequelize.fn(
"ST_Point",
longitudeColumn,
latitudeColumn
);
const pointWithSRID = sequelize.fn("ST_SetSRID", pointGeometry, 4326);
const areaToSearchAsGeometry = sequelize.fn(
"ST_GeomFromGeoJSON",
JSON.stringify(areaToSearch)
);
const areaToSearchWithSRID = sequelize.fn(
"ST_SetSRID",
areaToSearchAsGeometry,
4326
);
const contains = sequelize.fn(
"ST_Contains",
areaToSearchWithSRID,
pointWithSRID
);
const geoSearchQueryPart = sequelize.where(contains, true);
const query = {
adType,
realEstateType,
price: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
area: {
[Op.lte]: sizeMax,
[Op.gte]: sizeMin
},
[Op.and]: geoSearchQueryPart
};
const order = [["updatedAt", "desc"]];
return await db.RealEstate.findAll({
where: query,
limit: maxResults,
order
});
};
module.exports = {
bulkUpsertRealEstates,
getRealEstateById,
findRealEstatesForSearchRequest
};

View File

@@ -0,0 +1,74 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const Op = sequelize.Op;
const getSearchRequest = async searchRequestId => {
try {
return await db.SearchRequest.findByPk(searchRequestId);
} catch (error) {
return null;
}
};
const createSearchRequest = async (searchRequestFields = {}) => {
return await db.SearchRequest.create(searchRequestFields);
};
const findSearchRequestsForRealEstate = async realEstate => {
const {
price,
area,
adType,
realEstateType,
locationLat,
locationLong
} = realEstate;
if (!locationLat || !locationLong) {
return [];
}
const stGeometry = sequelize.fn(
"ST_GEOMFROMTEXT",
`POINT (${locationLong} ${locationLat})`,
4326
);
const areaToSearchColumn = sequelize.col("areaToSearch");
const contains = sequelize.fn("ST_Contains", areaToSearchColumn, stGeometry);
const geoSearchQueryPart = sequelize.where(contains, true);
const query = {
adType,
realEstateType,
subscribed: true,
[Op.and]: geoSearchQueryPart
};
if (price) {
query.priceMin = {
[Op.lte]: price
};
query.priceMax = {
[Op.gte]: price
};
}
if (area) {
query.sizeMin = {
[Op.lte]: area
};
query.sizeMax = {
[Op.gte]: area
};
}
return await db.SearchRequest.findAll({ where: query });
};
module.exports = {
getSearchRequest,
createSearchRequest,
findSearchRequestsForRealEstate
};

View File

@@ -0,0 +1,36 @@
"use strict";
const db = require("../../models/index");
const findRealEstatesForSearchRequest = async searchRequestId => {
const query = {
searchRequestId
};
const realEstatesModel = { model: db.RealEstate, as: "realEstates" };
const order = [[realEstatesModel, "updatedAt", "desc"]];
const include = [realEstatesModel];
const matches = await db.SearchRequestMatch.findAll({
where: query,
include,
order
});
const matchingRealEstates = [];
for (const match of matches) {
matchingRealEstates.push(...match.realEstates);
}
return matchingRealEstates;
};
const addMatches = async matchingRecords => {
return await db.SearchRequestMatch.bulkCreate(matchingRecords, {
ignoreDuplicates: true
});
};
module.exports = {
findRealEstatesForSearchRequest,
addMatches
};

8
app/helpers/email.js Normal file
View File

@@ -0,0 +1,8 @@
const isValidEmail = email => {
const simpleEmailRegex = /^.+@.+\..+$/;
return email && email.length < 250 && simpleEmailRegex.test(email);
};
module.exports = {
isValidEmail
};

View File

@@ -0,0 +1,112 @@
"use strict";
const { MAX_REAL_ESTATES_IN_EMAIL, APP_URL } = require("../config/appConfig");
const { AD_CATEGORY } = 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>
<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>`;
};
const generateRealEstateLinks = realEstates => {
let realEstateLinks = "";
for (const realEstate of realEstates) {
const { id: realEstateId, title } = realEstate;
realEstateLinks += `<li><a href="${APP_URL}/redirect/${realEstateId}">${title}</a></li>`;
}
return realEstateLinks;
};
const generateNotificationEmail = (realEstates, searchRequestId) => {
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 realEstateLinks = generateRealEstateLinks(realEstatesToShow);
const moreRealEstates = `<div>Kompletan spisak nekretnina možete pogledati na <a href="${allRealEstatesLink}">listi nekretnina</a><div>`;
const emailFooter = generateEmailFooter(searchRequestId);
return `<h3>Zdravo</h3>
<h4>Pronašli smo nekretnine koje odgovaraju Vašoj pretrazi</h4>
<div>
${realEstateLinks}
<div/>
${moreRealEstates}
</div>
<br/>
${emailFooter}`;
};
const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
const realEstateType = AD_CATEGORY[searchRequest.realEstateType];
const {
id,
gardenSizeMin,
gardenSizeMax,
sizeMin,
sizeMax,
priceMin,
priceMax
} = 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/>
${realEstateLinks}
</div>`;
const gardenSize = realEstateType.hasGardenSize
? `<div><strong>Kvadratura okućnice: Od ${gardenSizeMin} do ${gardenSizeMax} m2</strong></div>`
: ``;
const emailFooter = generateEmailFooter(id);
return `<h3>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>Kvadratura nekretnine:</strong> Od ${sizeMin} do ${sizeMax} m2</div>
${gardenSize}
<div><strong>Cijena:</strong> ${priceMin} do ${priceMax} KM</div>
</div>
${matchingRealEstates.length > 0 ? instantRealEstatesText : ""}
<br/>
${emailFooter}`;
};
const generateEmailSubject = (numberOfRealEstates, singleRealEstateTitle) => {
if (numberOfRealEstates === 1) {
return `Kivi: ${singleRealEstateTitle}`;
}
const leastSignificantDigit = numberOfRealEstates % 10;
const numberWithoutLastDigit = Math.floor(numberOfRealEstates / 10);
const secondLeastSignificantDigit = numberWithoutLastDigit % 10;
if (leastSignificantDigit === 1 && secondLeastSignificantDigit !== 1) {
return `Kivi : ${numberOfRealEstates} nova nekretnina`;
}
if (
leastSignificantDigit >= 2 &&
leastSignificantDigit <= 4 &&
secondLeastSignificantDigit !== 1
) {
return `Kivi: ${numberOfRealEstates} nove nekretnine`;
}
return `Kivi: ${numberOfRealEstates} novih nekretnina`;
};
module.exports = {
generateNotificationEmail,
generateNewSearchRequestEmail,
generateEmailSubject
};

25
app/helpers/forceSSL.js Normal file
View File

@@ -0,0 +1,25 @@
/**
* Force load with https on production environment
* https://devcenter.heroku.com/articles/http-routing#heroku-headers
*/
module.exports = function(environments, status) {
environments = environments || ["production"];
status = status || 301;
// console.log("New force SSL ");
// console.log("\tenvs : ", environments);
// console.log("\tstatus: ", status);
// console.log("\tENV : ", process.env.NODE_ENV);
return function(req, res, next) {
if (environments.indexOf(process.env.NODE_ENV) >= 0) {
if (req.headers["x-forwarded-proto"] !== "https") {
const urlToRedirectTo = `https://${req.hostname}${req.originalUrl}`;
// console.log("\tRedirect :", urlToRedirectTo);
res.redirect(status, urlToRedirectTo);
} else {
next();
}
} else {
next();
}
};
};

12
app/helpers/url.js Normal file
View File

@@ -0,0 +1,12 @@
const { getSearchRequest } = require("./db/searchRequest");
const currentSearchRequest = async req => {
const searchRequestId =
req && req.params ? req.params["searchRequestId"] : null;
if (!searchRequestId) return null;
return await getSearchRequest(searchRequestId);
};
module.exports = {
currentSearchRequest
};

View File

@@ -0,0 +1,34 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable("MarketAlerts", {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
olxUrl: {
type: Sequelize.STRING
},
lastDate: {
type: Sequelize.STRING
},
email: {
type: Sequelize.STRING,
allowNull: false
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable("MarketAlerts");
}
};

View File

@@ -0,0 +1,33 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable("RealEstateRequests", {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
uniqueId: {
type: Sequelize.UUID
},
realEstateType: {
type: Sequelize.STRING
},
email: {
type: Sequelize.STRING
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable("RealEstateRequests");
}
};

View File

@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
"RealEstateRequests",
"city",
Sequelize.STRING
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstateRequests", "city");
}
};

View File

@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
"RealEstateRequests",
"place",
Sequelize.STRING
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstateRequests", "place");
}
};

View File

@@ -0,0 +1,19 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
"RealEstateRequests",
"place",
"municipality"
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
"RealEstateRequests",
"municipality",
"place"
);
}
};

View File

@@ -0,0 +1,11 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.renameColumn("RealEstateRequests", "city", "region");
},
down: (queryInterface, Sequelize) => {
return queryInterface.renameColumn("RealEstateRequests", "region", "city");
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("RealEstateRequests", "size", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstateRequests", "size");
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("RealEstateRequests", "gardenSize", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstateRequests", "gardenSize");
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("RealEstateRequests", "price", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstateRequests", "price");
}
};

View File

@@ -0,0 +1,19 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize
.query("CREATE EXTENSION postgis")
.then(([results, metadata]) => {
/// No result
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize
.query("DROP EXTENSION IF EXISTS postgis")
.then(([results, metadata]) => {
/// No result
});
}
};

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize
.query(
'ALTER TABLE "RealEstateRequests" ADD COLUMN bounding_box geometry(Polygon);'
)
.then(([results, metadata]) => {
/// No result
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize
.query('ALTER TABLE "RealEstateRequests" DROP COLUMN bounding_box')
.then(([results, metadata]) => {
/// No result
});
}
};

View File

@@ -0,0 +1,48 @@
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.addColumn(
"RealEstateRequests",
"sizeRange",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSizeRange",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"priceRange",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("RealEstateRequests", "sizeRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "gardenSizeRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "priceRange", {
transaction: t
})
]);
});
}
};

View File

@@ -0,0 +1,147 @@
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("RealEstateRequests", "sizeRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "gardenSizeRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "priceRange", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "size", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "gardenSize", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "price", {
transaction: t
}),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSizeMin",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSizeMax",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"sizeMin",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"sizeMax",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"priceMin",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"priceMax",
{
type: Sequelize.INTEGER
},
{ transaction: t }
)
]);
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("RealEstateRequests", "gardenSizeMin", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "gardenSizeMax", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "sizeMin", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "sizeMax", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "priceMin", {
transaction: t
}),
queryInterface.removeColumn("RealEstateRequests", "priceMin", {
transaction: t
}),
queryInterface.addColumn(
"RealEstateRequests",
"priceMax",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSizeRange",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"priceRange",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"size",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"gardenSize",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"RealEstateRequests",
"price",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
}
};

View File

@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn(
"RealEstateRequests",
"subscribed",
Sequelize.BOOLEAN
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstateRequests", "subscribed");
}
};

View File

@@ -0,0 +1,70 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.addColumn(
"MarketAlerts",
"size",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"gardenSize",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"price",
{
type: Sequelize.INTEGER
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"municipality",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"region",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("MarketAlerts", "size", { transaction: t }),
queryInterface.removeColumn("MarketAlerts", "gardenSize", {
transaction: t
}),
queryInterface.removeColumn("MarketAlerts", "price", {
transaction: t
}),
queryInterface.removeColumn("MarketAlerts", "municipality", {
transaction: t
}),
queryInterface.removeColumn("MarketAlerts", "region", {
transaction: t
})
]);
});
}
};

View File

@@ -0,0 +1,59 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("MarketAlerts", "olxUrl", {
transaction: t
}),
queryInterface.addColumn(
"MarketAlerts",
"url",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"realestateOrigin",
{
type: Sequelize.STRING
},
{ transaction: t }
),
queryInterface.addColumn(
"MarketAlerts",
"originId",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.removeColumn("MarketAlerts", "url", { transaction: t }),
queryInterface.removeColumn("MarketAlerts", "realestateOrigin", {
transaction: t
}),
queryInterface.removeColumn("MarketAlerts", "originId", {
transaction: t
}),
queryInterface.addColumn(
"MarketAlerts",
"olxUrl",
{
type: Sequelize.STRING
},
{ transaction: t }
)
]);
});
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("MarketAlerts", "realEstateType", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("MarketAlerts", "realEstateType");
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("MarketAlerts", "notified", {
type: Sequelize.BOOLEAN
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("MarketAlerts", "notified");
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("MarketAlerts", "title", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("MarketAlerts", "title");
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("MarketAlerts", "request", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("MarketAlerts", "request");
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("MarketAlerts", "hasLocation", {
type: Sequelize.BOOLEAN
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("MarketAlerts", "hasLocation");
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("RealEstateRequests", "locationInput", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstateRequests", "locationInput");
}
};

View File

@@ -0,0 +1,19 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
"RealEstateRequests",
"bounding_box",
"boundingBox"
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.renameColumn(
"RealEstateRequests",
"boundingBox",
"bounding_box"
);
}
};

View File

@@ -0,0 +1,72 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
const tableFields = {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
allowNull: false,
primaryKey: true
},
url: {
type: Sequelize.TEXT,
allowNull: false
},
agencyObjectId: {
type: Sequelize.TEXT,
allowNull: false
},
originAgencyName: {
type: Sequelize.TEXT,
allowNull: false
},
realEstateType: {
type: Sequelize.TEXT,
allowNull: false
},
adType: {
type: Sequelize.TEXT,
allowNull: false
},
price: Sequelize.REAL,
area: Sequelize.REAL,
gardenSize: Sequelize.REAL,
streetNumber: Sequelize.INTEGER,
streetName: Sequelize.TEXT,
locality: Sequelize.TEXT,
municipality: Sequelize.TEXT,
city: Sequelize.TEXT,
region: Sequelize.TEXT,
entity: Sequelize.TEXT,
country: Sequelize.TEXT,
locationLat: Sequelize.REAL,
locationLong: Sequelize.REAL,
lastTimeCrawled: {
type: Sequelize.DATE,
allowNull: false
},
deleted: {
type: Sequelize.BOOLEAN,
allowNull: false
},
sold: {
type: Sequelize.BOOLEAN,
allowNull: false
},
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
}
};
return queryInterface.createTable("RealEstates", tableFields);
},
down: queryInterface => {
return queryInterface.dropTable("RealEstates", {});
}
};

View File

@@ -0,0 +1,79 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
const tableFields = {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true
},
areaToSearch: {
type: Sequelize.GEOMETRY("POLYGON", 4326),
allowNull: false,
defaultValue: {
type: "Polygon",
coordinates: [[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]],
crs: { type: "name", properties: { name: "EPSG:4326" } }
}
},
realEstateType: {
type: Sequelize.TEXT,
allowNull: false
},
adType: {
type: Sequelize.TEXT,
allowNull: false,
defaultValue: "sell"
},
email: Sequelize.TEXT,
locality: Sequelize.TEXT,
municipality: Sequelize.TEXT,
city: Sequelize.TEXT,
region: Sequelize.TEXT,
entity: Sequelize.TEXT,
country: Sequelize.TEXT,
sizeMin: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
sizeMax: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
priceMin: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
priceMax: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
gardenSizeMin: Sequelize.INTEGER,
gardenSizeMax: Sequelize.INTEGER,
subscribed: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
},
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
}
};
return queryInterface.createTable("SearchRequests", tableFields);
},
down: queryInterface => {
return queryInterface.dropTable("SearchRequests", {});
}
};

View File

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

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("RealEstates", "title", {
type: Sequelize.STRING
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstates", "title");
}
};

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("RealEstates", "shortDescription", {
type: Sequelize.STRING
}),
queryInterface.addColumn("RealEstates", "longDescription", {
type: Sequelize.STRING
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("RealEstates", "shortDescription"),
queryInterface.removeColumn("RealEstates", "longDescription")
]);
}
};

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("RealEstates", "adStatus", {
type: Sequelize.INTEGER
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstates", "adStatus");
}
};

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addConstraint(
"RealEstates",
["originAgencyName", "agencyObjectId"],
{
type: "unique",
name: "agencyNameObjectIdUniqueKey"
}
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeConstraint(
"RealEstates",
"agencyNameObjectIdUniqueKey"
);
}
};

View File

@@ -0,0 +1,14 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("RealEstates", "lastTimeCrawled");
},
down: (queryInterface, Sequelize) => {
return queryInterface.addColumn("RealEstates", "lastTimeCrawled", {
type: Sequelize.DATE,
notNull: true
});
}
};

View File

@@ -0,0 +1,23 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("RealEstates", "deleted"),
queryInterface.removeColumn("RealEstates", "sold")
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("RealEstates", "deleted", {
type: Sequelize.BOOLEAN,
notNull: true
}),
queryInterface.addColumn("RealEstates", "sold", {
type: Sequelize.BOOLEAN,
notNull: true
})
]);
}
};

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.changeColumn("RealEstates", "shortDescription", {
type: Sequelize.TEXT
}),
queryInterface.changeColumn("RealEstates", "longDescription", {
type: Sequelize.TEXT
}),
queryInterface.changeColumn("RealEstates", "title", {
type: Sequelize.TEXT
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([]);
}
};

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn("RealEstates", "publishedDate", {
type: Sequelize.DATE
}),
queryInterface.addColumn("RealEstates", "renewedDate", {
type: Sequelize.DATE
})
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn("RealEstates", "renewedDate"),
queryInterface.removeColumn("RealEstates", "publishedDate")
]);
}
};

View File

@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "adType" = 'SALE' WHERE "adType" = 'sell';`
);
},
down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "adType" = 'sell' WHERE "adType" = 'SALE';`
);
}
};

View File

@@ -0,0 +1,31 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'HOUSE' WHERE "realEstateType" = 'kuca';`
),
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'FLAT' WHERE "realEstateType" = 'stan';`
),
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'COTTAGE' WHERE "realEstateType" = 'vikendica';`
)
]);
},
down: (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'kuca' WHERE "realEstateType" = 'HOUSE';`
),
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'stan' WHERE "realEstateType" = 'FLAT';`
),
queryInterface.sequelize.query(
`UPDATE "SearchRequests" SET "realEstateType" = 'vikendica' WHERE "realEstateType" = 'COTTAGE';`
)
]);
}
};

49
app/models/index.js Normal file
View File

@@ -0,0 +1,49 @@
"use strict";
const fs = require("fs");
const path = require("path");
const Sequelize = require("sequelize");
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || "development";
const config = require(__dirname + "/../config/config.json")[env];
const db = {};
config.username = process.env.DB_USERNAME || config.username;
config.password = process.env.DB_PASSWORD || config.password;
config.database = process.env.DB_NAME || config.database;
config.port = process.env.DB_PORT || config.port;
config.logging = parseInt(process.env.SEQUELIZE_LOGGING) ? console.log : false;
let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(
config.database,
config.username,
config.password,
config
);
}
fs.readdirSync(__dirname)
.filter(file => {
return (
file.indexOf(".") !== 0 && file !== basename && file.slice(-3) === ".js"
);
})
.forEach(file => {
const model = sequelize["import"](path.join(__dirname, file));
db[model.name] = model;
});
Object.keys(db).forEach(modelName => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;

55
app/models/realEstate.js Normal file
View File

@@ -0,0 +1,55 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const RealEstate = sequelize.define("RealEstate", {
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
allowNull: false,
primaryKey: true
},
url: {
type: DataTypes.TEXT,
allowNull: false
},
originAgencyName: {
type: DataTypes.TEXT,
allowNull: false,
unique: true
},
agencyObjectId: {
type: DataTypes.TEXT,
allowNull: false,
unique: true
},
realEstateType: {
type: DataTypes.TEXT,
allowNull: false
},
adType: {
type: DataTypes.TEXT,
allowNull: false
},
price: DataTypes.REAL,
area: DataTypes.REAL,
gardenSize: DataTypes.REAL,
streetNumber: DataTypes.INTEGER,
streetName: DataTypes.TEXT,
locality: DataTypes.TEXT,
municipality: DataTypes.TEXT,
city: DataTypes.TEXT,
region: DataTypes.TEXT,
entity: DataTypes.TEXT,
country: DataTypes.TEXT,
locationLat: DataTypes.REAL,
locationLong: DataTypes.REAL,
title: DataTypes.TEXT,
shortDescription: DataTypes.TEXT,
longDescription: DataTypes.TEXT,
adStatus: DataTypes.INTEGER,
publishedDate: DataTypes.DATE,
renewedDate: DataTypes.DATE
});
return RealEstate;
};

View File

@@ -0,0 +1,68 @@
"use strict";
const { AD_TYPE } = require("../common/enums");
module.exports = (sequelize, DataTypes) => {
const SearchRequest = sequelize.define("SearchRequest", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false,
primaryKey: true
},
areaToSearch: {
type: DataTypes.GEOMETRY("POLYGON", 4326),
allowNull: false,
defaultValue: {
type: "Polygon",
coordinates: [[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]],
crs: { type: "name", properties: { name: "EPSG:4326" } }
}
},
realEstateType: {
type: DataTypes.TEXT,
allowNull: false
},
adType: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: AD_TYPE.AD_TYPE_SALE
},
email: DataTypes.TEXT,
locality: DataTypes.TEXT,
municipality: DataTypes.TEXT,
city: DataTypes.TEXT,
region: DataTypes.TEXT,
entity: DataTypes.TEXT,
country: DataTypes.TEXT,
sizeMin: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
sizeMax: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
priceMin: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
priceMax: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
gardenSizeMin: DataTypes.INTEGER,
gardenSizeMax: DataTypes.INTEGER,
subscribed: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
}
});
return SearchRequest;
};

View File

@@ -0,0 +1,54 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const SearchRequestMatch = sequelize.define(
"SearchRequestMatch",
{
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
allowNull: false
},
realEstateId: {
type: DataTypes.BIGINT,
allowNull: false,
primaryKey: true,
references: {
model: "RealEstate",
key: "id"
},
onUpdate: "CASCADE",
onDelete: "SET NULL"
},
searchRequestId: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: "SearchRequest",
key: "id"
}
},
notified: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
},
{
name: {
singular: "searchRequestMatch",
plural: "searchRequestMatches"
}
}
);
SearchRequestMatch.associate = models => {
SearchRequestMatch.hasMany(models.RealEstate, {
foreignKey: "id",
as: "realEstates"
});
};
return SearchRequestMatch;
};

View File

@@ -0,0 +1,32 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const MarketAlert = sequelize.define(
"MarketAlert",
{
url: DataTypes.STRING,
realestateOrigin: DataTypes.STRING,
originId: DataTypes.STRING,
lastDate: DataTypes.STRING,
size: DataTypes.INTEGER,
gardenSize: DataTypes.INTEGER,
price: DataTypes.INTEGER,
municipality: DataTypes.STRING,
region: DataTypes.STRING,
realEstateType: DataTypes.STRING,
notified: DataTypes.BOOLEAN,
title: DataTypes.STRING,
request: DataTypes.STRING,
hasLocation: DataTypes.BOOLEAN,
email: {
type: DataTypes.STRING,
allowNul: false
}
},
{}
);
MarketAlert.associate = function(models) {
// associations can be defined here
};
return MarketAlert;
};

View File

@@ -0,0 +1,32 @@
"use strict";
module.exports = (sequelize, DataTypes) => {
const RealEstateRequest = sequelize.define(
"RealEstateRequest",
{
uniqueId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false
},
realEstateType: DataTypes.STRING,
email: DataTypes.STRING,
region: DataTypes.STRING,
municipality: DataTypes.STRING,
sizeMin: DataTypes.INTEGER,
sizeMax: DataTypes.INTEGER,
gardenSizeMin: DataTypes.INTEGER,
gardenSizeMax: DataTypes.INTEGER,
priceMin: DataTypes.INTEGER,
priceMax: DataTypes.INTEGER,
boundingBox: DataTypes.GEOMETRY("POINT", 4326),
subscribed: DataTypes.BOOLEAN,
locationInput: DataTypes.STRING
},
{}
);
RealEstateRequest.associate = function(models) {
// associations can be defined here
};
return RealEstateRequest;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

122
app/public/images/logo.svg Normal file
View File

@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="61"
height="94"
viewBox="0 0 61 94"
version="1.1"
id="svg29"
sodipodi:docname="logo2.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<metadata
id="metadata33">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1853"
inkscape:window-height="1025"
id="namedview31"
showgrid="false"
inkscape:zoom="5.6568543"
inkscape:cx="29.58234"
inkscape:cy="42.092869"
inkscape:window-x="67"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg29" />
<!-- Generator: Sketch 51.3 (57544) - http://www.bohemiancoding.com/sketch -->
<desc
id="desc2">Created with Sketch.</desc>
<defs
id="defs4" />
<g
id="g938"
transform="translate(4.6566166)">
<g
id="Group"
transform="translate(21.468225,75.05246)"
style="fill:#02adba;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1">
<path
style="fill:#02adba;fill-opacity:1"
d="m 2.8937203,1.377828 h 0.00886 V 16.18948 h -0.00886 c -0.080215,0.775019 -0.6954121,1.377828 -1.4424314,1.377828 -0.74701929,0 -1.36221635,-0.602809 -1.4424314,-1.377828 H 0 V 1.377828 H 0.0088575 C 0.08907255,0.60280831 0.70426961,0 1.4512889,0 2.1983082,0 2.8135053,0.60280831 2.8937203,1.377828 Z"
id="Combined-Shape"
inkscape:connector-curvature="0" />
</g>
<g
id="Group-Copy"
transform="translate(40.284746,75.05246)"
style="fill:#02adba;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1">
<path
style="fill:#02adba;fill-opacity:1"
d="m 2.8937203,1.377828 h 0.00886 V 16.18948 h -0.00886 c -0.080215,0.775019 -0.6954121,1.377828 -1.4424314,1.377828 -0.74701929,0 -1.36221635,-0.602809 -1.4424314,-1.377828 H 0 V 1.377828 H 0.0088575 C 0.08907255,0.60280831 0.70426961,0 1.4512889,0 2.1983082,0 2.8135053,0.60280831 2.8937203,1.377828 Z"
id="path8"
inkscape:connector-curvature="0" />
</g>
<g
id="Group-2"
transform="translate(26.045022,75.05246)"
style="fill:#02adba;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1">
<path
style="fill:#02adba;fill-opacity:1"
sodipodi:nodetypes="cccsscccsscccsc"
d="M 4.7335014,16.043249 0.01556995,2.0709177 v 0 C -0.03604093,1.9151618 -0.08984375,1.7196494 -0.08984375,1.5461504 -0.08984375,0.69007771 0.68116079,0 1.5214152,0 2.3524826,0 2.7388538,0.67107889 3.0425868,1.5220354 L 6.255723,11.044343 9.4667171,1.5292951 C 9.7627803,0.71794799 10.154547,0 10.987999,0 c 0.840254,0 1.603446,0.68226521 1.603446,1.5383379 0,0.1793218 -0.04867,0.3879772 -0.103645,0.5481862 v 0 L 7.776097,16.026251 c -0.6976248,1.816338 -0.6840821,1.541057 -1.5213901,1.541057 -0.831732,0 -0.9926068,0.05035 -1.5212055,-1.524059 z"
id="path11"
inkscape:connector-curvature="0" />
</g>
<path
sodipodi:nodetypes="ccccsscccsscccccscscccssscc"
inkscape:connector-curvature="0"
id="path14"
d="m 12.013315,76.606127 v 4.050014 l 4.717498,-5.019565 v 0 c 0.404985,-0.305017 0.852764,-0.412505 1.290558,-0.412505 0.899311,0 1.590893,0.796979 1.590893,1.653051 0,0.589329 -0.373586,0.895853 -0.854196,1.364015 l -5.796302,6.120383 5.715392,5.649676 c 0.532765,0.486219 0.935106,0.782903 0.935106,1.402972 0,0.856073 -0.729035,1.550057 -1.628346,1.550057 -0.420934,0 -0.861077,0.0072 -1.216312,-0.275429 v 0 l -4.754291,-4.690453 v 3.249235 h -0.0031 c 0.0021,0.03199 0.0031,0.06425 0.0031,0.09674 0,0.856072 -0.729035,1.550056 -1.628346,1.550056 -0.8993109,-10e-7 -1.6283459,-0.693985 -1.6283459,-1.550057 0,-0.03249 10e-4,-0.06475 0.0031,-0.09674 h -0.0031 v -14.64145 -0.0036 c 0,-0.856083 0.729035,-1.550067 1.6283459,-1.550067 0.899311,0 1.628346,0.693984 1.628346,1.550057 0,0.0012 -1e-6,0.0024 -4e-6,0.0036 z"
style="fill:#02adba;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1" />
</g>
<g
style="fill:#02adba;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1"
id="Group-5-Copy"
transform="translate(0.30033447)">
<path
inkscape:connector-curvature="0"
id="Page-1-Copy-3"
d="m 28.864505,0.71910556 c 0.913861,-0.49697651 2.013643,-0.49697651 2.927503,0 L 58.602992,15.299478 c 1.00074,0.544224 1.624856,1.599801 1.624856,2.748147 V 52.95707 c 0,1.148497 -0.624278,2.204188 -1.625223,2.748347 L 31.791641,70.28107 c -0.913667,0.49671 -2.013102,0.49671 -2.926768,0 L 2.053889,55.705417 C 1.052944,55.161258 0.42866553,54.105567 0.42866553,52.95707 V 18.047625 c 0,-1.148346 0.62411557,-2.203923 1.62485607,-2.748147 z M 30.328257,3.4672523 3.5172731,18.047625 V 52.95707 L 30.328257,67.532724 57.13924,52.95707 V 18.047625 Z"
style="fill:#02adba;fill-opacity:1" />
<path
inkscape:connector-curvature="0"
transform="matrix(-1,0,0,1,38.607594,0)"
id="Page-1-Copy-2"
d="m 6.6881981,8.5296646 c 0,-1.1837877 1.2534136,-1.9364737 2.2824613,-1.3706383 L 31.113251,19.334421 c 0.496869,0.273211 0.806146,0.799054 0.806146,1.370639 v 29.035897 c 0,0.571661 -0.309358,1.097561 -0.806331,1.37074 L 8.970475,63.283151 C 7.9414326,63.848801 6.6881981,63.096106 6.6881981,61.912412 Z M 9.7768057,11.155344 V 59.287138 L 28.830789,48.813446 V 21.632428 Z"
style="fill:#02adba;fill-opacity:1" />
<path
inkscape:connector-curvature="0"
transform="rotate(4,36.513012,12.39682)"
id="Path-85"
d="m 29.442604,10.590184 12.454251,6.520214 c 0.879716,0.424975 1.970417,0.118743 2.436149,-0.683987 0.465733,-0.802729 0.130132,-1.797981 -0.749584,-2.222955 L 31.12917,7.6832415 C 30.249453,7.2582668 29.158752,7.564498 28.69302,8.367228 c -0.465732,0.8027299 -0.130132,1.797981 0.749584,2.222956 z"
style="fill:#02adba;fill-opacity:1" />
</g>
<path
style="fill:#02adba;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1"
inkscape:connector-curvature="0"
id="Path-87"
d="m 7.8821311,47.17753 c -0.7181691,0.486659 -0.8975584,1.451387 -0.4006772,2.15478 0.4968812,0.703394 1.4818741,0.879093 2.2000432,0.392434 L 31.518752,34.926994 c 0.939679,-0.636761 0.900745,-2.009481 -0.07358,-2.594168 L 9.6079198,19.228381 c -0.744646,-0.446859 -1.718161,-0.217874 -2.1744066,0.511452 -0.4562456,0.729326 -0.2224509,1.682812 0.5221952,2.129671 L 27.723653,33.732165 Z" />
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

101
app/public/main.css Normal file
View File

@@ -0,0 +1,101 @@
@font-face {
font-family: "Alte Haas Grotesk";
src: url("./fonts/altehaasgroteskregular.ttf");
}
@font-face {
font-family: "Alte Haas Grotesk";
src: url("./fonts/altehaasgroteskbold.ttf");
font-weight: bold;
}
.welcome-center-button {
width: 100%;
}
.next-center-button {
width: 50%;
left: 25%;
}
.no-ui-slider {
width: 95%;
}
.noUi-connect {
background: #02adba;
}
.centered-element {
margin-top: 200px;
}
.centered-element-small {
margin-top: 100px;
}
.btn,
.btn-floating {
background: #02adba;
font-weight: bold;
}
.kivi-color {
color: #02adba;
}
.kivi-spinner-color {
border-color: #02adba;
}
#map {
height: 50%;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
font-family: "Alte Haas Grotesk", serif;
box-sizing: border-box;
}
#floating-panel {
top: 10px;
left: 25%;
z-index: 5;
background-color: #fff;
border: 1px solid #999;
text-align: center;
font-family: "Roboto", "sans-serif";
line-height: 30px;
padding: 5px 5px 5px 10px;
}
.btn:hover {
background-color: white;
color: #02adba;
border: 1px solid rgb(0, 173, 187);
}
.btn-floating:hover {
background-color: #02adba;
border: 1px solid rgb(0, 173, 187);
}
h6.title {
margin-top: 0;
font-weight: bold;
padding-top: 20px;
font-size: 1.3rem;
}
.locate-me-container {
margin-right: 10px;
}
h3 {
font-size: 15px;
line-height: 1.5;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,212 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1136.000000pt" height="1136.000000pt" viewBox="0 0 1136.000000 1136.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,1136.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5559 11294 c-29 -7 -57 -10 -61 -8 -5 3 -8 1 -8 -5 0 -5 -13 -12
-30 -16 -16 -4 -30 -11 -30 -17 0 -6 -3 -9 -6 -5 -3 3 -27 -8 -54 -24 -26 -16
-49 -29 -52 -29 -2 0 -73 -38 -157 -85 -85 -47 -155 -85 -158 -85 -2 0 -21
-11 -43 -25 -22 -14 -40 -22 -40 -18 0 5 -4 3 -8 -2 -4 -6 -27 -20 -52 -33
-25 -13 -63 -33 -85 -46 -22 -12 -80 -44 -130 -70 -49 -27 -129 -71 -178 -97
-48 -27 -92 -49 -97 -49 -6 0 -10 -4 -10 -10 0 -5 -4 -10 -10 -10 -5 0 -71
-34 -146 -75 -75 -41 -139 -75 -141 -75 -2 0 -21 -11 -43 -25 -22 -14 -40 -22
-40 -18 0 5 -4 3 -8 -3 -6 -9 -44 -30 -277 -155 -44 -23 -81 -46 -83 -51 -2
-4 -11 -8 -21 -8 -9 0 -21 -4 -27 -9 -8 -8 -113 -66 -179 -99 -16 -9 -32 -18
-35 -22 -3 -3 -25 -16 -50 -28 -46 -22 -64 -32 -310 -167 -80 -44 -148 -80
-152 -80 -4 0 -13 -6 -20 -12 -6 -7 -17 -13 -24 -13 -7 0 -14 -3 -16 -7 -1 -5
-28 -20 -58 -36 -70 -35 -161 -86 -177 -99 -7 -6 -13 -7 -13 -2 0 5 -4 4 -8
-1 -4 -6 -34 -24 -67 -42 -33 -17 -89 -47 -125 -68 -36 -20 -82 -43 -102 -52
-21 -9 -38 -20 -38 -25 0 -4 -5 -8 -10 -8 -6 0 -42 -18 -80 -39 -39 -22 -70
-37 -70 -33 0 4 -4 2 -8 -4 -4 -5 -45 -31 -92 -56 -47 -25 -101 -54 -120 -65
-19 -11 -77 -42 -128 -69 -51 -27 -100 -53 -110 -59 -9 -5 -57 -32 -107 -59
-49 -27 -112 -61 -140 -76 -27 -15 -75 -40 -105 -56 -30 -16 -56 -32 -58 -36
-2 -5 -9 -8 -15 -8 -38 0 -175 -127 -209 -195 -11 -22 -23 -42 -27 -45 -3 -3
-8 -12 -10 -20 -2 -8 -8 -31 -13 -50 -5 -19 -11 -44 -14 -55 -6 -25 -6 -5710
0 -5745 19 -105 62 -191 132 -265 28 -30 56 -55 63 -55 6 0 11 -4 11 -9 0 -5
24 -21 53 -36 51 -27 120 -64 207 -113 25 -14 68 -37 95 -52 28 -15 82 -44
120 -65 39 -21 95 -51 125 -67 30 -16 73 -40 95 -52 22 -12 81 -44 130 -70 50
-27 115 -62 145 -79 30 -16 69 -37 85 -46 17 -9 38 -22 48 -30 9 -8 17 -10 17
-6 0 4 8 2 17 -6 10 -8 43 -28 73 -44 30 -16 69 -36 86 -46 18 -10 76 -41 130
-70 55 -29 111 -61 127 -71 15 -11 27 -16 27 -12 0 3 7 1 15 -6 8 -6 29 -20
47 -29 135 -71 214 -116 220 -125 4 -6 8 -8 8 -4 0 4 30 -10 68 -31 37 -21
108 -60 157 -86 50 -26 104 -56 120 -65 29 -16 71 -40 230 -125 100 -54 173
-94 230 -126 28 -16 61 -33 74 -39 13 -6 37 -20 52 -31 16 -11 29 -18 29 -15
0 3 26 -10 57 -29 32 -19 64 -35 71 -35 6 0 12 -4 12 -9 0 -5 9 -12 19 -16 18
-6 277 -144 381 -204 25 -14 47 -26 50 -26 5 -1 28 -14 75 -41 11 -7 43 -24
70 -38 28 -14 95 -51 150 -81 55 -31 116 -63 135 -73 19 -9 37 -19 40 -22 3
-3 30 -18 60 -34 57 -30 104 -56 225 -122 39 -21 83 -45 100 -53 16 -9 32 -18
35 -21 6 -7 53 -27 60 -27 3 0 16 -6 30 -13 89 -48 301 -39 385 16 12 8 25 11
28 7 4 -3 7 -1 7 5 0 7 6 12 14 12 7 0 19 7 26 15 7 8 21 15 30 15 10 0 20 4
22 8 2 5 41 29 88 54 47 25 121 64 165 88 44 23 101 56 128 71 26 16 54 29 62
29 8 0 15 3 15 8 0 4 31 23 70 41 38 19 70 38 70 43 0 4 8 8 19 8 10 0 24 7
31 15 7 8 16 15 21 15 5 0 85 42 178 94 94 51 172 91 175 88 4 -3 6 0 6 6 0 7
4 12 8 12 8 0 260 134 302 160 8 6 31 18 50 28 46 25 141 75 215 116 33 17 72
40 88 50 15 10 27 14 27 10 0 -5 4 -4 8 1 6 9 25 20 117 69 11 6 23 15 26 19
3 5 9 8 13 7 6 -1 40 17 241 128 33 18 76 40 95 49 19 8 37 18 40 22 3 3 23
16 45 27 176 94 231 123 308 166 48 26 92 48 97 48 6 0 10 4 10 8 0 5 17 16
38 25 20 9 64 31 97 49 33 19 96 53 140 77 44 25 100 55 125 69 25 14 68 37
95 51 28 14 52 28 55 31 3 3 23 14 45 25 22 11 85 45 141 75 55 31 116 64 135
74 46 24 130 69 364 198 39 21 81 44 95 51 14 6 27 14 30 17 3 3 34 20 70 39
136 71 224 169 271 301 l23 65 1 2877 1 2877 -22 69 c-20 61 -66 144 -106 192
-27 32 -106 89 -187 134 -46 25 -85 46 -87 46 -3 0 -22 11 -44 25 -22 14 -40
22 -40 18 0 -5 -4 -3 -8 3 -4 5 -23 18 -42 27 -19 10 -48 26 -65 36 -16 10
-70 39 -120 66 -49 26 -126 68 -170 92 -44 24 -100 54 -125 66 -25 12 -47 25
-50 28 -3 3 -18 12 -35 20 -45 23 -149 80 -161 90 -6 5 -13 7 -16 4 -3 -3 -11
1 -18 10 -7 8 -16 15 -21 15 -4 0 -30 13 -56 28 -26 16 -82 46 -123 67 -41 21
-83 45 -92 53 -10 8 -18 12 -18 8 0 -4 -17 5 -37 19 -21 14 -43 25 -49 25 -7
0 -14 3 -16 8 -1 4 -41 27 -88 51 -47 24 -97 53 -112 63 -16 11 -28 16 -28 12
0 -3 -6 -2 -12 4 -17 14 -101 60 -408 224 -41 23 -118 65 -171 94 -52 30 -101
54 -107 54 -7 0 -12 4 -12 8 0 4 -12 13 -27 19 -16 6 -59 29 -98 51 -38 21
-106 58 -150 81 -131 71 -140 76 -145 81 -3 3 -29 16 -57 29 -29 14 -53 28
-53 33 0 4 -9 8 -20 8 -11 0 -20 4 -20 8 0 4 -12 13 -27 19 -16 6 -57 28 -93
48 -36 21 -101 56 -145 80 -79 41 -146 78 -235 127 -48 27 -159 87 -225 123
-22 12 -70 38 -107 58 -65 35 -125 68 -268 145 -36 19 -78 43 -95 52 -64 38
-186 99 -220 110 -75 25 -173 30 -246 14z m176 -534 c39 -22 97 -53 130 -70
33 -17 87 -46 120 -65 33 -19 81 -45 108 -57 26 -13 47 -26 47 -30 0 -5 5 -8
11 -8 7 0 34 -13 60 -30 27 -16 49 -26 49 -22 0 4 4 3 8 -3 4 -5 32 -23 62
-39 30 -16 91 -49 135 -73 44 -24 96 -51 115 -61 19 -9 37 -20 38 -24 2 -5 7
-8 11 -8 4 0 91 -45 192 -100 101 -55 196 -106 212 -113 15 -6 27 -15 27 -19
0 -5 9 -8 20 -8 11 0 20 -4 20 -10 0 -5 4 -10 9 -10 5 0 40 -17 78 -38 103
-58 223 -123 268 -145 22 -11 42 -22 45 -26 3 -3 32 -20 65 -37 55 -28 161
-85 260 -140 36 -21 89 -49 215 -115 25 -13 47 -26 50 -29 3 -3 28 -16 55 -30
28 -14 52 -27 55 -30 3 -3 31 -18 62 -34 32 -15 60 -31 63 -37 4 -5 15 -9 26
-9 10 0 19 -3 19 -8 0 -4 30 -23 68 -41 37 -19 84 -45 105 -58 20 -13 37 -20
37 -17 0 4 8 0 18 -8 9 -9 60 -37 112 -64 52 -27 97 -52 100 -55 3 -3 39 -23
80 -44 41 -21 83 -45 93 -53 9 -8 17 -12 17 -7 0 4 6 3 13 -3 14 -12 156 -90
293 -163 54 -28 107 -59 117 -67 9 -8 17 -12 17 -8 0 4 13 -2 29 -13 16 -12
31 -21 33 -21 3 0 63 -31 133 -70 70 -38 132 -70 136 -70 4 0 9 -3 11 -7 2 -5
38 -26 80 -48 65 -33 78 -44 79 -65 0 -14 1 -1268 1 -2788 1 -2705 0 -2763
-18 -2780 -11 -9 -53 -35 -94 -56 -41 -22 -87 -48 -102 -58 -16 -11 -28 -17
-28 -14 0 4 -17 -4 -37 -17 -21 -12 -56 -31 -78 -42 -22 -11 -42 -22 -45 -25
-3 -3 -18 -12 -35 -20 -16 -8 -37 -20 -45 -26 -8 -7 -22 -14 -30 -16 -8 -2
-17 -6 -20 -9 -3 -3 -54 -32 -115 -64 -60 -32 -114 -61 -120 -65 -5 -4 -50
-29 -100 -55 -49 -26 -126 -68 -170 -92 -44 -24 -100 -54 -125 -66 -25 -12
-47 -25 -50 -28 -3 -3 -48 -28 -100 -56 -96 -51 -204 -110 -730 -396 -91 -49
-194 -106 -230 -125 -36 -19 -87 -47 -115 -62 -27 -15 -77 -42 -110 -60 -33
-18 -78 -42 -100 -53 -22 -12 -42 -24 -45 -27 -3 -3 -25 -15 -50 -28 -143 -73
-206 -108 -212 -117 -4 -5 -8 -6 -8 -1 0 5 -8 3 -17 -5 -10 -8 -31 -21 -48
-30 -16 -9 -55 -30 -85 -46 -30 -17 -80 -43 -110 -59 -30 -16 -57 -31 -60 -34
-3 -3 -23 -14 -45 -25 -22 -11 -69 -36 -105 -56 -88 -49 -164 -90 -230 -125
-30 -16 -58 -33 -62 -39 -4 -5 -8 -6 -8 -1 0 5 -6 4 -12 -2 -7 -5 -49 -29 -93
-52 -44 -24 -92 -52 -107 -62 -16 -11 -28 -16 -28 -12 0 3 -7 1 -15 -6 -8 -6
-29 -19 -47 -29 -116 -61 -237 -127 -260 -143 -14 -10 -32 -18 -40 -18 -7 0
-67 29 -132 65 -66 36 -123 65 -128 65 -4 0 -8 5 -8 12 0 6 -3 8 -7 5 -3 -4
-12 -2 -19 3 -6 6 -45 28 -85 49 -41 21 -91 48 -111 59 -21 12 -68 38 -105 58
-37 20 -136 74 -220 120 -84 46 -154 84 -157 84 -2 0 -31 15 -63 34 -32 18
-168 93 -303 166 -135 73 -270 146 -300 163 -30 16 -93 50 -140 75 -47 25 -86
49 -88 53 -2 5 -11 9 -21 9 -9 0 -21 4 -26 9 -9 8 -292 164 -500 276 -75 40
-185 99 -250 135 -27 15 -75 41 -105 57 -30 17 -90 49 -132 72 -43 23 -92 49
-110 59 -18 9 -36 22 -40 27 -4 6 -8 6 -8 2 0 -5 -12 0 -27 10 -16 10 -53 32
-83 47 -30 16 -59 32 -65 37 -5 4 -64 35 -130 69 -66 35 -123 68 -127 74 -4 6
-8 7 -8 3 0 -5 -12 0 -28 10 -15 11 -56 34 -92 53 -36 19 -83 44 -105 57 -22
12 -56 30 -75 40 -19 9 -60 31 -90 49 -30 17 -95 53 -145 79 -81 43 -143 76
-230 125 l-30 17 0 2794 0 2794 43 22 c24 12 48 26 55 31 7 5 57 33 112 62
136 72 220 117 280 150 28 15 77 42 110 60 94 51 184 100 255 139 36 20 79 43
95 51 17 8 32 17 35 20 3 3 21 13 40 24 236 125 224 118 244 135 9 7 16 10 16
6 0 -4 11 0 24 8 13 9 50 30 83 48 32 17 105 57 163 89 58 31 139 75 180 98
41 22 118 64 170 92 52 29 122 67 155 84 33 17 62 33 65 36 3 3 28 16 55 31
28 14 104 55 170 91 66 36 149 81 185 100 36 20 92 50 125 68 33 18 89 48 125
68 36 19 79 43 95 52 35 21 149 82 220 119 28 14 52 28 55 31 3 3 34 20 70 39
36 19 90 48 120 64 30 16 75 41 100 54 103 55 187 101 220 120 19 10 60 33 90
49 30 16 58 33 62 39 4 5 8 6 8 1 0 -5 6 -4 13 2 12 10 60 37 177 98 30 16 57
31 60 34 3 3 30 18 60 33 30 16 73 39 95 52 22 13 47 24 55 24 8 0 47 -17 85
-39z"/>
<path d="M5614 10238 c-3 -5 -11 -8 -17 -7 -7 1 -25 -3 -40 -9 -16 -7 -33 -12
-38 -12 -5 0 -9 -4 -9 -9 0 -5 -8 -11 -17 -15 -10 -3 -49 -24 -88 -46 -38 -22
-95 -52 -125 -67 -30 -16 -59 -32 -65 -36 -13 -10 -316 -177 -322 -177 -2 0
-20 -11 -40 -25 -20 -13 -40 -22 -45 -19 -4 3 -8 1 -8 -5 0 -5 -9 -13 -20 -16
-12 -4 -58 -29 -104 -56 -46 -27 -86 -49 -89 -49 -3 0 -48 -25 -101 -55 -52
-30 -97 -55 -99 -55 -3 0 -51 -26 -108 -58 -57 -33 -134 -75 -171 -95 -173
-94 -205 -112 -240 -134 -21 -13 -38 -20 -38 -16 0 5 -4 4 -8 -2 -8 -11 -16
-16 -147 -86 -52 -28 -97 -55 -101 -60 -3 -5 -14 -9 -25 -9 -10 0 -19 -4 -19
-9 0 -5 -8 -11 -17 -14 -16 -5 -72 -35 -208 -111 -27 -16 -108 -60 -180 -100
-71 -39 -131 -74 -133 -78 -2 -5 -12 -8 -22 -8 -10 0 -20 -3 -22 -7 -1 -5 -32
-23 -68 -42 -36 -19 -78 -43 -95 -53 -16 -10 -37 -21 -45 -25 -13 -6 -205
-111 -310 -170 -19 -11 -60 -33 -90 -49 -30 -16 -58 -33 -62 -39 -4 -5 -8 -6
-8 -2 0 4 -17 -4 -37 -18 -21 -14 -41 -25 -46 -25 -4 0 -24 -10 -45 -23 -20
-13 -59 -31 -85 -41 -27 -10 -60 -29 -74 -42 -14 -13 -30 -24 -34 -24 -5 0 -9
-7 -9 -15 0 -8 -4 -15 -8 -15 -5 0 -19 -21 -33 -47 l-24 -48 -2 -2384 c-2
-1741 0 -2388 8 -2398 6 -7 9 -13 5 -13 -4 0 0 -13 8 -28 23 -45 90 -105 153
-137 32 -16 99 -52 148 -80 50 -28 108 -59 130 -70 22 -11 42 -23 45 -26 3 -3
43 -26 90 -51 47 -25 105 -56 130 -70 97 -53 367 -201 395 -217 17 -9 32 -18
35 -21 3 -3 21 -13 40 -22 36 -18 163 -87 235 -128 22 -13 74 -41 115 -63 41
-22 80 -43 85 -47 6 -4 51 -29 100 -56 94 -50 127 -68 269 -146 47 -27 90 -48
96 -48 5 0 10 -3 10 -8 0 -4 17 -15 38 -24 20 -10 62 -32 92 -49 30 -17 84
-47 120 -67 36 -19 81 -44 100 -55 19 -11 62 -35 95 -52 33 -18 85 -47 115
-64 30 -17 71 -39 90 -49 19 -9 37 -19 40 -22 3 -3 21 -13 40 -22 19 -9 67
-35 105 -57 39 -23 93 -52 120 -66 28 -15 64 -34 80 -44 17 -10 62 -35 100
-56 39 -21 105 -58 149 -82 43 -23 82 -43 86 -43 5 0 10 -3 12 -7 2 -5 23 -19
48 -31 25 -13 79 -42 120 -65 65 -35 83 -40 136 -40 107 0 200 65 235 164 16
43 17 359 17 4088 0 2223 2 4044 4 4046 2 2 32 -14 66 -35 35 -22 65 -40 68
-40 3 0 32 -18 64 -40 32 -22 64 -40 69 -40 6 0 11 -4 11 -9 0 -5 8 -11 18
-14 9 -3 60 -33 112 -67 52 -34 103 -64 113 -67 9 -3 17 -9 17 -14 0 -5 6 -9
14 -9 8 0 16 -3 18 -7 2 -5 15 -15 30 -23 73 -40 223 -135 226 -142 2 -4 8 -8
13 -8 4 0 58 -31 120 -70 61 -38 113 -70 114 -70 2 0 24 -13 48 -30 25 -16 50
-30 56 -30 6 0 11 -4 11 -10 0 -5 7 -10 15 -10 8 0 15 -4 15 -9 0 -5 8 -11 18
-14 18 -6 73 -38 82 -48 3 -3 46 -29 95 -59 50 -30 92 -56 95 -60 3 -3 21 -13
40 -23 270 -125 536 135 378 370 -33 48 -74 81 -165 129 -18 10 -33 21 -33 26
0 4 -6 8 -12 8 -7 0 -44 21 -83 46 -38 25 -81 52 -95 60 -14 8 -38 23 -55 34
-16 11 -37 23 -45 27 -17 8 -92 55 -100 63 -10 10 -65 41 -82 47 -10 3 -18 9
-18 14 0 5 -7 9 -15 9 -8 0 -15 5 -15 10 0 6 -5 10 -12 10 -7 0 -28 11 -47 24
-20 13 -126 78 -236 146 -110 67 -216 133 -235 145 -83 53 -138 86 -175 106
-22 12 -42 24 -45 28 -6 7 -37 27 -65 41 -11 6 -32 18 -47 28 -121 77 -262
162 -269 162 -5 0 -9 3 -9 8 0 4 -10 12 -23 18 -12 6 -53 30 -91 54 -38 23
-74 43 -80 45 -6 1 -24 7 -41 14 -37 15 -124 21 -131 9z m-203 -695 c4 -186 0
-3133 -5 -3134 -4 0 -22 9 -42 20 -19 11 -37 20 -41 20 -3 0 -9 4 -12 9 -3 5
-25 20 -48 33 -24 13 -114 67 -200 119 -159 96 -219 132 -283 170 -52 31 -100
59 -192 115 -46 28 -87 52 -93 54 -5 2 -20 13 -33 25 -13 11 -26 20 -28 18 -5
-3 -95 49 -181 104 -28 19 -56 34 -62 34 -6 0 -11 5 -11 10 0 6 -6 10 -14 10
-7 0 -19 7 -26 15 -7 8 -16 12 -21 9 -5 -3 -9 -1 -9 4 0 6 -19 19 -42 31 -24
12 -113 64 -198 117 -85 52 -165 99 -177 104 -13 5 -23 15 -23 21 0 7 -3 10
-6 6 -3 -3 -41 15 -83 42 -42 26 -83 50 -91 54 -8 4 -27 15 -42 25 -14 9 -55
34 -90 55 -34 20 -70 42 -78 47 -64 40 -152 90 -160 90 -6 0 -10 4 -10 8 0 5
-17 17 -37 29 -83 45 -119 66 -135 81 -10 8 -18 11 -18 6 0 -5 -4 -4 -8 2 -8
12 -87 61 -114 70 -10 4 -18 10 -18 15 0 5 -4 9 -10 9 -14 0 -110 60 -110 70
0 4 15 13 33 20 17 8 39 19 47 25 8 7 58 34 110 61 52 27 97 51 100 54 3 3 21
13 40 24 51 27 150 81 278 153 63 35 115 63 117 63 2 0 30 15 62 34 70 40 160
90 228 125 28 14 52 29 53 34 2 4 8 7 13 7 5 0 45 20 87 44 43 24 118 66 168
92 49 26 92 52 96 58 4 6 8 8 8 4 0 -4 27 8 59 27 33 19 64 35 69 35 6 0 12 3
14 8 2 4 25 18 51 32 44 23 59 31 127 70 14 8 28 13 33 12 4 -1 7 3 7 8 0 6 4
10 9 10 5 0 51 24 102 53 52 29 112 61 134 72 22 11 42 22 45 25 3 3 14 10 25
16 358 195 584 320 600 330 28 20 35 17 36 -13z m-3000 -1923 c5 0 15 -5 22
-10 15 -14 257 -160 263 -160 3 0 32 -18 65 -40 32 -23 59 -38 59 -34 0 4 8 0
18 -8 22 -19 217 -138 226 -138 9 0 70 -43 74 -52 2 -5 12 -8 22 -8 10 0 20
-3 22 -7 2 -7 106 -70 298 -183 8 -5 193 -116 410 -247 217 -131 404 -243 416
-249 12 -6 39 -23 60 -38 21 -14 44 -26 50 -26 7 0 14 -3 16 -7 4 -10 375
-233 387 -233 5 0 11 -3 13 -7 2 -5 59 -42 128 -83 69 -41 128 -79 131 -83 3
-5 9 -8 13 -7 4 1 20 -7 37 -18 l30 -19 -86 -59 c-47 -32 -93 -63 -103 -69 -9
-5 -19 -12 -22 -15 -3 -3 -13 -9 -22 -15 -9 -5 -82 -55 -162 -110 -80 -55
-154 -104 -166 -110 -11 -5 -20 -13 -20 -17 0 -5 -7 -8 -15 -8 -8 0 -15 -4
-15 -10 0 -5 -7 -10 -15 -10 -8 0 -15 -4 -15 -8 0 -4 -15 -16 -32 -26 -18 -11
-50 -32 -70 -48 -21 -17 -38 -26 -38 -21 0 4 -5 0 -11 -9 -5 -10 -16 -18 -23
-18 -7 0 -21 -8 -32 -17 -18 -17 -255 -180 -309 -213 -68 -42 -330 -224 -333
-231 -2 -5 -8 -9 -13 -9 -8 0 -184 -115 -199 -130 -3 -3 -66 -45 -140 -95 -74
-49 -137 -92 -140 -95 -3 -3 -30 -21 -60 -40 -30 -19 -57 -37 -60 -40 -3 -3
-59 -42 -125 -85 -66 -44 -121 -83 -123 -87 -2 -5 -10 -8 -17 -8 -7 0 -15 -3
-17 -7 -1 -5 -86 -64 -188 -132 -102 -69 -187 -128 -188 -133 -2 -4 -8 -6 -13
-2 -5 3 -9 0 -9 -5 0 -6 -6 -11 -13 -11 -10 0 -12 348 -11 1788 1 983 2 1788
3 1790 0 2 8 -4 17 -12 8 -9 20 -16 25 -16z m3001 -3902 c0 -986 -1 -1803 -1
-1815 -1 -26 -13 -29 -33 -10 -7 6 -31 21 -53 32 -43 21 -260 140 -285 155 -8
6 -26 15 -39 20 -13 6 -36 19 -51 30 -15 11 -29 17 -32 14 -3 -2 -11 2 -18 11
-7 8 -16 15 -20 15 -4 0 -52 25 -106 56 -55 31 -128 72 -164 90 -36 19 -72 39
-80 44 -27 18 -271 150 -276 150 -2 0 -21 10 -42 23 -20 13 -77 45 -127 72
-49 26 -94 51 -100 55 -5 4 -50 29 -100 55 -49 27 -103 56 -120 66 -16 10 -55
30 -85 46 -30 15 -57 30 -60 33 -3 4 -21 14 -40 24 -19 9 -62 32 -95 50 -33
19 -91 50 -130 71 -38 21 -99 55 -135 75 -36 21 -110 61 -165 90 -55 29 -104
56 -110 60 -5 4 -36 21 -67 39 -189 102 -200 109 -345 190 l-92 52 182 122
c100 67 184 125 185 129 2 4 8 8 13 8 6 0 15 5 22 11 24 21 439 299 447 299 4
0 10 7 14 15 3 8 12 15 19 15 8 0 22 9 32 20 10 11 20 18 23 16 2 -3 10 0 17
7 27 26 55 47 55 41 0 -3 17 7 38 23 20 16 57 42 82 58 25 17 51 36 58 43 7 6
20 12 28 12 8 0 14 4 14 9 0 5 21 22 47 37 26 15 56 36 67 46 11 10 25 18 32
18 7 0 14 4 16 8 2 5 64 49 138 99 198 133 374 251 385 260 6 4 87 60 180 122
259 175 260 175 263 184 2 4 8 7 12 7 5 0 29 14 52 30 102 72 129 90 134 90 3
0 10 5 17 10 30 26 137 100 137 94 0 -3 6 1 13 8 8 7 35 26 60 42 26 17 47 34
47 38 0 4 4 8 10 8 5 0 35 18 67 40 31 21 59 37 61 35 3 -2 4 -811 4 -1797z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

47
app/routes/index.js Normal file
View File

@@ -0,0 +1,47 @@
"use strict";
const express = require("express");
const welcome = require("../controllers/welcome").getWelcome;
const {
getRealEstateTypes,
postRealEstateTypes
} = require("../controllers/realEstateTypes");
const {
getQueryReview,
postQueryReview
} = require("../controllers/queryReview");
const { getGoAgain } = require("../controllers/goAgain");
const { getLocation, postLocation } = require("../controllers/location");
const { getUnsubscribe } = require("../controllers/unsubscribe");
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("/vrstanekretnine/:searchRequestId", getRealEstateTypes);
router.get("/vrstanekretnine", getRealEstateTypes);
router.post("/vrstanekretnine/:searchRequestId", postRealEstateTypes);
router.post("/vrstanekretnine", postRealEstateTypes);
router.get("/lokacija/:searchRequestId", getLocation);
router.post("/lokacija/:searchRequestId", postLocation);
router.get("/filteri/:searchRequestId", getFilters);
router.post("/filteri/:searchRequestId", postFilters);
router.get("/pregled/:searchRequestId", getQueryReview);
router.post("/pregled/:searchRequestId", postQueryReview);
router.get("/odjava/:searchRequestId", getUnsubscribe);
router.get("/ponovo", getGoAgain);
router.get("/nekretnine/:searchRequestId", getRealEstates);
router.get("/redirect/:id", getRedirect);
module.exports = router;

View File

@@ -0,0 +1,56 @@
"use strict";
let AWS = require("aws-sdk");
const htmlToText = require("html-to-text");
const { AWS_EMAIL_CONFIG } = require("../config/appConfig");
AWS.config.update({
region: AWS_EMAIL_CONFIG.REGION,
credentials: {
accessKeyId: AWS_EMAIL_CONFIG.CREDENTIALS.ACCESS_KEY_ID,
secretAccessKey: AWS_EMAIL_CONFIG.CREDENTIALS.SECRET_ACCESS_KEY
}
});
const awsMailer = new AWS.SES({ apiVersion: "2010-12-01" });
const sendEmail = async (to, subject, message, from) => {
const params = {
Destination: {
ToAddresses: [to]
},
Message: {
Subject: {
Charset: "UTF-8",
Data: subject
},
Body: {
Html: {
Charset: "UTF-8",
Data: message
},
Text: {
Charset: "UTF-8",
Data: htmlToText.fromString(message)
}
}
},
ReturnPath: from ? from : AWS_EMAIL_CONFIG.SOURCE_EMAIL,
Source: from ? from : AWS_EMAIL_CONFIG.SOURCE_EMAIL
};
return new Promise((resolve, reject) => {
awsMailer.sendEmail(params, (error, data) => {
if (error) {
reject(error);
} else {
resolve(data);
}
});
});
};
module.exports = {
sendEmail
};

View File

@@ -0,0 +1,62 @@
"use strict";
const {
matchRealEstates,
matchSearchRequest
} = require("../services/searchMatchService");
const {
generateNotificationEmail,
generateNewSearchRequestEmail,
generateEmailSubject
} = require("../helpers/emailContentGenerator");
const { sendEmail } = require("../services/emailService");
const notifyForNewRealEstates = async newRealEstates => {
const matches = await matchRealEstates(newRealEstates);
await notifyMatches(matches);
};
const notifyForNewSearchRequest = async searchRequest => {
const matches = await matchSearchRequest(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);
};
const notifyMatches = async matches => {
const searchRequestsToNotify = Object.keys(matches);
const asyncSendEmailActions = [];
for (const id of searchRequestsToNotify) {
const { searchRequest } = matches[id];
const { email } = searchRequest;
const allMatchingRealEstates = matches[id].realEstates || [];
if (allMatchingRealEstates.length > 0) {
const emailContent = generateNotificationEmail(
allMatchingRealEstates,
id
);
const emailSubject = generateEmailSubject(
allMatchingRealEstates.length,
allMatchingRealEstates[0].title
);
const sendEmailPromise = sendEmail(email, emailSubject, emailContent);
asyncSendEmailActions.push(sendEmailPromise);
sendEmailPromise.catch(err => console.log("[Email Sending Failed]", err));
}
}
await Promise.all(asyncSendEmailActions);
};
module.exports = {
notifyForNewRealEstates,
notifyForNewSearchRequest
};

View File

@@ -0,0 +1,76 @@
"use strict";
const {
findSearchRequestsForRealEstate
} = require("../helpers/db/searchRequest");
const { findRealEstatesForSearchRequest } = require("../helpers/db/realEstate");
const { addMatches } = require("../helpers/db/searchRequestMatch");
const { MAX_REAL_ESTATES_IN_FIRST_EMAIL } = require("../config/appConfig");
const matchRealEstates = async realEstates => {
if (Array.isArray(realEstates)) {
const asyncMatchActions = [];
const matches = {};
const matchingRecords = [];
for (const realEstate of realEstates) {
const searchRequestsPromise = findSearchRequestsForRealEstate(realEstate);
asyncMatchActions.push(searchRequestsPromise);
searchRequestsPromise.then(searchRequests => {
for (const searchRequest of searchRequests) {
const { id } = searchRequest;
if (!matches[id]) {
matches[id] = {
searchRequest,
realEstates: []
};
}
matches[id].realEstates.push(realEstate);
matchingRecords.push({
searchRequestId: searchRequest.id,
realEstateId: realEstate.id,
notified: false
});
}
});
}
await Promise.all(asyncMatchActions);
await addMatches(matchingRecords);
return matches;
}
};
const matchSearchRequest = async searchRequest => {
const { id: searchRequestId } = searchRequest;
const realEstates = await findRealEstatesForSearchRequest(
searchRequest,
MAX_REAL_ESTATES_IN_FIRST_EMAIL
);
const matches = {
[searchRequestId]: {
searchRequest,
realEstates: []
}
};
const matchingRecords = [];
for (const realEstate of realEstates) {
matches[searchRequestId].realEstates.push(realEstate);
matchingRecords.push({
searchRequestId,
realEstateId: realEstate.id,
notified: false
});
}
await addMatches(matchingRecords);
return matches;
};
module.exports = {
matchRealEstates,
matchSearchRequest
};

10
app/views/goAgain.ejs Normal file
View File

@@ -0,0 +1,10 @@
<div class="row centered-element">
Super. Poslali smo Vam potvrdni email na Vašu email adresu.
Poslije tog emaila, svaki put kada nađemo nove nekretnine koje Vam odgovaraju
javićemo Vam emailom.
<br><br>
<a href="/" class="">
Nova pretraga
</a>
</div>

54
app/views/layout.ejs Normal file
View File

@@ -0,0 +1,54 @@
<!doctype>
<html>
<head>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=<%= process.env.GA_ID %>"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '<%= process.env.GA_ID %>');
</script>
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
<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">
<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>
<meta charset="UTF-8" />
<link rel="stylesheet" href="/assets/main.css">
<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">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png">
<link rel="manifest" href="/assets/site.webmanifest">
<link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<%if (title) { %>
<title> <%= title %> - Kivi.ba</title>
<% } else { %>
<title>Kivi.ba</title>
<% } %>
</head>
<body>
<%if (title) { %>
<nav style="background-color: #02adba; margin: auto;">
<div class="row center-align">
<h6 class="title"><%= title %></h6>
</div>
</nav>
<% } %>
<div class="container">
<%-body%>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/13.1.5/nouislider.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/wnumb/1.1.0/wNumb.min.js"></script>
</body>
</html>

181
app/views/location.ejs Normal file
View File

@@ -0,0 +1,181 @@
<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.
</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>
<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>
</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="locationInput" id="locationInput" />
<input type="hidden" name="locationInputData" id="locationInputData" />
</form>
<script>
let autocomplete;
let map;
let places;
let geocoder;
function locateMe() {
if (navigator.geolocation) {
const 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.70,
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);
}
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
}
$(document).ready(() => {
$("#submit").click(() => {
const mapBounds = map.getBounds();
$("#north").val(mapBounds.getNorthEast().lat());
$("#south").val(mapBounds.getSouthWest().lat());
$("#east").val(mapBounds.getNorthEast().lng());
$("#west").val(mapBounds.getSouthWest().lng());
$("#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>

6
app/views/notFound.ejs Normal file
View File

@@ -0,0 +1,6 @@
<div class="row center">
<h4>Ups...stranica ne postoji</h4>
</div>
<div class="row center">
<h5><a href="/">Nova pretraga</a></h5>
</div>

View File

@@ -0,0 +1,53 @@
<form method="POST" id="form-range">
<div class="row center-align no-ui-slider centered-element-small" id="slider"></div>
<div class="col s6 push-s3 centered-element-small">
<a id="btnsubmit" href="#" class="next-center-button waves-effect waves-light btn">
Dalje
</a>
</div>
<input type="hidden" name="from" id="from" />
<input type="hidden" name="to" id="to" />
</form>
<script>
$(document).ready(() => {
var slider = document.getElementById('slider');
const unitFormat = wNumb({
decimals: 3,
thousand: '.',
suffix: '<%= unit %>'
})
noUiSlider.create(slider, {
start: [<%= rangeFrom.value %>, <%= rangeTo.value %>],
connect: true,
tooltips: true,
step: <%= rangeFrom.step %>,
range: {
'min': <%= rangeFrom.min %>,
'max': <%= rangeTo.max %>
},
format: unitFormat
});
$("#btnsubmit").click(() => {
const sliderValues = slider.noUiSlider.get();
$("#from").val(unitFormat.from(sliderValues[0]));
$("#to").val(unitFormat.from(sliderValues[1]));
$("#form-range").submit();
// });
});
});
</script>

55
app/views/queryReview.ejs Normal file
View File

@@ -0,0 +1,55 @@
<form method="POST" id="form-queryreview">
<div class="row center-align">
<ul class="collection with-header">
<% for(const stepData of queryReviewData) { %>
<li class="collection-item" >
<div id="<%= stepData.id %>" ><%= stepData.title || '-' %>
<a href="<%= stepData.url %>" class="kivi-color secondary-content">
<i class="waves-effect material-icons">edit</i>
</a>
</div>
</li>
<% } %>
</ul>
</div>
<div class="row center-align">
<div class="col">
<input id="email" name="email" type="email" placeholder="vas.email@mail.com" <% if (email) { %>value="<%= email %>" <% } %> required size="250" />
</div>
</div>
<div class="row center-align">
<div class="col">
<input id="confirmEmail" name="confirmEmail" type="email" placeholder="potvrdite.email@mail.com" <% if (email) { %>value="<%= email %>" <% } %> required size="250" />
</div>
</div>
<div class="row center-align">
<div class="col">
<h6 id="error-label-email" style="color: red"><%= error %> </h6>
</div>
</div>
<div class="row center-align">
<div class="col">
<p>* U svakom trenutku možete prekinuti slanje objava kroz link u e-mailu</p>
</div>
</div>
<div class="row center-align">
<div class="col s6 push-s3">
<a id="submit" href="#" class="welcome-center-button waves-effect waves-light btn">
Javi mi
</a>
</div>
</div>
</form>
<script>
$(document).ready( () => {
$("#submit").click( () => {
$("#form-queryreview").submit();
});
});
</script>

View File

@@ -0,0 +1,86 @@
<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>
<input type="hidden" id="priceFilterMin" name="priceFilterMin">
<input type="hidden" id="priceFilterMax" name="priceFilterMax">
</div>
<br><br>
<div class="row center-align">
<h5>Površina</h5>
<br><br>
<div class="center-align no-ui-slider" id="sizeFilter"></div>
<input type="hidden" id="sizeFilterMin" name="sizeFilterMin">
<input type="hidden" id="sizeFilterMax" name="sizeFilterMax">
</div>
<br><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>
<input type="hidden" id="gardenSizeFilterMin" name="gardenSizeFilterMin">
<input type="hidden" id="gardenSizeFilterMax" name="gardenSizeFilterMax">
</div>
<br><br>
<% } %>
<div class="row">
<div class="col s6 push-s3">
<a id="submit" href="#" class="welcome-center-button waves-effect waves-light btn">Dalje</a>
</div>
</div>
</form>
<script>
$(document).ready(() => {
const priceFormat = wNumb({
thousand: ".",
suffix: " KM"
});
const sizeFormat = wNumb({
thousand: ".",
suffix: " m2"
});
const priceSlider = document.getElementById("priceFilter");
const extendedPriceSliderOptions = {...<%- priceSliderOptions %>, format: priceFormat};
noUiSlider.create(priceSlider, extendedPriceSliderOptions);
const sizeSlider = document.getElementById("sizeFilter");
const extendedSizeSliderOptions = {...<%- sizeSliderOptions %>, format: sizeFormat};
noUiSlider.create(sizeSlider, extendedSizeSliderOptions);
<% if(hasGardenSize) { %>
const gardenSizeSlider = document.getElementById("gardenSizeFilter");
const extendedGardenSizeSliderOptions = {...<%- gardenSizeSliderOptions %>, format: sizeFormat};
noUiSlider.create(gardenSizeSlider, extendedGardenSizeSliderOptions);
<% } %>
$("#submit").click(() => {
const priceFilterValues = priceSlider.noUiSlider.get();
$("#priceFilterMin").val(priceFormat.from(priceFilterValues[0]));
$("#priceFilterMax").val(priceFormat.from(priceFilterValues[1]));
const sizeFilterValues = sizeSlider.noUiSlider.get();
$("#sizeFilterMin").val(sizeFormat.from(sizeFilterValues[0]));
$("#sizeFilterMax").val(sizeFormat.from(sizeFilterValues[1]));
<% if (hasGardenSize) { %>
const gardenSizeFilterValues = gardenSizeSlider.noUiSlider.get();
$("#gardenSizeFilterMin").val(sizeFormat.from(gardenSizeFilterValues[0]));
$("#gardenSizeFilterMax").val(sizeFormat.from(gardenSizeFilterValues[1]));
<% } %>
$("#filtersForm").submit();
});
});
</script>

View File

@@ -0,0 +1,30 @@
<form method="POST" id="form-real-estate-type">
<div class="row center-align">
<div class="collection">
<% for(const realEstateType of realEstateTypes) { %>
<a href="#" class="waves-effect collection-item"
style="color: #02adba"
id="<%= realEstateType.id %>"
onclick="saveAndSubmit(this.id)"
>
<%= realEstateType.title %>
</a>
<% } %>
</div>
<input type="hidden" name="realEstateType" id="realEstateType" />
</div>
</form>
<script>
function saveAndSubmit(id) {
$("#realEstateType").val(id);
$("#form-real-estate-type").submit();
}
</script>

13
app/views/realEstates.ejs Normal file
View File

@@ -0,0 +1,13 @@
<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">
<i class="material-icons">send</i>
</a>
</div>
</li>
<% } %>
</ul>
</div>

26
app/views/redirect.ejs Normal file
View File

@@ -0,0 +1,26 @@
<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>
</div>
<br>
<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 = () => {
document.getElementById('realEstateUrl').click();
}
</script>

12
app/views/unsubscribe.ejs Normal file
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">
Nova pretraga
</a>
</div>
</div>

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