├── .eslintrc.js
├── .gitignore
├── Makefile
├── README.md
├── app
├── App.css
└── index.html
├── index.d.ts
├── package-lock.json
├── package.json
├── plugin-manifest.json
├── public
├── App.css
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.test.js
├── App.tsx
├── components
│ ├── DownloadContactListButton.tsx
│ ├── DownloadLeasesListButton.tsx
│ ├── DownloadMailingListButton.tsx
│ ├── DownloadSalesEvidenceListButton.tsx
│ ├── FilterRadioGroup.tsx
│ ├── LeasesSearch.tsx
│ ├── ManagedDrop.tsx
│ ├── MapWidget.tsx
│ ├── MassMailButton.tsx
│ ├── PrintButton.tsx
│ ├── PropertyGroupDropdown.tsx
│ ├── PropertyTypeDropdown.tsx
│ ├── ResultsTable.tsx
│ ├── SaleTypeDropdown.tsx
│ ├── SalesEvidenceSearchWidget.tsx
│ ├── SearchWidget.tsx
│ ├── SearchWidgetsWrapper.tsx
│ └── UpdateLastMailedButton.tsx
├── index.css
├── index.js
├── logo.svg
├── react-app-env.d.ts
├── serviceWorker.js
├── services
│ └── crmDataService.ts
├── setupTests.js
├── types.ts
├── utils
│ ├── emailAndIdExtract.ts
│ ├── filterResults.ts
│ ├── filterUtilityFunctions.ts
│ ├── getUniqueListBy.ts
│ ├── leasesEvidenceFilter.ts
│ ├── salesEvidenceFilter.ts
│ └── utils.ts
└── vendor
│ └── ZSDK.js
├── tsconfig.json
├── update-app-html.js
└── yarn.lock
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "ignorePatterns": ["build/", "node_modules/", "dist/", "cypress/", "app/static/"],
3 | "env": {
4 | "browser": true,
5 | "es6": true,
6 | "node": true,
7 | "jest/globals": true
8 | },
9 | "parser": "@typescript-eslint/parser",
10 | "parserOptions": {
11 | "project": "tsconfig.json",
12 | "sourceType": "module"
13 | },
14 | "plugins": [
15 | "@typescript-eslint",
16 | "import",
17 | "jest"
18 | ],
19 | "settings": {
20 | "react": {
21 | "version": "detect",
22 | }
23 | },
24 | "extends": [
25 | "eslint:recommended",
26 | "plugin:react/recommended",
27 | "standard"
28 | ],
29 | "rules": {
30 | "@typescript-eslint/await-thenable": "error",
31 | "@typescript-eslint/class-name-casing": "error",
32 | "@typescript-eslint/consistent-type-assertions": "error",
33 | "@typescript-eslint/no-empty-function": "error",
34 | "@typescript-eslint/no-floating-promises": ["error",
35 | {
36 | "ignoreVoid": true
37 | }
38 | ],
39 | "@typescript-eslint/no-misused-new": "error",
40 | "@typescript-eslint/no-unnecessary-qualifier": "error",
41 | "@typescript-eslint/no-unnecessary-type-assertion": "error",
42 | "@typescript-eslint/prefer-namespace-keyword": "error",
43 | "@typescript-eslint/quotes": [
44 | "error",
45 | "single",
46 | {
47 | "avoidEscape": true
48 | }
49 | ],
50 | "@typescript-eslint/semi": [
51 | "error",
52 | "never"
53 | ],
54 | "@typescript-eslint/type-annotation-spacing": "error",
55 | "@typescript-eslint/unified-signatures": "error",
56 | "camelcase": 0,
57 | "comma-dangle": "error",
58 | "curly": [
59 | "error",
60 | "multi-line"
61 | ],
62 | "eol-last": "error",
63 | "eqeqeq": [
64 | "error",
65 | "smart"
66 | ],
67 | "id-blacklist": [
68 | "error",
69 | "any",
70 | "number",
71 | "String",
72 | "string",
73 | "Boolean",
74 | "boolean",
75 | "Undefined"
76 | ],
77 | "id-length": [
78 | "error",
79 | {
80 | "min": 3,
81 | "exceptions": [
82 | "id",
83 | "i",
84 | "_p",
85 | "e",
86 | "y",
87 | "fs",
88 | "to"
89 | ]
90 | }
91 | ],
92 | "id-match": "error",
93 | "import/no-deprecated": "error",
94 | "new-parens": "error",
95 | "no-caller": "error",
96 | "no-cond-assign": "error",
97 | "no-constant-condition": "error",
98 | "no-control-regex": "error",
99 | "no-debugger": "error",
100 | "no-duplicate-imports": "error",
101 | "no-empty": "error",
102 | "no-eval": "error",
103 | "no-fallthrough": "error",
104 | "no-invalid-regexp": "error",
105 | "no-multiple-empty-lines": "error",
106 | "import/no-named-as-default": 0,
107 | "no-redeclare": "error",
108 | "no-regex-spaces": "error",
109 | "no-return-await": "error",
110 | "no-throw-literal": "error",
111 | "no-trailing-spaces": "error",
112 | "no-useless-constructor": "off",
113 | "no-underscore-dangle": "off",
114 | "no-unused-expressions": [
115 | "error",
116 | {
117 | "allowTaggedTemplates": true,
118 | "allowShortCircuit": true,
119 | "allowTernary": true
120 | }
121 | ],
122 | "no-unused-labels": "error",
123 | "no-unused-vars": "off",
124 | "@typescript-eslint/no-unused-vars": ["error", {
125 | "vars": "all",
126 | "args": "after-used",
127 | "ignoreRestSiblings": false
128 | }],
129 | "no-var": "error",
130 | "no-void": "off",
131 | "one-var": [
132 | "error",
133 | "never"
134 | ],
135 | "radix": "error",
136 | "space-before-function-paren": [
137 | "error",
138 | "always"
139 | ],
140 | "spaced-comment": "error",
141 | "use-isnan": "error",
142 | "block-spacing": [
143 | 'error',
144 | "always"
145 | ],
146 | "brace-style": [
147 | 'error',
148 | "1tbs",
149 | {
150 | "allowSingleLine": false
151 | }
152 | ],
153 | "handle-callback-err": [
154 | 'error',
155 | "^(err|error)$"
156 | ],
157 | "react/display-name": "off",
158 | "react/jsx-closing-tag-location": 'error',
159 | "react/jsx-boolean-value": 'error',
160 | "react/jsx-curly-spacing": [
161 | 'error',
162 | "never"
163 | ],
164 | "react/jsx-equals-spacing": [
165 | 'error',
166 | "never"
167 | ],
168 | "react/jsx-key": 'error',
169 | "react/jsx-no-bind": ['error',
170 | {
171 | 'allowArrowFunctions': true
172 | }
173 | ],
174 | "react/no-string-refs": 'error',
175 | // NB not relevant in nextjs
176 | // https://spectrum.chat/next-js/general/react-must-be-in-scope-when-using-jsx~6193ef62-4d5e-4681-8f51-8c7bf6b9d56d
177 | "react/react-in-jsx-scope": 'off',
178 | "react/self-closing-comp": 'error',
179 | "no-duplicate-case": 'error',
180 | "no-empty-character-class": 'error',
181 | "no-ex-assign": 'error',
182 | "no-extra-boolean-cast": 'error',
183 | "no-inner-declarations": [
184 | 'error',
185 | "functions"
186 | ],
187 | "no-multi-spaces": 'error',
188 | "no-unexpected-multiline": 'error',
189 | "arrow-spacing": [
190 | 'error'
191 | ],
192 | "space-in-parens": ["error", "never"],
193 | "func-call-spacing": [
194 | 'error',
195 | "never"
196 | ],
197 | "indent": [
198 | 'error',
199 | 4
200 | ],
201 | "no-irregular-whitespace": 'error',
202 | "no-sparse-arrays": 'error',
203 | "valid-typeof": 'error'
204 | }
205 | }
206 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .history
26 |
27 | dist
28 | app/static
29 | object_results.json
30 | object_results2.json
31 | object_results3.json
32 | app/index-*.html
33 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | build:
2 | node_modules/.bin/react-scripts build
3 | rm -f app/index-v*
4 | node update-app-html.js
5 | mkdir -p app/static
6 | mkdir -p app/static/js
7 | mkdir -p app/static/css
8 | rm -f app/static/js/*.js
9 | cp build/static/js/*.js app/static/js
10 | cp build/static/js/*.js.map app/static/js
11 | rm -f app/static/css/*.css
12 | cp build/static/css/*.css app/static/css
13 | npx zet pack
14 |
15 | .PHONY: build
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Zoho CRM Google Maps extension using React
2 |
3 | Widget for Zoho CRM that uses React JS to render a search form and a Google Maps widget
4 |
5 | ## Installing
6 |
7 | `npm i`
8 |
9 | ## Running locally
10 |
11 | `npm run start`
12 |
13 | ## Building
14 |
15 | `npm run dist`
16 |
17 | - Upload to the appropriate widget in Zoho
18 | - Rename `Index Page` field to the version from the CLI output. Example: `Widget path: /index-v9.html`.
--------------------------------------------------------------------------------
/app/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | font: 90%/1.5 "ProximaNovaRegular", "Lucida Grande", "Lucida Sans", Tahoma, Verdana, sans-serif;
3 | color: #000000;
4 | }
5 |
6 | .wrapper {
7 | display: grid;
8 | grid-template-columns: repeat(auto-fit, 2);
9 | grid-gap: .5em;
10 | grid-auto-rows: auto;
11 | grid-row-gap: 1em;
12 | padding: 10px;
13 | }
14 |
15 | label {
16 | height: 75%;
17 | padding: 4px;
18 | margin-top: 1px;
19 | }
20 |
21 | #numberOfRecords {
22 | min-width: 67%;
23 | }
24 |
25 | select {
26 | min-width: 68%;
27 | float: right;
28 | clear: both;
29 | border: 2px solid #000000;
30 | font-size: small;
31 | }
32 |
33 | .smaller-font {
34 | font-size: xx-small;
35 | }
36 |
37 | .align-paragraph {
38 | text-align: center;
39 | margin-left: 6em;
40 | margin-top: .5em;
41 | margin-bottom: 0;
42 | }
43 |
44 | /* Not a standard hence all the name craziness https://css-tricks.com/almanac/selectors/p/placeholder/ */
45 | ::-webkit-input-placeholder { /* Chrome/Opera/Safari */
46 | font-weight: lighter;
47 | font-style: italic;
48 | }
49 | ::-moz-placeholder { /* Firefox 19+ */
50 | font-weight: lighter;
51 | font-style: italic;
52 | }
53 | :-ms-input-placeholder { /* IE 10+ */
54 | font-weight: lighter;
55 | font-style: italic;
56 | }
57 | :-moz-placeholder { /* Firefox 18- */
58 | font-weight: lighter;
59 | font-style: italic;
60 | }
61 |
62 | button {
63 | max-width: 29%;
64 | padding: 4px;
65 | background-color: #E82620;
66 | border: 2px solid #E82620;
67 | color: white;
68 | font-size: medium;
69 | }
70 |
71 | #mapNote {
72 | display: none;
73 | }
74 |
75 | .flex {
76 | display: flex;
77 | }
78 |
79 | .wrapperBelowMap {
80 | display: flex;
81 | flex-wrap: wrap;
82 | justify-content: space-between;
83 | padding: 8px;
84 | padding-top: 16px;
85 | align-items: center;
86 | }
87 |
88 | .wrapperBelowMap strong {
89 | display: none;
90 | }
91 |
92 | .break {
93 | flex-basis: 100%;
94 | height: 0;
95 | }
96 |
97 | #searchResults {
98 | padding-right: 2em;
99 | }
100 |
101 | table {
102 | border: 2px solid #000000;
103 | border-bottom: none;
104 | border-collapse: collapse;
105 | }
106 |
107 | th {
108 | border-bottom: 2px solid #000000;
109 | border-left: 2px solid #000000;
110 | }
111 |
112 | td {
113 | border-left: 2px solid #000000;
114 | }
115 |
116 | .loading {
117 | display: none;
118 | }
119 |
120 | #map {
121 | height: 700px;
122 | width: auto;
123 | }
124 |
125 | #map > div > div > div.gmnoprint.gm-bundled-control.gm-bundled-control-on-bottom > div:nth-child(1) > div {
126 | display: flex;
127 | flex-direction: column;
128 | align-items: center;
129 | }
130 |
131 | body {
132 | margin: 0;
133 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
134 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
135 | sans-serif;
136 | -webkit-font-smoothing: antialiased;
137 | -moz-osx-font-smoothing: grayscale;
138 | }
139 |
140 | code {
141 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
142 | monospace;
143 | }
144 |
145 | body {
146 | font: 90%/1.5 "ProximaNovaRegular", "Lucida Grande", "Lucida Sans", Tahoma, Verdana, sans-serif;
147 | color: #000000;
148 | }
149 |
150 | .button-wrapper {
151 | padding: 10px;
152 | display: flex;
153 | }
154 |
155 | .search-params-wrapper {
156 | padding: 10px;
157 | margin: 10px;
158 | border: 1px solid black;
159 | }
160 |
161 | .wrapper {
162 | display: grid;
163 | grid-template-columns: auto;
164 | grid-gap: 0.5em;
165 | grid-row-gap: 1em;
166 | height: auto;
167 | width: auto;
168 | }
169 |
170 | .column-1 {
171 | grid-column: 1;
172 | }
173 |
174 | .column-2 {
175 | grid-column: 2;
176 | }
177 |
178 | .row-1 {
179 | grid-row: 1;
180 | }
181 |
182 | .row-2 {
183 | grid-row: 2;
184 | }
185 |
186 | .row-3 {
187 | grid-row: 3;
188 | }
189 |
190 | .row-4 {
191 | grid-row: 4;
192 | }
193 |
194 | .row-5 {
195 | grid-row: 5;
196 | }
197 |
198 | label {
199 | height: 75%;
200 | width: 500px;
201 | padding: 4px;
202 | margin-top: 1px;
203 | }
204 |
205 | .minWidth {
206 | min-width: 70%;
207 | }
208 |
209 | input.below-label {
210 | float: initial;
211 | clear: initial;
212 | }
213 |
214 | #numberOfRecords {
215 | min-width: 67%;
216 | }
217 |
218 | select {
219 | min-width: 68%;
220 | float: right;
221 | clear: both;
222 | border: 2px solid #000000;
223 | font-size: small;
224 | }
225 |
226 | .smaller-font {
227 | font-size: xx-small;
228 | }
229 |
230 | .align-paragraph {
231 | text-align: center;
232 | margin-left: 6em;
233 | margin-top: .5em;
234 | margin-bottom: 0;
235 | }
236 |
237 | /* Not a standard hence all the name craziness https://css-tricks.com/almanac/selectors/p/placeholder/ */
238 | ::-webkit-input-placeholder { /* Chrome/Opera/Safari */
239 | font-weight: lighter;
240 | font-style: italic;
241 | }
242 | ::-moz-placeholder { /* Firefox 19+ */
243 | font-weight: lighter;
244 | font-style: italic;
245 | }
246 | :-ms-input-placeholder { /* IE 10+ */
247 | font-weight: lighter;
248 | font-style: italic;
249 | }
250 | :-moz-placeholder { /* Firefox 18- */
251 | font-weight: lighter;
252 | font-style: italic;
253 | }
254 |
255 | button {
256 | max-width: 100%;
257 | padding: 4px;
258 | background-color: #E82620;
259 | border: 2px solid #E82620;
260 | color: white;
261 | font-size: medium;
262 | }
263 |
264 | .button {
265 | background-color: #E82620;
266 | border: 2px solid #E82620;
267 | padding: 10px;
268 | text-align: center;
269 | color: white;
270 | font-weight: bold;
271 | margin-right: 15px;
272 | width: 180px;
273 | display: flex;
274 | flex-direction: column;
275 | justify-content: center;
276 | align-items: center;
277 | text-decoration: none;
278 | }
279 |
280 | .download-button-wrapper {
281 | display: flex;
282 | }
283 |
284 | .mail-comment-button-wrapper {
285 | display: flex;
286 | padding: 10px 0;
287 | }
288 |
289 | .mail-comment-button-wrapper button {
290 | width: 205px;
291 | font-weight: bold;
292 | }
293 |
294 | .mail-comment-button-wrapper textarea {
295 | margin-right: 13px;
296 | width: 200px;
297 | }
298 |
299 | button.danger {
300 | background-color: black;
301 | color: white;
302 | border-color: black;
303 | margin-left: 4px;
304 | }
305 |
306 | button.secondary {
307 | background-color: orange;
308 | color: white;
309 | border-color: orange;
310 | }
311 |
312 | .wrapperBelowMap {
313 | display: flex;
314 | flex-wrap: wrap;
315 | justify-content: space-between;
316 | padding: 8px;
317 | padding-top: 16px;
318 | align-items: center;
319 | }
320 |
321 | .break {
322 | flex-basis: 100%;
323 | height: 0;
324 | }
325 |
326 | #searchResults {
327 | padding-right: 2em;
328 | }
329 |
330 | table {
331 | border: 2px solid #000000;
332 | border-collapse: collapse;
333 | }
334 |
335 | th {
336 | border-bottom: 2px solid #000000;
337 | border-left: 2px solid #000000;
338 | text-align: left;
339 | padding: 5px;
340 | }
341 |
342 | td {
343 | border-left: 2px solid #000000;
344 | text-align: left;
345 | padding: 5px;
346 | }
347 |
348 | #map {
349 | padding-top: 10px;
350 | height: 700px;
351 | width: auto;
352 | }
353 |
354 | #map div.gmnoprint.gm-bundled-control.gm-bundled-control-on-bottom > div:nth-child(1) > div {
355 | display: flex;
356 | flex-direction: column;
357 | align-items: center;
358 | }
359 | .side-padding {
360 | padding: 0 4px
361 | }
362 |
363 | @media print {
364 | .pagebreak { display: block; page-break-before: always; }
365 | }
366 |
367 | @media print {
368 | table {
369 | page-break-inside: avoid;
370 | }
371 | }
372 | .border {
373 | background-color: hsl(0,0%,100%);
374 | border-color: hsl(0,0%,80%);
375 | border-radius: 4px;
376 | border-style: solid;
377 | border-width: 1px;
378 | min-height: 38px;
379 | }
380 | .minMaxSize {
381 | min-width: 10% !important;
382 | margin-right: 5%;
383 | }
384 |
385 | .react-datepicker-wrapper {
386 | margin-right: 5%;
387 | }
388 | .hide-show-buttons {
389 | display:flex;
390 | flex-direction: row;
391 | justify-content: center;
392 | }
393 | .buttonSize {
394 | margin-right: 0.3%;
395 | }
396 |
397 | .radioName {
398 | padding-left: .5%;
399 | padding-right: .5%
400 | }
401 |
402 | .radio-box {
403 | text-align: center;
404 | }
405 |
406 | .radio-box label {
407 | padding: 0;
408 | }
409 |
410 | .radio-box .radio {
411 | margin-top: 10px;
412 | }
413 |
414 | .inputSize {
415 | min-width: 67%;
416 | }
417 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 | Map Widget
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*'
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-map-widget",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@react-google-maps/api": "^1.9.2",
7 | "@testing-library/jest-dom": "^4.2.4",
8 | "@testing-library/react": "^9.3.2",
9 | "@testing-library/user-event": "^7.1.2",
10 | "@types/jest": "^25.2.3",
11 | "@types/node": "^14.0.5",
12 | "@types/react": "^16.9.35",
13 | "@types/react-calendar": "^3.1.0",
14 | "@types/react-dom": "^16.9.8",
15 | "google-maps-react": "^2.0.6",
16 | "jquery": "^3.5.1",
17 | "localforage": "^1.10.0",
18 | "react": "^16.13.1",
19 | "react-datepicker": "^3.0.0",
20 | "react-dom": "^16.13.1",
21 | "react-scripts": "^3.4.3",
22 | "react-select": "^3.1.0",
23 | "typescript": "^3.9.3"
24 | },
25 | "devDependencies": {
26 | "@typescript-eslint/eslint-plugin": "^2.30.0",
27 | "@typescript-eslint/parser": "^2.30.0",
28 | "eslint": "^6.8.0",
29 | "eslint-config-standard": "^14.1.0",
30 | "eslint-plugin-import": "^2.20.1",
31 | "eslint-plugin-jest": "^23.8.2",
32 | "eslint-plugin-node": "^11.0.0",
33 | "eslint-plugin-promise": "^4.2.1",
34 | "eslint-plugin-react": "^7.19.0",
35 | "eslint-plugin-standard": "^4.0.1",
36 | "nodemon": "^2.0.4",
37 | "zoho-extension-toolkit": "^0.23.3"
38 | },
39 | "scripts": {
40 | "start": "react-scripts start",
41 | "build": "react-scripts build",
42 | "test": "react-scripts test",
43 | "eject": "react-scripts eject",
44 | "dist": "make build",
45 | "lint": "eslint ."
46 | },
47 | "eslintConfig": {
48 | "extends": "react-app"
49 | },
50 | "browserslist": {
51 | "production": [
52 | ">0.2%",
53 | "not dead",
54 | "not op_mini all"
55 | ],
56 | "development": [
57 | "last 1 chrome version",
58 | "last 1 firefox version",
59 | "last 1 safari version"
60 | ]
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/plugin-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "service": "CRM"
3 | }
4 |
--------------------------------------------------------------------------------
/public/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
15 | body {
16 | font: 90%/1.5 "ProximaNovaRegular", "Lucida Grande", "Lucida Sans", Tahoma,
17 | Verdana, sans-serif;
18 | color: #000000;
19 | }
20 |
21 | /* App.tsx styling */
22 |
23 | .app-wrapper {
24 | width: 98%;
25 | height: fit-content;
26 | margin: auto;
27 | margin-top: 1%;
28 | margin-bottom: 1%;
29 | }
30 |
31 | .refresh-button {
32 | position: absolute;
33 | right: 1%;
34 | top: 2%;
35 | }
36 |
37 | .form-and-map {
38 | display: grid;
39 | grid-template-columns: 60% 40%;
40 | grid-gap: 3em;
41 | justify-content: space-between;
42 | height: fit-content;
43 | }
44 |
45 | .form-and-map-fw {
46 | display: grid;
47 | grid-template-columns: 100%;
48 | grid-row-gap: 1em;
49 | height: fit-content;
50 | }
51 |
52 | .map-div-text {
53 | text-align: center;
54 | text-decoration: underline;
55 | margin: 0.5em;
56 | }
57 |
58 | .map-div-text:hover {
59 | cursor: pointer;
60 | }
61 |
62 | .map-div {
63 | grid-column: 2;
64 | grid-row: 1;
65 | width: 90%;
66 | height: 32em;
67 | text-align: center;
68 | margin-top: 40px;
69 | }
70 |
71 | .map-div-fw {
72 | grid-column: 1;
73 | grid-row: 1;
74 | width: 100%;
75 | height: 95vh;
76 | text-align: center;
77 | margin-top: 10px;
78 | }
79 |
80 | /* SearchWidgetsWrapper styling */
81 |
82 | .main-wrapper {
83 | grid-column: 1;
84 | grid-row: 1;
85 | display: flex;
86 | flex-direction: column;
87 | margin: 0;
88 | padding: 0;
89 | width: 100%;
90 | }
91 |
92 | .main-wrapper-fw {
93 | grid-column: 1;
94 | grid-row: 2;
95 | display: flex;
96 | flex-direction: column;
97 | margin: 0;
98 | padding: 0;
99 | width: 60%;
100 | }
101 |
102 | .search-params-wrapper {
103 | padding: 0;
104 | margin: 0;
105 | width: 100%;
106 | }
107 |
108 | .button-wrapper {
109 | padding: 20px 20px 20px 0px;
110 | display: flex;
111 | }
112 |
113 | .control-buttons {
114 | grid-column: 2;
115 | grid-row: 1;
116 | }
117 |
118 | .button-wrapper-1 {
119 | padding: 20px 0px;
120 | display: grid;
121 | grid-template-columns: 50% 50%;
122 | grid-gap: 0.5em;
123 | grid-row-gap: 1em;
124 | height: auto;
125 | width: 100%;
126 | }
127 |
128 | .search-button {
129 | width: fit-content;
130 | margin-left: 5px;
131 | grid-column: 1;
132 | grid-row: 1;
133 | }
134 |
135 | .search-button-2 {
136 | width: fit-content;
137 | margin-left: 5px;
138 | }
139 |
140 | .search-widget-form {
141 | display: grid;
142 | grid-template-columns: auto;
143 | grid-gap: 0.5em;
144 | grid-row-gap: 1em;
145 | height: auto;
146 | width: auto;
147 | width: 100%;
148 | }
149 |
150 | .search-widget {
151 | width: 100%;
152 | display: flex;
153 | flex-direction: column;
154 | }
155 |
156 | .one {
157 | grid-column: 1;
158 | grid-row: 1;
159 | }
160 |
161 | .two {
162 | grid-column: 1;
163 | grid-row: 2;
164 | }
165 |
166 | .three {
167 | grid-column: 1;
168 | grid-row: 3;
169 | }
170 |
171 | .four {
172 | grid-column: 1;
173 | grid-row: 4;
174 | }
175 |
176 | .five {
177 | grid-column: 2;
178 | grid-row: 3;
179 | }
180 |
181 | .six {
182 | grid-column: 2;
183 | grid-row: 4;
184 | }
185 |
186 | .seven {
187 | grid-column: 2;
188 | grid-row: 2;
189 | }
190 | .eight {
191 | grid-column: 1;
192 | grid-row: 5;
193 | }
194 |
195 | .nine {
196 | grid-column: 1;
197 | grid-row: 6;
198 | }
199 |
200 | .ten {
201 | grid-column: 1;
202 | grid-row: 7;
203 | }
204 |
205 | .eleven {
206 | grid-column: 2;
207 | grid-row: 5;
208 | }
209 |
210 | .twelve {
211 | grid-column: 2;
212 | grid-row: 6;
213 | }
214 | .thirteen {
215 | grid-column: 2;
216 | grid-row: 7;
217 | }
218 |
219 | .fourteen {
220 | grid-column: 1;
221 | grid-row: 8;
222 | }
223 |
224 | label {
225 | height: 75%;
226 | width: 350px;
227 | padding: 4px;
228 | margin-top: 1px;
229 | }
230 |
231 | input {
232 | clear: both;
233 | border: 2px solid #000000;
234 | font-size: small;
235 | }
236 |
237 | .minWidth {
238 | min-width: 70%;
239 | }
240 |
241 | input.below-label {
242 | float: initial;
243 | clear: initial;
244 | }
245 |
246 | #numberOfRecords {
247 | min-width: 67%;
248 | }
249 |
250 | select {
251 | min-width: 68%;
252 | float: right;
253 | clear: both;
254 | border: 2px solid #000000;
255 | font-size: small;
256 | }
257 |
258 | .smaller-font {
259 | font-size: xx-small;
260 | }
261 |
262 | .align-paragraph {
263 | text-align: center;
264 | margin-left: 6em;
265 | margin-top: 0.5em;
266 | margin-bottom: 0;
267 | }
268 |
269 | /* Not a standard hence all the name craziness https://css-tricks.com/almanac/selectors/p/placeholder/ */
270 | ::-webkit-input-placeholder {
271 | /* Chrome/Opera/Safari */
272 | font-weight: lighter;
273 | font-style: italic;
274 | }
275 | ::-moz-placeholder {
276 | /* Firefox 19+ */
277 | font-weight: lighter;
278 | font-style: italic;
279 | }
280 | :-ms-input-placeholder {
281 | /* IE 10+ */
282 | font-weight: lighter;
283 | font-style: italic;
284 | }
285 | :-moz-placeholder {
286 | /* Firefox 18- */
287 | font-weight: lighter;
288 | font-style: italic;
289 | }
290 |
291 | /* Results Widget Styling */
292 |
293 | button {
294 | max-width: 100%;
295 | padding: 4px;
296 | background-color: #e82620;
297 | border: 2px solid #e82620;
298 | color: white;
299 | font-size: medium;
300 | }
301 |
302 | .button {
303 | background-color: #e82620;
304 | border: 2px solid #e82620;
305 | padding: 5px;
306 | text-align: center;
307 | color: white;
308 | font-weight: bold;
309 | margin-right: 10px;
310 | width: 180px;
311 | height: min-content;
312 | }
313 |
314 | .download-button-wrapper {
315 | display: flex;
316 | flex-direction: row;
317 | align-items: center;
318 | padding: 0;
319 | margin-left: 7px;
320 | }
321 |
322 | .mail-comment-button-wrapper {
323 | display: flex;
324 | padding: 10px 0;
325 | margin-left: 10px;
326 | }
327 |
328 | .mail-comment-button-wrapper button {
329 | width: 205px;
330 | text-decoration: underline;
331 | font-weight: bold;
332 | }
333 |
334 | .mail-comment-button-wrapper textarea {
335 | margin-right: 13px;
336 | width: 200px;
337 | }
338 |
339 | button.danger {
340 | background-color: black;
341 | color: white;
342 | border-color: black;
343 | margin-left: 4px;
344 | }
345 |
346 | button.secondary {
347 | background-color: orange;
348 | color: white;
349 | border-color: orange;
350 | }
351 |
352 | .wrapperBelowMap {
353 | display: flex;
354 | flex-wrap: wrap;
355 | justify-content: space-between;
356 | padding: 8px;
357 | padding-top: 16px;
358 | align-items: center;
359 | }
360 |
361 | .break {
362 | flex-basis: 100%;
363 | height: 0;
364 | }
365 |
366 | #searchResults {
367 | padding-right: 2em;
368 | }
369 |
370 | table {
371 | border: 2px solid #000000;
372 | border-collapse: collapse;
373 | }
374 |
375 | th {
376 | border-bottom: 2px solid #000000;
377 | border-left: 2px solid #000000;
378 | text-align: left;
379 | padding: 5px;
380 | }
381 |
382 | td {
383 | border-left: 2px solid #000000;
384 | text-align: left;
385 | padding: 5px;
386 | }
387 |
388 | .side-padding {
389 | margin-left: 0;
390 | padding: 5px;
391 | height: min-content;
392 | }
393 |
394 | @media print {
395 | .pagebreak {
396 | display: block;
397 | page-break-before: auto;
398 | }
399 | }
400 |
401 | @media print {
402 | table {
403 | page-break-inside: avoid;
404 | }
405 | }
406 | .border {
407 | background-color: hsl(0, 0%, 100%);
408 | border-color: hsl(0, 0%, 80%);
409 | border-radius: 4px;
410 | border-style: solid;
411 | border-width: 1px;
412 | min-height: 38px;
413 | }
414 | .minMaxSize {
415 | min-width: 10% !important;
416 | margin-right: 5%;
417 | }
418 |
419 | .react-datepicker-wrapper {
420 | margin-right: 5%;
421 | }
422 |
423 | .buttonSize {
424 | margin-right: 0.3%;
425 | }
426 |
427 | .inputSize {
428 | min-width: 75%;
429 | }
430 |
431 | /* FilterForm Styling */
432 |
433 | .radioName {
434 | padding-left: 0.5%;
435 | padding-right: 0.5%;
436 | font-size: medium;
437 | }
438 | .radio-box {
439 | text-align: left;
440 | }
441 |
442 | /* MapWidget Styling */
443 |
444 | #map {
445 | padding: 0;
446 | margin: 0;
447 | height: 30em;
448 | width: 100%;
449 | }
450 |
451 | #map-fw {
452 | padding: 0;
453 | margin: 0;
454 | height: 90vh;
455 | width: 100%;
456 | }
457 |
458 | #map
459 | div.gmnoprint.gm-bundled-control.gm-bundled-control-on-bottom
460 | > div:nth-child(1)
461 | > div {
462 | display: flex;
463 | flex-direction: column;
464 | align-items: center;
465 | }
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/v2-software-pty-ltd/zoho-crm-react-js-typescript-google-maps-widget/d699d269f18bbe9048eca75e09aebaf0194d0c85/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
19 |
28 | React App
29 |
30 |
31 |
32 |
33 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/v2-software-pty-ltd/zoho-crm-react-js-typescript-google-maps-widget/d699d269f18bbe9048eca75e09aebaf0194d0c85/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/v2-software-pty-ltd/zoho-crm-react-js-typescript-google-maps-widget/d699d269f18bbe9048eca75e09aebaf0194d0c85/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { render } from '@testing-library/react'
3 | import App from './App'
4 |
5 | test('renders learn react link', () => {
6 | const { getByText } = render()
7 | const linkElement = getByText(/learn react/i)
8 | expect(linkElement).toBeInTheDocument()
9 | })
10 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import './index.css'
3 | import { SearchWidgetsWrapper } from './components/SearchWidgetsWrapper'
4 | import {
5 | findMatchingRecords,
6 | getGoogleMapsAPIKeyFromCRM,
7 | getSearchAddressPosition,
8 | updateCacheForGeocodingAndOwnerData
9 | } from './services/crmDataService'
10 | import { MapWidget } from './components/MapWidget'
11 | import { ResultsTableWidget } from './components/ResultsTable'
12 | import { DownloadMailingListButton } from './components/DownloadMailingListButton'
13 | import { DownloadContactListButton } from './components/DownloadContactListButton'
14 | import DownloadSalesEvidenceListButton from './components/DownloadSalesEvidenceListButton'
15 | import DownloadLeasesListButton from './components/DownloadLeasesListButton'
16 | import { UnprocessedResultsFromCRM, ResultsType, DEFAULT_SEARCH_PARAMS, PositionType, IntersectedSearchAndFilterParams } from './types'
17 | import { UpdateLastMailedButton } from './components/UpdateLastMailedButton'
18 | import { MassMailButton } from './components/MassMailButton'
19 | import { PrintButton } from './components/PrintButton'
20 | import { clearCacheAndReload } from './utils/utils'
21 |
22 | function prepareDataForMap (results: UnprocessedResultsFromCRM[], searchAddressPosition: PositionType): ResultsType | undefined {
23 | if (!results || results.length === 0) {
24 | return undefined
25 | }
26 |
27 | if (!searchAddressPosition) {
28 | return undefined
29 | }
30 |
31 | return {
32 | centrePoint: {
33 | lat: searchAddressPosition.lat,
34 | lng: searchAddressPosition.lng
35 | },
36 | addressesToRender: results.map((result) => {
37 | return {
38 | address: result.Deal_Name || result.Property.name,
39 | position: {
40 | lat: parseFloat(result.Latitude),
41 | lng: parseFloat(result.Longitude)
42 | }
43 | }
44 | })
45 | }
46 | }
47 |
48 | function renderResultsWidgets (results: UnprocessedResultsFromCRM[], googleMapsApiKey: string | undefined, isLoading: boolean, uniqueSearchRecords: number, searchAddressPosition: PositionType, filterInUse: string, searchParameters: IntersectedSearchAndFilterParams[]) {
49 | const dataForMap = prepareDataForMap(results, searchAddressPosition)
50 | if (results && dataForMap && googleMapsApiKey && !isLoading) {
51 | return (
52 |
53 |
54 | {filterInUse === 'BaseFilter' && (
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | )}
65 | {filterInUse === 'SalesEvidenceFilter' && (
66 |
70 | )}
71 | {filterInUse === 'LeasesEvidenceFilter' && (
72 |
76 | )}
77 |
78 |
79 |
80 | Unique Search Results: {uniqueSearchRecords}
81 |
82 |
83 |
84 |
85 | )
86 | }
87 | }
88 |
89 | function renderMapWidget (results: UnprocessedResultsFromCRM[], googleMapsApiKey: string | undefined, isLoading: boolean, searchAddressPosition: PositionType, isMapFullScreen: boolean, toggleMapSize: (isMapFulScreen: boolean) => void) {
90 | const dataForMap = prepareDataForMap(results, searchAddressPosition)
91 | if (results) {
92 | console.log('Fetched Properties:', results)
93 | }
94 | if (results && dataForMap && googleMapsApiKey && !isLoading) {
95 | return (
96 |
97 |
98 | toggleMapSize(isMapFullScreen)}>{isMapFullScreen ? 'Minimize' : 'View Full-Screen'}
99 |
100 | )
101 | }
102 | }
103 |
104 | function App () {
105 | const [searchParameters, changeSearchParameters] = useState([{ ...DEFAULT_SEARCH_PARAMS }])
106 | const [isReadyForSearch, setReadyForSearch] = useState(false)
107 | const [results, updateResults] = useState([])
108 | const [googleMapsApiKey, updateGoogleMapsApiKey] = useState()
109 | const [isLoading, setLoading] = useState(false)
110 | const [uniqueSearchRecords, setUniqueSearchRecords] = useState(0)
111 | const [searchAddressPosition, setSearchAddressPosition] = useState()
112 | const [filterInUse, setFilterInUse] = useState('BaseFilter')
113 | const [isMapFullScreen, setIsMapFullScreen] = useState(false)
114 |
115 | useEffect(() => {
116 | if (isReadyForSearch) {
117 | const getDataFromCrm = async () => {
118 | setLoading(true)
119 | const searchAddressPosition = await getSearchAddressPosition(searchParameters)
120 | const { matchedProperties, numberOfUniqueSearchRecords } = await findMatchingRecords(searchParameters, filterInUse, searchAddressPosition)
121 | setSearchAddressPosition(searchAddressPosition[0].position)
122 | setUniqueSearchRecords(numberOfUniqueSearchRecords)
123 | updateResults(matchedProperties)
124 | setLoading(false)
125 | setReadyForSearch(false)
126 | }
127 |
128 | void getDataFromCrm()
129 | }
130 | }, [searchParameters, isReadyForSearch, filterInUse])
131 |
132 | useEffect(() => {
133 | const getMapsAPIKeyFromCRM = async () => {
134 | const apiKey = await getGoogleMapsAPIKeyFromCRM()
135 | updateGoogleMapsApiKey(apiKey)
136 | }
137 |
138 | void getMapsAPIKeyFromCRM()
139 |
140 | setTimeout(() => {
141 | void updateCacheForGeocodingAndOwnerData()
142 | }, 500)
143 | }, [])
144 |
145 | function toggleMapSize (isMapFullScreen: boolean) {
146 | return setIsMapFullScreen(!isMapFullScreen)
147 | }
148 |
149 | return (
150 |
151 |
152 |
153 |
154 |
155 |
156 | {isLoading &&
Loading map...
}
157 | {searchAddressPosition && renderMapWidget(results, googleMapsApiKey, isLoading, searchAddressPosition, isMapFullScreen, toggleMapSize)}
158 |
159 |
160 | {isLoading && (
161 |
162 | Loading...estimated waiting time 10 seconds.
163 |
164 | )}
165 | {searchAddressPosition && renderResultsWidgets(results, googleMapsApiKey, isLoading, uniqueSearchRecords, searchAddressPosition, filterInUse, searchParameters)}
166 |
167 |
168 | )
169 | }
170 |
171 | export default App
172 |
--------------------------------------------------------------------------------
/src/components/DownloadContactListButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { UnprocessedResultsFromCRM, OwnerType } from '../types'
3 | import getUniqueListBy from '../utils/getUniqueListBy'
4 |
5 | type DownloadButtonProps = {
6 | results: UnprocessedResultsFromCRM[]
7 | }
8 |
9 | export function DownloadContactListButton (props: DownloadButtonProps) {
10 | const csvHeader = 'Property Address,Owner Name,Owner Mobile,Owner Work Phone,Contact Name,Contact Mobile,Contact Work Phone\r\n'
11 | const arrayOfPropertyObjects = props.results
12 |
13 | const uniqueProperties = getUniqueListBy(arrayOfPropertyObjects, 'id')
14 | const csvRows = uniqueProperties.map((result: UnprocessedResultsFromCRM) => {
15 | if (result.owner_details && Array.isArray(result.owner_details)) {
16 | const mobileNumbers = result.owner_details.map((owner: OwnerType) => owner.Mobile).filter((Mobile: string) => Mobile)
17 | const mobile = mobileNumbers.length > 0 ? mobileNumbers[0] : null
18 | const workPhones = result.owner_details.map((owner: OwnerType) => owner.Work_Phone).filter((Work_Phone: string) => Work_Phone)
19 | const workPhone = workPhones.length > 0 ? workPhones[0] : null
20 | const propertyAddress = result.Deal_Name
21 | const contact = result.owner_details.find((owner: OwnerType) => owner.Contact_Type === 'Director')
22 | const owner = result.owner_details.find((owner: OwnerType) => owner.Contact_Type === 'Owner')
23 |
24 | if (mobile || workPhone) {
25 | const newRow = `"${propertyAddress}","${owner?.Name || ''}","${owner?.Mobile || ''}","${owner?.Work_Phone || ''}",${contact?.Name || ''},"${contact?.Mobile || ''}","${contact?.Work_Phone || ''}"\r\n`
26 | return newRow.replace(/null/g, '-')
27 | }
28 | }
29 | return null
30 | }).filter((row) => row).join('')
31 |
32 | const csvData = `${csvHeader}${csvRows}`
33 | const resultsBlob = new Blob(
34 | [csvData],
35 | {
36 | type: 'text/csv;charset=utf-8'
37 | }
38 | )
39 | const downloadUrl = URL.createObjectURL(resultsBlob)
40 | return (Download Call List)
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/DownloadLeasesListButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { UnprocessedResultsFromCRM } from '../types'
3 | import { formatDate, convertToCurrency } from '../utils/utils'
4 |
5 | type DownloadButtonProps = {
6 | results: UnprocessedResultsFromCRM[]
7 | }
8 |
9 | export default function DownloadLeasesListButton (props: DownloadButtonProps) {
10 | let downloadUrl = null
11 | function generateCSVRow (propertyObject: UnprocessedResultsFromCRM) {
12 | const propertyAddress = propertyObject.Deal_Name || propertyObject.Reverse_Geocoded_Address
13 | const tenancyName = propertyObject.Name
14 | const rentPerDollarMeter = propertyObject.Area_sqm
15 | const landArea = propertyObject.Land_Area_sqm
16 | const buildArea = propertyObject.Build_Area_sqm
17 | const rentCommence = convertToCurrency(propertyObject.Base_Rental)
18 | const rentCurrent = convertToCurrency(propertyObject.Current_AI_New_Market_Rental)
19 | const tenantName = propertyObject.Lessee?.name || ''
20 | const leasedDate = formatDate(propertyObject.Start_Date)
21 | const reviewDate = formatDate(propertyObject.Last_MR_Start_Date)
22 | let csvRow = `"${propertyAddress}","${tenancyName}","${rentPerDollarMeter}","${landArea}","${buildArea}","${rentCommence}","${rentCurrent}",${tenantName.replace(/,/gi, '')},"${leasedDate}","${reviewDate}"\r\n`
23 | csvRow = csvRow.replace(/null/g, '-')
24 | return csvRow
25 | }
26 | const HEADER_ROW = 'Property Address,Tenancy Name,Rent Per (sqm),Land Area (sqm),Build Area (sqm),Commencement Rental,Rent (Gross) Current,Tenant Name,Lease Start Date,Last Market Review Date\r\n'
27 | const csvRows = props.results.map(generateCSVRow).join('')
28 | const csvData = `${HEADER_ROW}${csvRows}`
29 | const resultsBlob = new Blob(
30 | [csvData],
31 | {
32 | type: 'text/csv;charset=utf-8'
33 | }
34 | )
35 |
36 | downloadUrl = URL.createObjectURL(resultsBlob)
37 |
38 | return (Download Leases List)
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/DownloadMailingListButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { UnprocessedResultsFromCRM, IntersectedSearchAndFilterParams } from '../types'
3 |
4 | type DownloadButtonProps = {
5 | searchParameters: IntersectedSearchAndFilterParams[];
6 | results: UnprocessedResultsFromCRM[]
7 | }
8 | export function DownloadMailingListButton (props: DownloadButtonProps) {
9 | const searchedAddress = props.searchParameters[0].searchAddress
10 | let downloadUrl = null
11 | const matchingPropertiesAndOwners = props.results
12 | const ownerContactDupeRemoval: string[] = []
13 | function generateCSVRow (propertyObject: UnprocessedResultsFromCRM) {
14 | let csvRowsForProperty = ''
15 | let doNotMail
16 | let returnToSender
17 | let postalAddress
18 |
19 | const propertyAddress = propertyObject.Deal_Name
20 | const propertyType = propertyObject.Property_Category_Mailing.join('; ')
21 | const ownersArray = propertyObject.owner_details
22 | const propertyFullAddress = `${propertyObject.Deal_Name}, ${propertyObject.State}, ${propertyObject.Postcode}`
23 | const isExactMatchForSearchAddress = propertyFullAddress.includes(searchedAddress)
24 | const ownerData = propertyObject.owner_details?.find((owner) => owner.Contact_Type === 'Owner')
25 |
26 | if (ownersArray) {
27 | ownersArray.forEach(function (arrayItem) {
28 | doNotMail = arrayItem.Do_Not_Mail
29 | returnToSender = arrayItem.Return_to_Sender
30 | let ownerNameOnTitle = arrayItem.Contact_Type === 'Owner' ? arrayItem?.Company : ownerData?.Name
31 | postalAddress = arrayItem.Postal_Address ? arrayItem.Postal_Address.split(', ')[0] : `${arrayItem.Postal_Unit ? `${arrayItem.Postal_Unit}/` : ''} ${arrayItem.Postal_Street_No} ${arrayItem.Postal_Street}`
32 | const isDupe = ownerContactDupeRemoval.includes(`${postalAddress}-${arrayItem?.Name}`)
33 | if (!doNotMail && !returnToSender) {
34 | if (!postalAddress.includes('null') && !isExactMatchForSearchAddress) {
35 | if (!isDupe) {
36 | const lastMailed = arrayItem.Last_Mailed || 'Last mailed has not been found'
37 | ownerContactDupeRemoval.push(`${postalAddress}-${arrayItem?.Name}`)
38 | csvRowsForProperty += `"${propertyAddress}","${ownerNameOnTitle || ''}","${arrayItem?.First_Name} ${arrayItem?.Last_Name}","${postalAddress}","${arrayItem?.Postal_Suburb}","${arrayItem?.Postal_State}","${arrayItem?.Postal_Postcode}","${arrayItem?.Salutation_Dear}","${arrayItem?.Email}",${propertyType},${lastMailed}\r\n`
39 | csvRowsForProperty = csvRowsForProperty.replace(/null/g, '')
40 | }
41 | }
42 | }
43 | })
44 | return csvRowsForProperty
45 | }
46 | }
47 |
48 | const HEADER_ROW = 'Property Address,Owner - Name On Title,Contact Name,Mailing Street Address,Mailing Suburb,Mailing State,Mailing Postcode,Salutation,Email,Property Type (Marketing),Last Mailed\r\n'
49 | const csvRows = matchingPropertiesAndOwners.map(generateCSVRow).join('')
50 | const csvData = `${HEADER_ROW}${csvRows}`
51 | const resultsBlob = new Blob(
52 | [csvData],
53 | {
54 | type: 'text/csv;charset=utf-8'
55 | }
56 | )
57 |
58 | downloadUrl = URL.createObjectURL(resultsBlob)
59 |
60 | return (Download Mailing List)
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/DownloadSalesEvidenceListButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { UnprocessedResultsFromCRM } from '../types'
3 | import getUniqueListBy from '../utils/getUniqueListBy'
4 | import { formatDate, convertToCurrency } from '../utils/utils'
5 |
6 | type DownloadButtonProps = {
7 | results: UnprocessedResultsFromCRM[]
8 | }
9 |
10 | export default function DownloadSalesEvidenceListButton (props: DownloadButtonProps) {
11 | let downloadUrl = null
12 | const matchingPropertiesAndOwners = props.results
13 | const dedupedProperties = getUniqueListBy(matchingPropertiesAndOwners, 'id')
14 | function generateCSVRow (propertyObject: UnprocessedResultsFromCRM) {
15 | const propertyAddress = propertyObject.Deal_Name
16 | const landArea = propertyObject.Land_Area_sqm
17 | const buildArea = propertyObject.Build_Area_sqm
18 | const dateSold = formatDate(propertyObject.Sale_Date)
19 | const salePrice = convertToCurrency(propertyObject.Sale_Price)
20 |
21 | let csvRow: string = ''
22 | if (landArea || buildArea || dateSold || salePrice) {
23 | csvRow = `"${propertyAddress}","${landArea}","${buildArea}","${dateSold}","${salePrice}"\r\n`
24 | }
25 | csvRow = csvRow.replace(/null/g, '-')
26 | return csvRow
27 | }
28 | const HEADER_ROW = 'Property Address,Land Area (sqm),Build Area (sqm),Date Sold,Sale Price\r\n'
29 | const csvRows = dedupedProperties.map(generateCSVRow).join('')
30 | const csvData = `${HEADER_ROW}${csvRows}`
31 | const resultsBlob = new Blob(
32 | [csvData],
33 | {
34 | type: 'text/csv;charset=utf-8'
35 | }
36 | )
37 |
38 | downloadUrl = URL.createObjectURL(resultsBlob)
39 |
40 | return (Download Sales Evidence List)
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/FilterRadioGroup.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | DEFAULT_SEARCH_PARAMS,
4 | IntersectedSearchAndFilterParams,
5 | UnprocessedResultsFromCRM
6 | } from '../types'
7 |
8 | type FilterFormProps = {
9 | changeSearchParameters: (
10 | newParameters: IntersectedSearchAndFilterParams[]
11 | ) => void;
12 | setFilterInUse: (stateChange: string) => void;
13 | filterInUse: string;
14 | updateResults: (results: UnprocessedResultsFromCRM[]) => void;
15 | }
16 |
17 | export function FilterRadioGroup (props: FilterFormProps) {
18 | return (
19 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/LeasesSearch.tsx:
--------------------------------------------------------------------------------
1 | import React, { ChangeEvent } from 'react'
2 | import { SearchWidget } from './SearchWidget'
3 | import DatePicker from 'react-datepicker'
4 | import 'react-datepicker/dist/react-datepicker.css'
5 |
6 | import { IntersectedSearchAndFilterParams } from '../types'
7 |
8 | type LeasesSearchProps = {
9 | searchParameters: IntersectedSearchAndFilterParams;
10 | changeSearchParameters: (newParameters: IntersectedSearchAndFilterParams) => void;
11 | }
12 |
13 | export function LeasesSearch (props: LeasesSearchProps) {
14 | return (
15 | <>
16 |
17 |
142 | >
143 | )
144 | }
145 |
--------------------------------------------------------------------------------
/src/components/ManagedDrop.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Select from 'react-select'
3 | import { ReactSelectOption } from '../types'
4 |
5 | type DropdownProps = {
6 | managed: string
7 | changedManaged: (managed: string) => void
8 | }
9 |
10 | export function ManagedDrop (props: DropdownProps) {
11 | const yesNo = [
12 | { value: 'All', label: 'All' },
13 | { value: 'Yes', label: 'Yes' },
14 | { value: 'No', label: 'No' }
15 | ]
16 |
17 | return (
18 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/MapWidget.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { GoogleMap, LoadScript, Marker } from '@react-google-maps/api'
3 |
4 | import { AddressType, PositionType } from '../types'
5 |
6 | type MapProps = {
7 | addressesToRender: AddressType[]
8 | centrePoint: PositionType
9 | mapsApiKey: string
10 | isMapFullScreen: boolean
11 | }
12 |
13 | export function MapWidget (props: MapProps) {
14 | const containerStyle = {
15 | width: '100%',
16 | height: '100%'
17 | }
18 | let rangeOfPropertiesSameGeoLocation: number[] = []
19 | return (
20 |
21 |
24 |
29 |
33 | {props.addressesToRender.map((address, index) => {
34 | const sameGeoLocation = address.position.lat === props.addressesToRender[index + 1]?.position.lat && address.position.lng === props.addressesToRender[index + 1]?.position.lng
35 | let markerValue
36 | if (sameGeoLocation) {
37 | rangeOfPropertiesSameGeoLocation.push(index)
38 | } else {
39 | const markerRange = rangeOfPropertiesSameGeoLocation.length
40 | markerValue = markerRange !== 0 ? `${rangeOfPropertiesSameGeoLocation[0] + 1}-${index + 1}` : `${index + 1}`
41 | rangeOfPropertiesSameGeoLocation = []
42 | }
43 | return (
44 |
48 | )
49 | })}
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/MassMailButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { UnprocessedResultsFromCRM } from '../types'
3 | import { massMailResults, unselectMassEmailField } from '../services/crmDataService'
4 |
5 | type MassMailButton = {
6 | results: UnprocessedResultsFromCRM[]
7 | }
8 | export function MassMailButton (props: MassMailButton) {
9 | const [isLoading, setIsLoading] = useState(false)
10 | return (
11 |
12 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/PrintButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export function PrintButton () {
4 | return (
5 |
6 |
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/PropertyGroupDropdown.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Select from 'react-select'
3 | import { ReactSelectOption } from '../types'
4 |
5 | type DropdownProps = {
6 | chosenPropertyGroups: string[]
7 | changePropertyGroups: (propertyGroups: string[]) => void
8 | }
9 |
10 | export function PropertyGroupDropdown (props: DropdownProps) {
11 | const possiblePropertyGroups = [
12 | { value: 'Retail', label: 'Retail' },
13 | { value: 'Industrial', label: 'Industrial' },
14 | { value: 'Commercial', label: 'Commercial' }
15 | ]
16 |
17 | return (
18 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/PropertyTypeDropdown.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Select from 'react-select'
3 | import { ReactSelectOption } from '../types'
4 |
5 | type DropdownProps = {
6 | chosenPropertyTypes: string[]
7 | changePropertyTypes: (propertyType: string[]) => void
8 | }
9 |
10 | export function PropertyTypeDropdown (props: DropdownProps) {
11 | const possiblePropertyTypes = [
12 | { value: 'Automotive', label: 'Automotive' },
13 | { value: 'Bulky Goods/Showroom', label: 'Bulky Goods/Showroom' },
14 | { value: 'Child Care', label: 'Child Care' },
15 | { value: 'Commercial Fast Food', label: 'Commercial Fast Food' },
16 | { value: 'Development', label: 'Development' },
17 | { value: 'Food - General', label: 'Food - General' },
18 | { value: 'Gym/Fitness', label: 'Gym/Fitness' },
19 | { value: 'Hotel', label: 'Hotel' },
20 | { value: 'Medical/Dental', label: 'Medical/Dental' },
21 | { value: 'Mixed Use', label: 'Mixed Use' },
22 | { value: 'Office', label: 'Office' },
23 | { value: 'Petrol', label: 'Petrol' },
24 | { value: 'Retail', label: 'Retail' },
25 | { value: 'Warehouse', label: 'Warehouse' }
26 | ]
27 |
28 | return (
29 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/ResultsTable.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { UnprocessedResultsFromCRM } from '../types'
4 | import { formatDate, convertToCurrency } from '../utils/utils'
5 |
6 | type ResultsTableProps = {
7 | results: UnprocessedResultsFromCRM[]
8 | filterInUse: string
9 | }
10 |
11 | export function ResultsTableWidget (props: ResultsTableProps) {
12 | return (
13 |
14 | {props.filterInUse === 'BaseFilter' &&
15 | (
16 |
17 |
18 |
19 |
20 | No. |
21 | Property Address |
22 | Owner |
23 | Contact |
24 |
25 |
26 |
27 | {props.results.map((result, index) => {
28 | let propertyAddress = result.Deal_Name
29 | if (!result.Latitude || !result.Longitude) {
30 | propertyAddress = `${result.Deal_Name} - Geocoordinates N/A, cannot display on map.`
31 | }
32 | const ownerData = result.owner_details?.find((owner) => owner.Contact_Type === 'Owner')
33 | const contactData = result.owner_details?.find((owner) => owner.Contact_Type === 'Director')
34 | return (
35 |
36 | {index + 1} |
37 | {propertyAddress} |
38 | {ownerData?.Name || ''} |
39 | {contactData?.Name || 'Contact Is Not Found'} |
40 |
41 | )
42 | })}
43 |
44 |
45 |
46 | )
47 | }
48 | {props.filterInUse === 'SalesEvidenceFilter' &&
49 | (
50 |
51 |
52 |
53 |
54 | No. |
55 | Address |
56 | Land Area |
57 | Build Area |
58 | Date Sold |
59 | Sale Price |
60 |
61 |
62 |
63 | {props.results.map((result, index) => {
64 | return (
65 |
66 | {index + 1} |
67 | {result.Deal_Name} |
68 | {result.Land_Area_sqm} |
69 | {result.Build_Area_sqm} |
70 | {formatDate(result.Sale_Date)} |
71 | {convertToCurrency(result.Sale_Price)} |
72 |
73 | )
74 | })}
75 |
76 |
77 |
78 | )
79 | }
80 | {props.filterInUse === 'LeasesEvidenceFilter' &&
81 | (
82 |
83 |
84 |
85 |
86 | No. |
87 | Address |
88 | Tenancy Name |
89 | Current $ Per Sqm |
90 | Land Area |
91 | Area(sqm) |
92 | Current Rent (Gross) |
93 |
94 |
95 |
96 | {props.results.map((result, index) => {
97 | return (
98 |
99 | {index + 1} |
100 | {result.Property.name} |
101 | {result.Name} |
102 | {convertToCurrency(result.Current_Per_Sqm)} |
103 | {result.Land_Area_sqm} |
104 | {result.Area_sqm} |
105 | {convertToCurrency(result.Current_AI_New_Market_Rental)} |
106 |
107 | )
108 | })}
109 |
110 |
111 |
112 | )
113 | }
114 |
115 | )
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/SaleTypeDropdown.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Select from 'react-select'
3 | import { ReactSelectOptionEnum, SaleTypeEnum } from '../types'
4 |
5 | type DropdownProps = {
6 | chosenSaleType: SaleTypeEnum[]
7 | changeSaleTypes: (saleType: SaleTypeEnum[]) => void
8 | }
9 |
10 | export function SaleTypeDropdown (props: DropdownProps) {
11 | const possibleSaleType = [
12 | { value: SaleTypeEnum.INV, label: SaleTypeEnum.INV },
13 | { value: SaleTypeEnum.VP, label: SaleTypeEnum.VP },
14 | { value: SaleTypeEnum.DEV, label: SaleTypeEnum.DEV }
15 | ]
16 |
17 | return (
18 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/SalesEvidenceSearchWidget.tsx:
--------------------------------------------------------------------------------
1 | import React, { ChangeEvent } from 'react'
2 | import { SearchWidget } from './SearchWidget'
3 | import DatePicker from 'react-datepicker'
4 | import 'react-datepicker/dist/react-datepicker.css'
5 | import { SaleTypeDropdown } from './SaleTypeDropdown'
6 | import { IntersectedSearchAndFilterParams } from '../types'
7 |
8 | type SearchWidgetProps = {
9 | searchParameters: IntersectedSearchAndFilterParams;
10 | changeSearchParameters: (newParameters: IntersectedSearchAndFilterParams) => void;
11 |
12 | }
13 |
14 | export function SalesEvidenceSearchWidget (props: SearchWidgetProps) {
15 | return (
16 | <>
17 |
19 |
90 | >
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/SearchWidget.tsx:
--------------------------------------------------------------------------------
1 | import React, { ChangeEvent } from 'react'
2 | import { PropertyTypeDropdown } from './PropertyTypeDropdown'
3 | import { PropertyGroupDropdown } from './PropertyGroupDropdown'
4 | import { ManagedDrop } from './ManagedDrop'
5 | import { IntersectedSearchAndFilterParams } from '../types'
6 |
7 | type SearchWidgetProps = {
8 | searchParameters: IntersectedSearchAndFilterParams
9 | changeSearchParameters: (newParameters: IntersectedSearchAndFilterParams) => void
10 | }
11 |
12 | export function SearchWidget (props: SearchWidgetProps) {
13 | return (
14 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/SearchWidgetsWrapper.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { SearchWidget } from './SearchWidget'
3 | import { SalesEvidenceSearchWidget } from './SalesEvidenceSearchWidget'
4 | import { LeasesSearch } from './LeasesSearch'
5 | import { FilterRadioGroup } from './FilterRadioGroup'
6 | import { DEFAULT_SEARCH_PARAMS, IntersectedSearchAndFilterParams, UnprocessedResultsFromCRM } from '../types'
7 |
8 | type SearchWidgetProps = {
9 | searchParameters: IntersectedSearchAndFilterParams[];
10 | changeSearchParameters: (newParameters: IntersectedSearchAndFilterParams[]) => void;
11 | setReadyForSearch: (isReady: boolean) => void;
12 | setFilterInUse: (stateChange: string) => void;
13 | filterInUse: string;
14 | updateResults: (results: UnprocessedResultsFromCRM[]) => void;
15 | isMapFullScreen: boolean
16 | }
17 |
18 | export function SearchWidgetsWrapper (props: SearchWidgetProps) {
19 | return (
20 |
21 |
22 | {props.filterInUse === 'BaseFilter' &&
23 | props.searchParameters.map((searchParameters, idx) => {
24 | return (
25 |
26 |
{
29 | const updatedSearchParams = [...props.searchParameters]
30 | updatedSearchParams[idx] = newSearchParams
31 | props.changeSearchParameters(updatedSearchParams)
32 | }}
33 | />
34 |
35 |
43 |
44 |
50 |
51 |
57 |
58 |
59 |
60 | )
61 | })}
62 | {props.filterInUse === 'SalesEvidenceFilter' &&
63 | (
64 |
65 | {
68 | const updatedSearchParams = [...props.searchParameters]
69 | updatedSearchParams[0] = newSearchParams
70 | props.changeSearchParameters(updatedSearchParams)
71 | }}
72 | />
73 |
74 | )}
75 | {props.filterInUse === 'LeasesEvidenceFilter' &&
76 | (
77 |
78 | {
81 | const updatedSearchParams = [...props.searchParameters]
82 | updatedSearchParams[0] = newSearchParams
83 | props.changeSearchParameters(updatedSearchParams)
84 | }} />
85 |
86 | )
87 | }
88 | {props.filterInUse !== 'BaseFilter' && (
89 |
90 |
96 |
97 | )}
98 |
99 | )
100 | }
101 |
--------------------------------------------------------------------------------
/src/components/UpdateLastMailedButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { UnprocessedResultsFromCRM } from '../types'
3 | import { updateMailComment } from '../services/crmDataService'
4 |
5 | type UpdateLastMailedProps = {
6 | results: UnprocessedResultsFromCRM[]
7 | }
8 | export function UpdateLastMailedButton (props: UpdateLastMailedProps) {
9 | const [comment, changeComment] = useState('')
10 | const [isLoading, setLoading] = useState(false)
11 | return (
12 |
13 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
15 | body {
16 | font: 90%/1.5 "ProximaNovaRegular", "Lucida Grande", "Lucida Sans", Tahoma,
17 | Verdana, sans-serif;
18 | color: #000000;
19 | }
20 |
21 | /* App.tsx styling */
22 |
23 | .app-wrapper {
24 | width: 98%;
25 | height: fit-content;
26 | margin: auto;
27 | margin-top: 1%;
28 | margin-bottom: 1%;
29 | }
30 |
31 | .refresh-button {
32 | position: absolute;
33 | right: 1%;
34 | top: 2%;
35 | }
36 |
37 | .form-and-map {
38 | display: grid;
39 | grid-template-columns: 60% 40%;
40 | grid-gap: 3em;
41 | justify-content: space-between;
42 | height: fit-content;
43 | }
44 |
45 | .form-and-map-fw {
46 | display: grid;
47 | grid-template-columns: 100%;
48 | grid-row-gap: 1em;
49 | height: fit-content;
50 | }
51 |
52 | .map-div-text {
53 | text-align: center;
54 | text-decoration: underline;
55 | margin: 0.5em;
56 | }
57 |
58 | .map-div-text:hover {
59 | cursor: pointer;
60 | }
61 |
62 | .map-div {
63 | grid-column: 2;
64 | grid-row: 1;
65 | width: 90%;
66 | height: 32em;
67 | text-align: center;
68 | margin-top: 40px;
69 | }
70 |
71 | .map-div-fw {
72 | grid-column: 1;
73 | grid-row: 1;
74 | width: 100%;
75 | height: 90vh;
76 | text-align: center;
77 | margin-top: 35px;
78 | }
79 |
80 | /* SearchWidgetsWrapper styling */
81 |
82 | .main-wrapper {
83 | grid-column: 1;
84 | grid-row: 1;
85 | display: flex;
86 | flex-direction: column;
87 | margin: 0;
88 | padding: 0;
89 | width: 100%;
90 | }
91 |
92 | .main-wrapper-fw {
93 | grid-column: 1;
94 | grid-row: 2;
95 | display: flex;
96 | flex-direction: column;
97 | margin: 0;
98 | padding: 0;
99 | width: 60%;
100 | }
101 |
102 | .search-params-wrapper {
103 | padding: 0;
104 | margin: 0;
105 | width: 100%;
106 | }
107 |
108 | .button-wrapper {
109 | padding: 20px 20px 20px 0px;
110 | display: flex;
111 | }
112 |
113 | .control-buttons {
114 | grid-column: 2;
115 | grid-row: 1;
116 | }
117 |
118 | .button-wrapper-1 {
119 | padding: 20px 0px;
120 | display: grid;
121 | grid-template-columns: 50% 50%;
122 | grid-gap: 0.5em;
123 | grid-row-gap: 1em;
124 | height: auto;
125 | width: 100%;
126 | }
127 |
128 | .search-button {
129 | width: fit-content;
130 | margin-left: 5px;
131 | grid-column: 1;
132 | grid-row: 1;
133 | }
134 |
135 | .search-button-2 {
136 | width: fit-content;
137 | margin-left: 5px;
138 | }
139 |
140 | .search-widget-form {
141 | display: grid;
142 | grid-template-columns: auto;
143 | grid-gap: 0.5em;
144 | grid-row-gap: 1em;
145 | height: auto;
146 | width: auto;
147 | width: 100%;
148 | }
149 |
150 | .search-widget {
151 | width: 100%;
152 | display: flex;
153 | flex-direction: column;
154 | }
155 |
156 | .one {
157 | grid-column: 1;
158 | grid-row: 1;
159 | }
160 |
161 | .two {
162 | grid-column: 1;
163 | grid-row: 2;
164 | }
165 |
166 | .three {
167 | grid-column: 1;
168 | grid-row: 3;
169 | }
170 |
171 | .four {
172 | grid-column: 1;
173 | grid-row: 4;
174 | }
175 |
176 | .five {
177 | grid-column: 2;
178 | grid-row: 3;
179 | }
180 |
181 | .six {
182 | grid-column: 2;
183 | grid-row: 4;
184 | }
185 |
186 | .seven {
187 | grid-column: 2;
188 | grid-row: 2;
189 | }
190 | .eight {
191 | grid-column: 1;
192 | grid-row: 5;
193 | }
194 |
195 | .nine {
196 | grid-column: 1;
197 | grid-row: 6;
198 | }
199 |
200 | .ten {
201 | grid-column: 1;
202 | grid-row: 7;
203 | }
204 |
205 | .eleven {
206 | grid-column: 2;
207 | grid-row: 5;
208 | }
209 |
210 | .twelve {
211 | grid-column: 2;
212 | grid-row: 6;
213 | }
214 | .thirteen {
215 | grid-column: 2;
216 | grid-row: 7;
217 | }
218 |
219 | .fourteen {
220 | grid-column: 1;
221 | grid-row: 8;
222 | }
223 |
224 | label {
225 | height: 75%;
226 | width: 350px;
227 | padding: 4px;
228 | margin-top: 1px;
229 | }
230 |
231 | input {
232 | clear: both;
233 | border: 2px solid #000000;
234 | font-size: small;
235 | }
236 |
237 | .minWidth {
238 | min-width: 70%;
239 | }
240 |
241 | input.below-label {
242 | float: initial;
243 | clear: initial;
244 | }
245 |
246 | #numberOfRecords {
247 | min-width: 67%;
248 | }
249 |
250 | select {
251 | min-width: 68%;
252 | float: right;
253 | clear: both;
254 | border: 2px solid #000000;
255 | font-size: small;
256 | }
257 |
258 | .smaller-font {
259 | font-size: xx-small;
260 | }
261 |
262 | .align-paragraph {
263 | text-align: center;
264 | margin-left: 6em;
265 | margin-top: 0.5em;
266 | margin-bottom: 0;
267 | }
268 |
269 | /* Not a standard hence all the name craziness https://css-tricks.com/almanac/selectors/p/placeholder/ */
270 | ::-webkit-input-placeholder {
271 | /* Chrome/Opera/Safari */
272 | font-weight: lighter;
273 | font-style: italic;
274 | }
275 | ::-moz-placeholder {
276 | /* Firefox 19+ */
277 | font-weight: lighter;
278 | font-style: italic;
279 | }
280 | :-ms-input-placeholder {
281 | /* IE 10+ */
282 | font-weight: lighter;
283 | font-style: italic;
284 | }
285 | :-moz-placeholder {
286 | /* Firefox 18- */
287 | font-weight: lighter;
288 | font-style: italic;
289 | }
290 |
291 | /* Results Widget Styling */
292 |
293 | button {
294 | max-width: 100%;
295 | padding: 4px;
296 | background-color: #e82620;
297 | border: 2px solid #e82620;
298 | color: white;
299 | font-size: medium;
300 | }
301 |
302 | .button {
303 | background-color: #e82620;
304 | border: 2px solid #e82620;
305 | padding: 5px;
306 | text-align: center;
307 | color: white;
308 | font-weight: bold;
309 | margin-right: 10px;
310 | width: 180px;
311 | height: min-content;
312 | }
313 |
314 | .download-button-wrapper {
315 | display: flex;
316 | flex-direction: row;
317 | align-items: center;
318 | padding: 0;
319 | margin-left: 7px;
320 | }
321 |
322 | .mail-comment-button-wrapper {
323 | display: flex;
324 | padding: 10px 0;
325 | margin-left: 10px;
326 | }
327 |
328 | .mail-comment-button-wrapper button {
329 | width: 205px;
330 | text-decoration: underline;
331 | font-weight: bold;
332 | }
333 |
334 | .mail-comment-button-wrapper textarea {
335 | margin-right: 13px;
336 | width: 200px;
337 | }
338 |
339 | button.danger {
340 | background-color: black;
341 | color: white;
342 | border-color: black;
343 | margin-left: 4px;
344 | }
345 |
346 | button.secondary {
347 | background-color: orange;
348 | color: white;
349 | border-color: orange;
350 | }
351 |
352 | .wrapperBelowMap {
353 | display: flex;
354 | flex-wrap: wrap;
355 | justify-content: space-between;
356 | padding: 8px;
357 | padding-top: 16px;
358 | align-items: center;
359 | }
360 |
361 | .break {
362 | flex-basis: 100%;
363 | height: 0;
364 | }
365 |
366 | #searchResults {
367 | padding-right: 2em;
368 | }
369 |
370 | table {
371 | border: 2px solid #000000;
372 | border-collapse: collapse;
373 | }
374 |
375 | th {
376 | border-bottom: 2px solid #000000;
377 | border-left: 2px solid #000000;
378 | text-align: left;
379 | padding: 5px;
380 | }
381 |
382 | td {
383 | border-left: 2px solid #000000;
384 | text-align: left;
385 | padding: 5px;
386 | }
387 |
388 | .side-padding {
389 | margin-left: 0;
390 | padding: 5px;
391 | height: min-content;
392 | }
393 |
394 | @media print {
395 | .pagebreak {
396 | display: block;
397 | page-break-before: auto;
398 | }
399 | }
400 |
401 | @media print {
402 | table {
403 | page-break-inside: avoid;
404 | }
405 | }
406 | .border {
407 | background-color: hsl(0, 0%, 100%);
408 | border-color: hsl(0, 0%, 80%);
409 | border-radius: 4px;
410 | border-style: solid;
411 | border-width: 1px;
412 | min-height: 38px;
413 | }
414 | .minMaxSize {
415 | min-width: 10% !important;
416 | margin-right: 5%;
417 | }
418 |
419 | .react-datepicker-wrapper {
420 | margin-right: 5%;
421 | }
422 |
423 | .buttonSize {
424 | margin-right: 0.3%;
425 | }
426 |
427 | .inputSize {
428 | min-width: 75%;
429 | }
430 |
431 | /* FilterForm Styling */
432 |
433 | .radioName {
434 | padding-left: 0.5%;
435 | padding-right: 0.5%;
436 | font-size: medium;
437 | }
438 | .radio-box {
439 | text-align: left;
440 | }
441 |
442 | /* MapWidget Styling */
443 |
444 | #map {
445 | padding: 0;
446 | margin: 0;
447 | height: 30em;
448 | width: 100%;
449 | }
450 |
451 | #map-fw {
452 | padding: 0;
453 | margin: 0;
454 | height: 85vh;
455 | width: 100%;
456 | }
457 |
458 | #map
459 | div.gmnoprint.gm-bundled-control.gm-bundled-control-on-bottom
460 | > div:nth-child(1)
461 | > div {
462 | display: flex;
463 | flex-direction: column;
464 | align-items: center;
465 | }
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 |
2 | import React from 'react'
3 | import ReactDOM from 'react-dom'
4 | import './index.css'
5 | import App from './App'
6 | import * as serviceWorker from './serviceWorker'
7 |
8 | ReactDOM.render(
9 |
10 |
11 | ,
12 | document.getElementById('root')
13 | )
14 |
15 | // If you want your app to work offline and load faster, you can change
16 | // unregister() to register() below. Note this comes with some pitfalls.
17 | // Learn more about service workers: https://bit.ly/CRA-PWA
18 | serviceWorker.unregister()
19 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 |
3 | // This optional code is used to register a service worker.
4 | // register() is not called by default.
5 |
6 | // This lets the app load faster on subsequent visits in production, and gives
7 | // it offline capabilities. However, it also means that developers (and users)
8 | // will only see deployed updates on subsequent visits to a page, after all the
9 | // existing tabs open on the page have been closed, since previously cached
10 | // resources are updated in the background.
11 |
12 | // To learn more about the benefits of this model and instructions on how to
13 | // opt-in, read https://bit.ly/CRA-PWA
14 |
15 | const isLocalhost = Boolean(
16 | window.location.hostname === 'localhost' ||
17 | // [::1] is the IPv6 localhost address.
18 | window.location.hostname === '[::1]' ||
19 | // 127.0.0.0/8 are considered localhost for IPv4.
20 | window.location.hostname.match(
21 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
22 | )
23 | )
24 |
25 | export function register (config) {
26 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
27 | // The URL constructor is available in all browsers that support SW.
28 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href)
29 | if (publicUrl.origin !== window.location.origin) {
30 | // Our service worker won't work if PUBLIC_URL is on a different origin
31 | // from what our page is served on. This might happen if a CDN is used to
32 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
33 | return
34 | }
35 |
36 | window.addEventListener('load', () => {
37 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
38 |
39 | if (isLocalhost) {
40 | // This is running on localhost. Let's check if a service worker still exists or not.
41 | checkValidServiceWorker(swUrl, config)
42 |
43 | // Add some additional logging to localhost, pointing developers to the
44 | // service worker/PWA documentation.
45 | navigator.serviceWorker.ready.then(() => {
46 | console.log(
47 | 'This web app is being served cache-first by a service ' +
48 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
49 | )
50 | })
51 | } else {
52 | // Is not localhost. Just register service worker
53 | registerValidSW(swUrl, config)
54 | }
55 | })
56 | }
57 | }
58 |
59 | function registerValidSW (swUrl, config) {
60 | navigator.serviceWorker
61 | .register(swUrl)
62 | .then(registration => {
63 | registration.onupdatefound = () => {
64 | const installingWorker = registration.installing
65 | if (installingWorker == null) {
66 | return
67 | }
68 | installingWorker.onstatechange = () => {
69 | if (installingWorker.state === 'installed') {
70 | if (navigator.serviceWorker.controller) {
71 | // At this point, the updated precached content has been fetched,
72 | // but the previous service worker will still serve the older
73 | // content until all client tabs are closed.
74 | console.log(
75 | 'New content is available and will be used when all ' +
76 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
77 | )
78 |
79 | // Execute callback
80 | if (config && config.onUpdate) {
81 | config.onUpdate(registration)
82 | }
83 | } else {
84 | // At this point, everything has been precached.
85 | // It's the perfect time to display a
86 | // "Content is cached for offline use." message.
87 | console.log('Content is cached for offline use.')
88 |
89 | // Execute callback
90 | if (config && config.onSuccess) {
91 | config.onSuccess(registration)
92 | }
93 | }
94 | }
95 | }
96 | }
97 | })
98 | .catch(error => {
99 | console.error('Error during service worker registration:', error)
100 | })
101 | }
102 |
103 | function checkValidServiceWorker (swUrl, config) {
104 | // Check if the service worker can be found. If it can't reload the page.
105 | fetch(swUrl, {
106 | headers: { 'Service-Worker': 'script' }
107 | })
108 | .then(response => {
109 | // Ensure service worker exists, and that we really are getting a JS file.
110 | const contentType = response.headers.get('content-type')
111 | if (
112 | response.status === 404 ||
113 | (contentType != null && contentType.indexOf('javascript') === -1)
114 | ) {
115 | // No service worker found. Probably a different app. Reload the page.
116 | navigator.serviceWorker.ready.then(registration => {
117 | registration.unregister().then(() => {
118 | window.location.reload()
119 | })
120 | })
121 | } else {
122 | // Service worker found. Proceed as normal.
123 | registerValidSW(swUrl, config)
124 | }
125 | })
126 | .catch(() => {
127 | console.log(
128 | 'No internet connection found. App is running in offline mode.'
129 | )
130 | })
131 | }
132 |
133 | export function unregister () {
134 | if ('serviceWorker' in navigator) {
135 | navigator.serviceWorker.ready
136 | .then(registration => {
137 | registration.unregister()
138 | })
139 | .catch(error => {
140 | console.error(error.message)
141 | })
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/src/services/crmDataService.ts:
--------------------------------------------------------------------------------
1 | import { IntersectedSearchAndFilterParams, UnprocessedResultsFromCRM, AddressType, CachedDataType } from '../types'
2 | import { ZOHO } from '../vendor/ZSDK'
3 | import filterResults from '../utils/filterResults'
4 | import emailAndIdExtract from '../utils/emailAndIdExtract'
5 | import { orderResultsByDistance } from '../utils/filterUtilityFunctions'
6 | import localforage from 'localforage'
7 |
8 | async function safelyRetrieveLocalStorageItem (storageKey: string): Promise {
9 | let dataFromCache: CachedDataType = { lastRetrievalDate: new Date().toISOString(), data: [] }
10 | try {
11 | const data = await localforage.getItem(storageKey)
12 | if (data) {
13 | dataFromCache = data
14 | }
15 | } catch (e) {
16 | console.error('Issue retrieving data from local storage')
17 | }
18 |
19 | return dataFromCache
20 | }
21 |
22 | export function safelySetLocalStorageItem (storageKey: string, value: CachedDataType) {
23 | try {
24 | return localforage.setItem(storageKey, value)
25 | } catch (e) {
26 | console.error('Issue setting data in local storage')
27 | }
28 | }
29 |
30 | async function getPageOfRecords (pageNumber: number, zohoModuleToUse: string) {
31 | const response = await ZOHO.CRM.API.getAllRecords({
32 | Entity: zohoModuleToUse,
33 | page: pageNumber,
34 | per_page: 200
35 | })
36 | if (!response.data) {
37 | return []
38 | }
39 | return response.data
40 | }
41 |
42 | const retrieveRecordsFromLocalStorageIfAvailable = async (localStorageKey: string): Promise => {
43 | const data = await safelyRetrieveLocalStorageItem(localStorageKey)
44 | console.log(data)
45 | const MILLISECONDS_IN_ONE_HOUR = 1000 * 60 * 60
46 |
47 | let propertiesData: UnprocessedResultsFromCRM[] = []
48 |
49 | if (data?.lastRetrievalDate) {
50 | const millisecondsSinceLastRetrieval = new Date().valueOf() - new Date(data.lastRetrievalDate).valueOf()
51 | if (millisecondsSinceLastRetrieval < MILLISECONDS_IN_ONE_HOUR && data.data) {
52 | propertiesData = data.data
53 | }
54 | }
55 |
56 | return propertiesData
57 | }
58 |
59 | export function updateCacheForGeocodingAndOwnerData () {
60 | void ZOHO.CRM.FUNCTIONS.execute(
61 | 'update_contact_owner_cache_in_properties',
62 | {}
63 | )
64 |
65 | void ZOHO.CRM.FUNCTIONS.execute(
66 |
67 | 'geocode_addresses1',
68 |
69 | {}
70 | )
71 | }
72 |
73 | const retrieveRecords = async function (pageNumber: number, retrievedProperties: UnprocessedResultsFromCRM[], zohoModuleToUse: string, retrieveFromLocalStorage = true): Promise {
74 | const localStorageKey = `cached${zohoModuleToUse}`
75 | if (retrieveFromLocalStorage) {
76 | const dataFromLocalStorage = await retrieveRecordsFromLocalStorageIfAvailable(localStorageKey)
77 | if (dataFromLocalStorage.length > 0) {
78 | return dataFromLocalStorage
79 | }
80 | return retrieveRecords(
81 | pageNumber,
82 | [],
83 | zohoModuleToUse,
84 | false
85 | )
86 | }
87 |
88 | const thisPageResults = await getPageOfRecords(pageNumber, zohoModuleToUse)
89 |
90 | if (thisPageResults.length === 0) {
91 | void safelySetLocalStorageItem(localStorageKey, {
92 | lastRetrievalDate: new Date().toISOString(),
93 | data: retrievedProperties
94 | })
95 | return retrievedProperties
96 | }
97 | return retrieveRecords(
98 | pageNumber + 1,
99 | retrievedProperties.concat(thisPageResults),
100 | zohoModuleToUse,
101 | false
102 | )
103 | }
104 |
105 | export async function findMatchingRecords (searchParameters: IntersectedSearchAndFilterParams[], filterInUse: string, searchAddressPosition: AddressType[]): Promise<{ matchedProperties: UnprocessedResultsFromCRM[], numberOfUniqueSearchRecords: number }> {
106 | const zohoModuleToUse = filterInUse === 'LeasesEvidenceFilter' ? 'Properties' : 'Deals'
107 | const matchingResults = await retrieveRecords(0, [], zohoModuleToUse)
108 |
109 | if (Object.keys(matchingResults).includes('Error')) {
110 | alert('Error retrieving search results')
111 | }
112 |
113 | const resultsOrderedByDistance = searchAddressPosition.map((addressObject: AddressType) => {
114 | return orderResultsByDistance(matchingResults, addressObject.position)
115 | })
116 |
117 | const matchedProperties = filterResults(resultsOrderedByDistance, searchParameters, filterInUse)
118 | const numberOfUniqueSearchRecords = matchedProperties.length
119 | return { matchedProperties, numberOfUniqueSearchRecords }
120 | }
121 |
122 | export async function getSearchAddressPosition (searchParameters: IntersectedSearchAndFilterParams[]): Promise {
123 | const searchAddresses = await Promise.all(searchParameters.map(async (searchParams: IntersectedSearchAndFilterParams) => {
124 | const geocodeResult = await ZOHO.CRM.FUNCTIONS.execute('geocode_address', {
125 | arguments: JSON.stringify({
126 | search_address: searchParams.searchAddress
127 | })
128 | })
129 |
130 | return {
131 | address: searchParams.searchAddress,
132 | position: JSON.parse(geocodeResult.details.output)
133 | }
134 | }))
135 |
136 | return searchAddresses
137 | }
138 |
139 | export async function updateMailComment (comment: string, results: UnprocessedResultsFromCRM[]): Promise {
140 | const recordData = results.filter((result) => result.id && result.owner_details?.length).flatMap((result) => result.owner_details).map((owner) => {
141 | return {
142 | id: owner.id,
143 | Last_Mailed_Date: owner.Last_Mailed_Date,
144 | Last_Mailed: owner.Last_Mailed,
145 | Postal_Address: owner.Postal_Address
146 | }
147 | })
148 |
149 | // batch updates so we don't exceed the payload limit
150 | const BATCH_SIZE = 20
151 | const batches = []
152 | for (let i = 0; i < recordData.length; i += BATCH_SIZE) {
153 | const dataForThisBatch = recordData.slice(i, i + BATCH_SIZE)
154 | batches.push(dataForThisBatch)
155 | }
156 |
157 | const waitMilliseconds = async (millisecondsToWait: number) => {
158 | return new Promise((resolve) => {
159 | setTimeout(resolve, millisecondsToWait)
160 | })
161 | }
162 |
163 | await Promise.all(
164 | batches.map(async (messageBatch, idx) => {
165 | const payload = {
166 | arguments: JSON.stringify({
167 | results_str: messageBatch,
168 | comment: comment
169 | })
170 | }
171 | await waitMilliseconds(idx * 500)
172 | await ZOHO.CRM.FUNCTIONS.execute('update_mail_comment', payload)
173 | })
174 | )
175 | }
176 |
177 | export async function massMailResults (results: UnprocessedResultsFromCRM[]): Promise {
178 | const emailsAndIds = emailAndIdExtract(results)
179 |
180 | await ZOHO.CRM.FUNCTIONS.execute('mass_email_button', {
181 | arguments: JSON.stringify({
182 | emails_and_ids: emailsAndIds
183 | })
184 | })
185 | }
186 |
187 | export async function unselectMassEmailField (): Promise {
188 | await ZOHO.CRM.FUNCTIONS.execute('unselect_mass_email_field', {})
189 | }
190 |
191 | export async function getGoogleMapsAPIKeyFromCRM () {
192 | await ZOHO.embeddedApp.init()
193 | const googleMapsAPIKey = await ZOHO.CRM.API.getOrgVariable('ethicaltechnology_google_maps_api_key')
194 |
195 | if (Object.keys(googleMapsAPIKey).includes('Error')) {
196 | alert(`Issue with google maps API organisation variable: ${googleMapsAPIKey.Error.Content}. Make sure you've added the key`)
197 | }
198 |
199 | return googleMapsAPIKey?.Success?.Content
200 | }
201 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect'
6 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | const DEFAULT_SALES_EVIDENCE_PARAMS = {
2 | dateSold: {
3 | min: undefined,
4 | max: undefined
5 | },
6 | salePrice: {
7 | min: -1,
8 | max: -1
9 | },
10 | saleType: []
11 | }
12 |
13 | const DEFAULT_LEASE_EVIDENCE_PARAMS = {
14 | rentGross: {
15 | min: -1,
16 | max: -1
17 | },
18 | rentPerDollarMeter: {
19 | min: -1,
20 | max: -1
21 | },
22 | leasedDate: {
23 | min: undefined,
24 | max: undefined
25 | },
26 | reviewDate: {
27 | min: undefined,
28 | max: undefined
29 | }
30 | }
31 |
32 | const SHARED_SALE_LEASE_DEFAULT_PARAMS = {
33 | landArea: {
34 | min: -1,
35 | max: -1
36 | },
37 | buildArea: {
38 | min: -1,
39 | max: -1
40 | }
41 | }
42 |
43 | export const DEFAULT_BASE_FILTER_PARAMS = {
44 | searchAddress: '528 Kent St, Sydney, NSW, 2000',
45 | propertyGroupsMaxResults: Infinity,
46 | propertyTypesMaxResults: Infinity,
47 | neighboursSearchMaxRecords: Infinity,
48 | propertyTypes: [],
49 | propertyGroups: [],
50 | managed: 'All',
51 | readyForSearch: false,
52 | id: `search:${(Math.random() * 1000)}`
53 | }
54 |
55 | export const DEFAULT_SEARCH_PARAMS = {
56 | ...DEFAULT_BASE_FILTER_PARAMS,
57 | ...DEFAULT_SALES_EVIDENCE_PARAMS,
58 | ...DEFAULT_LEASE_EVIDENCE_PARAMS,
59 | ...SHARED_SALE_LEASE_DEFAULT_PARAMS
60 | }
61 | export type SalesAndLeasesFilterParams = SalesEvidenceFilterParams & LeasesEvidenceFilterParams
62 | export type IntersectedSearchAndFilterParams = SalesEvidenceFilterParams & BaseSearchParamsType & LeasesEvidenceFilterParams
63 |
64 | export type BaseSearchParamsType = {
65 | [index: string]: any
66 | searchAddress: string
67 | propertyTypes: string[]
68 | propertyGroups: string[]
69 | neighboursSearchMaxRecords: number
70 | propertyGroupsMaxResults: number
71 | propertyTypesMaxResults: number
72 | managed: string
73 | id: string
74 | }
75 |
76 | export type SalesEvidenceFilterParams = {
77 | landArea: MinMaxNumberType
78 | buildArea: MinMaxNumberType
79 | dateSold: MinMaxDateType
80 | salePrice: MinMaxNumberType
81 | saleType: SaleTypeEnum[]
82 | }
83 |
84 | export type LeasesEvidenceFilterParams = {
85 | landArea: MinMaxNumberType
86 | buildArea: MinMaxNumberType
87 | rentGross: MinMaxNumberType
88 | rentPerDollarMeter: MinMaxNumberType
89 | leasedDate: MinMaxDateType
90 | reviewDate: MinMaxDateType
91 | }
92 |
93 | export enum SaleTypeEnum {
94 | ALL = 'ALL',
95 | INV = 'INV',
96 | VP = 'VP',
97 | DEV = 'DEV'
98 | }
99 |
100 | export const SalesTypeArray = [
101 | SaleTypeEnum.INV,
102 | SaleTypeEnum.VP,
103 | SaleTypeEnum.DEV
104 | ]
105 |
106 | export type MinMaxNumberType = {
107 | min: number
108 | max: number
109 | }
110 |
111 | export type MinMaxDateType = {
112 | min: Date | undefined
113 | max: Date | undefined
114 | }
115 |
116 | export type PositionType = {
117 | lat: number
118 | lng: number
119 | }
120 |
121 | export type AddressType = {
122 | address: string
123 | position: PositionType
124 | }
125 |
126 | export type ResultsType = {
127 | addressesToRender: AddressType[]
128 | centrePoint: PositionType
129 | }
130 |
131 | type TitleOwner = {
132 | email: string
133 | id: string
134 | name: string
135 | }
136 |
137 | export type OwnerType = {
138 | Email: string
139 | Do_Not_Mail: boolean
140 | Return_to_Sender: boolean
141 | Postal_Postcode: string
142 | Postal_State: string
143 | Postal_Address: string
144 | Postal_Street: string
145 | Postal_Street_No: string
146 | Postal_Suburb: string
147 | Postal_Unit: string
148 | Name: string
149 | Contact_Type: string
150 | Company: string
151 | Mobile: string
152 | Work_Phone: string
153 | id: number
154 | Last_Mailed: string
155 | Last_Mailed_Date: string
156 | First_Name: string
157 | Last_Name: string
158 | Salutation_Dear: string
159 | Owner: TitleOwner
160 | }
161 |
162 | export type UnprocessedResultsFromCRM = {
163 | [index: string]: string | number | OwnerType[] | string[] | AddressForLease | Date | boolean
164 | Latitude: string
165 | Longitude: string
166 | Deal_Name: string
167 | id: string
168 | distance: number
169 | owner_details: OwnerType[]
170 | Postcode: string
171 | State: string
172 | Property_Category_Mailing: string[]
173 | Managed: string | boolean
174 | Reverse_Geocoded_Address: string
175 | Property_Type_Portals: string
176 | Property_Contacts: string
177 | Property_Owners: string
178 | Land_Area_sqm: string
179 | Build_Area_sqm: string
180 | Sale_Type: string[]
181 | Sale_Date: string
182 | Sale_Price: number
183 | Area_sqm: number
184 | Base_Rental: number
185 | Current_AI_New_Market_Rental: number
186 | Start_Date: string
187 | Last_MR_Start_Date: string
188 | Property: AddressForLease
189 | Lessee: TenantNameType
190 | Current_Per_Sqm: number
191 | }
192 |
193 | type TenantNameType = {
194 | name: string
195 | }
196 |
197 | export type AddressForLease = {
198 | name: string
199 | }
200 |
201 | export type ReactSelectOption = {
202 | value: string
203 | label: string
204 | }
205 |
206 | export type ReactSelectOptionEnum = {
207 | value: SaleTypeEnum
208 | label: SaleTypeEnum
209 | }
210 |
211 | export type CachedDataType = {
212 | lastRetrievalDate: string
213 | data: UnprocessedResultsFromCRM[]
214 | }
215 |
--------------------------------------------------------------------------------
/src/utils/emailAndIdExtract.ts:
--------------------------------------------------------------------------------
1 | import { UnprocessedResultsFromCRM } from '../types'
2 |
3 | type MassMailObject = {
4 | email: string
5 | id: string | number
6 | }
7 |
8 | export default function emailAndIdExtract (results: UnprocessedResultsFromCRM[]) {
9 | const emailsAndIds = results.flatMap((property: UnprocessedResultsFromCRM) => {
10 | return property.owner_details?.reduce((emailsAndIdsArray: MassMailObject[], ownerOrContact) => {
11 | if (ownerOrContact.Email !== null) {
12 | emailsAndIdsArray.push({
13 | email: ownerOrContact.Email,
14 | id: ownerOrContact.id
15 | })
16 | }
17 | return emailsAndIdsArray
18 | }, []) || []
19 | })
20 |
21 | const dupeEmailsRemoved = [...new Map(emailsAndIds.map((item: MassMailObject | undefined) => [item?.email, item])).values()]
22 |
23 | return dupeEmailsRemoved
24 | }
25 |
--------------------------------------------------------------------------------
/src/utils/filterResults.ts:
--------------------------------------------------------------------------------
1 | import { IntersectedSearchAndFilterParams, UnprocessedResultsFromCRM, OwnerType, DEFAULT_BASE_FILTER_PARAMS, SaleTypeEnum, MinMaxDateType, MinMaxNumberType, SalesTypeArray } from '../types'
2 | import salesEvidenceFilter from './salesEvidenceFilter'
3 | import leasesEvidenceFilter from './leasesEvidenceFilter'
4 |
5 | type MatchTallies = {
6 | [index: string]: number
7 | neighbour: number
8 | propertyType: number
9 | propertyGroup: number
10 | }
11 |
12 | function matchForPropertyTypes (property: UnprocessedResultsFromCRM, desiredPropertyTypes: string[]): boolean {
13 | return desiredPropertyTypes.some((propertyType: string) => {
14 | return (desiredPropertyTypes.includes('All') || property.Property_Category_Mailing.includes(propertyType))
15 | })
16 | }
17 |
18 | function matchForPropertyGroups (property: UnprocessedResultsFromCRM, desiredPropertyGroups: string[]): boolean {
19 | return desiredPropertyGroups.some((propertyGroup: string) => {
20 | return (desiredPropertyGroups.includes('All') || property.Property_Type_Portals.includes(propertyGroup))
21 | })
22 | }
23 |
24 | function getOwnerData (property: UnprocessedResultsFromCRM) {
25 | const ownerData: OwnerType[] = []
26 |
27 | const parsedPropertyContacts = !property.Property_Contacts ? [] : JSON.parse(property.Property_Contacts)
28 | parsedPropertyContacts.forEach((contact: OwnerType) => {
29 | contact.Contact_Type = 'Director'
30 | ownerData.push(contact)
31 | })
32 |
33 | const parsedPropertyOwners = !property.Property_Owners ? [] : JSON.parse(property.Property_Owners)
34 |
35 | parsedPropertyOwners.forEach((owner: OwnerType) => {
36 | owner.Contact_Type = 'Owner'
37 | ownerData.push(owner)
38 | })
39 | return ownerData
40 | }
41 |
42 | function checkSalesOrLeaseFilter (searchParams: IntersectedSearchAndFilterParams) {
43 | const defaultBaseFilterKeys = Object.keys(DEFAULT_BASE_FILTER_PARAMS)
44 | const newSearchParamObject: IntersectedSearchAndFilterParams = Object.assign({}, searchParams)
45 | defaultBaseFilterKeys.forEach((baseFilterKey: string) => {
46 | delete newSearchParamObject[baseFilterKey]
47 | })
48 | const salesAndLeaseSearchParamValues: (undefined | boolean | number)[] = Object.values(newSearchParamObject).flatMap((valueObject: SaleTypeEnum[] | MinMaxDateType | MinMaxNumberType) => {
49 | if (Array.isArray(valueObject)) {
50 | return SalesTypeArray.some((salesType: SaleTypeEnum) => valueObject.includes(salesType))
51 | } else {
52 | return Object.values(valueObject)
53 | }
54 | })
55 |
56 | return salesAndLeaseSearchParamValues.some((value: number | undefined | boolean) => {
57 | const isDateUsed = typeof value === 'undefined'
58 | const isNumberUsed = value === -1
59 | if (isDateUsed) {
60 | return !isDateUsed
61 | } else if (isNumberUsed) {
62 | return !isNumberUsed
63 | } else {
64 | return value
65 | }
66 | })
67 | }
68 |
69 | export default function filterResults (unsortedPropertyResults: UnprocessedResultsFromCRM[][], searchParameters: IntersectedSearchAndFilterParams[], filterInUse: string): UnprocessedResultsFromCRM[] {
70 | const matchedProperties: UnprocessedResultsFromCRM[] = []
71 | const isSearchMultiProperties = searchParameters.length > 1
72 | const searchMultiPropertyDupes: string[] = []
73 |
74 | searchParameters.forEach((searchParams: IntersectedSearchAndFilterParams, index: number) => {
75 | const desiredPropertyTypes = searchParams.propertyTypes
76 | const desiredPropertyGroups = searchParams.propertyGroups
77 | const desiredManaged = searchParams.managed
78 | const maxResultsForPropertyTypes: number = searchParams.propertyTypesMaxResults
79 | const maxResultsForPropertyGroups: number = searchParams.propertyGroupsMaxResults
80 |
81 | const isPropertyTypeFilterInUse = desiredPropertyTypes.length !== 0
82 | const isPropertyGroupFilterInUse = desiredPropertyGroups.length !== 0
83 | const isPropertyFiltersInUse = isPropertyGroupFilterInUse || isPropertyTypeFilterInUse
84 | const isManagedFilterInUse = desiredManaged !== 'All'
85 | const isBaseFiltersInUse = isPropertyGroupFilterInUse || isPropertyTypeFilterInUse || isManagedFilterInUse
86 | const isPropertyTypeOrGroupMaxRecordInUse = searchParams.propertyTypesMaxResults !== Infinity || searchParams.propertyGroupsMaxResults !== Infinity
87 | const isNeighbourMaxInUse = searchParams.neighboursSearchMaxRecords !== Infinity
88 | const isSalesOrLeaseFiltersInUse = checkSalesOrLeaseFilter(searchParams)
89 | const isSubFilterInUse = filterInUse === 'SalesEvidenceFilter' || filterInUse === 'LeasesEvidenceFilter'
90 |
91 | let maxNumNeighbours = searchParams.neighboursSearchMaxRecords
92 |
93 | const areAnyFiltersBesidesNeighbourFilterEnabled = (isBaseFiltersInUse || isSalesOrLeaseFiltersInUse) && !isNeighbourMaxInUse
94 |
95 | if (areAnyFiltersBesidesNeighbourFilterEnabled) {
96 | maxNumNeighbours = 0
97 | }
98 |
99 | const matchTallies: MatchTallies = {
100 | neighbour: 0,
101 | propertyType: 0,
102 | propertyGroup: 0
103 | }
104 |
105 | unsortedPropertyResults[index].forEach((property: UnprocessedResultsFromCRM) => {
106 | const isUnderNeighbourLimit = matchTallies.neighbour < maxNumNeighbours
107 | const isUnderPropertyTypeLimit = areAnyFiltersBesidesNeighbourFilterEnabled && matchTallies.propertyType < maxResultsForPropertyTypes
108 | const isUnderPropertyGroupLimit = areAnyFiltersBesidesNeighbourFilterEnabled && matchTallies.propertyGroup < maxResultsForPropertyGroups
109 | const canAddBasedOnMaxResults = isUnderNeighbourLimit || isUnderPropertyTypeLimit || isUnderPropertyGroupLimit
110 |
111 | if (canAddBasedOnMaxResults) {
112 | const propertyTypeMatch = isPropertyTypeFilterInUse && isUnderPropertyTypeLimit && matchForPropertyTypes(property, desiredPropertyTypes)
113 | const propertyGroupMatch = isPropertyGroupFilterInUse && isUnderPropertyGroupLimit && matchForPropertyGroups(property, desiredPropertyGroups)
114 | const propertyGroupAndTypeMatch = propertyGroupMatch || propertyTypeMatch
115 |
116 | let canAddBasedOnFilters: boolean = propertyGroupAndTypeMatch
117 | let salesOrLeaseMatch: boolean = false
118 |
119 | if (isSubFilterInUse) {
120 | // N.B. when using sales evidence filter and type or group aren't used
121 | if (!isPropertyGroupFilterInUse && !isPropertyTypeFilterInUse) {
122 | canAddBasedOnFilters = true
123 | }
124 | if (filterInUse === 'SalesEvidenceFilter') {
125 | salesOrLeaseMatch = salesEvidenceFilter(searchParams, property)
126 | canAddBasedOnFilters = canAddBasedOnFilters && salesOrLeaseMatch
127 | }
128 | if (filterInUse === 'LeasesEvidenceFilter') {
129 | salesOrLeaseMatch = leasesEvidenceFilter(searchParams, property)
130 | canAddBasedOnFilters = canAddBasedOnFilters && salesOrLeaseMatch
131 | }
132 | }
133 |
134 | const isManaged = typeof property.Managed === 'string' ? property.Managed === desiredManaged || desiredManaged === 'All' : property.Managed
135 |
136 | let shouldAddProperty
137 | if (isManagedFilterInUse && !isPropertyFiltersInUse && !isSalesOrLeaseFiltersInUse) {
138 | // N.B. used to show all properties that are managed
139 | shouldAddProperty = isManaged
140 | } else if (isManagedFilterInUse && (isPropertyFiltersInUse || isSalesOrLeaseFiltersInUse)) {
141 | if (isSubFilterInUse) {
142 | // N.B. Each base filter (Managed, Type, Group) has to work independently with the sub filters. This is to get managed to work independently with subfilter
143 | shouldAddProperty = isPropertyFiltersInUse ? (isManaged && salesOrLeaseMatch) || canAddBasedOnFilters : isManaged && salesOrLeaseMatch
144 | } else {
145 | // N.B. used to show properties type or group and if they are managed
146 | shouldAddProperty = isManaged && canAddBasedOnFilters
147 | }
148 | } else {
149 | shouldAddProperty = canAddBasedOnFilters
150 | }
151 |
152 | shouldAddProperty = shouldAddProperty || isUnderNeighbourLimit
153 |
154 | let shouldAddMultiSearchProperty: boolean = true
155 | if (isSearchMultiProperties) {
156 | shouldAddMultiSearchProperty = !searchMultiPropertyDupes.includes(property.id)
157 | }
158 |
159 | if (shouldAddProperty && shouldAddMultiSearchProperty) {
160 | // N.B. Owner is not required in leases evidence filter
161 | if (filterInUse !== 'LeasesEvidenceFilter') {
162 | const ownerData = getOwnerData(property)
163 | if (ownerData.length > 0) {
164 | property.owner_details = ownerData
165 | }
166 | }
167 | if (propertyTypeMatch) {
168 | matchTallies.propertyType += 1
169 | }
170 | if (propertyGroupMatch && !propertyTypeMatch) {
171 | matchTallies.propertyGroup += 1
172 | }
173 | // N.B. to correctly add neighbours to the count depending on what filter is used
174 | // and whether managed filter is used. If these filters are used it won't add to
175 | // the neighbours count
176 | let canAddToNeighbourCountBasedOnFilters: boolean | string = canAddBasedOnFilters
177 | if (filterInUse === 'BaseFilter') {
178 | // Managed in base filter
179 | if (isManagedFilterInUse && !isPropertyFiltersInUse) {
180 | canAddToNeighbourCountBasedOnFilters = !canAddBasedOnFilters && !isManaged
181 | } else if (isManagedFilterInUse && isPropertyFiltersInUse) {
182 | canAddToNeighbourCountBasedOnFilters = !canAddBasedOnFilters || !isManaged
183 | } else {
184 | canAddToNeighbourCountBasedOnFilters = !canAddBasedOnFilters
185 | }
186 | } else {
187 | // Managed in sub filters
188 | if (isManagedFilterInUse && !isPropertyFiltersInUse) {
189 | canAddToNeighbourCountBasedOnFilters = !salesOrLeaseMatch || !isManaged
190 | } else if (isManagedFilterInUse && isPropertyFiltersInUse) {
191 | if (isPropertyTypeOrGroupMaxRecordInUse) {
192 | canAddToNeighbourCountBasedOnFilters = filterInUse === 'LeasesEvidenceFilter' ? (!isManaged && !propertyGroupAndTypeMatch) || !salesOrLeaseMatch : !isManaged && !propertyGroupAndTypeMatch
193 | } else {
194 | canAddToNeighbourCountBasedOnFilters = (!isManaged && !propertyGroupAndTypeMatch) || !salesOrLeaseMatch
195 | }
196 | } else {
197 | // sub filter +
198 | if (isPropertyTypeOrGroupMaxRecordInUse) {
199 | if (isNeighbourMaxInUse) {
200 | // group/type + max group/type + neighbour
201 | canAddToNeighbourCountBasedOnFilters = !propertyGroupAndTypeMatch
202 | } else {
203 | // group/type + group/type max
204 | canAddToNeighbourCountBasedOnFilters = !propertyGroupAndTypeMatch || !salesOrLeaseMatch
205 | }
206 | } else {
207 | canAddToNeighbourCountBasedOnFilters = isPropertyFiltersInUse ? !propertyGroupAndTypeMatch || !salesOrLeaseMatch : !propertyGroupAndTypeMatch && !salesOrLeaseMatch
208 | }
209 | }
210 | }
211 |
212 | if (isUnderNeighbourLimit && canAddToNeighbourCountBasedOnFilters) {
213 | matchTallies.neighbour += 1
214 | }
215 | matchedProperties.push(property)
216 |
217 | if (isSearchMultiProperties) {
218 | searchMultiPropertyDupes.push(property.id)
219 | }
220 | }
221 | }
222 | })
223 | })
224 | return matchedProperties
225 | }
226 |
--------------------------------------------------------------------------------
/src/utils/filterUtilityFunctions.ts:
--------------------------------------------------------------------------------
1 | import { UnprocessedResultsFromCRM, MinMaxNumberType, MinMaxDateType, PositionType } from '../types'
2 |
3 | export function genericNumberFilter (filterValues: MinMaxNumberType, filterType: string, property: UnprocessedResultsFromCRM) {
4 | if (typeof property[filterType] === 'number') {
5 | if (filterValues.min !== -1 && filterValues.max === -1) {
6 | return property[filterType] >= filterValues.min
7 | } else if (filterValues.max !== -1 && filterValues.min === -1) {
8 | return property[filterType] <= filterValues.max
9 | } else {
10 | return property[filterType] >= filterValues.min && property[filterType] <= filterValues.max
11 | }
12 | }
13 | return false
14 | }
15 |
16 | export function genericDateFilter (dateSold: MinMaxDateType, filterType: string, property: UnprocessedResultsFromCRM): boolean {
17 | if (typeof dateSold.min !== 'undefined' && typeof dateSold.max !== 'undefined') {
18 | const minDate = dateSold.min.toISOString().split('T')[0]
19 | const maxDate = dateSold.max.toISOString().split('T')[0]
20 | return !!property[filterType] && property[filterType] >= minDate && property[filterType] <= maxDate
21 | } else if (typeof dateSold.min !== 'undefined') {
22 | const minDate = dateSold.min.toISOString().split('T')[0]
23 | return !!property[filterType] && property[filterType] >= minDate
24 | } else if (typeof dateSold.max !== 'undefined') {
25 | const maxDate = dateSold.max.toISOString().split('T')[0]
26 | return !!property[filterType] && property[filterType] <= maxDate
27 | }
28 | return false
29 | }
30 |
31 | function toRad (value: number) {
32 | return value * Math.PI / 180
33 | }
34 |
35 | function calculateDistance (searchAddress: PositionType, propertyLat: number, propertyLng: number) {
36 | const toKilometers = 6371
37 | const dLat = toRad(propertyLat - searchAddress.lat)
38 | const dLon = toRad(propertyLng - searchAddress.lng)
39 | const lat1 = toRad(searchAddress.lat)
40 | const lat2 = toRad(propertyLat)
41 |
42 | const calculation1 = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
43 | Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2)
44 | const calculation2 = 2 * Math.atan2(Math.sqrt(calculation1), Math.sqrt(1 - calculation1))
45 | const distance = toKilometers * calculation2
46 | return distance
47 | }
48 |
49 | export function orderResultsByDistance (matchingResults: UnprocessedResultsFromCRM[], searchAddressPosition: PositionType): UnprocessedResultsFromCRM[] {
50 | const uniqueSearchRecords: string[] = []
51 |
52 | const propertiesWithDistance = matchingResults.reduce((acc: UnprocessedResultsFromCRM[], property) => {
53 | const isDupeId = uniqueSearchRecords.includes(property.id)
54 | if (!isDupeId && (property.Latitude || property.Longitude)) {
55 | // N. B. This is to remove dupes retrieved during the getPageOfRecords function.
56 | uniqueSearchRecords.push(property.id)
57 | const propertyLat = parseFloat(property.Latitude)
58 | const propertyLng = parseFloat(property.Longitude)
59 | const distanceFromSearchAddress = calculateDistance(searchAddressPosition, propertyLat, propertyLng)
60 | property.distance = distanceFromSearchAddress
61 | acc.push(property)
62 | }
63 | return acc
64 | }, [])
65 | // N.B. group properties by distance and sort by ascending order
66 | const resultsOrderedByDistance: UnprocessedResultsFromCRM[] = Object.values(propertiesWithDistance.reduce(groupByDistance, {}))
67 | .map((group: UnprocessedResultsFromCRM[]) => group.sort((property1: UnprocessedResultsFromCRM, property2: UnprocessedResultsFromCRM) => property1.distance - property2.distance))
68 | .sort((property1: UnprocessedResultsFromCRM[], property2: UnprocessedResultsFromCRM[]) => property1[0].distance - property2[0].distance).flat()
69 |
70 | return resultsOrderedByDistance
71 | }
72 |
73 | type GroupByDistanceType = {
74 | [index: number]: UnprocessedResultsFromCRM[]
75 | }
76 |
77 | function groupByDistance (acc: GroupByDistanceType, property: UnprocessedResultsFromCRM) {
78 | const distance = property.distance
79 | if (distance in acc) {
80 | acc[distance].push(property)
81 | } else {
82 | acc[distance] = [property]
83 | }
84 | return acc
85 | }
86 |
--------------------------------------------------------------------------------
/src/utils/getUniqueListBy.ts:
--------------------------------------------------------------------------------
1 | import { UnprocessedResultsFromCRM } from '../types'
2 |
3 | export default function getUniqueListBy (arr: UnprocessedResultsFromCRM[], key: string) {
4 | return [...new Map(arr.map((eachPropertyObject: UnprocessedResultsFromCRM) => [eachPropertyObject[key], eachPropertyObject])).values()]
5 | }
6 |
--------------------------------------------------------------------------------
/src/utils/leasesEvidenceFilter.ts:
--------------------------------------------------------------------------------
1 | // TODO - tsconfig replace CommonJS with esnext
2 | import { LeasesEvidenceFilterParams, UnprocessedResultsFromCRM } from '../types'
3 | import { genericNumberFilter, genericDateFilter } from './filterUtilityFunctions'
4 |
5 | export default function leasesEvidenceFilter (filterParameters: LeasesEvidenceFilterParams, property: UnprocessedResultsFromCRM): boolean {
6 | const {
7 | landArea,
8 | buildArea,
9 | rentGross,
10 | rentPerDollarMeter,
11 | leasedDate,
12 | reviewDate
13 | } = filterParameters
14 | // N.B. to get the sub filters to work as AND logic
15 | let doesPropertyFitCriteria = false
16 |
17 | // Filter field - Land Area m2
18 | const FILTER_NOT_USED_NUM_TYPE = -1
19 | const isLandAreaFilterInUse = landArea.min !== FILTER_NOT_USED_NUM_TYPE || landArea.max !== FILTER_NOT_USED_NUM_TYPE
20 | if (isLandAreaFilterInUse) {
21 | doesPropertyFitCriteria = genericNumberFilter(landArea, 'Land_Area_sqm', property)
22 | }
23 |
24 | // Filter field - Build Area m2
25 | const isBuildAreaFilterInUse = buildArea.min !== FILTER_NOT_USED_NUM_TYPE || buildArea.max !== FILTER_NOT_USED_NUM_TYPE
26 | if (isBuildAreaFilterInUse) {
27 | doesPropertyFitCriteria = doesPropertyFitCriteria && genericNumberFilter(buildArea, 'Build_Area_sqm', property)
28 | }
29 |
30 | // Filter field - Rent Gross (Current Market Rental)
31 | const isRentGrossFilterInUse = rentGross.min !== FILTER_NOT_USED_NUM_TYPE || rentGross.max !== FILTER_NOT_USED_NUM_TYPE
32 | if (isRentGrossFilterInUse) {
33 | doesPropertyFitCriteria = doesPropertyFitCriteria && genericNumberFilter(rentGross, 'Current_AI_New_Market_Rental', property)
34 | }
35 |
36 | // Filter field - Rent $/m2
37 | const isRentPerDollarFilterInUse = rentPerDollarMeter.min !== FILTER_NOT_USED_NUM_TYPE || rentPerDollarMeter.max !== FILTER_NOT_USED_NUM_TYPE
38 | if (isRentPerDollarFilterInUse) {
39 | doesPropertyFitCriteria = doesPropertyFitCriteria && genericNumberFilter(rentPerDollarMeter, 'Current_Per_Sqm', property)
40 | }
41 |
42 | // Filter field - Leases Date
43 | const isLeaseDateFilterInUse = leasedDate.min !== leasedDate.max
44 | if (isLeaseDateFilterInUse) {
45 | doesPropertyFitCriteria = doesPropertyFitCriteria && genericDateFilter(leasedDate, 'Rent_Start_Date', property)
46 | }
47 |
48 | // Filter field - Review Date
49 | const isReviewDateFilterInUse = reviewDate.min !== reviewDate.max
50 | if (isReviewDateFilterInUse) {
51 | doesPropertyFitCriteria = doesPropertyFitCriteria && genericDateFilter(reviewDate, 'Next_MR_Start_Date', property)
52 | }
53 |
54 | return doesPropertyFitCriteria
55 | }
56 |
--------------------------------------------------------------------------------
/src/utils/salesEvidenceFilter.ts:
--------------------------------------------------------------------------------
1 | import { SalesEvidenceFilterParams, SaleTypeEnum, UnprocessedResultsFromCRM } from '../types'
2 | import { genericDateFilter, genericNumberFilter } from './filterUtilityFunctions'
3 |
4 | function saleTypeFilter (saleTypes: SaleTypeEnum[], property: UnprocessedResultsFromCRM): boolean {
5 | return saleTypes.some((saleType: SaleTypeEnum) => {
6 | return property.Sale_Type.includes(saleType)
7 | })
8 | }
9 |
10 | export default function salesEvidenceFilter (filterParameters: SalesEvidenceFilterParams, property: UnprocessedResultsFromCRM): boolean {
11 | const {
12 | landArea,
13 | buildArea,
14 | salePrice,
15 | saleType,
16 | dateSold
17 | } = filterParameters
18 | // N.B. to get the sub filters to work as AND logic
19 | let doesPropertyFitCriteria = false
20 |
21 | // Filter field - Land Area m2
22 | const BLANK_FILTER_VALUE = -1
23 | const isLandAreaFilterInUse = landArea.min !== BLANK_FILTER_VALUE || landArea.max !== BLANK_FILTER_VALUE
24 | if (isLandAreaFilterInUse) {
25 | doesPropertyFitCriteria = genericNumberFilter(landArea, 'Land_Area_sqm', property)
26 | }
27 |
28 | // Filter field - Build Area m2
29 | const isBuildAreaFilterInUse = buildArea.min !== BLANK_FILTER_VALUE || buildArea.max !== BLANK_FILTER_VALUE
30 | if (isBuildAreaFilterInUse) {
31 | doesPropertyFitCriteria = doesPropertyFitCriteria && genericNumberFilter(buildArea, 'Build_Area_sqm', property)
32 | }
33 |
34 | // Filter field - Sale Price $
35 | const isSalePriceFilterInUse = salePrice.min !== BLANK_FILTER_VALUE || salePrice.max !== BLANK_FILTER_VALUE
36 | if (isSalePriceFilterInUse) {
37 | doesPropertyFitCriteria = doesPropertyFitCriteria && genericNumberFilter(salePrice, 'Sale_Price', property)
38 | }
39 |
40 | // Filter field - Sale Type
41 | const isSaleTypeFilterInUse = saleType.length !== 0
42 | if (isSaleTypeFilterInUse) {
43 | doesPropertyFitCriteria = doesPropertyFitCriteria && saleTypeFilter(saleType, property)
44 | }
45 |
46 | // Filter field - Date Sold
47 | const isDateSoldFilterInUse = dateSold.min !== dateSold.max
48 | if (isDateSoldFilterInUse) {
49 | doesPropertyFitCriteria = doesPropertyFitCriteria && genericDateFilter(dateSold, 'Sale_Date', property)
50 | }
51 |
52 | return doesPropertyFitCriteria
53 | }
54 |
--------------------------------------------------------------------------------
/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import localforage from 'localforage'
2 |
3 | export function formatDate (date: string): string {
4 | if (date) {
5 | const splitDate = date.split('-')
6 | return `${splitDate[2]}/${splitDate[1]}/${splitDate[0]}`
7 | }
8 | return ''
9 | }
10 |
11 | export function convertToCurrency (dollarAmount: number): string {
12 | if (dollarAmount) {
13 | const convertToCurrency = dollarAmount.toLocaleString('en-AU', {
14 | style: 'currency',
15 | currency: 'AUD',
16 | minimumFractionDigits: 0
17 | })
18 | return convertToCurrency
19 | }
20 | return ''
21 | }
22 |
23 | export function clearCacheAndReload () {
24 | void localforage.clear()
25 |
26 | setTimeout(() => {
27 | window.location.reload()
28 | }, 100)
29 | }
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "esnext",
4 | "target": "esnext",
5 | "moduleResolution": "node",
6 | "removeComments": true,
7 | "sourceMap": true,
8 | "allowJs": false,
9 | "esModuleInterop": true,
10 | "isolatedModules": true,
11 | "jsx": "preserve",
12 | "lib": [
13 | "dom",
14 | "es2017",
15 | "es2019",
16 | "es5",
17 | "es6",
18 | "dom.iterable",
19 | "es2020.string"
20 | ],
21 | "noEmit": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "skipLibCheck": true,
24 | "forceConsistentCasingInFileNames": true,
25 | "resolveJsonModule": true,
26 | "strict": true,
27 | "noImplicitAny": true,
28 | "noImplicitThis": true,
29 | "alwaysStrict": true,
30 | "strictBindCallApply": true,
31 | "strictFunctionTypes": true,
32 | "strictNullChecks": true,
33 | "strictPropertyInitialization": true,
34 | "allowSyntheticDefaultImports": true
35 | },
36 | "exclude": [
37 | "node_modules",
38 | "cypress"
39 | ],
40 | "include": [
41 | "next-env.d.ts",
42 | "**/*.ts",
43 | "**/*.tsx",
44 | "**/*.js",
45 | "**/*.jsx"
46 | ]
47 | }
48 |
--------------------------------------------------------------------------------
/update-app-html.js:
--------------------------------------------------------------------------------
1 | const PLACEHOLDER_FOR_SCRIPTS = /.*/s
2 | const PLACEHOLDER_FOR_CSS = /.*/s
3 | const SCRIPT_REGEX = /<\/div>(.*)<\/body>/
4 | const CSS_REGEX = /<\/title>(.*)<\/head>/
5 |
6 | const builtFilePath = './build/index.html'
7 | const appFilePath = './app/index.html'
8 | const fs = require('fs')
9 |
10 | const htmlData = fs.readFileSync(builtFilePath).toString()
11 | const appHTML = fs.readFileSync(appFilePath).toString()
12 |
13 | const scriptMatches = htmlData.match(SCRIPT_REGEX)
14 | const processedScript = scriptMatches[1].replace(/\/static/g, 'static')
15 |
16 | const cssMatches = htmlData.match(CSS_REGEX)
17 | const processedCSS = cssMatches[1].replace(/\/static/g, 'static')
18 |
19 | let processedAppHTML = appHTML.replace(
20 | PLACEHOLDER_FOR_SCRIPTS,
21 | `
22 |
23 | ${processedScript}
24 |
25 | `
26 | )
27 |
28 | processedAppHTML = processedAppHTML.replace(
29 | PLACEHOLDER_FOR_CSS,
30 | `
31 |
32 | ${processedCSS}
33 |
34 | `
35 | )
36 |
37 | // remove empty lines
38 | processedAppHTML = processedAppHTML.replace(/^[\s]+$/gm, '').replace(/[\n]+/g, '\n')
39 |
40 | fs.writeFileSync(appFilePath, processedAppHTML)
41 | const filename = `index-v${(Math.random() * 100).toFixed(0)}.html`
42 | const newAppHTMLPath = `./app/${filename}`
43 | fs.writeFileSync(newAppHTMLPath, processedAppHTML)
44 | console.log(`Widget path: /${filename}`)
45 |
--------------------------------------------------------------------------------