├── .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 |
67 | 68 | 69 |
70 | )} 71 | {filterInUse === 'LeasesEvidenceFilter' && ( 72 |
73 | 74 | 75 |
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 |
20 |
21 | 43 |
44 |
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 |
18 | 38 | 39 | 59 | 60 | 80 | 100 | 120 | 121 | 141 |
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 | 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 | 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 |
20 | 34 | 35 | 53 | 54 | 69 | 83 | { 84 | props.changeSearchParameters({ 85 | ...props.searchParameters, 86 | saleType: newSaleTypes 87 | }) 88 | }} /> 89 | 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 |
15 | 25 | { 26 | props.changeSearchParameters({ 27 | ...props.searchParameters, 28 | propertyTypes: newPropertyTypes 29 | }) 30 | }} /> 31 | 46 | 61 | { 62 | props.changeSearchParameters({ 63 | ...props.searchParameters, 64 | propertyGroups: newPropertyGroups 65 | }) 66 | }} /> 67 | 82 | 83 | { 84 | props.changeSearchParameters({ 85 | ...props.searchParameters, 86 | managed: isManaged 87 | }) 88 | }} /> 89 | 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 |