1const budget = { 2 api_base: portalFormSettings.base_url, 3 ajax_started: false, 4 5 data: { 6 uuid: window.uuidv4(), 7 user_identifier: null, 8 client_ip: portalFormSettings.client_ip, 9 source: portalFormSettings.source, 10 11 title: null, 12 first_name: null, 13 last_name: null, 14 email: null, 15 telephone: null, 16 telephone_secondary: null, 17 best_time_to_contact: null, 18 19 from: { 20 loqate_id: null, 21 address_1: null, 22 address_2: null, 23 locality: null, 24 state: null, 25 postcode: null, 26 country: 'gb', 27 }, 28 29 to: { 30 loqate_id: null, 31 address_1: null, 32 address_2: null, 33 locality: null, 34 state: null, 35 postcode: null, 36 country: 'gb', 37 }, 38 39 to_unknown: false, 40 to_approx_location: null, 41 estimated_removal_date: null, 42 estimated_removal_date_confirmed: null, 43 comments: null, 44 45 from_property: { 46 bedrooms: null, 47 is_flat: null, 48 floor: null, 49 has_lift: null, 50 }, 51 52 to_property: { 53 bedrooms: null, 54 is_flat: null, 55 floor: null, 56 has_lift: null, 57 }, 58 59 services: { 60 packing: null, 61 storage: null, 62 storage_weeks: null, 63 }, 64 65 quote_method: null, 66 slot_date: null, 67 slot_time: null, 68 slot_id: null, 69 reservation_id: null, 70 71 video_attendee_name: null, 72 video_attendee_contact_no: null, 73 }, 74 step: 1, 75 steps: 4, 76 step_is_valid: true, 77 budget: null, 78 distance: null, 79 slot_picker_el: null, 80 slots_loaded: false, 81 slot_date_invalid: false, 82 slots: [], 83 time_slots: [], 84 reserved_slot: {}, 85 error: null, 86 87 init() { 88 // this.setTestData(); 89 90 window.dataLayer = window.dataLayer || []; 91 92 // State management 93 window.history.replaceState( 94 { step: this.step }, 95 '', 96 '#step' + this.step 97 ); 98 window.addEventListener('popstate', (event) => { 99 if (event.state.step) { 100 this.step = event.state.step; 101 } 102 }); 103 104 this.$refs.step1.addEventListener('submit', function (e) { 105 e.preventDefault(); 106 }); 107 108 this.$refs.step2.addEventListener('submit', function (e) { 109 e.preventDefault(); 110 }); 111 112 this.$refs.step3.addEventListener('submit', function (e) { 113 e.preventDefault(); 114 }); 115 116 this.$refs.step4.addEventListener('submit', function (e) { 117 e.preventDefault(); 118 }); 119 120 window.addEventListener('budgetFormStepComplete', function (event) { 121 window.dataLayer.push({ 122 event: 'formSubmission', 123 formProgress: 'form_step_' + event.detail.step + '_completed', 124 formName: 'cost_calculator', 125 }); 126 }); 127 128 window.addEventListener('budgetFormSubmitted', function () { 129 window.dataLayer.push({ 130 event: 'formSubmission', 131 formProgress: 'form_completed', 132 formName: 'cost_calculator', 133 }); 134 }); 135 136 this.$nextTick(() => { 137 this.initLoqate(); 138 }); 139 140 // Clear any error classes after change of state 141 const inputs = document.querySelectorAll('input, select, textarea'); 142 inputs.forEach((input) => { 143 input.addEventListener('change', () => { 144 const inputsToClear = document.querySelectorAll( 145 'input, select, textarea' 146 ); 147 inputsToClear.forEach((inputToClear) => { 148 inputToClear.classList.remove('tw-ring-2'); 149 inputToClear.classList.remove('tw-ring-red-400'); 150 inputToClear.setCustomValidity(''); 151 }); 152 this.error = ''; 153 }); 154 }); 155 }, 156 157 initLoqate() { 158 const options = { 159 key: portalFormSettings.loqate_api_key, 160 countries: { 161 codesList: 'GB', 162 }, 163 setCountryByIp: false 164 }; 165 166 const fromFields = [ 167 { 168 element: 'from.address_1', 169 field: 'Line1', 170 mode: pca.fieldMode.POPULATE | pca.fieldMode.SEARCH, 171 }, 172 { 173 element: 'from.address_2', 174 field: 'Line2', 175 mode: pca.fieldMode.POPULATE, 176 }, 177 { 178 element: 'from.locality', 179 field: 'City', 180 mode: pca.fieldMode.POPULATE, 181 }, 182 { 183 element: 'from.state', 184 field: 'Province', 185 mode: pca.fieldMode.POPULATE, 186 }, 187 { 188 element: 'from.postcode', 189 field: 'PostalCode', 190 mode: pca.fieldMode.POPULATE, 191 }, 192 { 193 element: 'from.country', 194 field: 'CountryCode', 195 mode: pca.fieldMode.COUNTRY | pca.fieldMode.POPULATE, 196 }, 197 ]; 198 199 const toFields = [ 200 { 201 element: 'to.address_1', 202 field: 'Line1', 203 mode: pca.fieldMode.POPULATE | pca.fieldMode.SEARCH, 204 }, 205 { 206 element: 'to.address_2', 207 field: 'Line2', 208 mode: pca.fieldMode.POPULATE, 209 }, 210 { 211 element: 'to.locality', 212 field: 'City', 213 mode: pca.fieldMode.POPULATE, 214 }, 215 { 216 element: 'to.state', 217 field: 'Province', 218 mode: pca.fieldMode.POPULATE, 219 }, 220 { 221 element: 'to.postcode', 222 field: 'PostalCode', 223 mode: pca.fieldMode.POPULATE, 224 }, 225 { 226 element: 'to.country', 227 field: 'CountryCode', 228 mode: pca.fieldMode.COUNTRY | pca.fieldMode.POPULATE, 229 }, 230 ]; 231 232 const fromControl = new pca.Address(fromFields, options); 233 const toControl = new pca.Address(toFields, options); 234 235 fromFields.forEach((field) => { 236 const fieldElement = document.getElementById(field.element); 237 if (!fieldElement) { 238 return; 239 } 240 241 fieldElement.addEventListener( 242 'change', 243 () => (this.data.from.loqate_id = null) 244 ); 245 }); 246 247 toFields.forEach((field) => { 248 const fieldElement = document.getElementById(field.element); 249 if (!fieldElement) { 250 return; 251 } 252 253 fieldElement.addEventListener( 254 'change', 255 () => (this.data.to.loqate_id = null) 256 ); 257 }); 258 259 fromControl.listen('populate', (address) => { 260 this.populateAddress(this.data.from, address); 261 }); 262 263 toControl.listen('populate', (address) => { 264 this.populateAddress(this.data.to, address); 265 }); 266 }, 267 268 populateAddress(address, loqateData) { 269 let line1 = loqateData.Line1; 270 let line2 = loqateData.Line2; 271 if (loqateData.Line3 || loqateData.Line4 || loqateData.Line5) { 272 line1 += ', ' + loqateData.Line2; 273 line2 = [loqateData.Line3, loqateData.Line4, loqateData.Line5] 274 .filter((value) => value) 275 .join(', '); 276 } 277 278 address.loqate_id = loqateData.Id; 279 address.address_1 = line1; 280 address.address_2 = line2; 281 address.locality = loqateData.City; 282 address.state = loqateData.Province; 283 address.postcode = loqateData.PostalCode; 284 }, 285 286 getStepForm($refs) { 287 if (!$refs) { 288 $refs = this.$refs; 289 } 290 291 /** 292 * @member FormElement form 293 */ 294 let form; 295 switch (this.step) { 296 case 1: 297 form = $refs.step1; 298 break; 299 case 2: 300 form = $refs.step2; 301 break; 302 case 3: 303 form = $refs.step3; 304 break; 305 case 4: 306 form = $refs.step4; 307 break; 308 } 309 310 return form; 311 }, 312 313 async prevStep() { 314 if (this.ajax_started === true) { 315 return; 316 } 317 318 this.error = null; 319 this.step = Math.max(this.step - 1, 1); 320 this.validateStep(this.$refs, true); 321 322 await this.$nextTick; 323
324 this.getStepForm().scrollIntoView({ 325 behavior: 'smooth', 326 inline: 'start', 327 }); 328 }, 329 330 async nextStep() { 331 if (this.ajax_started === true) { 332 return; 333 } 334 335 this.error = null; 336 this.validateStep(this.$refs, true); 337 338 if (!this.step_is_valid) { 339 return; 340 } 341 342 const event = new CustomEvent('budgetFormStepComplete', { 343 detail: { 344 step: this.step, 345 }, 346 }); 347 window.dispatchEvent(event); 348 349 this.step = Math.min(this.step + 1, this.steps); 350 window.history.pushState({ step: this.step }, '', '#step' + this.step); 351 352 if (this.step === 3) { 353 await this.getQuote(); 354 } 355 356 if (this.step === 4) { 357 await this.submitLead(); 358 } 359 360 await this.$nextTick; 361 362 this.getStepForm().scrollIntoView({ 363 behavior: 'smooth', 364 inline: 'start', 365 }); 366 }, 367 368 setUserIdentifier(identifier) { 369 this.data.user_identifier = identifier; 370 }, 371 372 buildBudgetRequestData() { 373 const data = this.data; 374 375 data.form = 'ONLINE_BUDGET_FORM'; 376 data.state = 'DRAFT'; 377 378 return data; 379 }, 380 381 buildLeadRequestData() { 382 const data = this.data; 383 384 data.form = 'ONLINE_BUDGET_FORM'; 385 data.state = 'COMPLETE'; 386 387 return data; 388 }, 389 390 setTestData() { 391 this.data.title = 'Mr'; 392 this.data.first_name = 'Matt'; 393 this.data.last_name = 'Jones'; 394 this.data.email = '[email protected]'; 395 this.data.telephone = '01568612460'; 396 this.data.telephone_secondary = '07815192734'; 397 this.data.best_time_to_contact = 'Evening'; 398 399 this.data.from.address_1 = '3 Arrow Close'; 400 this.data.from.address_2 = 'Leominster Enterprise Park'; 401 this.data.from.locality = 'Leominster'; 402 this.data.from.state = 'Herefordshire'; 403 this.data.from.postcode = 'HR6 0LD'; 404 this.data.from.country = 'gb'; 405 406 this.data.to.address_1 = 'Skylon Court'; 407 this.data.to.address_2 = 'Coldnose Road'; 408 this.data.to.locality = 'Hereford'; 409 this.data.to.state = 'Herefordshire'; 410 this.data.to.postcode = 'HR2 6JS'; 411 this.data.to.country = 'gb'; 412 413 this.data.to_unknown = false; 414 this.data.estimated_removal_date = '2022-06-01'; 415 this.data.estimated_removal_date_confirmed = true; 416 this.data.comments = 'Please be careful with the grand piano.'; 417 418 this.data.from_property.bedrooms = '5'; 419 this.data.from_property.is_flat = false; 420 this.data.from_property.floor = null; 421 this.data.from_property.has_lift = null; 422 423 this.data.to_property.bedrooms = '3'; 424 this.data.to_property.is_flat = true; 425 this.data.to_property.floor = 'ground'; 426 this.data.to_property.has_lift = true; 427 428 this.data.quote_method = 'video'; 429 430 this.data.services.packing = true; 431 this.data.services.storage = true; 432 this.data.services.storage_weeks = 12; 433 434 this.data.video_attendee_name = 'Matt Jones'; 435 this.data.video_attendee_contact_no = '01568612460'; 436 }, 437 438 async handleRemoteErrors(error, requestType) { 439 // Something went wrong with getting the budget, put back to step 3 440 this.errors = error.response.data.errors; 441 442 if (error.response.status === 422) { 443 this.step = this.getErrorStep(error.response.data.errors); 444 await this.$nextTick(); 445 this.addCustomErrors(error.response.data.errors); 446 this.validateStep(this.$refs, true); 447 this.error = 448 'Some fields failed validation, please check for any errors above and contact us if you continue to have difficulty'; 449 return; 450 } 451 452 if (requestType === 'getAvailableSlots') { 453 this.step = 3; 454 this.addCustomErrors({ 455 quote_method: [ 456 "Unable to load calendar for video slots. Please select 'callback' for help from our team.", 457 ], 458 }); 459 this.validateStep(this.$refs, true); 460 this.error = 461 "Unable to load calendar for video slots. Please select 'callback' for help from our team."; 462 return; 463 } 464 465 if (requestType === 'submitLead') { 466 this.step = 3; 467 this.validateStep(this.$refs, true); 468 this.error = 'Unable to submit form. Please contact us.'; 469 return; 470 } 471 472 if (error.response.status === 404) { 473 switch (requestType) { 474 case 'reserveSlot': 475 this.addCustomErrors({ 476 slot_time: [ 477 'Slot no longer available, please choose another.', 478 ], 479 }); 480 this.validateStep(this.$refs, true); 481 this.error = 482 'Unable to reserve video slot. Please choose another.'; 483 break; 484 case 'cancelSlotReservation': 485 this.validateStep(this.$refs, true); 486 this.error = 487 'Unable to cancel video survey reservation. Please contact us.'; 488 break; 489 } 490 } 491 }, 492 493 initDatePicker($el) { 494 const currentDate = new Date(); 495 let bankHolidays = []; 496 497 axios 498 .get(`https://www.gov.uk/bank-holidays.json`, { 499 headers: { 500 Accept: 'application/json', 501 }, 502 }) 503 .then((response) => { 504 bankHolidays = response.data['england-and-wales'].events.map( 505 (item) => item.date 506 ); 507 }) 508 .finally(() => { 509 new easepick.create({ 510 element: $el, 511 css: [ 512 'https://cdn.jsdelivr.net/npm/@easepick/[email protected]/dist/index.css', 513 'https://cdn.jsdelivr.net/npm/@easepick/[email protected]/dist/index.css', 514 portalFormSettings.css_path + 'easepick.css', 515 ], 516 zIndex: 10, 517 inline: true, 518 readonly: false, 519 plugins: [LockPlugin], 520 LockPlugin: { 521 minDate: currentDate.setDate(currentDate.getDate() + 1), 522 filter(date) { 523 if (date.getDay() === 0) { 524 return true; 525 } 526 527 if ( 528 bankHolidays.includes( 529 date.format('YYYY-MM-DD') 530 ) 531 ) { 532 return true; 533 } 534 }, 535 }, 536 setup: (picker) => { 537 picker.on('select', (e) => { 538 const { date } = e.detail; 539 this.data.estimated_removal_date = 540 date.format('YYYY-MM-DD'); 541 }); 542 }, 543 }); 544 }); 545 }, 546 547 initSlotPicker($el) { 548 this.slot_picker_el = $el; 549 550 this.getAvailableSlots(); 551 552 setInterval(this.getAvailableSlots, 1000 * 60 * 30); 553 }, 554 555 async getQuote() { 556 this.ajax_started = true; 557 return axios 558 .post(`${this.api_base}budget`, this.buildBudgetRequestData(), { 559 headers: { 560 'Content-Type': 'application/json', 561 Accept: 'application/json', 562 }, 563 }) 564 .then((response) => { 565 this.budget = new Intl.NumberFormat('en-GB').format( 566 response.data.budget 567 ); 568 this.distance = response.data.distance; 569 const event = new CustomEvent('budgetShown', { 570 detail: { budget: this.budget }, 571 }); 572 window.dispatchEvent(event); 573 this.ajax_started = false;
574 }) 575 .catch(async (error) => { 576 await this.handleRemoteErrors(error, 'getQuote'); 577 this.ajax_started = false; 578 }); 579 }, 580 581 getAvailableSlots() { 582 this.slots_loaded = false; 583 this.time_slots = []; 584 this.ajax_started = true; 585 586 axios 587 .get(`${this.api_base}surveys/slots`, { 588 headers: { 589 Accept: 'application/json', 590 }, 591 }) 592 .then((response) => this.processAvailableSlots(response)) 593 .catch(async (error) => { 594 this.ajax_started = false; 595 await this.handleRemoteErrors(error, 'getAvailableSlots'); 596 }); 597 }, 598 599 processAvailableSlots(response) { 600 const currentDate = new Date(); 601 this.slots = response.data; 602 this.slots_loaded = true; 603 this.ajax_started = false; 604 605 const allowedDates = this.slots.map((slot) => { 606 return slot.date; 607 }); 608 609 new easepick.create({ 610 element: this.slot_picker_el, 611 css: [ 612 'https://cdn.jsdelivr.net/npm/@easepick/[email protected]/dist/index.css', 613 'https://cdn.jsdelivr.net/npm/@easepick/[email protected]/dist/index.css', 614 portalFormSettings.css_path + 'easepick.css', 615 ], 616 zIndex: 10, 617 inline: true, 618 readonly: false, 619 plugins: [LockPlugin], 620 LockPlugin: { 621 minDate: currentDate.setDate(currentDate.getDate() + 1), 622 filter(date) { 623 return !allowedDates.includes(date.format('YYYY-MM-DD')); 624 }, 625 }, 626 setup: (picker) => { 627 picker.on('preselect', () => { 628 this.time_slots = []; 629 }); 630 picker.on('select', async (e) => { 631 const { date } = e.detail; 632 this.data.slot_date = date.format('YYYY-MM-DD'); 633 this.data.slot_time = null; 634 635 await this.cancelSlotReservation(); 636 637 this.time_slots = this.slots 638 .filter((slot) => { 639 return slot.date === this.data.slot_date; 640 }) 641 .map((slot) => { 642 return slot.startTime; 643 }); 644 }); 645 }, 646 }); 647 }, 648 649 async changeReservedSlot($el) { 650 $el.classList.remove('tw-ring-2'); 651 $el.classList.remove('tw-ring-red-400'); 652 $el.setCustomValidity(''); 653 this.error = ''; 654 655 if (this.data.reservation_id) { 656 await this.cancelSlotReservation(); 657 } 658 659 if (this.data.slot_time && this.data.slot_time !== 'null') { 660 await this.reserveSlot(); 661 } 662 }, 663 664 async cancelSlotReservation() { 665 if (!this.data.reservation_id) { 666 this.reserved_slot = null; 667 return; 668 } 669 670 this.ajax_started = true; 671 672 return axios 673 .post( 674 `${this.api_base}surveys/cancel`, 675 { 676 reservation_id: this.data.reservation_id, 677 }, 678 { 679 headers: { 680 Accept: 'application/json', 681 }, 682 } 683 ) 684 .then(() => { 685 this.reserved_slot = null; 686 this.data.reservation_id = null; 687 this.ajax_started = false; 688 }) 689 .catch(async () => { 690 // await this.handleRemoteErrors(error, 'cancelSlotReservation'); 691 this.ajax_started = false; 692 }); 693 }, 694 695 async reserveSlot() { 696 this.ajax_started = true; 697 698 return axios 699 .post( 700 `${this.api_base}surveys/reserve`, 701 { 702 date: this.data.slot_date, 703 time: this.data.slot_time, 704 }, 705 { 706 headers: { 707 Accept: 'application/json', 708 }, 709 } 710 ) 711 .then((response) => { 712 this.reserved_slot = response.data; 713 this.data.slot_id = response.data.slotId; 714 this.data.reservation_id = response.data.reservationId; 715 this.ajax_started = false; 716 }) 717 .catch(async (error) => { 718 await this.handleRemoteErrors(error, 'reserveSlot'); 719 this.ajax_started = false; 720 }); 721 }, 722 723 submitLead() { 724 this.ajax_started = true; 725 return axios 726 .post(`${this.api_base}lead/move`, this.buildLeadRequestData(), { 727 headers: { 728 Accept: 'application/json', 729 }, 730 }) 731 .then(() => { 732 this.ajax_started = false; 733 const event = new CustomEvent('budgetFormSubmitted'); 734 window.dispatchEvent(event); 735 }) 736 .catch(async (error) => { 737 await this.handleRemoteErrors(error, 'submitLead'); 738 this.ajax_started = false; 739 }); 740 }, 741 742 validateStep($refs, showErrors = false) { 743 const form = this.getStepForm($refs); 744 this.step_is_valid = form.checkValidity(); 745 this.slot_date_invalid = false; 746 747 if (showErrors) { 748 form.reportValidity(); 749 750 // If showing errors, then highlight error fields 751 const inputs = form.querySelectorAll('input, select, textarea');
752 inputs.forEach((input) => { 753 if (input.name === 'slot_date') { 754 this.slot_date_invalid = true; 755 } 756 if (!input.checkValidity()) { 757 input.classList.add('tw-ring-2'); 758 input.classList.add('tw-ring-red-400'); 759 } else { 760 input.classList.remove('tw-ring-2'); 761 input.classList.remove('tw-ring-red-400'); 762 } 763 }); 764 } 765 }, 766 767 timeToDisplayTime(time) { 768 const utcDate = moment.tz( 769 this.data.slot_date + 'T' + time, 770 'Europe/London' 771 ); 772 return utcDate.format('dddd, MMMM Do YYYY - h:mm a') + ' (UK Time)'; 773 }, 774 775 getErrorStep(errors) { 776 if (typeof errors !== 'undefined') { 777 const firstError = Object.keys(errors)[0]; 778 const fieldName = firstError.replace('.', '\\.'); 779 const field = document.querySelector('[name=' + fieldName + ']'); 780 if (field) { 781 const step = field.closest('form'); 782 if (step) { 783 return parseInt(step.dataset.step); 784 } 785 } 786 } 787 return Math.max(this.step - 1, 1); 788 }, 789 790 addCustomErrors(errors) { 791 if (typeof errors !== 'undefined') { 792 Object.keys(errors).forEach((field) => { 793 const fieldName = field.replace('.', '\\.'); 794 const fieldElement = document.querySelector( 795 '[name=' + fieldName + ']' 796 ); 797 if (fieldElement && field) { 798 fieldElement.setCustomValidity(errors[field][0]); 799 } 800 }); 801 } 802 }, 803 804 amendQuote() { 805 this.data.uuid = window.uuidv4(); 806 this.step = 1; 807 }, 808 809 formatAddress(address, lookForUnknown = false) { 810 if (lookForUnknown && this.data.to_unknown) { 811 return this.data.to_approx_location; 812 } 813 return `${address.address_1}, ${ 814 address.address_2 ? `${address.address_2},` : '' 815 } ${address.locality}, ${address.state ? `${address.state},` : ''} ${ 816 address.postcode 817 }`; 818 }, 819};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.