1/* MP Suite â Nursery Finder engine. Card-based, no map. 2 Home page: set data-redirect so a search sends the visitor to the finder page with the 3 query, where results render inline. Status shows as a floating tip, never shifting layout. */ 4(function () { 5 'use strict'; 6 7 var cfg = window.mpSuiteFinder || {}; 8 var root = document.getElementById('mp-finder'); 9 if (!root) { return; } 10 11 var redirectUrl = root.getAttribute('data-redirect') || ''; 12 var elFeatured = document.getElementById('mp-nf-featured'); 13 var elOthers = document.getElementById('mp-nf-others'); 14 var elOthersTitle = document.getElementById('mp-nf-others-head'); 15 var elAllHead = document.getElementById('mp-nf-all-head'); 16 var elTip = document.getElementById('mp-nf-status'); 17 var elEnquiry = document.getElementById('mp-nf-enquiry'); 18 var elEnqEyebrow = document.getElementById('mp-nf-enq-eyebrow'); 19 var form = document.getElementById('mp-nf-search'); 20 var dataLayer = (window.dataLayer = window.dataLayer || []); 21 var jq = window.jQuery || null; 22 if (jq && cfg.enquiryFormId) { 23 jq(document).on('gform_confirmation_loaded', function (e, formId) { 24 if (parseInt(formId, 10) === parseInt(cfg.enquiryFormId, 10)) { 25 dataLayer.push({ event: 'gf_find_nursery_submission', nursery_id: state.featuredId || null }); 26 } 27 }); 28 } 29 var tipTimer = null; 30 31 // searched: a real search has run, so the landing list must not overwrite its results. 32 var state = { results: [], featuredId: null, nearestId: null, searched: false, search: { start: '', dob: '', postcode: '' } }; 33 34 var fpStart = null, fpDob = null; 35 if (window.flatpickr) { 36 var common = { dateFormat: 'd/m/Y', disableMobile: true, allowInput: true }; 37 fpStart = window.flatpickr($('mp-nf-start'), Object.assign({ minDate: 'today' }, common)); 38 // DOB allows up to a year ahead: parents enquire before the baby is born. 39 fpDob = window.flatpickr($('mp-nf-dob'), Object.assign({ maxDate: new Date().fp_incr(365) }, common)); 40 } 41 42 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { 43 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } 44 function $(id) { return document.getElementById(id); } 45 function miles(d) { return (Math.round(parseFloat(d) * 10) / 10) + ' mi'; } 46 // "X miles away", matching the quiz results cards. 47 function milesAway(d) { var m = Math.round((parseFloat(d) || 0) * 10) / 10; return m + (m === 1 ? ' mile away' : ' miles away'); } 48 // The bare number for the CRM ("2.3", not "2.3 miles away"), so the Monday column can be a 49 // Numbers one and the board can sort and filter on it. Returned as a string so a legitimate 50 // 0 survives the truthiness guard in setEnqField. 51 function distValue(d) { return (d == null || d === '') ? '' : String(Math.round((parseFloat(d) || 0) * 10) / 10); } 52 function byId(id) { return state.results.filter(function (n) { return String(n.id) === String(id); })[0] || null; } 53 function nameHtml(t) { return String(t == null ? '' : t).split(/<br\s*\/?>/i).map(function (s) { return esc(s.trim()); }).filter(Boolean).join('<br>'); } 54 function nudge(el) { if (!el) { return; } var r = el.getBoundingClientRect(); var vh = window.innerHeight || document.documentElement.clientHeight; if (r.top < 0 || r.top > vh) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } 55 // Scroll so the top of the featured card sits just below the site's sticky header. 56 var HEADER_OFFSET = 110; 57 function scrollToFeatured() { 58 var r = elFeatured.getBoundingClientRect(); 59 var y = r.top + (window.pageYOffset || document.documentElement.scrollTop || 0) - HEADER_OFFSET; 60 window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' }); 61 } 62 63 function tip(msg, sticky) { 64 if (tipTimer) { clearTimeout(tipTimer); tipTimer = null; } 65 if (!msg) { elTip.hidden = true; elTip.textContent = ''; return; } 66 elTip.textContent = msg; elTip.hidden = false; 67 if (!sticky) { tipTimer = setTimeout(function () { elTip.hidden = true; }, 4000); } 68 } 69 70 function buildRedirect() { 71 var p = []; 72 if (state.search.postcode) { p.push('postcode=' + encodeURIComponent(state.search.postcode)); } 73 if (state.search.start) { p.push('start=' + encodeURIComponent(state.search.start)); } 74 if (state.search.dob) { p.push('dob=' + encodeURIComponent(state.search.dob)); } 75 return redirectUrl + (redirectUrl.indexOf('?') > -1 ? '&' : '?') + p.join('&'); 76 } 77 78 /* ---------- search ---------- */ 79 function lookup(params) { 80 var url = new URL(cfg.restUrl, window.location.origin); 81 if (params.lat != null) { url.searchParams.set('lat', params.lat); url.searchParams.set('lng', params.lng); } 82 else { url.searchParams.set('postcode', params.postcode); }
83 url.searchParams.set('radius', cfg.radius || 10); 84 url.searchParams.set('limit', cfg.limit || 6); 85 state.searched = true; 86 tip('Searching\u2026', true); 87 fetch(url.toString(), { headers: { 'Accept': 'application/json' } }) 88 .then(function (r) { return r.json(); }) 89 .then(function (b) { 90 if (b && b.error === 'location_unresolved') { tip('We couldn\u2019t find that location. Try a postcode or a nearby town.'); clearResults(); return; } 91 state.results = (b && b.results) || []; 92 dataLayer.push({ event: 'nursery_search', postcode: state.search.postcode, results: state.results.length, in_range: !!(b && b.in_range) }); 93 if (!state.results.length) { tip(cfg.noResults || 'No nurseries found near there yet.'); clearResults(); return; } 94 state.nearestId = state.results[0].id; 95 state.featuredId = state.results[0].id; 96 tip((b && b.in_range) ? '' : 'Nothing within range, here\u2019s your closest nursery.'); 97 render(); 98 }) 99 .catch(function (e) { console.error('[mp-suite] finder', e); tip('Something went wrong with the search.'); }); 100 } 101 102 function clearResults() { 103 elFeatured.innerHTML = ''; elOthers.innerHTML = ''; 104 elOthersTitle.hidden = true; elAllHead.hidden = true; 105 } 106 107 // Landing state: every nursery A-Z in the same 3-column cards a search renders, minus the 108 // distances. Skipped if a search has already run (a fast typist beats the fetch). 109 function loadAll() { 110 if (!cfg.listUrl) { return; } 111 fetch(cfg.listUrl, { headers: { 'Accept': 'application/json' } }) 112 .then(function (r) { return r.json(); }) 113 .then(function (b) { 114 if (state.searched) { return; } 115 state.results = (b && b.results) || []; 116 state.featuredId = null; 117 state.nearestId = null; 118 render(); 119 }) 120 .catch(function (e) { console.error('[mp-suite] finder list', e); }); 121 } 122 123 /* ---------- render ---------- */ 124 var ICON_PIN = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Z"/><circle cx="12" cy="10" r="2.5"/></svg>'; 125 var ICON_PHONE = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z"/></svg>'; 126 var ICON_CLOCK = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'; 127 var ICON_ARROW = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>'; 128 129 // Slides plus arrows and dots for a nursery's gallery; shared by the featured and list cards. 130 function slidesHtml(n, imgs) { 131 var label = plainName(n.title) || 'Nursery'; 132 var slides = imgs.map(function (u, i) { return '<div class="mp-nf__slide" role="img" aria-label="' + esc(label) + ' photo ' + (i + 1) + '" style="background-image:url(\'' + encodeURI(u) + '\')"></div>'; }).join(''); 133 var dots = imgs.length > 1 ? '<div class="mp-nf__dots">' + imgs.map(function (_, i) { return '<button type="button" class="mp-nf__dot' + (i === 0 ? ' is-on' : '') + '" data-slide="' + i + '" aria-label="Image ' + (i + 1) + '"></button>'; }).join('') + '</div>' : ''; 134 var arrows = imgs.length > 1 ? '<button type="button" class="mp-nf__sl-arrow mp-nf__sl-prev" data-slide-dir="-1" aria-label="Previous">\u2039</button><button type="button" class="mp-nf__sl-arrow mp-nf__sl-next" data-slide-dir="1" aria-label="Next">\u203a</button>' : ''; 135 return '<div class="mp-nf__slides">' + slides + '</div>' + arrows + dots; 136 } 137 138 function illus(n) { 139 var imgs = (n.images && n.images.length) ? n.images : []; 140 if (!imgs.length) { 141 if (cfg.placeholder) { 142 return '<div class="mp-nf__card-illus" style="background-image:url(\'' + encodeURI(cfg.placeholder) + '\')">{BADGE}</div>'; 143 } 144 return '<div class="mp-nf__card-illus">{BADGE}</div>'; 145 } 146 return '<div class="mp-nf__card-illus mp-nf__slider" data-index="0">' + slidesHtml(n, imgs) + '{BADGE}</div>'; 147 } 148 149 // List-card image: the same slider at list height, with the distance badge (if any) on top. 150 function listIllus(n, badge) { 151 var imgs = (n.images && n.images.length) ? n.images : []; 152 if (!imgs.length) { 153 if (cfg.placeholder) { 154 return '<div class="mp-nf__list-illus" style="background-image:url(\'' + encodeURI(cfg.placeholder) + '\')">' + badge + '</div>'; 155 } 156 return '<div class="mp-nf__list-illus mp-nf__list-illus--ph">' + badge + '</div>'; 157 } 158 return '<div class="mp-nf__list-illus mp-nf__slider mp-nf__slider--list" data-index="0">' + slidesHtml(n, imgs) + badge + '</div>'; 159 } 160 161 function initSliders(scope) { 162 scope.querySelectorAll('.mp-nf__slider').forEach(function (slider) { 163 var track = slider.querySelector('.mp-nf__slides'); 164 var count = track ? track.children.length : 0; 165 if (count < 2) { return; } 166 function go(i) { 167 var idx = ((i % count) + count) % count; 168 slider.setAttribute('data-index', idx); 169 track.style.transform = 'translateX(' + (-idx * 100) + '%)'; 170 var dots = slider.querySelectorAll('.mp-nf__dot');
171 dots.forEach(function (d, di) { d.classList.toggle('is-on', di === idx); }); 172 } 173 slider.addEventListener('click', function (e) { 174 var dir = e.target.closest && e.target.closest('[data-slide-dir]'); 175 var dot = e.target.closest && e.target.closest('[data-slide]'); 176 if (dir) { e.stopPropagation(); go(parseInt(slider.getAttribute('data-index'), 10) + parseInt(dir.getAttribute('data-slide-dir'), 10)); } 177 else if (dot) { e.stopPropagation(); go(parseInt(dot.getAttribute('data-slide'), 10)); } 178 }); 179 }); 180 } 181 182 function listCard(n) { 183 var badge = n.distance != null ? '<span class="mp-nf__list-dist">' + esc(milesAway(n.distance)) + '</span>' : ''; 184 var addr = [n.address, n.postcode].filter(Boolean).join(', '); 185 return '<div class="mp-nf__list-card mp-card">' + 186 listIllus(n, badge) + 187 '<div class="mp-nf__list-body">' + 188 '<div class="mp-nf__list-head">' + 189 '<h3 class="mp-nf__name">' + nameHtml(n.title) + '</h3>' + 190 (n.review ? '<span class="mp-nf__score">' + esc(n.review) + '</span>' : '') + 191 '</div>' + 192 (addr ? '<p class="mp-nf__meta"><span class="mp-nf__meta-ic">' + ICON_PIN + '</span>' + esc(addr) + '</p>' : '') + 193 '<div class="mp-nf__list-actions">' + 194 '<button type="button" class="mp-nf__btn mp-nf__btn--primary" data-enquire-id="' + esc(n.id) + '">Enquire now</button>' + 195 '<a class="mp-nf__btn mp-nf__btn--ghost" href="' + nurseryHref(n) + '"' + nurseryTarget(n) + '>View nursery</a>' + 196 '</div>' + 197 '</div>' + 198 '</div>'; 199 } 200 201 function render() { 202 // Search results carry a distance; the landing list (every nursery, A-Z) does not, so it 203 // gets no distance badges, no "nearest" eyebrow, no featured card until one is chosen, 204 // and its own heading. 205 var located = state.results.length > 0 && state.results[0].distance != null; 206 var featured = byId(state.featuredId) || (located ? state.results[0] : null); 207 208 if (featured) { 209 var badge = featured.distance != null ? '<span class="mp-nf__badge">' + esc(milesAway(featured.distance)) + '</span>' : ''; 210 var addr = [featured.address, featured.postcode].filter(Boolean).join(', '); 211 elFeatured.innerHTML = 212 '<div class="mp-nf__card mp-card">' + 213 illus(featured).replace('{BADGE}', badge) + 214 '<div class="mp-nf__card-body">' + 215 (featured.review ? '<div class="mp-nf__score-wrap"><span class="mp-nf__score">' + esc(featured.review) + '</span><div class="mp-nf__score-label">review score</div></div>' : '') + 216 (located ? '<p class="mp-nf__eyebrow">Your nearest Monkey Puzzle</p>' : '') + 217 '<h3 class="mp-nf__name">' + nameHtml(featured.title) + '</h3>' + 218 (featured.desc ? '<p class="mp-nf__desc">' + esc(featured.desc) + '</p>' : '') + 219 (addr ? '<p class="mp-nf__meta"><span class="mp-nf__meta-ic">' + ICON_PIN + '</span>' + esc(addr) + '</p>' : '') + 220 (featured.hours ? '<p class="mp-nf__meta"><span class="mp-nf__meta-ic">' + ICON_CLOCK + '</span>' + esc(featured.hours) + '</p>' : '') + 221 (featured.phone ? '<p class="mp-nf__meta"><span class="mp-nf__meta-ic">' + ICON_PHONE + '</span><a href="tel:' + esc(featured.phone.replace(/\s+/g, '')) + '">' + esc(featured.phone) + '</a></p>' : '') + 222 '<div class="mp-nf__actions">' + 223 '<button type="button" class="mp-nf__btn mp-nf__btn--primary" data-enquire>ENQUIRE NOW</button>' + 224 '<a class="mp-nf__btn mp-nf__btn--ghost" href="' + nurseryHref(featured) + '"' + nurseryTarget(featured) + '>VIEW NURSERY</a>' + 225 '</div>' + 226 '</div>' + 227 '</div>'; 228 initSliders(elFeatured); 229 } else { 230 elFeatured.innerHTML = ''; 231 } 232 233 var others = featured ? state.results.filter(function (n) { return String(n.id) !== String(featured.id); }) : state.results; 234 elOthersTitle.hidden = !located || others.length === 0; 235 elAllHead.hidden = located || others.length === 0; 236 elOthers.innerHTML = others.map(listCard).join(''); 237 initSliders(elOthers); 238 239 elEnquiry.hidden = true; 240 } 241 242 /* ---------- inline enquiry ---------- */ 243 function setEnqField(cls, value, tint) { 244 if (!value) { return; } 245 var wrap = elEnquiry.querySelector('.' + cls); 246 if (!wrap) { return; } 247 var input = wrap.querySelector('input, select, textarea'); 248 if (input) { input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } 249 if (tint) { wrap.classList.add('mp-prefilled'); } 250 } 251 function plainName(t) { return String(t == null ? '' : t).replace(/<br\s*\/?>/ig, ' ').replace(/\s+/g, ' ').trim(); } 252 // "View nursery" links to the nursery's own website (ACF), in a new tab; falls back to the 253 // CPT permalink when no website is set. 254 function nurseryHref(n) { return esc((n && n.website) || (n && n.permalink) || '#'); } 255 function nurseryTarget(n) { return (n && n.website) ? ' target="_blank" rel="noopener"' : ''; } 256 257 function openEnquiry() { 258 var featured = byId(state.featuredId); 259 if (!featured) { return; } 260 elEnqEyebrow.textContent = 'ENQUIRY \u00b7 ' + plainName(featured.title).toUpperCase(); 261 setEnqField('nursery_id', featured.id, false); 262 setEnqField('mp-nursery-name', plainName(featured.title), false); 263 setEnqField('mp-distance', distValue(featured.distance), false); 264 setEnqField('mp-start-date', state.search.start, true); 265 setEnqField('mp-child-dob', state.search.dob, true); 266 setEnqField('mp-postcode', state.search.postcode, true); 267 elEnquiry.hidden = false;
268 nudge(elEnquiry); 269 } 270 function viewNursery(id) { 271 state.featuredId = id; 272 dataLayer.push({ event: 'nursery_result_click', nursery_id: id }); 273 render(); 274 nudge(elFeatured); 275 } 276 277 /* ---------- events ---------- */ 278 function readPills() { 279 state.search = { start: $('mp-nf-start').value.trim(), dob: $('mp-nf-dob').value.trim(), postcode: $('mp-nf-postcode').value.trim() }; 280 } 281 282 form.addEventListener('submit', function (e) { 283 e.preventDefault(); 284 readPills(); 285 if (!state.search.postcode) { tip('Enter a postcode or location to search.'); return; } 286 if (redirectUrl) { window.location.href = buildRedirect(); return; } 287 lookup({ postcode: state.search.postcode }); 288 }); 289 290 root.addEventListener('click', function (e) { 291 var enq = e.target.closest ? e.target.closest('[data-enquire]') : null; 292 if (enq) { openEnquiry(); return; } 293 // "Enquire now" on a grid card: promote it to the featured (top) card, re-render so it 294 // moves up and out of the list, open the enquiry pointed at it, and scroll to the card 295 // itself (not the form beneath it) so the nursery's name and photos are what you see. 296 var enqId = e.target.closest ? e.target.closest('[data-enquire-id]') : null; 297 if (enqId) { 298 state.featuredId = enqId.getAttribute('data-enquire-id'); 299 dataLayer.push({ event: 'nursery_result_click', nursery_id: state.featuredId }); 300 render(); 301 openEnquiry(); 302 scrollToFeatured(); 303 return; 304 } 305 var view = e.target.closest ? e.target.closest('[data-view]') : null; 306 if (view) { viewNursery(view.getAttribute('data-view')); return; } 307 if (e.target.id === 'mp-nf-enq-close') { elEnquiry.hidden = true; } 308 }); 309 310 /* optional Places Autocomplete on the location pill */ 311 if (cfg.hasMap && window.mpSuiteMaps) { 312 window.mpSuiteMaps.onReady(function () { 313 try { 314 var pc = $('mp-nf-postcode'); 315 var ac = new google.maps.places.Autocomplete(pc, { componentRestrictions: { country: 'gb' }, fields: ['geometry'], types: ['geocode'] }); 316 ac.addListener('place_changed', function () { 317 readPills(); 318 var p = ac.getPlace(); 319 if (redirectUrl) { window.location.href = buildRedirect(); return; } 320 if (p && p.geometry && p.geometry.location) { lookup({ lat: p.geometry.location.lat(), lng: p.geometry.location.lng() }); } 321 else if (state.search.postcode) { lookup({ postcode: state.search.postcode }); } 322 }); 323 } catch (e) { /* server geocode fallback on submit */ } 324 }); 325 } 326 327 /* arriving with a query (redirected from the home page): prefill and search inline. 328 Arriving without one: show every nursery. */ 329 (function fromQuery() { 330 if (redirectUrl) { return; } 331 var q = new URLSearchParams(window.location.search); 332 var pc = q.get('postcode'); 333 if (!pc) { loadAll(); return; } 334 $('mp-nf-postcode').value = pc; 335 if (q.get('start')) { fpStart ? fpStart.setDate(q.get('start'), false, 'd/m/Y') : ($('mp-nf-start').value = q.get('start')); } 336 if (q.get('dob')) { fpDob ? fpDob.setDate(q.get('dob'), false, 'd/m/Y') : ($('mp-nf-dob').value = q.get('dob')); } 337 readPills(); 338 lookup({ postcode: pc }); 339 })(); 340})();
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.