1/** 2 * Cornish Stays Availability Search 3 * 4 * Results mode: 5 * - all active mapped properties show on initial load 6 * - filters update results via AJAX 7 * - valid dates trigger cached availability/pricing 8 * - map stays hidden until Show Map is clicked 9 * - map markers update with AJAX results 10 * - card hover focuses the matching map marker when map is open 11 * 12 * Home mode: 13 * - keeps Search Properties button 14 * - submits selections to /properties/ 15 */ 16const CSAvailabilitySuperControlCart = { 17 ready: false, 18 pending: null, 19 pendingTimer: null 20}; 21 22 23window.addEventListener( 24 "message", 25 function (event) { 26 27 let data = 28 event 29 ? event.data 30 : null; 31 32 if (typeof data === "string") { 33 try { 34 data = JSON.parse(data); 35 } catch (error) { 36 return; 37 } 38 } 39 40 if ( 41 !data 42 || 43 typeof data !== "object" 44 || 45 !data.action 46 ) { 47 return; 48 } 49 50 if (data.action === "pickerLoaded") { 51 52 CSAvailabilitySuperControlCart.ready = 53 true; 54 55 if ( 56 CSAvailabilitySuperControlCart.pending 57 ) { 58 59 const pending = 60 CSAvailabilitySuperControlCart.pending; 61 62 CSAvailabilitySuperControlCart.pending = 63 null; 64 65 clearTimeout( 66 CSAvailabilitySuperControlCart.pendingTimer 67 ); 68 69 window.postMessage( 70 pending.details, 71 "*" 72 ); 73 74 restoreBookingButton( 75 pending.button 76 ); 77 } 78 } 79 80 if (data.action === "closePopup") { 81 82 document 83 .querySelectorAll( 84 ".cs-availability-card__book.is-booking" 85 ) 86 .forEach( 87 function (button) { 88 restoreBookingButton( 89 button 90 ); 91 } 92 ); 93 } 94 } 95); 96 97 98function restoreBookingButton(button) { 99 100 if (!button) { 101 return; 102 } 103 104 button.disabled = false; 105 button.classList.remove( 106 "is-booking" 107 ); 108 109 button.textContent = 110 button.getAttribute( 111 "data-default-label" 112 ) 113 || 114 "Book Now"; 115} 116 117 118function initialiseCSAvailabilitySearch() { 119 120 document 121 .querySelectorAll(".cs-availability-search") 122 .forEach(function (root) { 123 124 if (root.dataset.csAvailabilityInitialised === "true") { 125 return; 126 } 127 128 root.dataset.csAvailabilityInitialised = "true"; 129 initialiseSearch(root); 130 }); 131 132 133 function initialiseSearch(root) { 134 135 const mode = 136 root.getAttribute("data-mode") 137 || 138 "results"; 139 140 const state = { 141 debounceTimer: null, 142 abortController: null, 143 map: null, 144 markerLayer: null, 145 markersByPostId: new Map(), 146 markerData: readInitialMarkerData(root), 147 mapInitialised: false 148 }; 149 150 root._csAvailabilityState = state; 151 152 if (mode === "homepage-results") { 153 bindBookingCart(root, null); 154 bindViewPropertyButtons(root); 155 bindPremiumHomepageResultActions(root); 156 bindAvailabilitySorting(root); 157 bindMapToggle(root, state); 158 bindCardInteractions(root, state); 159 bindPremiumHomepageResultsReset(root, state); 160 initPropertyExcerptCrop(root); 161 return; 162 } 163 164 const form = 165 root.querySelector( 166 ".cs-availability-search__form" 167 ); 168 169 if (!form) { 170 return; 171 } 172 173 if (mode === "homepage") { 174 175 [ 176 root.closest(".et_pb_module"), 177 root.closest(".et_pb_column"), 178 root.closest(".et_pb_row"), 179 root.closest(".et_pb_section") 180 ].forEach(function (host) { 181 if (host) { 182 host.classList.add("cs-availability-layer-host"); 183 } 184 }); 185 186 initialisePremiumHomepageControls( 187 root, 188 form 189 ); 190 191 bindPremiumHomepageSearch( 192 root, 193 form 194 ); 195 196 return; 197 } 198 199 initialiseFormControls( 200 root, 201 form 202 ); 203 204 if (mode !== "results") { 205 return; 206 } 207 208 bindBookingCart( 209 root, 210 form 211 ); 212 213 bindViewPropertyButtons(root); 214 215 bindLiveFiltering( 216 root, 217 form, 218 state 219 ); 220 221 bindMapToggle( 222 root, 223 state 224 ); 225 226 bindCardInteractions( 227 root, 228 state 229 ); 230 231 initPropertyExcerptCrop(root); 232 } 233 234 235 /* ===================================================== 236 Form controls 237 ===================================================== */ 238 239 function initialiseFormControls(root, form) { 240 241 const area = 242 form.querySelector( 243 'select[name="cs_area"]' 244 ); 245 246 const town = 247 form.querySelector( 248 'select[name="cs_town"]' 249 ); 250 251 const arrival = 252 form.querySelector( 253 'input[name="cs_arrival"]' 254 ); 255 256 const departure = 257 form.querySelector( 258 'input[name="cs_departure"]' 259 ); 260 261 const filterToggle = 262 form.querySelector( 263 ".cs-availability-search__filter-toggle" 264 ); 265 266 const filters = 267 form.querySelector( 268 ".cs-availability-search__filters" 269 ); 270 271 const clearButton = 272 form.querySelector( 273 ".cs-availability-search__clear" 274 ); 275 276 277 function getConfig() { 278 279 return ( 280 window.CSAvailabilitySearch 281 && 282 CSAvailabilitySearch.locations 283 ) 284 ? CSAvailabilitySearch.locations 285 : null; 286 } 287 288 289 function rebuildTowns( 290 keepCurrent 291 ) { 292 293 if (!area || !town) { 294 return; 295 } 296 297 const config = 298 getConfig(); 299 300 if ( 301 !config 302 || 303 !config.towns 304 ) { 305 return; 306 } 307 308 const current = 309 keepCurrent 310 ? town.value 311 : ""; 312
313 const key = 314 area.value === "north" 315 || 316 area.value === "south" 317 ? area.value 318 : "all"; 319 320 const towns = 321 config.towns[key] 322 || 323 []; 324 325 town.innerHTML = ""; 326 327 town.add( 328 new Option( 329 "All Towns", 330 "" 331 ) 332 ); 333 334 towns.forEach( 335 function (item) { 336 town.add( 337 new Option( 338 String( 339 item.label 340 ), 341 String( 342 item.value 343 ) 344 ) 345 ); 346 } 347 ); 348 349 if ( 350 current 351 && 352 Array.from( 353 town.options 354 ).some( 355 function (option) { 356 return option.value === current; 357 } 358 ) 359 ) { 360 town.value = 361 current; 362 } 363 } 364 365 366 if (area && town) { 367 368 area.addEventListener( 369 "change", 370 function () { 371 rebuildTowns(false); 372 } 373 ); 374 375 rebuildTowns(true); 376 } 377 378 379 function localDateString(date) { 380 381 return [ 382 date.getFullYear(), 383 String( 384 date.getMonth() + 1 385 ).padStart(2, "0"), 386 String( 387 date.getDate() 388 ).padStart(2, "0") 389 ].join("-"); 390 } 391 392 393 function updateDateLimits() { 394 395 if (!arrival || !departure) { 396 return; 397 } 398 399 const today = 400 new Date(); 401 402 arrival.min = 403 localDateString(today); 404 405 if (!arrival.value) { 406 departure.removeAttribute( 407 "min" 408 ); 409 return; 410 } 411 412 const next = 413 new Date( 414 arrival.value 415 + 416 "T00:00:00" 417 ); 418 419 next.setDate( 420 next.getDate() + 1 421 ); 422 423 const minimum = 424 localDateString(next); 425 426 departure.min = 427 minimum; 428 429 if ( 430 departure.value 431 && 432 departure.value < minimum 433 ) { 434 departure.value = ""; 435 } 436 } 437 438 439 initialiseDateDisplay(arrival); 440 initialiseDateDisplay(departure); 441 442 if (arrival && departure) { 443 444 arrival.addEventListener( 445 "change", 446 updateDateLimits 447 ); 448 449 departure.addEventListener( 450 "change", 451 updateDateLimits 452 ); 453 454 updateDateLimits(); 455 } 456 457 458 if (filterToggle && filters) { 459 460 filterToggle.addEventListener( 461 "click", 462 function () { 463 464 const open = 465 filters.classList.toggle( 466 "is-open" 467 ); 468 469 filterToggle.setAttribute( 470 "aria-expanded", 471 open 472 ? "true" 473 : "false" 474 ); 475 } 476 ); 477 } 478 479 480 form.addEventListener( 481 "change", 482 function (event) { 483 484 const input = 485 event.target; 486 487 if ( 488 input 489 && 490 input.matches( 491 'input[type="checkbox"]' 492 ) 493 ) { 494 495 const tile = 496 input.closest( 497 ".ps-overlay-variable-item" 498 ); 499 500 if (tile) { 501 tile.classList.toggle( 502 "checked", 503 input.checked 504 ); 505 } 506 507 updateFilterCount( 508 form 509 ); 510 } 511 } 512 ); 513 514 515 if (clearButton) { 516 517 clearButton.addEventListener( 518 "click", 519 function () { 520 521 if (area) { 522 area.value = "all"; 523 } 524 525 if (town) { 526 rebuildTowns(false); 527 } 528 529 form 530 .querySelectorAll( 531 'input[type="date"]' 532 ) 533 .forEach( 534 function (input) { 535 input.value = ""; 536 input.dispatchEvent( 537 new Event( 538 "change", 539 { 540 bubbles: false 541 } 542 ) 543 ); 544 } 545 ); 546 547 const flexibility = 548 form.querySelector( 549 'select[name="cs_date_flexibility"]' 550 ); 551 552 if (flexibility) { 553 flexibility.value = "0"; 554 } 555 556 const guests = 557 form.querySelector( 558 'select[name="cs_guests"]' 559 ); 560 561 if (guests) { 562 guests.value = "0"; 563 } 564 565 form 566 .querySelectorAll( 567 'input[type="checkbox"]' 568 ) 569 .forEach( 570 function (input) { 571 input.checked = false;
572 573 const tile = 574 input.closest( 575 ".ps-overlay-variable-item" 576 ); 577 578 if (tile) { 579 tile.classList.remove( 580 "checked" 581 ); 582 } 583 } 584 ); 585 586 if (filters) { 587 filters.classList.remove( 588 "is-open" 589 ); 590 } 591 592 if (filterToggle) { 593 filterToggle.setAttribute( 594 "aria-expanded", 595 "false" 596 ); 597 } 598 599 updateFilterCount( 600 form 601 ); 602 603 updateDateLimits(); 604 605 form.dispatchEvent( 606 new CustomEvent( 607 "csAvailabilityClear" 608 ) 609 ); 610 } 611 ); 612 } 613 } 614 615 616 function initialiseDateDisplay(input) { 617 618 if (!input) { 619 return; 620 } 621 622 const field = 623 input.closest( 624 ".cs-availability-search__date-field" 625 ); 626 627 const display = 628 field 629 ? field.querySelector( 630 ".cs-availability-search__date-display" 631 ) 632 : null; 633 634 if (!display) { 635 return; 636 } 637 638 function formatValue(value) { 639 640 if (!value) { 641 return ""; 642 } 643 644 const parts = 645 value.split("-"); 646 647 if (parts.length !== 3) { 648 return value; 649 } 650 651 const months = [ 652 "Jan", "Feb", "Mar", "Apr", 653 "May", "Jun", "Jul", "Aug", 654 "Sep", "Oct", "Nov", "Dec" 655 ]; 656 657 const year = 658 Number(parts[0]); 659
660 const month = 661 Number(parts[1]); 662 663 const day = 664 Number(parts[2]); 665 666 if ( 667 !year 668 || 669 month < 1 670 || 671 month > 12 672 || 673 day < 1 674 || 675 day > 31 676 ) { 677 return value; 678 } 679 680 return ( 681 day 682 + 683 " " 684 + 685 months[month - 1] 686 + 687 " " 688 + 689 year 690 ); 691 } 692 693 function syncDisplay() { 694 display.value = 695 formatValue( 696 input.value 697 ); 698 } 699 700 function openPicker() { 701 702 /* 703 * showPicker() works when called directly from a genuine 704 * user click/keypress. If a browser does not support it, 705 * fall back to focusing/clicking the native date input. 706 */ 707 if ( 708 typeof input.showPicker 709 === 710 "function" 711 ) { 712 try { 713 input.showPicker(); 714 return; 715 } catch (error) { 716 // Fall through to the focus/click fallback. 717 } 718 } 719 720 try { 721 input.focus(); 722 input.click(); 723 } catch (error) { 724 input.focus(); 725 } 726 } 727 728 display.addEventListener( 729 "click", 730 openPicker 731 ); 732 733 display.addEventListener( 734 "keydown", 735 function (event) { 736 737 if ( 738 event.key === "Enter" 739 || 740 event.key === " " 741 ) { 742 event.preventDefault(); 743 openPicker(); 744 } 745 } 746 ); 747 748 input.addEventListener( 749 "change", 750 syncDisplay 751 ); 752 753 input.addEventListener( 754 "input", 755 syncDisplay 756 ); 757 758 syncDisplay(); 759 } 760 761 762 function updateFilterCount(form) { 763 764 const count = 765 form.querySelectorAll( 766 '.cs-availability-search__filters input[type="checkbox"]:checked' 767 ).length; 768 769 const badge = 770 form.querySelector( 771 ".cs-availability-search__filter-count" 772 ); 773 774 if (!badge) { 775 return; 776 } 777 778 badge.textContent = 779 String(count); 780 781 badge.hidden = 782 count === 0; 783 } 784 785 786 /* ===================================================== 787 Premium homepage state / sharing helpers 788 ===================================================== */ 789 790 const premiumStateKey = 'cornishStaysAvailabilitySearch'; 791 792 function serialisePremiumSearch(form) { 793 const params = new URLSearchParams(); 794 795 new FormData(form).forEach(function (value, key) { 796 const stringValue = String(value); 797 const isDefault = 798 (key === 'cs_area' && stringValue === 'all') 799 || (key === 'cs_town' && stringValue === '') 800 || (key === 'cs_arrival' && stringValue === '') 801 || (key === 'cs_departure' && stringValue === '') 802 || (key === 'cs_date_flexibility' && stringValue === '0') 803 || (key === 'cs_guests' && stringValue === '0'); 804 805 if (!isDefault) { 806 params.append(key, stringValue); 807 } 808 }); 809 810 return params; 811 } 812 813 function savePremiumSearchState(form) { 814 try { 815 window.sessionStorage.setItem( 816 premiumStateKey, 817 serialisePremiumSearch(form).toString() 818 ); 819 } catch (error) { 820 /* Storage is an enhancement only. */ 821 } 822 } 823 824 function applyPremiumSearchState(form, params) { 825 if (!params) { 826 return; 827 } 828 829 ['cs_area', 'cs_town', 'cs_arrival', 'cs_departure', 'cs_guests'].forEach(function (name) { 830 const input = form.querySelector('[name="' + name + '"]'); 831 if (input && params.has(name)) { 832 input.value = params.get(name) || ''; 833 } 834 }); 835 836 const flex = params.get('cs_date_flexibility'); 837 if (flex !== null) { 838 const radio = form.querySelector('[name="cs_date_flexibility"][value="' + flex + '"]'); 839 if (radio) { 840 radio.checked = true; 841 } 842 } 843 844 ['cs_pets', 'cs_hot_tub', 'cs_sauna', 'cs_ev_charger', 'cs_accessible'].forEach(function (name) { 845 const input = form.querySelector('[name="' + name + '"]'); 846 if (input) { 847 input.checked = params.get(name) === '1'; 848 } 849 }); 850 } 851 852 function restorePremiumSearchState(form) {
853 const urlParams = new URLSearchParams(window.location.search); 854 const hasSharedState = Array.from(urlParams.keys()).some(function (key) { 855 return key.indexOf('cs_') === 0; 856 }); 857 858 if (hasSharedState) { 859 applyPremiumSearchState(form, urlParams); 860 return true; 861 } 862 863 try { 864 const stored = window.sessionStorage.getItem(premiumStateKey); 865 if (stored) { 866 applyPremiumSearchState(form, new URLSearchParams(stored)); 867 } 868 } catch (error) { 869 /* Storage is an enhancement only. */ 870 } 871 872 return false; 873 } 874 875 /* ===================================================== 876 Premium homepage controls 877 ===================================================== */ 878 879 function initialisePremiumHomepageControls(root, form) { 880 881 const areaInput = 882 form.querySelector('[name="cs_area"]'); 883 884 const townInput = 885 form.querySelector('[name="cs_town"]'); 886 887 const arrivalInput = 888 form.querySelector('[name="cs_arrival"]'); 889 890 const departureInput = 891 form.querySelector('[name="cs_departure"]'); 892 893 const guestInput = 894 form.querySelector('[name="cs_guests"]'); 895 896 const openedFromSharedUrl = restorePremiumSearchState(form); 897 form.dataset.csOpenedFromSharedUrl = openedFromSharedUrl ? '1' : '0'; 898 899 const destinationSummary = 900 form.querySelector('[data-destination-summary]'); 901 902 const dateSummary = 903 form.querySelector('[data-date-summary]'); 904 905 const guestSummary = 906 form.querySelector('[data-guest-summary]'); 907 908 const arrivalLabel = 909 form.querySelector('[data-arrival-label]'); 910 911 const departureLabel = 912 form.querySelector('[data-departure-label]'); 913 914 const nightsLabel = 915 form.querySelector('[data-nights-label]'); 916 917 const guestCount = 918 form.querySelector('[data-guest-count]'); 919 920 const triggers = 921 Array.from( 922 form.querySelectorAll('[data-premium-panel]') 923 ); 924 925 const panels = 926 Array.from( 927 form.querySelectorAll('[data-panel-name]') 928 ); 929 930 931 function closePanels() { 932 933 panels.forEach(function (panel) { 934 panel.hidden = true; 935 }); 936 937 triggers.forEach(function (trigger) { 938 trigger.setAttribute( 939 'aria-expanded', 940 'false' 941 ); 942 }); 943 944 form.classList.remove('is-panel-open'); 945 } 946 947 948 function openPanel(name) { 949 950 const panel = 951 form.querySelector( 952 '[data-panel-name="' + name + '"]' 953 ); 954 955 if (!panel) { 956 return; 957 } 958 959 const isAlreadyOpen = !panel.hidden; 960 961 closePanels(); 962 963 if (isAlreadyOpen) { 964 return; 965 } 966 967 panel.hidden = false; 968 form.classList.add('is-panel-open'); 969 970 const trigger = 971 form.querySelector( 972 '[data-premium-panel="' + name + '"]' 973 ); 974 975 if (trigger) { 976 trigger.setAttribute( 977 'aria-expanded', 978 'true' 979 ); 980 } 981 } 982 983 984 triggers.forEach(function (trigger) { 985 trigger.addEventListener( 986 'click', 987 function () { 988 openPanel( 989 trigger.getAttribute('data-premium-panel') 990 ); 991 } 992 ); 993 }); 994 995 form 996 .querySelectorAll('.cs-premium-search__panel-close, .cs-premium-search__done') 997 .forEach(function (button) { 998 button.addEventListener('click', closePanels); 999 }); 1000 1001 /* 1002 * Keep the panel open while the guest interacts with it. The date 1003 * calendar redraws its buttons after a selection, which removes the 1004 * original event.target before the document-level click handler runs. 1005 * composedPath() preserves the original click path and prevents that 1006 * redraw from being mistaken for an outside click. 1007 */ 1008 document.addEventListener( 1009 'click', 1010 function (event) { 1011 const path = 1012 typeof event.composedPath === 'function' 1013 ? event.composedPath() 1014 : []; 1015 1016 const clickedInsideForm = 1017 path.length > 0 1018 ? path.indexOf(form) !== -1 1019 : form.contains(event.target); 1020 1021 if (!clickedInsideForm) { 1022 closePanels(); 1023 } 1024 } 1025 ); 1026 1027 document.addEventListener( 1028 'keydown', 1029 function (event) { 1030 if (event.key === 'Escape') { 1031 closePanels(); 1032 } 1033 } 1034 ); 1035 1036 1037 /* Destination hierarchy. */ 1038 form 1039 .querySelectorAll('[data-area][data-town]') 1040 .forEach(function (button) { 1041 button.addEventListener( 1042 'click', 1043 function () { 1044 1045 if (areaInput) { 1046 areaInput.value = 1047 button.getAttribute('data-area') || 'all'; 1048 } 1049 1050 if (townInput) { 1051 townInput.value = 1052 button.getAttribute('data-town') || ''; 1053 } 1054 1055 if (destinationSummary) { 1056 destinationSummary.textContent = 1057 button.getAttribute('data-label') 1058 || 1059 'All Cornwall'; 1060 } 1061 1062 form 1063 .querySelectorAll('[data-area][data-town]') 1064 .forEach(function (item) { 1065 item.classList.toggle( 1066 'is-selected', 1067 item === button 1068 ); 1069 }); 1070 1071 closePanels(); 1072 } 1073 ); 1074 }); 1075 1076 1077 const selectedDestinationButton = 1078 form.querySelector( 1079 '[data-area="' + (areaInput ? areaInput.value : 'all') + '"][data-town="' + (townInput ? townInput.value : '') + '"]' 1080 ); 1081 1082 if (selectedDestinationButton) { 1083 selectedDestinationButton.classList.add('is-selected'); 1084 if (destinationSummary) { 1085 destinationSummary.textContent = 1086 selectedDestinationButton.getAttribute('data-label') || 'All Cornwall'; 1087 } 1088 } 1089 1090 function parseIsoDate(value) { 1091 if (!value) { 1092 return null; 1093 } 1094 1095 const parts = value.split('-'); 1096 1097 if (parts.length !== 3) { 1098 return null; 1099 } 1100 1101 const date = new Date( 1102 Number(parts[0]), 1103 Number(parts[1]) - 1, 1104 Number(parts[2]) 1105 ); 1106 1107 return Number.isNaN(date.getTime()) 1108 ? null 1109 : date; 1110 } 1111 1112 1113 function isoDate(date) { 1114 return [ 1115 date.getFullYear(), 1116 String(date.getMonth() + 1).padStart(2, '0'), 1117 String(date.getDate()).padStart(2, '0') 1118 ].join('-'); 1119 } 1120 1121 1122 function shortDate(date, includeYear) { 1123 1124 if (!date) { 1125 return ''; 1126 } 1127 1128 return new Intl.DateTimeFormat( 1129 'en-GB', 1130 includeYear 1131 ? { day: 'numeric', month: 'short', year: 'numeric' } 1132 : { day: 'numeric', month: 'short' } 1133 ).format(date); 1134 } 1135 1136 1137 function updateDateSummary() { 1138 1139 const arrival = 1140 arrivalInput
1141 ? parseIsoDate(arrivalInput.value) 1142 : null; 1143 1144 const departure = 1145 departureInput 1146 ? parseIsoDate(departureInput.value) 1147 : null; 1148 1149 if (arrivalLabel) { 1150 arrivalLabel.textContent = 1151 arrival 1152 ? shortDate(arrival, true) 1153 : 'Add date'; 1154 } 1155 1156 if (departureLabel) { 1157 departureLabel.textContent = 1158 departure 1159 ? shortDate(departure, true) 1160 : 'Add date'; 1161 } 1162 1163 if (arrival && departure) { 1164 1165 if (dateSummary) { 1166 dateSummary.textContent = 1167 shortDate(arrival, false) 1168 + 1169 ' â ' 1170 + 1171 shortDate(departure, true); 1172 } 1173 1174 const nights = 1175 Math.round( 1176 (departure.getTime() - arrival.getTime()) 1177 / 1178 86400000 1179 ); 1180 1181 if (nightsLabel) { 1182 nightsLabel.textContent = 1183 nights 1184 + 1185 (nights === 1 ? ' night' : ' nights'); 1186 nightsLabel.hidden = false; 1187 } 1188 1189 } else { 1190 1191 if (dateSummary) { 1192 dateSummary.textContent = 'Add dates'; 1193 } 1194 1195 if (nightsLabel) { 1196 nightsLabel.hidden = true; 1197 } 1198 } 1199 } 1200 1201 1202 let premiumCalendar = null; 1203 const calendarWrap = 1204 form.querySelector('.cs-premium-search__calendar-wrap'); 1205 1206 if (calendarWrap) { 1207 1208 const today = new Date(); 1209 today.setHours(0, 0, 0, 0); 1210 1211 const initialArrival = 1212 arrivalInput 1213 ? parseIsoDate(arrivalInput.value) 1214 : null; 1215 1216 let viewDate = new Date( 1217 (initialArrival || today).getFullYear(), 1218 (initialArrival || today).getMonth(), 1219 1 1220 ); 1221 1222 const monthNames = [ 1223 'January', 'February', 'March', 'April', 'May', 'June', 1224 'July', 'August', 'September', 'October', 'November', 'December' 1225 ]; 1226 1227 const weekdayNames = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; 1228 1229 function sameDay(a, b) { 1230 return !!a && !!b 1231 && a.getFullYear() === b.getFullYear() 1232 && a.getMonth() === b.getMonth() 1233 && a.getDate() === b.getDate(); 1234 } 1235 1236 function dateValue(date) { 1237 return new Date( 1238 date.getFullYear(), 1239 date.getMonth(), 1240 date.getDate() 1241 ).getTime(); 1242 } 1243 1244 function monthsToShow() { 1245 return window.matchMedia('(max-width: 767px)').matches ? 1 : 2; 1246 } 1247 1248 function renderMonth(year, month) { 1249 1250 const monthEl = document.createElement('div'); 1251 monthEl.className = 'cs-premium-calendar__month'; 1252 1253 const title = document.createElement('div'); 1254 title.className = 'cs-premium-calendar__month-title'; 1255 title.textContent = monthNames[month] + ' ' + year; 1256 monthEl.appendChild(title); 1257 1258 const weekdays = document.createElement('div'); 1259 weekdays.className = 'cs-premium-calendar__weekdays'; 1260 1261 weekdayNames.forEach(function (day) { 1262 const span = document.createElement('span'); 1263 span.textContent = day; 1264 weekdays.appendChild(span); 1265 }); 1266 1267 monthEl.appendChild(weekdays); 1268 1269 const days = document.createElement('div'); 1270 days.className = 'cs-premium-calendar__days'; 1271 1272 const firstDay = new Date(year, month, 1); 1273 const mondayOffset = (firstDay.getDay() + 6) % 7; 1274 const daysInMonth = new Date(year, month + 1, 0).getDate(); 1275 1276 for (let i = 0; i < mondayOffset; i += 1) { 1277 const spacer = document.createElement('span'); 1278 spacer.className = 'cs-premium-calendar__spacer'; 1279 days.appendChild(spacer); 1280 } 1281
1282 const arrival = arrivalInput ? parseIsoDate(arrivalInput.value) : null; 1283 const departure = departureInput ? parseIsoDate(departureInput.value) : null; 1284 1285 for (let day = 1; day <= daysInMonth; day += 1) { 1286 const date = new Date(year, month, day); 1287 const button = document.createElement('button'); 1288 button.type = 'button'; 1289 button.className = 'cs-premium-calendar__day'; 1290 button.textContent = String(day); 1291 button.setAttribute('aria-label', shortDate(date, true)); 1292 button.setAttribute('data-date', isoDate(date)); 1293 1294 if (dateValue(date) < dateValue(today)) { 1295 button.disabled = true; 1296 button.classList.add('is-disabled'); 1297 } 1298 1299 if (sameDay(date, arrival)) { 1300 button.classList.add('is-arrival'); 1301 } 1302 1303 if (sameDay(date, departure)) { 1304 button.classList.add('is-departure'); 1305 } 1306 1307 if ( 1308 arrival 1309 && departure 1310 && dateValue(date) > dateValue(arrival) 1311 && dateValue(date) < dateValue(departure) 1312 ) { 1313 button.classList.add('is-in-range'); 1314 } 1315 1316 button.addEventListener('mouseenter', function () { 1317 const currentArrival = arrivalInput ? parseIsoDate(arrivalInput.value) : null; 1318 const currentDeparture = departureInput ? parseIsoDate(departureInput.value) : null; 1319 1320 if (currentArrival && !currentDeparture && dateValue(date) > dateValue(currentArrival)) { 1321 updateCalendarPreview(date); 1322 } 1323 }); 1324 1325 button.addEventListener('focus', function () { 1326 const currentArrival = arrivalInput ? parseIsoDate(arrivalInput.value) : null; 1327 const currentDeparture = departureInput ? parseIsoDate(departureInput.value) : null; 1328 1329 if (currentArrival && !currentDeparture && dateValue(date) > dateValue(currentArrival)) { 1330 updateCalendarPreview(date); 1331 } 1332 }); 1333 1334 button.addEventListener('click', function () { 1335 const currentArrival = arrivalInput ? parseIsoDate(arrivalInput.value) : null; 1336 const currentDeparture = departureInput ? parseIsoDate(departureInput.value) : null; 1337 1338 if ( 1339 !currentArrival 1340 || currentDeparture 1341 || dateValue(date) <= dateValue(currentArrival) 1342 ) { 1343 if (arrivalInput) { 1344 arrivalInput.value = isoDate(date); 1345 } 1346 if (departureInput) { 1347 departureInput.value = ''; 1348 } 1349 } else { 1350 if (departureInput) { 1351 departureInput.value = isoDate(date); 1352 } 1353 } 1354 1355 updateDateSummary(); 1356 renderCalendar(); 1357 }); 1358 1359 days.appendChild(button); 1360 } 1361 1362 monthEl.appendChild(days); 1363 return monthEl; 1364 } 1365 1366 function updateCalendarPreview(hoverDate) { 1367 const arrival = arrivalInput ? parseIsoDate(arrivalInput.value) : null; 1368 1369 calendarWrap 1370 .querySelectorAll('.cs-premium-calendar__day') 1371 .forEach(function (dayButton) { 1372 dayButton.classList.remove('is-preview-range', 'is-preview-departure'); 1373 1374 if (!arrival || !hoverDate) { 1375 return; 1376 } 1377 1378 const dayDate = parseIsoDate(dayButton.getAttribute('data-date')); 1379 if (!dayDate) { 1380 return; 1381 } 1382 1383 if (dateValue(dayDate) > dateValue(arrival) && dateValue(dayDate) < dateValue(hoverDate)) { 1384 dayButton.classList.add('is-preview-range'); 1385 } 1386 1387 if (sameDay(dayDate, hoverDate)) { 1388 dayButton.classList.add('is-preview-departure'); 1389 } 1390 }); 1391 } 1392
1393 function renderCalendar() { 1394 calendarWrap.innerHTML = ''; 1395 1396 const toolbar = document.createElement('div'); 1397 toolbar.className = 'cs-premium-calendar__toolbar'; 1398 1399 const previous = document.createElement('button'); 1400 previous.type = 'button'; 1401 previous.className = 'cs-premium-calendar__nav cs-premium-calendar__nav--prev'; 1402 previous.setAttribute('aria-label', 'Previous month'); 1403 previous.innerHTML = '‹'; 1404 1405 const next = document.createElement('button'); 1406 next.type = 'button'; 1407 next.className = 'cs-premium-calendar__nav cs-premium-calendar__nav--next'; 1408 next.setAttribute('aria-label', 'Next month'); 1409 next.innerHTML = '›'; 1410 1411 const earliestMonth = new Date(today.getFullYear(), today.getMonth(), 1); 1412 previous.disabled = viewDate.getTime() <= earliestMonth.getTime(); 1413 1414 previous.addEventListener('click', function () { 1415 viewDate = new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1); 1416 renderCalendar(); 1417 }); 1418 1419 next.addEventListener('click', function () { 1420 viewDate = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1); 1421 renderCalendar(); 1422 }); 1423 1424 toolbar.appendChild(previous); 1425 toolbar.appendChild(next); 1426 calendarWrap.appendChild(toolbar); 1427 1428 const months = document.createElement('div'); 1429 months.className = 'cs-premium-calendar__months'; 1430 1431 for (let i = 0; i < monthsToShow(); i += 1) { 1432 const date = new Date(viewDate.getFullYear(), viewDate.getMonth() + i, 1); 1433 months.appendChild(renderMonth(date.getFullYear(), date.getMonth())); 1434 } 1435 1436 calendarWrap.appendChild(months); 1437 1438 months.addEventListener('mouseleave', function () { 1439 updateCalendarPreview(null); 1440 }); 1441 } 1442 1443 let resizeTimer = null; 1444 window.addEventListener('resize', function () { 1445 clearTimeout(resizeTimer); 1446 resizeTimer = setTimeout(renderCalendar, 120); 1447 }); 1448 1449 premiumCalendar = { 1450 clear: function () { 1451 if (arrivalInput) { 1452 arrivalInput.value = ''; 1453 } 1454 if (departureInput) { 1455 departureInput.value = ''; 1456 } 1457 viewDate = new Date(today.getFullYear(), today.getMonth(), 1); 1458 renderCalendar(); 1459 }, 1460 render: renderCalendar 1461 }; 1462 1463 renderCalendar(); 1464 } 1465 1466 const clearDates = 1467 form.querySelector('.cs-premium-search__clear-dates'); 1468 1469 if (clearDates) { 1470 clearDates.addEventListener( 1471 'click', 1472 function () { 1473 if (premiumCalendar) { 1474 premiumCalendar.clear(); 1475 } 1476 if (arrivalInput) { 1477 arrivalInput.value = ''; 1478 } 1479 if (departureInput) { 1480 departureInput.value = ''; 1481 } 1482 updateDateSummary(); 1483 } 1484 ); 1485 } 1486 1487 updateDateSummary(); 1488 1489 1490 /* Guests and features. */ 1491 function featureCount() { 1492 return form.querySelectorAll( 1493 '.cs-premium-search__feature-tile input[type="checkbox"]:checked' 1494 ).length; 1495 } 1496 1497 1498 function updateGuestSummary() { 1499 1500 let guests = 1501 guestInput 1502 ? Number(guestInput.value) || 0 1503 : 0; 1504 1505 guests = Math.max(0, guests); 1506 1507 if (guestCount) { 1508 guestCount.textContent = String(guests); 1509 } 1510 1511 const features = featureCount(); 1512 let label = 1513 guests > 0 1514 ? guests + (guests === 1 ? ' guest' : ' guests') 1515 : 'Add guests & features'; 1516 1517 if (features > 0) { 1518 label += 1519 ' · ' 1520 + 1521 features 1522 + 1523 (features === 1 ? ' feature' : ' features'); 1524 } 1525 1526 if (guestSummary) { 1527 guestSummary.textContent = label; 1528 } 1529 } 1530 1531 1532 form 1533 .querySelectorAll('[data-guest-step]') 1534 .forEach(function (button) { 1535 button.addEventListener( 1536 'click', 1537 function () { 1538 1539 const step = 1540 Number( 1541 button.getAttribute('data-guest-step') 1542 ) || 0; 1543 1544 let value = 1545 guestInput 1546 ? Number(guestInput.value) || 0 1547 : 0; 1548 1549 value = Math.max( 1550 0, 1551 Math.min(30, value + step) 1552 ); 1553 1554 if (guestInput) {
1555 guestInput.value = String(value); 1556 } 1557 1558 updateGuestSummary(); 1559 } 1560 ); 1561 }); 1562 1563 form 1564 .querySelectorAll('.cs-premium-search__feature-tile input[type="checkbox"]') 1565 .forEach(function (input) { 1566 1567 function syncTile() { 1568 const tile = 1569 input.closest('.cs-premium-search__feature-tile'); 1570 1571 if (tile) { 1572 tile.classList.toggle( 1573 'is-selected', 1574 input.checked 1575 ); 1576 } 1577 1578 updateGuestSummary(); 1579 } 1580 1581 input.addEventListener('change', syncTile); 1582 syncTile(); 1583 }); 1584 1585 const clearFeatures = 1586 form.querySelector('.cs-premium-search__clear-features'); 1587 1588 if (clearFeatures) { 1589 clearFeatures.addEventListener( 1590 'click', 1591 function () { 1592 if (guestInput) { 1593 guestInput.value = '0'; 1594 } 1595 1596 form 1597 .querySelectorAll('.cs-premium-search__feature-tile input[type="checkbox"]') 1598 .forEach(function (input) { 1599 input.checked = false; 1600 input.dispatchEvent( 1601 new Event('change', { bubbles: false }) 1602 ); 1603 }); 1604 1605 updateGuestSummary(); 1606 } 1607 ); 1608 } 1609 1610 updateGuestSummary(); 1611 1612 1613 form.csPremiumReset = function () { 1614 1615 if (areaInput) { 1616 areaInput.value = 'all'; 1617 } 1618 if (townInput) { 1619 townInput.value = ''; 1620 } 1621 if (destinationSummary) { 1622 destinationSummary.textContent = 1623 'Where would you like to stay?'; 1624 } 1625 1626 if (premiumCalendar) { 1627 premiumCalendar.clear(); 1628 } 1629 if (arrivalInput) { 1630 arrivalInput.value = ''; 1631 } 1632 if (departureInput) { 1633 departureInput.value = ''; 1634 } 1635 1636 const exact = 1637 form.querySelector( 1638 'input[name="cs_date_flexibility"][value="0"]' 1639 ); 1640 if (exact) { 1641 exact.checked = true; 1642 } 1643 1644 if (guestInput) { 1645 guestInput.value = '0'; 1646 } 1647 1648 form 1649 .querySelectorAll('.cs-premium-search__feature-tile input[type="checkbox"]') 1650 .forEach(function (input) { 1651 input.checked = false; 1652 input.dispatchEvent( 1653 new Event('change', { bubbles: false }) 1654 ); 1655 }); 1656 1657 form 1658 .querySelectorAll('[data-area][data-town]') 1659 .forEach(function (item) { 1660 item.classList.remove('is-selected'); 1661 }); 1662 1663 updateDateSummary(); 1664 updateGuestSummary(); 1665 closePanels(); 1666 }; 1667 } 1668 1669 1670 function bindPremiumHomepageSearch(root, form) { 1671 1672 let searchInProgress = false; 1673 1674 function runHomepageSearch(event) { 1675 1676 if (event) { 1677 event.preventDefault(); 1678 event.stopPropagation(); 1679 } 1680 1681 const target = 1682 document.getElementById('cs-availability-home-results') 1683 || 1684 document.querySelector('.cs-availability-results-target'); 1685 1686 if (!target) { 1687 const existing = root.querySelector('.cs-premium-search__target-message'); 1688 1689 if (!existing) { 1690 const message = document.createElement('p'); 1691 message.className = 'cs-premium-search__target-message'; 1692 message.textContent = 'The results section is not on this page. Add [availability_results] below the hero.'; 1693 message.setAttribute('role', 'status'); 1694 form.insertAdjacentElement('afterend', message); 1695 } 1696 1697 return; 1698 } 1699 1700 if (searchInProgress) { 1701 return; 1702 } 1703 1704 searchInProgress = true; 1705 savePremiumSearchState(form); 1706 1707 const searchButton = form.querySelector('.cs-premium-search__submit'); 1708 const searchButtonLabel = searchButton ? searchButton.querySelector('span:first-child') : null; 1709 1710 if (searchButton) { 1711 searchButton.disabled = true; 1712 searchButton.classList.add('is-searching'); 1713 } 1714 1715 if (searchButtonLabel) { 1716 searchButtonLabel.textContent = 'Finding your stayâ¦'; 1717 } 1718 1719 const state = 1720 target._csAvailabilityState 1721 || 1722 { 1723 debounceTimer: null, 1724 abortController: null, 1725 map: null, 1726 markerLayer: null, 1727 markersByPostId: new Map(), 1728 markerData: readInitialMarkerData(target),
1729 mapInitialised: false 1730 }; 1731 1732 target._csAvailabilityState = state; 1733 target.hidden = false; 1734 target.classList.add('has-results'); 1735 1736 /* Reveal and move to the results immediately so the button always 1737 * gives the guest visible feedback, even while AJAX is loading. */ 1738 target.scrollIntoView({ 1739 behavior: 'smooth', 1740 block: 'start' 1741 }); 1742 1743 Promise.resolve( 1744 fetchResults( 1745 target, 1746 form, 1747 state 1748 ) 1749 ).finally(function () { 1750 searchInProgress = false; 1751 1752 if (searchButton) { 1753 searchButton.disabled = false; 1754 searchButton.classList.remove('is-searching'); 1755 } 1756 1757 if (searchButtonLabel) { 1758 searchButtonLabel.textContent = 'Search Properties'; 1759 } 1760 }); 1761 } 1762 1763 form.addEventListener('submit', runHomepageSearch); 1764 1765 /* Divi/optimisation layers can occasionally interfere with native form 1766 * submission. Binding the actual search button as well gives us a 1767 * reliable explicit path without changing keyboard Enter behaviour. */ 1768 const searchButton = 1769 form.querySelector('.cs-premium-search__submit'); 1770 1771 if (searchButton) { 1772 searchButton.addEventListener( 1773 'click', 1774 function (event) { 1775 runHomepageSearch(event); 1776 } 1777 ); 1778 } 1779 1780 if (form.dataset.csOpenedFromSharedUrl === '1') { 1781 window.setTimeout(function () { 1782 runHomepageSearch(); 1783 1784 if (window.history && window.history.replaceState) { 1785 window.history.replaceState({}, '', window.location.pathname + window.location.hash); 1786 } 1787 }, 220); 1788 } 1789 } 1790 1791 1792 function bindPremiumHomepageResultActions(root) { 1793 root.addEventListener('click', function (event) { 1794 const propertyLink = event.target.closest('.ps-card a[href]'); 1795 if (!propertyLink) { 1796 return; 1797 } 1798 1799 const searchRoot = document.querySelector('.cs-availability-search--homepage[data-mode="homepage"]'); 1800 const form = searchRoot ? searchRoot.querySelector('.cs-availability-search__form') : null; 1801 if (form) { 1802 savePremiumSearchState(form); 1803 } 1804 }); 1805 1806 const changeButton = root.querySelector('.cs-premium-search__change-search'); 1807 const shareButton = root.querySelector('[data-share-search]'); 1808 1809 if (changeButton) { 1810 changeButton.addEventListener('click', function () { 1811 const searchRoot = document.querySelector('.cs-availability-search--homepage[data-mode="homepage"]'); 1812 if (searchRoot) { 1813 searchRoot.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1814 } 1815 }); 1816 } 1817 1818 if (shareButton) { 1819 shareButton.addEventListener('click', async function () { 1820 const searchRoot = document.querySelector('.cs-availability-search--homepage[data-mode="homepage"]'); 1821 const form = searchRoot ? searchRoot.querySelector('.cs-availability-search__form') : null; 1822 1823 if (!form) { 1824 return; 1825 } 1826 1827 const params = serialisePremiumSearch(form); 1828 const shareUrl = window.location.origin + window.location.pathname + (params.toString() ? '?' + params.toString() : ''); 1829 const label = shareButton.querySelector('span'); 1830 const original = label ? label.textContent : 'Share Search'; 1831 1832 try { 1833 if (navigator.share) { 1834 await navigator.share({ 1835 title: 'Cornish Stays search', 1836 text: 'Take a look at these Cornish Stays properties.', 1837 url: shareUrl 1838 }); 1839 } else if (navigator.clipboard && navigator.clipboard.writeText) { 1840 await navigator.clipboard.writeText(shareUrl); 1841 if (label) { 1842 label.textContent = 'Link Copied'; 1843 } 1844 window.setTimeout(function () { 1845 if (label) { 1846 label.textContent = original; 1847 } 1848 }, 1800); 1849 } else { 1850 window.prompt('Copy this search link:', shareUrl); 1851 } 1852 } catch (error) {
1853 if (error && error.name !== 'AbortError') { 1854 window.prompt('Copy this search link:', shareUrl); 1855 } 1856 } 1857 }); 1858 } 1859 } 1860 1861 function bindPremiumHomepageResultsReset(root, state) { 1862 1863 const resetButton = 1864 root.querySelector('.cs-premium-search__reset-results'); 1865 1866 if (!resetButton) { 1867 return; 1868 } 1869 1870 resetButton.addEventListener( 1871 'click', 1872 function () { 1873 1874 const searchRoot = 1875 document.querySelector('.cs-availability-search--homepage[data-mode="homepage"]'); 1876 1877 const form = 1878 searchRoot 1879 ? searchRoot.querySelector('.cs-availability-search__form') 1880 : null; 1881 1882 if ( 1883 form 1884 && 1885 typeof form.csPremiumReset === 'function' 1886 ) { 1887 form.csPremiumReset(); 1888 } 1889 1890 if (state.abortController) { 1891 state.abortController.abort(); 1892 } 1893 1894 state.markerData = []; 1895 1896 if (state.markerLayer) { 1897 state.markerLayer.clearLayers(); 1898 } 1899 1900 const mapWrap = 1901 root.querySelector('.cs-availability-search__map-wrap'); 1902 1903 const mapToggle = 1904 root.querySelector('.cs-availability-search__map-toggle'); 1905 1906 if (mapWrap) { 1907 mapWrap.hidden = true; 1908 } 1909 1910 if (mapToggle) { 1911 mapToggle.setAttribute('aria-expanded', 'false'); 1912 1913 const label = 1914 mapToggle.querySelector('.cs-availability-search__map-toggle-label'); 1915 1916 if (label) { 1917 label.textContent = 'Show Map'; 1918 } 1919 } 1920 1921 root.hidden = true; 1922 root.classList.remove('has-results'); 1923 1924 try { 1925 window.sessionStorage.removeItem(premiumStateKey); 1926 } catch (error) { 1927 /* Storage is an enhancement only. */ 1928 } 1929 1930 if (searchRoot) { 1931 searchRoot.scrollIntoView({ 1932 behavior: 'smooth', 1933 block: 'center' 1934 }); 1935 } 1936 } 1937 ); 1938 } 1939 1940 1941 /* ===================================================== 1942 Live AJAX filtering 1943 ===================================================== */ 1944 1945 function bindLiveFiltering( 1946 root, 1947 form, 1948 state 1949 ) { 1950 1951 form.addEventListener( 1952 "submit", 1953 function (event) { 1954 event.preventDefault(); 1955 } 1956 ); 1957 1958 form.addEventListener( 1959 "change", 1960 function (event) { 1961 1962 const target = 1963 event.target; 1964 1965 if ( 1966 !target 1967 || 1968 !target.matches( 1969 "select, input" 1970 ) 1971 ) { 1972 return; 1973 } 1974 1975 queueFetch( 1976 root, 1977 form, 1978 state 1979 ); 1980 } 1981 ); 1982 1983 form.addEventListener( 1984 "csAvailabilityClear", 1985 function () { 1986 queueFetch( 1987 root, 1988 form, 1989 state, 1990 0 1991 ); 1992 } 1993 ); 1994 } 1995 1996 1997 function queueFetch( 1998 root, 1999 form, 2000 state, 2001 delay 2002 ) { 2003 2004 clearTimeout( 2005 state.debounceTimer 2006 ); 2007 2008 state.debounceTimer = 2009 setTimeout( 2010 function () { 2011 fetchResults( 2012 root, 2013 form, 2014 state 2015 ); 2016 },
2017 typeof delay === "number" 2018 ? delay 2019 : 280 2020 ); 2021 } 2022 2023 2024 async function fetchResults( 2025 root, 2026 form, 2027 state 2028 ) { 2029 2030 const settings = 2031 window.CSAvailabilitySearch 2032 || 2033 {}; 2034 2035 if ( 2036 !settings.ajaxUrl 2037 || 2038 !settings.nonce 2039 ) { 2040 return; 2041 } 2042 2043 if (state.abortController) { 2044 state.abortController.abort(); 2045 } 2046 2047 state.abortController = 2048 new AbortController(); 2049 2050 const data = 2051 new FormData(form); 2052 2053 data.append( 2054 "action", 2055 "cornish_stays_availability_filter" 2056 ); 2057 2058 data.append( 2059 "nonce", 2060 settings.nonce 2061 ); 2062 2063 setLoading( 2064 root, 2065 true 2066 ); 2067 2068 try { 2069 2070 const response = 2071 await fetch( 2072 settings.ajaxUrl, 2073 { 2074 method: "POST", 2075 credentials: "same-origin", 2076 body: data, 2077 signal: 2078 state 2079 .abortController 2080 .signal 2081 } 2082 ); 2083 2084 const json = 2085 await response.json(); 2086 2087 if ( 2088 !json 2089 || 2090 !json.success 2091 || 2092 !json.data 2093 ) { 2094 2095 const message = 2096 json 2097 && 2098 json.data 2099 && 2100 json.data.message 2101 ? json.data.message 2102 : "Could not update properties. Please try again."; 2103 2104 throw new Error( 2105 message 2106 ); 2107 } 2108 2109 updateResults( 2110 root, 2111 json.data 2112 ); 2113 2114 state.markerData = 2115 Array.isArray( 2116 json.data.markers 2117 ) 2118 ? json.data.markers 2119 : []; 2120 2121 if (state.mapInitialised) { 2122 renderMarkers( 2123 root, 2124 state 2125 ); 2126 } 2127 2128 bindCardInteractions( 2129 root, 2130 state 2131 ); 2132 2133 2134 } catch (error) { 2135 2136 if ( 2137 error.name === 2138 "AbortError" 2139 ) { 2140 return; 2141 } 2142 2143 const results = 2144 root.querySelector( 2145 "#cs-availability-results" 2146 ); 2147 2148 if (results) { 2149 results.innerHTML = 2150 '<div class="ps-empty">' 2151 + 2152 escapeHtml( 2153 error.message 2154 || 2155 "Could not update properties. Please try again." 2156 ) 2157 + 2158 "</div>"; 2159 } 2160 2161 } finally { 2162 2163 setLoading( 2164 root, 2165 false 2166 ); 2167 } 2168 } 2169 2170 2171 /* ========================================================= 2172 * Match the existing Cornish Stays property-listing excerpt crop. 2173 * Crops .ps-excerpt elements to 40 words and marks each element 2174 * with data-excerpt-crop-initialised="true". 2175 * ========================================================= */ 2176 function initPropertyExcerptCrop(scope) { 2177 2178 const container = scope || document; 2179 const excerpts = container.querySelectorAll(".ps-excerpt"); 2180 2181 if (!excerpts.length) { 2182 return; 2183 } 2184 2185 excerpts.forEach(function (excerpt) { 2186 2187 if (excerpt.dataset.excerptCropInitialised === "true") { 2188 return; 2189 } 2190 2191 const fullText = excerpt.textContent.trim(); 2192 const words = fullText.split(/\s+/); 2193 2194 if (words.length > 40) { 2195 excerpt.textContent = 2196 words.slice(0, 40).join(" ") 2197 + 2198 "..."; 2199 } 2200
2201 excerpt.dataset.excerptCropInitialised = "true"; 2202 }); 2203 } 2204 2205 2206 function updateResults( 2207 root, 2208 data 2209 ) { 2210 2211 const title = 2212 root.querySelector( 2213 "#cs-availability-results-title" 2214 ); 2215 2216 const detail = 2217 root.querySelector( 2218 "#cs-availability-results-detail" 2219 ); 2220 2221 const results = 2222 root.querySelector( 2223 "#cs-availability-results" 2224 ); 2225 2226 if (title) { 2227 title.textContent = 2228 data.summaryTitle 2229 || 2230 ""; 2231 } 2232 2233 if (detail) { 2234 detail.textContent = 2235 data.summaryDetail 2236 || 2237 ""; 2238 } 2239 2240 if (results) { 2241 results.innerHTML = 2242 data.html 2243 || 2244 ""; 2245 2246 initPropertyExcerptCrop(results); 2247 } 2248 2249 updateAvailabilitySortState( 2250 root, 2251 Boolean(data.hasDates) 2252 ); 2253 2254 applyAvailabilitySort(root); 2255 } 2256 2257 2258 /* ========================================================= 2259 * Client-side result sorting. 2260 * Reorders the cards already returned by the availability search, 2261 * so changing the dropdown never makes another API/AJAX request. 2262 * ========================================================= */ 2263 function bindAvailabilitySorting(root) { 2264 2265 const select = 2266 root.querySelector( 2267 "[data-availability-sort]" 2268 ); 2269 2270 if (!select || select.dataset.sortBound === "1") { 2271 return; 2272 } 2273 2274 select.dataset.sortBound = "1"; 2275 2276 select.addEventListener( 2277 "change", 2278 function () { 2279 applyAvailabilitySort(root); 2280 } 2281 ); 2282 } 2283 2284 2285 function updateAvailabilitySortState(root, hasDates) { 2286 2287 const select = 2288 root.querySelector( 2289 "[data-availability-sort]" 2290 ); 2291 2292 if (!select) { 2293 return; 2294 } 2295 2296 select 2297 .querySelectorAll( 2298 'option[data-requires-price="1"]' 2299 ) 2300 .forEach(function (option) { 2301 option.disabled = !hasDates; 2302 }); 2303 2304 if ( 2305 !hasDates 2306 && 2307 ( 2308 select.value === "price-asc" 2309 || 2310 select.value === "price-desc" 2311 ) 2312 ) { 2313 select.value = "recommended"; 2314 } 2315 } 2316 2317 2318 function applyAvailabilitySort(root) { 2319 2320 const select = 2321 root.querySelector( 2322 "[data-availability-sort]" 2323 ); 2324 2325 const results = 2326 root.querySelector( 2327 "#cs-availability-results" 2328 ); 2329 2330 if (!select || !results) { 2331 return; 2332 } 2333 2334 const sortMode = select.value || "recommended"; 2335 2336 results 2337 .querySelectorAll( 2338 ".cs-availability-results-grid" 2339 ) 2340 .forEach(function (grid) { 2341 2342 const cards = 2343 Array.from( 2344 grid.querySelectorAll( 2345 ".cs-availability-card" 2346 ) 2347 ); 2348 2349 if (cards.length < 2) { 2350 return; 2351 } 2352 2353 cards.forEach(function (card, index) { 2354 if (!card.hasAttribute("data-sort-original")) { 2355 card.setAttribute( 2356 "data-sort-original", 2357 String(index) 2358 ); 2359 } 2360 }); 2361 2362 cards.sort(function (a, b) { 2363 2364 const originalA = Number(a.dataset.sortOriginal || 0); 2365 const originalB = Number(b.dataset.sortOriginal || 0); 2366 2367 if (sortMode === "recommended") { 2368 return originalA - originalB; 2369 } 2370 2371 if (sortMode === "title-asc") { 2372 return String(a.dataset.sortTitle || "") 2373 .localeCompare( 2374 String(b.dataset.sortTitle || ""), 2375 undefined, 2376 { sensitivity: "base" } 2377 ); 2378 } 2379 2380 if ( 2381 sortMode === "price-asc" 2382 || 2383 sortMode === "price-desc" 2384 ) { 2385 const priceA = Number(a.dataset.sortPrice); 2386 const priceB = Number(b.dataset.sortPrice); 2387 const validA = Number.isFinite(priceA) && priceA > 0; 2388 const validB = Number.isFinite(priceB) && priceB > 0; 2389 2390 if (!validA && !validB) { 2391 return originalA - originalB; 2392 } 2393 2394 if (!validA) { 2395 return 1; 2396 } 2397 2398 if (!validB) { 2399 return -1; 2400 } 2401 2402 return sortMode === "price-asc" 2403 ? priceA - priceB 2404 : priceB - priceA; 2405 } 2406 2407 if ( 2408 sortMode === "occupancy-asc" 2409 || 2410 sortMode === "occupancy-desc" 2411 ) { 2412 const occupancyA = Number(a.dataset.sortOccupancy || 0); 2413 const occupancyB = Number(b.dataset.sortOccupancy || 0); 2414 2415 if (occupancyA === occupancyB) { 2416 return originalA - originalB; 2417 } 2418 2419 return sortMode === "occupancy-asc" 2420 ? occupancyA - occupancyB 2421 : occupancyB - occupancyA; 2422 } 2423 2424 return originalA - originalB; 2425 }); 2426 2427 cards.forEach(function (card) { 2428 grid.appendChild(card); 2429 }); 2430 }); 2431 } 2432 2433 2434 function setLoading( 2435 root, 2436 loading 2437 ) { 2438 2439 const shell = 2440 root.querySelector( 2441 ".cs-availability-search__results-shell" 2442 ); 2443
2444 const overlay = 2445 root.querySelector( 2446 ".cs-availability-search__loading" 2447 ); 2448 2449 if (shell) { 2450 shell.setAttribute( 2451 "aria-busy", 2452 loading 2453 ? "true" 2454 : "false" 2455 ); 2456 } 2457 2458 if (overlay) { 2459 overlay.hidden = 2460 !loading; 2461 } 2462 } 2463 2464 2465 function updateBrowserUrl(form) { 2466 2467 if ( 2468 !window.history 2469 || 2470 !window.history.replaceState 2471 ) { 2472 return; 2473 } 2474 2475 const params = 2476 new URLSearchParams(); 2477 2478 new FormData(form) 2479 .forEach( 2480 function (value, key) { 2481 2482 const stringValue = 2483 String(value); 2484 2485 const isDefault = 2486 (key === "cs_area" && stringValue === "all") 2487 || 2488 (key === "cs_town" && stringValue === "") 2489 || 2490 (key === "cs_arrival" && stringValue === "") 2491 || 2492 (key === "cs_departure" && stringValue === "") 2493 || 2494 (key === "cs_date_flexibility" && stringValue === "0") 2495 || 2496 (key === "cs_guests" && stringValue === "0"); 2497 2498 if (!isDefault) { 2499 params.append( 2500 key, 2501 stringValue 2502 ); 2503 } 2504 } 2505 ); 2506 2507 const query = 2508 params.toString(); 2509 2510 const nextUrl = 2511 window.location.pathname 2512 + 2513 (query ? "?" + query : "") 2514 + 2515 window.location.hash; 2516 2517 window.history.replaceState( 2518 {}, 2519 "", 2520 nextUrl 2521 ); 2522 } 2523 2524 2525 /* ===================================================== 2526 Property card navigation 2527 ===================================================== */ 2528 2529 function bindViewPropertyButtons(root) { 2530 2531 if (root.dataset.csViewPropertyBound === "true") { 2532 return; 2533 } 2534 2535 root.dataset.csViewPropertyBound = "true"; 2536 2537 root.addEventListener( 2538 "click", 2539 function (event) { 2540 2541 const button = 2542 event.target.closest( 2543 ".cs-availability-card__view[data-view-property-url]" 2544 ); 2545 2546 if (!button || !root.contains(button)) { 2547 return; 2548 } 2549 2550 const url = 2551 button.getAttribute( 2552 "data-view-property-url" 2553 ); 2554 2555 if (url) { 2556 window.location.href = url; 2557 } 2558 } 2559 ); 2560 } 2561 2562 2563 /* ===================================================== 2564 SuperControl booking cart 2565 ===================================================== */ 2566 2567 function bindBookingCart( 2568 root, 2569 form 2570 ) { 2571 2572 if ( 2573 root.dataset 2574 .bookingCartBound 2575 === 2576 "1" 2577 ) { 2578 return; 2579 } 2580 2581 root.dataset 2582 .bookingCartBound = 2583 "1"; 2584 2585 root.addEventListener( 2586 "click", 2587 function (event) { 2588 2589 const button = 2590 event.target.closest( 2591 ".cs-availability-card__book" 2592 ); 2593 2594 if ( 2595 !button 2596 || 2597 !root.contains(button) 2598 ) { 2599 return; 2600 } 2601 2602 event.preventDefault(); 2603 2604 const arrival = 2605 button.getAttribute( 2606 "data-booking-arrival" 2607 ) 2608 || 2609 ""; 2610 2611 const departure = 2612 button.getAttribute( 2613 "data-booking-departure" 2614 ) 2615 || 2616 ""; 2617 2618 const nights = 2619 button.getAttribute( 2620 "data-booking-nights" 2621 ) 2622 || 2623 ""; 2624 2625 const cottageID = 2626 button.getAttribute( 2627 "data-booking-cottage-id" 2628 ) 2629 || 2630 ""; 2631 2632 if ( 2633 !arrival 2634 || 2635 !departure 2636 || 2637 !nights 2638 || 2639 !cottageID 2640 ) { 2641 return; 2642 } 2643 2644 /* 2645 * Anonymous first-party analytics. This is deliberately 2646 * fire-and-forget so logging can never delay booking. 2647 */ 2648 try { 2649 const settings = window.CSAvailabilitySearch || {}; 2650 const card = button.closest(".cs-availability-card"); 2651 const analyticsData = new FormData(); 2652 const parseScDate = function (value) { 2653 const parts = String(value).split("/"); 2654 return parts.length === 3 2655 ? parts[2] + "-" + parts[1] + "-" + parts[0] 2656 : ""; 2657 }; 2658 2659 analyticsData.append("action", "cornish_stays_availability_log_book_click"); 2660 analyticsData.append("nonce", settings.nonce || ""); 2661 analyticsData.append("post_id", card ? (card.getAttribute("data-property-id") || "") : ""); 2662 analyticsData.append("property_id", cottageID); 2663 analyticsData.append("arrival", parseScDate(arrival)); 2664 analyticsData.append("departure", parseScDate(departure)); 2665 analyticsData.append("price", card ? (card.getAttribute("data-sort-price") || "") : ""); 2666 2667 if (settings.ajaxUrl && settings.nonce) { 2668 fetch(settings.ajaxUrl, { 2669 method: "POST", 2670 credentials: "same-origin", 2671 body: analyticsData, 2672 keepalive: true 2673 }).catch(function () {}); 2674 } 2675 } catch (analyticsError) { 2676 /* Analytics must never interrupt the booking flow. */ 2677 } 2678 2679 const cartDetails = { 2680 action: "cartOpen", 2681 arrivalDate: 2682 String(arrival), 2683 departureDate: 2684 String(departure), 2685 numberNights: 2686 String(nights), 2687 cottageID: 2688 String(cottageID) 2689 }; 2690 2691 button.setAttribute( 2692 "data-default-label", 2693 button.textContent.trim() 2694 || 2695 "Book Now" 2696 ); 2697 2698 button.disabled = true; 2699 button.classList.add( 2700 "is-booking" 2701 ); 2702 2703 if ( 2704 CSAvailabilitySuperControlCart.ready 2705 ) { 2706 2707 window.postMessage( 2708 cartDetails, 2709 "*" 2710 ); 2711 2712 restoreBookingButton( 2713 button 2714 ); 2715 2716 return; 2717 } 2718 2719 button.textContent = 2720 "Loading Bookingâ¦"; 2721 2722 CSAvailabilitySuperControlCart.pending = { 2723 details: cartDetails, 2724 button: button 2725 }; 2726 2727 clearTimeout( 2728 CSAvailabilitySuperControlCart.pendingTimer 2729 ); 2730 2731 CSAvailabilitySuperControlCart.pendingTimer = 2732 setTimeout( 2733 function () { 2734 2735 const pending = 2736 CSAvailabilitySuperControlCart.pending; 2737 2738 CSAvailabilitySuperControlCart.pending = 2739 null; 2740 2741 if ( 2742 pending 2743 && 2744 pending.button 2745 ) { 2746 restoreBookingButton( 2747 pending.button 2748 ); 2749 } 2750 }, 2751 10000 2752 ); 2753 } 2754 ); 2755 } 2756 2757 2758 /* ===================================================== 2759 Expandable map 2760 ===================================================== */ 2761 2762 function bindMapToggle( 2763 root, 2764 state 2765 ) { 2766 2767 const button = 2768 root.querySelector( 2769 ".cs-availability-search__map-toggle" 2770 ); 2771 2772 const wrap = 2773 root.querySelector( 2774 ".cs-availability-search__map-wrap" 2775 ); 2776 2777 if (!button || !wrap) { 2778 return; 2779 } 2780 2781 button.addEventListener( 2782 "click", 2783 function () { 2784 2785 const opening = 2786 button.getAttribute( 2787 "aria-expanded" 2788 ) 2789 !== 2790 "true"; 2791 2792 button.setAttribute( 2793 "aria-expanded", 2794 opening 2795 ? "true" 2796 : "false" 2797 ); 2798 2799 wrap.hidden = 2800 !opening; 2801 2802 const label = 2803 button.querySelector( 2804 ".cs-availability-search__map-toggle-label" 2805 ); 2806 2807 if (label) { 2808 label.textContent = 2809 opening 2810 ? "Hide Map" 2811 : "Show Map"; 2812 } 2813 2814 if (!opening) { 2815 return; 2816 } 2817 2818 if (!state.mapInitialised) { 2819 initialiseMap( 2820 root, 2821 state 2822 ); 2823 } else if (state.map) { 2824 setTimeout( 2825 function () { 2826 state.map.invalidateSize(); 2827 renderMarkers( 2828 root, 2829 state 2830 ); 2831 }, 2832 60 2833 ); 2834 } 2835 } 2836 ); 2837 } 2838 2839 2840 function readInitialMarkerData(root) { 2841 2842 const element = 2843 root.querySelector( 2844 "#cs-availability-map-data" 2845 ); 2846 2847 if (!element) { 2848 return []; 2849 } 2850 2851 try { 2852 return JSON.parse( 2853 element.textContent 2854 || 2855 "[]" 2856 ); 2857 } catch (error) { 2858 return []; 2859 } 2860 } 2861 2862 2863 function initialiseMap( 2864 root, 2865 state 2866 ) { 2867 2868 const mapElement = 2869 root.querySelector( 2870 ".cs-availability-search__map" 2871 ); 2872 2873 if ( 2874 !mapElement 2875 || 2876 typeof L === "undefined" 2877 ) { 2878 return; 2879 } 2880 2881 const config = 2882 ( 2883 window.CSAvailabilitySearch 2884 && 2885 CSAvailabilitySearch.map 2886 ) 2887 ? CSAvailabilitySearch.map 2888 : { 2889 defaultLat: 50.266, 2890 defaultLng: -5.0527, 2891 defaultZoom: 9 2892 }; 2893 2894 state.map = 2895 L.map( 2896 mapElement, 2897 { 2898 scrollWheelZoom: false 2899 } 2900 ).setView( 2901 [ 2902 Number( 2903 config.defaultLat 2904 ), 2905 Number( 2906 config.defaultLng 2907 ) 2908 ], 2909 Number( 2910 config.defaultZoom 2911 ) 2912 ); 2913 2914 L.tileLayer( 2915 "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 2916 { 2917 maxZoom: 19, 2918 attribution: 2919 "© OpenStreetMap contributors" 2920 } 2921 ).addTo( 2922 state.map 2923 ); 2924 2925 state.markerLayer = 2926 L.layerGroup() 2927 .addTo( 2928 state.map 2929 ); 2930
2931 state.mapInitialised = 2932 true; 2933 2934 renderMarkers( 2935 root, 2936 state 2937 ); 2938 2939 setTimeout( 2940 function () { 2941 state.map.invalidateSize(); 2942 }, 2943 80 2944 ); 2945 } 2946 2947 2948 function renderMarkers( 2949 root, 2950 state 2951 ) { 2952 2953 if ( 2954 !state.map 2955 || 2956 !state.markerLayer 2957 ) { 2958 return; 2959 } 2960 2961 state.markerLayer.clearLayers(); 2962 state.markersByPostId.clear(); 2963 2964 const bounds = []; 2965 2966 state.markerData.forEach( 2967 function (markerData) { 2968 2969 const lat = 2970 Number( 2971 markerData.lat 2972 ); 2973 2974 const lng = 2975 Number( 2976 markerData.lng 2977 ); 2978 2979 if ( 2980 !Number.isFinite(lat) 2981 || 2982 !Number.isFinite(lng) 2983 ) { 2984 return; 2985 } 2986 2987 const marker = 2988 L.marker( 2989 [lat, lng] 2990 ) 2991 .addTo( 2992 state.markerLayer 2993 ) 2994 .bindPopup( 2995 markerData.popupHtml 2996 || 2997 "", 2998 { 2999 maxWidth: 270 3000 } 3001 ); 3002 3003 const propertyId = 3004 String( 3005 markerData.postId 3006 ); 3007 3008 state.markersByPostId.set( 3009 propertyId, 3010 marker 3011 ); 3012 3013 bounds.push( 3014 [lat, lng] 3015 ); 3016 } 3017 ); 3018 3019 if (bounds.length) { 3020 3021 state.map.fitBounds( 3022 bounds, 3023 { 3024 padding: [35, 35], 3025 maxZoom: 13 3026 } 3027 ); 3028 3029 } else { 3030 3031 const config = 3032 CSAvailabilitySearch.map; 3033 3034 state.map.setView( 3035 [ 3036 Number( 3037 config.defaultLat 3038 ), 3039 Number( 3040 config.defaultLng 3041 ) 3042 ], 3043 Number( 3044 config.defaultZoom 3045 ) 3046 ); 3047 } 3048 } 3049 3050 3051 /* ===================================================== 3052 Card -> map interaction 3053 ===================================================== */ 3054 3055 function bindCardInteractions( 3056 root, 3057 state 3058 ) { 3059 3060 root 3061 .querySelectorAll( 3062 ".cs-availability-card[data-property-id]" 3063 ) 3064 .forEach( 3065 function (card) { 3066 3067 if ( 3068 card.dataset 3069 .mapInteractionBound 3070 === 3071 "1" 3072 ) { 3073 return; 3074 } 3075 3076 card.dataset 3077 .mapInteractionBound = 3078 "1"; 3079 3080 const propertyId = 3081 String( 3082 card.getAttribute( 3083 "data-property-id" 3084 ) 3085 ); 3086 3087 card.addEventListener( 3088 "mouseenter", 3089 function () { 3090 3091 if ( 3092 !state.mapInitialised 3093 || 3094 !state.map 3095 ) { 3096 return; 3097 } 3098 3099 const marker = 3100 state 3101 .markersByPostId 3102 .get( 3103 propertyId 3104 ); 3105 3106 if (!marker) { 3107 return; 3108 } 3109 3110 root 3111 .querySelectorAll( 3112 ".cs-availability-card.is-map-active" 3113 ) 3114 .forEach( 3115 function (item) { 3116 item.classList.remove( 3117 "is-map-active" 3118 ); 3119 } 3120 ); 3121 3122 state 3123 .markersByPostId 3124 .forEach( 3125 function (item) { 3126 3127 const icon = 3128 item.getElement(); 3129 3130 if (icon) { 3131 icon.classList.remove( 3132 "is-highlighted" 3133 ); 3134 } 3135 } 3136 ); 3137 3138 card.classList.add( 3139 "is-map-active" 3140 ); 3141 3142 const icon = 3143 marker.getElement(); 3144 3145 if (icon) { 3146 icon.classList.add( 3147 "is-highlighted" 3148 ); 3149 } 3150 3151 state.map.flyTo( 3152 marker.getLatLng(), 3153 Math.max( 3154 state.map.getZoom(), 3155 14 3156 ), 3157 { 3158 animate: true, 3159 duration: .4 3160 } 3161 ); 3162 3163 marker.openPopup(); 3164 } 3165 ); 3166 3167 card.addEventListener( 3168 "mouseleave", 3169 function () { 3170 3171 card.classList.remove( 3172 "is-map-active" 3173 ); 3174 3175 const marker = 3176 state 3177 .markersByPostId 3178 .get( 3179 propertyId 3180 ); 3181 3182 if (marker) { 3183 const icon = 3184 marker.getElement(); 3185 3186 if (icon) { 3187 icon.classList.remove( 3188 "is-highlighted" 3189 ); 3190 } 3191 } 3192 } 3193 ); 3194 } 3195 ); 3196 } 3197 3198 3199 /* ===================================================== 3200 Utilities 3201 ===================================================== */ 3202 3203 function escapeHtml(string) { 3204 3205 return String(
3206 string 3207 ).replace( 3208 /[&<>"']/g, 3209 function (character) { 3210 3211 return { 3212 "&": "&", 3213 "<": "<", 3214 ">": ">", 3215 '"': """, 3216 "'": "'" 3217 }[character]; 3218 } 3219 ); 3220 } 3221} 3222 3223/* 3224 * Divi and optimisation plugins can alter script timing. Run once immediately, 3225 * then retry at the common lifecycle points. The per-root data guard above 3226 * prevents duplicate listeners while allowing late-rendered shortcode markup 3227 * to initialise reliably. 3228 */ 3229initialiseCSAvailabilitySearch(); 3230 3231if (document.readyState === "loading") { 3232 document.addEventListener( 3233 "DOMContentLoaded", 3234 initialiseCSAvailabilitySearch 3235 ); 3236} 3237 3238window.addEventListener( 3239 "load", 3240 initialiseCSAvailabilitySearch 3241); 3242 3243window.addEventListener( 3244 "pageshow", 3245 initialiseCSAvailabilitySearch 3246); 3247 3248setTimeout(initialiseCSAvailabilitySearch, 0); 3249setTimeout(initialiseCSAvailabilitySearch, 250); 3250setTimeout(initialiseCSAvailabilitySearch, 1000); 3251 3252if (typeof MutationObserver !== "undefined") { 3253 const csAvailabilityObserver = new MutationObserver(function () { 3254 initialiseCSAvailabilitySearch(); 3255 }); 3256 3257 csAvailabilityObserver.observe( 3258 document.documentElement, 3259 { childList: true, subtree: true } 3260 ); 3261 3262 window.setTimeout(function () { 3263 csAvailabilityObserver.disconnect(); 3264 }, 5000); 3265}
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.