1/* ================================================ 2 TRANSFER LIMO TOUR EXCLUSIVE - Booking Calculator & UI Logic 3 ================================================ */ 4 5// --- PRICING DATA (market-average, adjustable) --- 6// Fixed prices for local routes only. Long distance uses per-km calculation. 7const PRICES = { 8 'civitavecchia-port': { 9 'rome-center': { t1: 160, t2: 180, t3: 210, km: 80 }, 10 'rome-termini': { t1: 160, t2: 180, t3: 210, km: 80 }, 11 'fiumicino': { t1: 150, t2: 170, t3: 190, km: 60 }, 12 'ciampino': { t1: 180, t2: 200, t3: 230, km: 100 }, 13 }, 14 'fiumicino': { 15 'rome-center': { t1: 60, t2: 75, t3: 95, km: 30 }, 16 'rome-termini': { t1: 60, t2: 75, t3: 95, km: 33 }, 17 'civitavecchia-port': { t1: 150, t2: 170, t3: 190, km: 60 }, 18 'ciampino': { t1: 80, t2: 95, t3: 110, km: 45 }, 19 }, 20 'ciampino': { 21 'rome-center': { t1: 55, t2: 70, t3: 90, km: 15 }, 22 'rome-termini': { t1: 55, t2: 70, t3: 90, km: 18 }, 23 'civitavecchia-port': { t1: 180, t2: 200, t3: 230, km: 100 }, 24 'fiumicino': { t1: 80, t2: 95, t3: 110, km: 45 }, 25 }, 26 'rome-center': { 27 'civitavecchia-port': { t1: 160, t2: 180, t3: 210, km: 80 }, 28 'fiumicino': { t1: 60, t2: 75, t3: 95, km: 30 }, 29 'ciampino': { t1: 55, t2: 70, t3: 90, km: 15 }, 30 'rome-termini': { t1: 25, t2: 35, t3: 45, km: 5 }, 31 }, 32 'rome-termini': { 33 'civitavecchia-port': { t1: 160, t2: 180, t3: 210, km: 80 }, 34 'fiumicino': { t1: 60, t2: 75, t3: 95, km: 33 }, 35 'ciampino': { t1: 55, t2: 70, t3: 90, km: 18 }, 36 'rome-center': { t1: 25, t2: 35, t3: 45, km: 5 }, 37 }, 38}; 39 40const NIGHT_SURCHARGE = 10; 41const CHILD_SEAT_FEE = 10; 42const EXTRA_BAGGAGE_FEE = 5; // +â¬5 per extra piece 43const ANIMAL_FEE = 15; // +â¬15 per pet 44const WAITING_FREE_MIN = 20; // 20 min free waiting 45const WAITING_HOURLY = 50; // â¬50/hr after free period 46const ROUNDTRIP_DISCOUNT = 0.10; // 10% discount for round-trip bookings 47const WHATSAPP_NUMBER = '393803444687'; 48 49const LOCATION_COORDS = { 50 'civitavecchia-port': [42.0984, 11.7892], // Largo della Pace - NCC meeting point 51 'fiumicino': [41.7928, 12.2522], // T3 NCC/Limo Service zone 52 'ciampino': [41.7994, 12.5916], 53 'rome-center': [41.89571, 12.48304], 54 'rome-termini': [41.9022, 12.5009], // Via Marsala 27 - NCC stop 55 'florence': [43.7766, 11.2479], // Largo V. Giudice - SMN drop-off 56 'naples': [40.8518, 14.2681], 57 'amalfi-coast': [40.6334, 14.6026], // Piazza Flavio Gioia, Amalfi 58 'reggia-caserta': [41.0732, 14.3271], // Piazza Carlo di Borbone 59}; 60 61// Predefined address labels for destination pre-fill (NCC-specific stop points) 62const LOCATION_ADDRESSES = { 63 'civitavecchia-port': 'Largo della Pace, Civitavecchia (Cruise Terminal shuttle hub)', 64 'fiumicino': 'Terminal 3 - NCC/Limo Meeting Point, Fiumicino Airport (FCO)', 65 'rome-termini': 'Via Marsala 27, Roma Termini (NCC pickup zone)', 66 'rome-center': 'Monti, Municipio Roma I, Rome, 00187', 67 'florence': 'Largo Vincenzo Giudice, Stazione Santa Maria Novella, Florence', 68 'amalfi-coast': 'Piazza Flavio Gioia, Amalfi (port & bus terminal)', 69 'reggia-caserta': 'Piazza Carlo di Borbone, Caserta (main entrance)', 70}; 71const CUSTOM_RATES = { t1: 1.80, t2: 2.10, t3: 2.40, minFare: 40 }; 72const LONG_DISTANCE_THRESHOLD_KM = 100; // Road km above this = round-trip charge 73const CARD_SURCHARGE = 0.10; // 10% card surcharge 74const AIRPORT_LOCATIONS = ['fiumicino', 'ciampino']; 75const PORT_LOCATIONS = ['civitavecchia-port']; 76const AIRPORT_KEYWORDS = /airport|aeroporto|aeropuerto|a[eé]roport|flughafen|aerodrome|aeroport/i; 77const PORT_KEYWORDS = /\bport[oe]?\b|harbour|harbor|cruise.?terminal|terminal.?crociere|molo|banchina|porto\b/i; 78 79// --- LOCATION LABELS (updated dynamically by i18n) --- 80let LOCATION_NAMES = { 81 'civitavecchia-port': 'Civitavecchia Cruise Port', 82 'fiumicino': 'Fiumicino Airport (FCO)', 83 'ciampino': 'Ciampino Airport (CIA)', 84 'rome-center': 'Rome City Center', 85 'rome-termini': 'Roma Termini Station', 86 'florence': 'Florence', 87 'naples': 'Naples', 88 'amalfi-coast': 'Amalfi Coast', 89 'reggia-caserta': 'Reggia di Caserta', 90 'custom': 'Custom location', 91};
92 93// --- DOM ELEMENTS --- 94const $ = (sel) => document.querySelector(sel); 95const pickup = $('#pickupLocation'); 96const dropoff = $('#dropoffLocation'); 97const passengers = $('#passengers'); 98const tripDate = $('#tripDate'); 99const tripTime = $('#tripTime'); 100const priceAmount = $('#priceAmount'); 101const priceNote = $('#priceNote'); 102const priceRoundTripInfo = $('#priceRoundTripInfo'); 103const priceRoundTripText = $('#priceRoundTripText'); 104const bookingForm = $('#bookingForm'); 105const childSeatCheckbox = $('#childSeat'); 106const flightShipRow = $('#flightShipRow'); 107const flightShipRef = $('#flightShipRef'); 108const flightShipIcon = $('#flightShipIcon'); 109const flightShipLabelText = $('#flightShipLabelText'); 110let currentPaymentMethod = 'cash'; 111 112// --- CURRENT LANGUAGE --- 113let currentLang = localStorage.getItem('transfer-limo-lang') || 'en'; 114 115// --- FLATPICKR INSTANCES --- 116let fpDate = null, fpTime = null; 117 118// --- i18n SYSTEM --- 119function setLanguage(lang) { 120 if (!TRANSLATIONS[lang]) return; 121 currentLang = lang; 122 localStorage.setItem('transfer-limo-lang', lang); 123 document.documentElement.lang = lang === 'en' ? 'en' : lang === 'it' ? 'it' : 'es'; 124 125 // Update all elements with data-i18n 126 document.querySelectorAll('[data-i18n]').forEach(el => { 127 const key = el.getAttribute('data-i18n'); 128 const translation = TRANSLATIONS[lang][key]; 129 if (!translation) return; 130 131 if (el.hasAttribute('data-i18n-html')) { 132 el.innerHTML = translation; 133 } else if (el.tagName === 'OPTION') { 134 el.textContent = translation; 135 } else { 136 el.textContent = translation; 137 } 138 }); 139 140 // Update placeholders 141 document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { 142 const key = el.getAttribute('data-i18n-placeholder'); 143 const translation = TRANSLATIONS[lang][key]; 144 if (translation) el.placeholder = translation; 145 }); 146 147 // Update location names for booking messages 148 LOCATION_NAMES = { 149 'civitavecchia-port': TRANSLATIONS[lang]['loc.civitavecchia'], 150 'fiumicino': TRANSLATIONS[lang]['loc.fiumicino'], 151 'ciampino': TRANSLATIONS[lang]['loc.ciampino'], 152 'rome-center': TRANSLATIONS[lang]['loc.rome-center'], 153 'rome-termini': TRANSLATIONS[lang]['loc.rome-termini'], 154 'florence': TRANSLATIONS[lang]['loc.florence'], 155 'naples': TRANSLATIONS[lang]['loc.naples'], 156 'custom': TRANSLATIONS[lang]['loc.custom'], 157 }; 158 159 // Update active button state 160 document.querySelectorAll('.lang-btn').forEach(btn => { 161 btn.classList.toggle('active', btn.getAttribute('data-lang') === lang); 162 }); 163 164 // Update Flatpickr locale 165 if (fpDate) { fpDate.set('locale', getFpLocale(lang)); fpDate.redraw(); } 166 if (fpTime) { fpTime.set('locale', getFpLocale(lang)); fpTime.redraw(); } 167 168 // Update flight/ship field labels for new language 169 updateFlightShipField(); 170 171 // Recalculate price to update note text 172 calculatePrice(); 173 174 // Re-render time slots 175 renderTimeSlots(); 176} 177 178function initLanguage() { 179 // Bind click handlers to all lang buttons 180 document.querySelectorAll('.lang-btn').forEach(btn => { 181 btn.addEventListener('click', () => setLanguage(btn.getAttribute('data-lang'))); 182 }); 183 184 // Set initial language 185 setLanguage(currentLang); 186} 187 188// --- SET DEFAULT DATE (tomorrow) with Flatpickr --- 189function getFpLocale(lang) { 190 if (lang === 'it' && flatpickr.l10ns.it) return 'it'; 191 if (lang === 'es' && flatpickr.l10ns.es) return 'es'; 192 return 'default'; 193} 194 195function initDate() { 196 const tomorrow = new Date(); 197 tomorrow.setDate(tomorrow.getDate() + 1); 198 199 if (typeof flatpickr !== 'undefined') { 200 fpDate = flatpickr(tripDate, { 201 dateFormat: 'Y-m-d', 202 altInput: true, 203 altFormat: 'D, j M Y', 204 defaultDate: tomorrow, 205 minDate: 'today', 206 disableMobile: true, 207 locale: getFpLocale(currentLang), 208 onChange: function() { 209 renderTimeSlots(); 210 calculatePrice(); 211 } 212 }); 213 214 fpTime = flatpickr(tripTime, { 215 enableTime: true, 216 noCalendar: true, 217 dateFormat: 'H:i', 218 defaultDate: '10:00', 219 time_24hr: true, 220 disableMobile: true, 221 locale: getFpLocale(currentLang), 222 onChange: function() { 223 renderTimeSlots();
224 calculatePrice(); 225 } 226 }); 227 } else { 228 // Fallback if Flatpickr CDN fails 229 tripDate.value = tomorrow.toISOString().split('T')[0]; 230 tripDate.min = new Date().toISOString().split('T')[0]; 231 tripTime.value = '10:00'; 232 } 233} 234 235// --- DISTANCE HELPERS --- 236function haversineKm(lat1, lon1, lat2, lon2) { 237 var R = 6371; 238 var dLat = (lat2 - lat1) * Math.PI / 180; 239 var dLon = (lon2 - lon1) * Math.PI / 180; 240 var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + 241 Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * 242 Math.sin(dLon / 2) * Math.sin(dLon / 2); 243 return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 244} 245 246function getMarkerCoords(type) { 247 var marker = type === 'pickup' ? pickupMarker : dropoffMarker; 248 if (!marker) return null; 249 var ll = marker.getLatLng(); 250 return [ll.lat, ll.lng]; 251} 252 253// --- ROUND-TRIP INFO HELPER --- 254function showRoundTripInfo(show, t) { 255 if (!priceRoundTripInfo) return; 256 if (show && t) { 257 priceRoundTripText.textContent = t['booking.price.roundTripInfo'] || 'Distance over 100 km \u2014 price includes driver\u2019s return trip'; 258 priceRoundTripInfo.style.display = 'flex'; 259 } else { 260 priceRoundTripInfo.style.display = 'none'; 261 } 262} 263 264// --- PRICE CALCULATOR --- 265function calculatePrice() { 266 const from = pickup.value; 267 const to = dropoff.value; 268 const pax = parseInt(passengers.value); 269 const t = TRANSLATIONS[currentLang]; 270 271 if (!from || !to) { 272 priceAmount.textContent = '\u2014'; 273 priceNote.textContent = t['booking.price.select'] || 'Select pickup & destination'; 274 showRoundTripInfo(false); 275 return null; 276 } 277 278 if (from === 'custom' || to === 'custom') { 279 let fromCoords = from === 'custom' ? getMarkerCoords('pickup') : LOCATION_COORDS[from];
280 let toCoords = to === 'custom' ? getMarkerCoords('dropoff') : LOCATION_COORDS[to]; 281 282 if (!fromCoords || !toCoords) { 283 priceAmount.textContent = '~'; 284 priceNote.textContent = t['booking.price.customMap'] || 'Pick a spot on the map for an estimate'; 285 showRoundTripInfo(false); 286 return null; 287 } 288 289 let straightKm = haversineKm(fromCoords[0], fromCoords[1], toCoords[0], toCoords[1]); 290 let roadKmOneWay = Math.round(straightKm * 1.3); 291 let isRoundTrip = roadKmOneWay > LONG_DISTANCE_THRESHOLD_KM; 292 let roadKm = isRoundTrip ? roadKmOneWay * 2 : roadKmOneWay; 293 let rate = pax <= 3 ? CUSTOM_RATES.t1 : pax <= 6 ? CUSTOM_RATES.t2 : CUSTOM_RATES.t3; 294 let vehicleType = pax <= 3 ? 'E-Class (1-3 pax)' : pax <= 6 ? 'V-Class (4-6 pax)' : 'V-Class (7-8 pax)'; 295 let basePrice = Math.max(CUSTOM_RATES.minFare, Math.round(roadKm * rate)); 296 297 let time2 = tripTime.value; 298 let isNight2 = false; 299 if (time2) { 300 let h = parseInt(time2.split(':')[0]); 301 isNight2 = h >= 22 || h < 6; 302 } 303 let hasChildSeat2 = childSeatCheckbox && childSeatCheckbox.checked; 304 let tripPrice2 = getCardPrice(basePrice); 305 let totalPrice2 = tripPrice2 + (isNight2 ? NIGHT_SURCHARGE : 0) + (hasChildSeat2 ? CHILD_SEAT_FEE : 0); 306 let displayPrice2 = totalPrice2; 307 308 priceAmount.style.opacity = '0.3'; 309 setTimeout(function() { 310 priceAmount.textContent = '~\u20AC' + displayPrice2; 311 priceAmount.style.opacity = '1'; 312 }, 150); 313 let nightText2 = isNight2 ? ' (incl. night surcharge)' : ''; 314 let seatText2 = hasChildSeat2 ? ' + child seat' : '';
315 let cardText2 = currentPaymentMethod === 'card' ? ' \u2022 Card +10%' : ''; 316 let rtText2 = isRoundTrip ? ' \u2022 ' + (t['booking.price.roundTrip'] || 'Incl. return') : ''; 317 priceNote.textContent = vehicleType + ' \u2022 ' + (t['booking.price.estimate'] || 'Estimated') + rtText2 + nightText2 + seatText2 + cardText2; 318 showRoundTripInfo(isRoundTrip, t); 319 320 return { price: totalPrice2, displayPrice: displayPrice2, vehicleType: vehicleType, km: roadKm, isNight: isNight2, estimated: true, roundTrip: isRoundTrip, childSeat: hasChildSeat2, paymentMethod: currentPaymentMethod }; 321 } 322 323 if (from === to) { 324 priceAmount.textContent = '\u2014'; 325 priceNote.textContent = t['booking.price.sameLocation'] || 'Pickup and destination must be different'; 326 showRoundTripInfo(false); 327 return null; 328 } 329 330 // Look up price (try both directions) 331 let route = PRICES[from]?.[to] || PRICES[to]?.[from]; 332 333 if (!route) { 334 // No fixed price â calculate per-km for long distance routes 335 let fromCoords2 = LOCATION_COORDS[from]; 336 let toCoords2 = LOCATION_COORDS[to]; 337 if (fromCoords2 && toCoords2) { 338 let straightKm2 = haversineKm(fromCoords2[0], fromCoords2[1], toCoords2[0], toCoords2[1]); 339 let roadKmOneWay2 = Math.round(straightKm2 * 1.3); 340 let isRoundTrip2 = roadKmOneWay2 > LONG_DISTANCE_THRESHOLD_KM; 341 let roadKm2 = isRoundTrip2 ? roadKmOneWay2 * 2 : roadKmOneWay2; 342 let rate2 = pax <= 3 ? CUSTOM_RATES.t1 : pax <= 6 ? CUSTOM_RATES.t2 : CUSTOM_RATES.t3; 343 let vehicleType2 = pax <= 3 ? 'E-Class (1-3 pax)' : pax <= 6 ? 'V-Class (4-6 pax)' : 'V-Class (7-8 pax)'; 344 let basePrice2 = Math.max(CUSTOM_RATES.minFare, Math.round(roadKm2 * rate2)); 345 let time3 = tripTime.value; 346 let isNight3 = false; 347 if (time3) { let h3 = parseInt(time3.split(':')[0]); isNight3 = h3 >= 22 || h3 < 6; } 348 let hasChildSeat3 = childSeatCheckbox && childSeatCheckbox.checked; 349 let tripPrice3 = getCardPrice(basePrice2); 350 let totalPrice3 = tripPrice3 + (isNight3 ? NIGHT_SURCHARGE : 0) + (hasChildSeat3 ? CHILD_SEAT_FEE : 0); 351 let displayPrice3 = totalPrice3; 352 priceAmount.style.opacity = '0.3'; 353 setTimeout(function() { priceAmount.textContent = '~\u20AC' + displayPrice3; priceAmount.style.opacity = '1'; }, 150); 354 let rtText3 = isRoundTrip2 ? ' \u2022 ' + (t['booking.price.roundTrip'] || 'Incl. return') : ''; 355 let nightText3 = isNight3 ? ' (incl. night surcharge)' : ''; 356 let seatText3 = hasChildSeat3 ? ' + child seat' : ''; 357 let cardText3 = currentPaymentMethod === 'card' ? ' \u2022 Card +10%' : ''; 358 priceNote.textContent = vehicleType2 + ' \u2022 ' + (t['booking.price.estimate'] || 'Estimated') + rtText3 + nightText3 + seatText3 + cardText3; 359 showRoundTripInfo(isRoundTrip2, t); 360 return { price: totalPrice3, displayPrice: displayPrice3, vehicleType: vehicleType2, km: roadKm2, isNight: isNight3, estimated: true, roundTrip: isRoundTrip2, childSeat: hasChildSeat3, paymentMethod: currentPaymentMethod }; 361 } 362 priceAmount.textContent = 'Quote'; 363 priceNote.textContent = t['booking.price.select'] || 'Contact us for this route\'s price'; 364 showRoundTripInfo(false); 365 return null; 366 } 367 368 // 3-tier pricing: 1-3 pax, 4-6 pax, 7-8 pax 369 const basePrice = pax <= 3 ? route.t1 : pax <= 6 ? route.t2 : route.t3; 370 const vehicleType = pax <= 3 ? 'E-Class (1-3 pax)' : pax <= 6 ? 'V-Class (4-6 pax)' : 'V-Class (7-8 pax)'; 371 372 // Night surcharge check 373 const time = tripTime.value; 374 let isNight = false; 375 if (time) { 376 const hour = parseInt(time.split(':')[0]); 377 isNight = hour >= 22 || hour < 6; 378 } 379 380 const hasChildSeat = childSeatCheckbox && childSeatCheckbox.checked; 381 const tripPrice = getCardPrice(basePrice); 382 const totalPrice = tripPrice + (isNight ? NIGHT_SURCHARGE : 0) + (hasChildSeat ? CHILD_SEAT_FEE : 0); 383 const displayPrice = totalPrice; 384 385 priceAmount.style.opacity = '0.3'; 386 setTimeout(() => { 387 priceAmount.textContent = `\u20AC${displayPrice}`; 388 priceAmount.style.opacity = '1'; 389 }, 150); 390 const nightText = isNight ? ' (incl. night surcharge)' : ''; 391 const seatText = hasChildSeat ? ' + child seat' : ''; 392 const cardText = currentPaymentMethod === 'card' ? ' \u2022 Card +10%' : '';
393 priceNote.textContent = `${vehicleType} \u2022 All inclusive${nightText}${seatText}${cardText}`; 394 showRoundTripInfo(false); 395 396 return { price: totalPrice, displayPrice, vehicleType, km: route.km, isNight, childSeat: hasChildSeat, paymentMethod: currentPaymentMethod }; 397} 398 399// --- LUGGAGE HELPERS --- 400function getLuggageSummary() { 401 const cabin = parseInt(document.getElementById('luggageCabin')?.textContent || '0'); 402 const medium = parseInt(document.getElementById('luggageMedium')?.textContent || '0'); 403 const large = parseInt(document.getElementById('luggageLarge')?.textContent || '0'); 404 if (cabin + medium + large === 0) return ''; 405 const t = TRANSLATIONS[currentLang]; 406 const parts = []; 407 if (cabin > 0) parts.push(`${cabin} ${t['booking.luggage.cabin'] || 'Cabin'}`); 408 if (medium > 0) parts.push(`${medium} ${t['booking.luggage.medium'] || 'Medium'}`); 409 if (large > 0) parts.push(`${large} ${t['booking.luggage.large'] || 'Large'}`); 410 return parts.join(' + '); 411} 412 413function initLuggage() { 414 document.querySelectorAll('.luggage-btn').forEach(btn => { 415 btn.addEventListener('click', () => { 416 const targetId = btn.dataset.target; 417 const countEl = document.getElementById(targetId); 418 if (!countEl) return; 419 let val = parseInt(countEl.textContent) || 0; 420 if (btn.classList.contains('luggage-plus')) { 421 if (val < 10) countEl.textContent = val + 1; 422 } else { 423 if (val > 0) countEl.textContent = val - 1; 424 } 425 }); 426 }); 427} 428 429// --- FLIGHT / SHIP REFERENCE --- 430function detectLocationCategory(locValue, type) { 431 // 1. Check predefined dropdown locations 432 if (AIRPORT_LOCATIONS.includes(locValue)) return 'airport'; 433 if (PORT_LOCATIONS.includes(locValue)) return 'port'; 434 // 2. For custom locations, check Nominatim metadata 435 if (locValue === 'custom') { 436 const meta = customLocationType[type]; 437 if (meta) { 438 if (meta.class === 'aeroway' || meta.type === 'aerodrome') return 'airport'; 439 if (meta.type === 'port' || meta.type === 'harbour' || meta.class === 'harbour') return 'port';
440 // 3. Keyword fallback on display_name 441 if (AIRPORT_KEYWORDS.test(meta.display_name)) return 'airport'; 442 if (PORT_KEYWORDS.test(meta.display_name)) return 'port'; 443 } 444 // 4. Also check the address input text directly 445 const input = document.getElementById(type === 'pickup' ? 'pickupCustom' : 'dropoffCustom'); 446 if (input && input.value) { 447 if (AIRPORT_KEYWORDS.test(input.value)) return 'airport'; 448 if (PORT_KEYWORDS.test(input.value)) return 'port'; 449 } 450 } 451 return null; 452} 453 454let flightShipMandatory = false; 455 456function updateFlightShipField() { 457 const from = pickup.value; 458 const to = dropoff.value; 459 const t = TRANSLATIONS[currentLang]; 460 const pickupCat = detectLocationCategory(from, 'pickup'); 461 const dropoffCat = detectLocationCategory(to, 'dropoff'); 462 const hasAirport = pickupCat === 'airport' || dropoffCat === 'airport'; 463 const hasPort = pickupCat === 'port' || dropoffCat === 'port'; 464 465 // Mandatory when predefined airport or port is selected from dropdown 466 const predefinedAirportOrPort = 467 AIRPORT_LOCATIONS.includes(from) || AIRPORT_LOCATIONS.includes(to) || 468 PORT_LOCATIONS.includes(from) || PORT_LOCATIONS.includes(to); 469 flightShipMandatory = predefinedAirportOrPort; 470 flightShipRef.required = predefinedAirportOrPort; 471 472 // Always visible â adapt icon and label to context 473 flightShipRow.style.display = ''; 474 const optionalTag = predefinedAirportOrPort ? '' : (' (' + (t['booking.form.optional'] || 'optional') + ')'); 475 if (hasAirport) { 476 flightShipIcon.className = 'fas fa-plane'; 477 flightShipLabelText.textContent = (t['booking.form.flightNumber'] || 'Flight number') + optionalTag; 478 flightShipRef.placeholder = t['booking.form.flightPlaceholder'] || 'e.g. FR1234'; 479 } else if (hasPort) { 480 flightShipIcon.className = 'fas fa-ship'; 481 flightShipLabelText.textContent = (t['booking.form.shipName'] || 'Ship / cruise name') + optionalTag; 482 flightShipRef.placeholder = t['booking.form.shipPlaceholder'] || 'e.g. MSC Grandiosa'; 483 } else { 484 flightShipIcon.className = 'fas fa-ticket-alt'; 485 flightShipLabelText.textContent = (t['booking.form.refNumber'] || 'Flight / ship / train number') + optionalTag; 486 flightShipRef.placeholder = t['booking.form.refPlaceholder'] || 'e.g. FR1234, MSC Grandiosa'; 487 } 488} 489 490// --- PAYMENT METHOD --- 491function initPaymentToggle() { 492 const btns = document.querySelectorAll('.payment-btn'); 493 btns.forEach(function(btn) { 494 btn.addEventListener('click', function() { 495 btns.forEach(function(b) { b.classList.remove('active'); }); 496 btn.classList.add('active'); 497 currentPaymentMethod = btn.dataset.method; 498 calculatePrice(); 499 }); 500 }); 501} 502 503function getCardPrice(basePrice) { 504 if (currentPaymentMethod === 'card') { 505 return Math.ceil(basePrice * (1 + CARD_SURCHARGE)); 506 } 507 return basePrice; 508} 509 510// --- BOOKING DATA --- 511// --- STRUCTURED ADDRESS STORAGE (from Nominatim addressdetails) --- 512var customLocationDetails = { pickup: null, dropoff: null }; 513var customLocationType = { pickup: null, dropoff: null }; // { class, type, display_name } 514 515// Extract a professional label from stored Nominatim address parts 516// Fallback chain: road+city â city/town/village â county â state 517function getLabelFromAddressDetails(addr) { 518 if (!addr) return null; 519 var city = addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || ''; 520 var road = addr.road || ''; 521 var county = addr.county || addr.state_district || ''; 522 var state = addr.state || ''; 523 if (road && city) return road + ', ' + city; 524 if (city) return city; 525 if (county) return county; 526 if (state) return state; 527 return null; 528} 529 530// Extract a short, professional location label from a Nominatim display_name string 531// "Strada Pozzacchio, Latina, Lazio, 04100, Italy" â "Strada Pozzacchio, Latina" 532function getShortLocationName(fullAddress) { 533 if (!fullAddress) return null; 534 var trimmed = fullAddress.trim(); 535 if (!trimmed) return null; 536 // Raw coordinates aren't useful as a name 537 if (/^\d+\.\d+\s*,\s*\d+\.\d+$/.test(trimmed)) return null; 538 var parts = trimmed.split(',').map(function(p) { return p.trim(); }
538).filter(Boolean); 539 // Filter out postal codes and country names 540 var meaningful = parts.filter(function(p) { 541 return !/^\d{4,5}$/.test(p) && !/^(italy|italia|españa|spain|france|francia|deutschland|germany)$/i.test(p); 542 }); 543 return meaningful.length > 0 544 ? meaningful.slice(0, 2).join(', ') 545 : trimmed.substring(0, 50); 546} 547 548function getBookingData() { 549 const fromVal = pickup.value; 550 const toVal = dropoff.value; 551 const fromCustomAddr = fromVal === 'custom' ? ($('#pickupCustom')?.value || '') : ''; 552 const toCustomAddr = toVal === 'custom' ? ($('#dropoffCustom')?.value || '') : ''; 553 return { 554 name: $('#clientName').value || 'Not provided', 555 phone: $('#clientPhone').value || 'Not provided', 556 email: $('#clientEmail').value || '', 557 notes: $('#clientNotes').value || '', 558 from: fromVal === 'custom' 559 ? (getShortLocationName(fromCustomAddr) || getLabelFromAddressDetails(customLocationDetails.pickup) || LOCATION_NAMES['custom']) 560 : (LOCATION_NAMES[fromVal] || fromVal), 561 to: toVal === 'custom' 562 ? (getShortLocationName(toCustomAddr) || getLabelFromAddressDetails(customLocationDetails.dropoff) || LOCATION_NAMES['custom']) 563 : (LOCATION_NAMES[toVal] || toVal), 564 pax: passengers.value, 565 date: tripDate.value, 566 time: tripTime.value, 567 priceInfo: calculatePrice(), 568 fromAddr: fromCustomAddr, 569 toAddr: toCustomAddr, 570 fromCoords: fromVal === 'custom' ? getMarkerCoords('pickup') : LOCATION_COORDS[fromVal], 571 toCoords: toVal === 'custom' ? getMarkerCoords('dropoff') : LOCATION_COORDS[toVal], 572 hasChildSeat: childSeatCheckbox && childSeatCheckbox.checked, 573 luggage: getLuggageSummary(), 574 flightShip: flightShipRef.value || '', 575 paymentMethod: currentPaymentMethod 576 }; 577} 578 579// --- WHATSAPP BOOKING --- 580function buildWhatsAppMessage() { 581 const d = getBookingData(); 582 const dispPrice = d.priceInfo ? (d.priceInfo.displayPrice || d.priceInfo.price) : null; 583 const priceText = dispPrice ? `\u20AC${dispPrice} (${d.priceInfo.vehicleType})` : 'To be quoted'; 584 const payLabel = d.paymentMethod === 'card' ? 'Card' : 'Cash'; 585 586 let msg = `\u2736 *BOOKING REQUEST*\n\n`; 587 588 msg += `\u25B8 *ROUTE*\n`; 589 msg += `*From:* ${d.from}\n`; 590 if (d.fromAddr) msg += ` _${d.fromAddr}_\n`; 591 if (d.fromCoords) msg += ` Map: https://maps.google.com/?q=${d.fromCoords[0]},${d.fromCoords[1]}\n`; 592 msg += `*To:* ${d.to}\n`; 593 if (d.toAddr) msg += ` _${d.toAddr}_\n`; 594 if (d.toCoords) msg += ` Map: https://maps.google.com/?q=${d.toCoords[0]},${d.toCoords[1]}\n`; 595 if (d.flightShip) msg += `*Flight/Ship:* ${d.flightShip}\n`; 596 597 msg += `\n\u25B8 *PASSENGERS & LUGGAGE*\n`; 598 msg += `*Travelers:* ${d.pax}\n`; 599 msg += `*Luggage:* ${d.luggage || 'No luggage'}\n`; 600 if (d.hasChildSeat) msg += `*Child Seat:* Yes (+\u20AC${CHILD_SEAT_FEE})\n`; 601 602 msg += `\n\u25B8 *SCHEDULE*\n`; 603 msg += `*Date:* ${d.date}\n`; 604 msg += `*Time:* ${d.time}\n`; 605 606 msg += `\n\u25B8 *CLIENT*\n`; 607 msg += `*Name:* ${d.name}\n`; 608 msg += `*Phone:* ${d.phone}\n`; 609 if (d.email) msg += `*Email:* ${d.email}\n`; 610 611 if (d.notes) { 612 msg += `\n\u25B8 *NOTES*\n`; 613 msg += `${d.notes}\n`; 614 } 615 616 msg += `\n\u25B8 *Price:* ${priceText}`; 617 if (d.priceInfo?.roundTrip) msg += ` \u2714 incl. driver return`; 618 msg += `\n\u25B8 *Payment:* ${payLabel}`; 619 if (d.paymentMethod === 'card') msg += ` (incl. 10% card fee)`; 620 msg += `\n\n_Sent from transferlimotour.it_`; 621 622 return msg; 623} 624 625function buildEmailMessage() { 626 const d = getBookingData(); 627 const dispPrice = d.priceInfo ? (d.priceInfo.displayPrice || d.priceInfo.price) : null; 628 const priceText = dispPrice ? `\u20AC${dispPrice} (${d.priceInfo.vehicleType})` : 'To be quoted'; 629 const payLabel = d.paymentMethod === 'card' ? 'Card' : 'Cash'; 630 631 const subject = `Transfer Booking: ${d.from} \u2192 ${d.to} on ${d.date} at ${d.time}`; 632 633 let body = `BOOKING REQUEST\n\n`; 634 635 body += `--- ROUTE ---\n`; 636 body += `From: ${d.from}\n`; 637 if (d.fromAddr) body += ` ${d.fromAddr}\n`; 638 if (d.fromCoords) body += ` Map: https://maps.google.com/?q=${d.fromCoords[0]},${d.fromCoords[1]}\n`; 639 body += `To: ${d.to}\n`; 640 if (d.toAddr) body += ` ${d.toAddr}\n`; 641 if (d.toCoords) body += ` Map: https://maps.google.com/?q=${d.toCoords[0]},${d.toCoords[1]}\n`; 642 if (d.flightShip) body += `Flight/Ship: ${d.flightShip}\n`; 643 644 body += `\n--- PASSENGERS & LUGGAGE ---\n`; 645 body += `Travelers: ${d.pax}\n`; 646 body += `Luggage: ${d.luggage || 'No luggage'}\n`; 647 if (d.hasChildSeat) body += `Child Seat: Yes (+\u20AC${CHILD_SEAT_FEE})\n`; 648 649 body += `\n--- SCHEDULE ---\n`; 650 body += `Date: ${d.date}\n`; 651 body += `Time: ${d.time}\n`; 652 653 body += `\n--- CLIENT ---\n`; 654 body += `Name: ${d.name}\n`; 655 body += `Phone: ${d.phone}\n`; 656 if (d.email) body += `Email: ${d.email}\n`; 657 658 if (d.notes) { 659 body += `\n--- NOTES ---\n`; 660 body += `${d.notes}\n`; 661 } 662 663 body += `\nPrice: ${priceText}`; 664 if (d.priceInfo?.roundTrip) body += ` - incl. driver return`; 665 body += `\nPayment: ${payLabel}`; 666 if (d.paymentMethod === 'card') body += ` (incl. 10% card fee)`; 667 body += `\n\nSent from transferlimotour.it`; 668 669 return { subject, body }; 670} 671
672// --- PREVIEW MODAL --- 673function formatPreviewHTML(rawMsg) { 674 let html = rawMsg 675 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 676 .replace(/\*([^*]+)\*/g, '<strong>$1</strong>') 677 .replace(/_([^_]+)_/g, '<em>$1</em>') 678 .replace(/-{4,}/g, '<div class="preview-divider"></div>') 679 .replace(/https:\/\/maps\.google\.com[^\s<]+/g, '<a href="$&" target="_blank" rel="noopener">View on Map</a>') 680 .replace(/\n/g, '<br>'); 681 return html; 682} 683 684function buildPreviewHTML() { 685 const d = getBookingData(); 686 const priceAmount = d.priceInfo ? `\u20AC${d.priceInfo.price}` : 'To be quoted'; 687 const vehicleText = d.priceInfo ? d.priceInfo.vehicleType : ''; 688 const esc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); 689 const mapLink = coords => coords 690 ? `<a class="preview-map-link" href="https://maps.google.com/?q=${coords[0]},${coords[1]}" target="_blank" rel="noopener"><i class="fas fa-map-marker-alt"></i> View on Map</a>` 691 : ''; 692 693 let h = '<div class="preview-title">BOOKING REQUEST</div>'; 694 695 // Route 696 h += '<div class="preview-section">'; 697 h += '<div class="preview-section-header"><i class="fas fa-route"></i> Route</div>'; 698 h += '<div class="preview-section-content">'; 699 h += `<div class="preview-field"><strong>From:</strong> ${esc(d.from)}</div>`; 700 if (d.fromAddr) h += `<div class="preview-field-sub">${esc(d.fromAddr)}</div>`; 701 h += mapLink(d.fromCoords); 702 h += `<div class="preview-field" style="margin-top:6px"><strong>To:</strong> ${esc(d.to)}</div>`; 703 if (d.toAddr) h += `<div class="preview-field-sub">${esc(d.toAddr)}</div>`; 704 h += mapLink(d.toCoords); 705 h += '</div></div>'; 706 707 // Passengers & Luggage 708 h += '<div class="preview-section">'; 709 h += '<div class="preview-section-header"><i class="fas fa-users"></i> Passengers & Luggage</div>'; 710 h += '<div class="preview-section-content">'; 711 h += `<div class="preview-field"><strong>Travelers:</strong> ${esc(d.pax)}</div>`; 712 h += `<div class="preview-field"><strong>Luggage:</strong> ${esc(d.luggage || 'No luggage')}</div>`; 713 if (d.hasChildSeat) h += `<div class="preview-field"><strong>Child Seat:</strong> Yes (+\u20AC${CHILD_SEAT_FEE})</div>`; 714 h += '</div></div>'; 715 716 // Schedule 717 h += '<div class="preview-section">'; 718 h += '<div class="preview-section-header"><i class="fas fa-calendar-alt"></i> Schedule</div>'; 719 h += '<div class="preview-section-content">'; 720 h += `<div class="preview-field"><strong>Date:</strong> ${esc(d.date)}</div>`; 721 h += `<div class="preview-field"><strong>Time:</strong> ${esc(d.time)}</div>`; 722 h += '</div></div>'; 723 724 // Client 725 h += '<div class="preview-section">'; 726 h += '<div class="preview-section-header"><i class="fas fa-user"></i> Client Details</div>'; 727 h += '<div class="preview-section-content">'; 728 h += `<div class="preview-field"><strong>Name:</strong> ${esc(d.name)}</div>`; 729 h += `<div class="preview-field"><strong>Phone:</strong> ${esc(d.phone)}</div>`; 730 if (d.email) h += `<div class="preview-field"><strong>Email:</strong> ${esc(d.email)}</div>`; 731 h += '</div></div>'; 732 733 // Notes 734 if (d.notes) { 735 h += '<div class="preview-section">'; 736 h += '<div class="preview-section-header"><i class="fas fa-comment-alt"></i> Notes</div>'; 737 h += '<div class="preview-section-content">'; 738 h += `<div class="preview-notes">${esc(d.notes).replace(/\n/g, '<br>')}</div>`; 739 h += '</div></div>'; 740 } 741 742 // Price 743 h += '<div class="preview-price">'; 744 h += `<i class="fas fa-tag"></i> ${priceAmount}`; 745 if (vehicleText) h += ` <span>${esc(vehicleText)}</span>`; 746 h += '</div>'; 747 748 h += '<div class="preview-footer-text">Sent from transferlimotour.it</div>'; 749 return h; 750} 751 752function buildEmailPreviewHTML() { 753 const d = getBookingData(); 754 const priceAmount = d.priceInfo ? `\u20AC${d.priceInfo.price}` : 'To be quoted'; 755 const vehicleText = d.priceInfo ? d.priceInfo.vehicleType : ''; 756 const esc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); 757 const mapLink = coords => coords 758 ? `<a class="preview-map-link" href="https://maps.google.com/?q=${coords[0]},${coords[1]}" target="_blank" rel="noopener"><i class="fas fa-map-marker-alt"></i> View on Map</a>` 759 : ''; 760 761 let h = '<div class="preview-title">EMAIL PREVIEW</div>'; 762 763 // Subject 764 h += '<div class="preview-section">'; 765 h += '<div class="preview-section-header"><i class="fas fa-envelope"></i> Subject</div>'; 766 h += '<div class="preview-section-content">'; 767 h += `<div class="preview-field"><strong>${esc(d.from)}</strong> \u2192 <strong>${esc(d.to)}</strong> on ${esc(d.date)}</div>`; 768 h += '</div></div>'; 769 770 // Route 771 h += '<div class="preview-section">'; 772 h += '<div class="preview-section-header"><i class="fas fa-route"></i> Route</div>'; 773 h += '<div class="preview-section-content">'; 774 h += `<div class="preview-field"><strong>From:</strong> ${esc(d.from)}</div>`; 775 if (d.fromAddr) h += `<div class="preview-field-sub">${esc(d.fromAddr)}</div>`; 776 h += mapLink(d.fromCoords);
777 h += `<div class="preview-field" style="margin-top:6px"><strong>To:</strong> ${esc(d.to)}</div>`; 778 if (d.toAddr) h += `<div class="preview-field-sub">${esc(d.toAddr)}</div>`; 779 h += mapLink(d.toCoords); 780 h += '</div></div>'; 781 782 // Passengers & Luggage 783 h += '<div class="preview-section">'; 784 h += '<div class="preview-section-header"><i class="fas fa-users"></i> Passengers & Luggage</div>'; 785 h += '<div class="preview-section-content">'; 786 h += `<div class="preview-field"><strong>Travelers:</strong> ${esc(d.pax)}</div>`; 787 h += `<div class="preview-field"><strong>Luggage:</strong> ${esc(d.luggage || 'No luggage')}</div>`; 788 if (d.hasChildSeat) h += `<div class="preview-field"><strong>Child Seat:</strong> Yes (+\u20AC${CHILD_SEAT_FEE})</div>`; 789 h += '</div></div>'; 790 791 // Schedule 792 h += '<div class="preview-section">'; 793 h += '<div class="preview-section-header"><i class="fas fa-calendar-alt"></i> Schedule</div>'; 794 h += '<div class="preview-section-content">'; 795 h += `<div class="preview-field"><strong>Date:</strong> ${esc(d.date)}</div>`; 796 h += `<div class="preview-field"><strong>Time:</strong> ${esc(d.time)}</div>`; 797 h += '</div></div>'; 798 799 // Client 800 h += '<div class="preview-section">'; 801 h += '<div class="preview-section-header"><i class="fas fa-user"></i> Client Details</div>'; 802 h += '<div class="preview-section-content">'; 803 h += `<div class="preview-field"><strong>Name:</strong> ${esc(d.name)}</div>`; 804 h += `<div class="preview-field"><strong>Phone:</strong> ${esc(d.phone)}</div>`; 805 if (d.email) h += `<div class="preview-field"><strong>Email:</strong> ${esc(d.email)}</div>`; 806 h += '</div></div>'; 807 808 // Notes 809 if (d.notes) { 810 h += '<div class="preview-section">'; 811 h += '<div class="preview-section-header"><i class="fas fa-comment-alt"></i> Notes</div>'; 812 h += '<div class="preview-section-content">'; 813 h += `<div class="preview-notes">${esc(d.notes).replace(/\n/g, '<br>')}</div>`; 814 h += '</div></div>'; 815 } 816 817 // Price 818 h += '<div class="preview-price">'; 819 h += `<i class="fas fa-tag"></i> ${priceAmount}`; 820 if (vehicleText) h += ` <span>${esc(vehicleText)}</span>`; 821 h += '</div>'; 822 823 h += '<div class="preview-footer-text">Sent from transferlimotour.it</div>'; 824 return h; 825} 826 827function showPreview(type) { 828 const overlay = $('#previewOverlay'); 829 const titleEl = $('#previewTitle'); 830 const textEl = $('#previewText'); 831 const sendBtn = $('#previewSend'); 832 833 let previewMsg = ''; 834 let sendAction = null; 835 836 const t = TRANSLATIONS[currentLang] || TRANSLATIONS['en']; 837 838 if (type === 'whatsapp') { 839 previewMsg = buildPreviewHTML(); 840 titleEl.textContent = t['preview.whatsapp.title'] || 'WhatsApp Message Preview'; 841 sendBtn.innerHTML = `<i class="fab fa-whatsapp"></i> ${t['preview.send.whatsapp'] || 'Send via WhatsApp'}`; 842 sendBtn.className = 'btn btn-gold'; 843 sendAction = () => { 844 const encoded = encodeURIComponent(buildWhatsAppMessage()); 845 window.open(`https://wa.me/${WHATSAPP_NUMBER}?text=${encoded}`, '_blank'); 846 }; 847 } else { 848 previewMsg = buildEmailPreviewHTML(); 849 titleEl.textContent = t['preview.email.title'] || 'Email Preview'; 850 sendBtn.innerHTML = `<i class="fas fa-envelope"></i> ${t['preview.send.email'] || 'Send Email'}`; 851 sendBtn.className = 'btn btn-gold'; 852 sendAction = () => { 853 const email2 = buildEmailMessage(); 854 window.open(`mailto:[email protected]?subject=${encodeURIComponent(email2.subject)}&body=${encodeURIComponent(email2.body)}`, '_blank'); 855 }; 856 } 857 858 textEl.innerHTML = previewMsg; 859 860 // No-luggage warning 861 const existingWarning = overlay.querySelector('.preview-luggage-warning'); 862 if (existingWarning) existingWarning.remove(); 863 if (!getLuggageSummary()) { 864 const warning = document.createElement('div'); 865 warning.className = 'preview-luggage-warning'; 866 warning.innerHTML = `<i class="fas fa-exclamation-triangle"></i> ${t['preview.noLuggage'] || 'No luggage selected. Go back to add luggage if needed.'}`; 867 textEl.parentNode.insertBefore(warning, textEl.nextSibling); 868 } 869 870 overlay.classList.add('active'); 871 document.body.style.overflow = 'hidden'; 872 873 // Wire up send button (replace old listeners) 874 const newSendBtn = sendBtn.cloneNode(true); 875 sendBtn.parentNode.replaceChild(newSendBtn, sendBtn); 876 newSendBtn.addEventListener('click', () => { 877 overlay.classList.remove('active'); 878 document.body.style.overflow = ''; 879 sendAction(); 880 }); 881} 882 883function closePreview() {
884 const overlay = $('#previewOverlay'); 885 overlay.classList.remove('active'); 886 document.body.style.overflow = ''; 887} 888 889function checkFlightShipWarning() { 890 const warning = document.getElementById('flightShipWarning'); 891 if (!warning) return true; 892 // Skip soft warning for mandatory fields â browser required validation handles those 893 if (flightShipMandatory) return true; 894 // For optional: if field is visible but empty, show soft warning once 895 if (flightShipRow.style.display !== 'none' && !flightShipRef.value.trim()) { 896 warning.style.display = ''; 897 flightShipRef.style.borderColor = '#eab308'; 898 flightShipRef.focus(); 899 // Auto-hide warning after interaction 900 flightShipRef.addEventListener('input', function onInput() { 901 if (flightShipRef.value.trim()) { 902 warning.style.display = 'none'; 903 flightShipRef.style.borderColor = ''; 904 } 905 flightShipRef.removeEventListener('input', onInput); 906 }); 907 return false; 908 } 909 warning.style.display = 'none'; 910 flightShipRef.style.borderColor = ''; 911 return true; 912} 913 914let flightShipWarningShown = false; 915 916function bookViaWhatsApp(e) { 917 e.preventDefault(); 918 if (!bookingForm.checkValidity()) { 919 bookingForm.reportValidity(); 920 return; 921 } 922 // Show warning once â second click proceeds anyway 923 if (!flightShipWarningShown && !checkFlightShipWarning()) { 924 flightShipWarningShown = true; 925 return; 926 } 927 flightShipWarningShown = false; 928 showPreview('whatsapp'); 929} 930 931function sendEmailInquiry() { 932 if (!bookingForm.checkValidity()) { 933 bookingForm.reportValidity(); 934 return; 935 } 936 if (!flightShipWarningShown && !checkFlightShipWarning()) { 937 flightShipWarningShown = true; 938 return; 939 } 940 flightShipWarningShown = false; 941 showPreview('email'); 942} 943 944// --- FLEET GALLERY --- 945function initFleetGallery() { 946 document.querySelectorAll('.fleet-showcase').forEach(showcase => { 947 const thumbs = showcase.querySelectorAll('.fleet-thumb'); 948 const mainImg = showcase.querySelector('.fleet-img-main img'); 949 if (!mainImg) return; 950 951 thumbs.forEach(thumb => { 952 const switchImage = () => { 953 thumbs.forEach(t => t.classList.remove('active')); 954 thumb.classList.add('active'); 955 const src = thumb.getAttribute('data-img'); 956 mainImg.src = src; 957 mainImg.alt = thumb.querySelector('img')?.alt || ''; 958 }; 959 thumb.addEventListener('click', switchImage); 960 thumb.addEventListener('keydown', (e) => { 961 if (e.key === 'Enter' || e.key === ' ') { 962 e.preventDefault(); 963 switchImage(); 964 } 965 }); 966 }); 967 }); 968} 969 970// --- FLEET LIGHTBOX --- 971function initFleetLightbox() { 972 // Create lightbox DOM 973 const overlay = document.createElement('div'); 974 overlay.className = 'lightbox-overlay'; 975 overlay.innerHTML = ` 976 <button class="lightbox-close" aria-label="Close">×</button> 977 <button class="lightbox-nav lightbox-prev" aria-label="Previous"><i class="fas fa-chevron-left"></i></button> 978 <img class="lightbox-img" src="" alt=""> 979 <button class="lightbox-nav lightbox-next" aria-label="Next"><i class="fas fa-chevron-right"></i></button> 980 <div class="lightbox-counter"></div> 981 `; 982 document.body.appendChild(overlay); 983 984 const lbImg = overlay.querySelector('.lightbox-img'); 985 const lbCounter = overlay.querySelector('.lightbox-counter'); 986 let images = []; 987 let currentIdx = 0; 988 989 function open(showcase, startIdx) { 990 // Collect all image srcs from this showcase's thumbs 991 const thumbs = showcase.querySelectorAll('.fleet-thumb'); 992 images = Array.from(thumbs).map(t => t.getAttribute('data-img')); 993 if (images.length === 0) return; 994 currentIdx = startIdx || 0; 995 show(); 996 overlay.classList.add('active'); 997 document.body.style.overflow = 'hidden'; 998 // Push history state so mobile back button closes lightbox instead of navigating away 999 history.pushState({ lightbox: true }, ''); 1000 } 1001 1002 function close() { 1003 if (!overlay.classList.contains('active')) return; 1004 overlay.classList.remove('active'); 1005 document.body.style.overflow = ''; 1006 } 1007 1008 // Mobile back button closes lightbox 1009 window.addEventListener('popstate', function(e) { 1010 if (overlay.classList.contains('active')) { 1011 close(); 1012 } 1013 }); 1014 1015 function show() { 1016 lbImg.style.opacity = '0'; 1017 setTimeout(() => { 1018 lbImg.src = images[currentIdx]; 1019 lbImg.alt = `Fleet image ${currentIdx + 1} of ${images.length}`; 1020 lbImg.style.opacity = '1'; 1021 }, 100); 1022 lbCounter.textContent = `${currentIdx + 1} / ${images.length}`; 1023 } 1024 1025 function next() { currentIdx = (currentIdx + 1) % images.length; show(); } 1026 function prev() { currentIdx = (currentIdx - 1 + images.length) % images.length; show(); } 1027 1028 // Close via history.back() so the pushed state gets popped cleanly 1029 function closeViaHistory() { if (overlay.classList.contains('active')) history.back(); } 1030 1031 overlay.querySelector('.lightbox-close').addEventListener('click', closeViaHistory); 1032 overlay.querySelector('.lightbox-prev').addEventListener('click', prev); 1033 overlay.querySelector('.lightbox-next').addEventListener('click', next); 1034 overlay.addEventListener('click', (e) => { if (e.target === overlay) closeViaHistory(); }); 1035 1036 document.addEventListener('keydown', (e) => { 1037 if (!overlay.classList.contains('active')) return; 1038 if (e.key === 'Escape') closeViaHistory(); 1039 if (e.key === 'ArrowRight') next(); 1040 if (e.key === 'ArrowLeft') prev(); 1041 }); 1042 1043 // Touch swipe support (single finger only â ignore pinch zoom) 1044 let touchStartX = 0; 1045 let touchFingers = 0; 1046 overlay.addEventListener('touchstart', (e) => { 1047 touchFingers = e.touches.length; 1048 if (touchFingers === 1) touchStartX = e.changedTouches[0].screenX; 1049 }, { passive: true });
1050 overlay.addEventListener('touchend', (e) => { 1051 if (touchFingers !== 1) return; // was a pinch/zoom gesture, skip 1052 const diff = e.changedTouches[0].screenX - touchStartX; 1053 if (Math.abs(diff) > 50) { diff > 0 ? prev() : next(); } 1054 }, { passive: true }); 1055 1056 // Bind click on each showcase 1057 document.querySelectorAll('.fleet-showcase').forEach(showcase => { 1058 const mainImg = showcase.querySelector('.fleet-img-main'); 1059 if (mainImg) { 1060 mainImg.addEventListener('click', () => { 1061 const thumbs = showcase.querySelectorAll('.fleet-thumb'); 1062 const activeSrc = mainImg.querySelector('img')?.src || ''; 1063 let idx = 0; 1064 thumbs.forEach((t, i) => { 1065 if (activeSrc.includes(t.getAttribute('data-img'))) idx = i; 1066 }); 1067 open(showcase, idx); 1068 }); 1069 } 1070 showcase.querySelectorAll('.fleet-thumb').forEach((thumb, i) => { 1071 thumb.addEventListener('dblclick', () => open(showcase, i)); 1072 }); 1073 }); 1074} 1075 1076// --- FLEET CARD THUMBNAILS --- 1077function initFleetCardThumbs() { 1078 document.querySelectorAll('.fleet-card').forEach(card => { 1079 const thumbs = card.querySelectorAll('.fleet-card-thumb'); 1080 const mainImg = card.querySelector('.fleet-card-img img'); 1081 if (!mainImg || !thumbs.length) return; 1082 1083 thumbs.forEach(thumb => { 1084 thumb.addEventListener('click', (e) => { 1085 e.stopPropagation(); 1086 thumbs.forEach(t => t.classList.remove('active')); 1087 thumb.classList.add('active'); 1088 mainImg.src = thumb.getAttribute('data-img'); 1089 mainImg.alt = thumb.querySelector('img')?.alt || ''; 1090 }); 1091 }); 1092 }); 1093} 1094 1095// --- FLEET STRIP MOUSE DRAG SCROLL --- 1096function initFleetDragScroll() { 1097 const strip = document.querySelector('.fleet-strip'); 1098 if (!strip) return; 1099 1100 let isDown = false, startX, scrollLeft; 1101 1102 strip.addEventListener('mousedown', (e) => { 1103 if (e.target.closest('.fleet-card-thumb, a, button')) return; 1104 isDown = true; 1105 strip.classList.add('dragging'); 1106 startX = e.pageX - strip.offsetLeft; 1107 scrollLeft = strip.scrollLeft; 1108 }); 1109 strip.addEventListener('mouseleave', () => { isDown = false; strip.classList.remove('dragging'); }); 1110 strip.addEventListener('mouseup', () => { isDown = false; strip.classList.remove('dragging'); }); 1111 strip.addEventListener('mousemove', (e) => { 1112 if (!isDown) return; 1113 e.preventDefault(); 1114 const x = e.pageX - strip.offsetLeft; 1115 strip.scrollLeft = scrollLeft - (x - startX) * 1.5; 1116 }); 1117} 1118 1119// --- FAQ ACCORDION --- 1120function initFAQ() { 1121 document.querySelectorAll('.faq-question').forEach(btn => { 1122 btn.addEventListener('click', () => { 1123 const item = btn.closest('.faq-item'); 1124 const wasOpen = item.classList.contains('open'); 1125 1126 // Close all others 1127 document.querySelectorAll('.faq-item.open').forEach(openItem => { 1128 openItem.classList.remove('open'); 1129 openItem.querySelector('.faq-question').setAttribute('aria-expanded', 'false'); 1130 }); 1131 1132 // Toggle current 1133 if (!wasOpen) { 1134 item.classList.add('open'); 1135 btn.setAttribute('aria-expanded', 'true'); 1136 } 1137 }); 1138 }); 1139} 1140 1141// --- NAVIGATION --- 1142function initNav() { 1143 const toggle = $('#navToggle'); 1144 const menu = $('#navMenu'); 1145 const navbar = $('#navbar'); 1146 1147 // Mobile toggle 1148 toggle.addEventListener('click', () => { 1149 menu.classList.toggle('open'); 1150 toggle.classList.toggle('active'); 1151 }); 1152 1153 // Close menu on link click 1154 menu.querySelectorAll('a').forEach(link => { 1155 link.addEventListener('click', () => { 1156 menu.classList.remove('open'); 1157 toggle.classList.remove('active'); 1158 }); 1159 }); 1160 1161 // Scroll effect 1162 window.addEventListener('scroll', () => { 1163 navbar.classList.toggle('scrolled', window.scrollY > 50); 1164 }, { passive: true }); 1165} 1166 1167// --- SMOOTH SCROLL FOR ANCHOR LINKS --- 1168function initSmoothScroll() { 1169 document.querySelectorAll('a[href^="#"]').forEach(anchor => { 1170 anchor.addEventListener('click', (e) => { 1171 const target = document.querySelector(anchor.getAttribute('href')); 1172 if (target) { 1173 e.preventDefault(); 1174 const topbar = document.querySelector('#topbar'); 1175 const navbar = document.querySelector('#navbar'); 1176 const offset = (topbar ? topbar.offsetHeight : 0) + (navbar ? navbar.offsetHeight : 0) + 16; 1177 const targetPos = target.getBoundingClientRect().top + window.scrollY - offset; 1178 window.scrollTo({ top: targetPos, behavior: 'smooth' });
1179 } 1180 }); 1181 }); 1182} 1183 1184// --- INTERSECTION OBSERVER (fade-in animations) --- 1185function initAnimations() { 1186 const observer = new IntersectionObserver((entries) => { 1187 entries.forEach(entry => { 1188 if (entry.isIntersecting) { 1189 entry.target.classList.add('visible'); 1190 observer.unobserve(entry.target); 1191 } 1192 }); 1193 }, { threshold: 0.1 }); 1194 1195 document.querySelectorAll('.service-card, .trust-card, .dest-card, .contact-card, .step-card, .faq-item').forEach(el => { 1196 el.classList.add('animate-on-scroll'); 1197 observer.observe(el); 1198 }); 1199} 1200 1201// --- BUSY SLOTS (driver updates this manually or via Google Calendar API later) --- 1202const BUSY_SLOTS = [ 1203 // { date: '2026-02-23', hours: [8, 9, 10, 11] }, 1204]; 1205 1206// --- TIME SLOT PICKER (integrated into booking form) --- 1207function renderTimeSlots() { 1208 const container = document.getElementById('timeSlots'); 1209 if (!container) return; 1210 1211 const dateVal = tripDate.value; 1212 if (!dateVal) { container.innerHTML = '<span style="color:var(--gray);font-size:0.82rem;">Select a date first</span>'; return; } 1213 1214 const selectedDate = new Date(dateVal + 'T00:00:00'); 1215 const now = new Date(); 1216 1217 container.innerHTML = ''; 1218 1219 for (let hour = 5; hour < 23; hour++) { 1220 const slot = document.createElement('div'); 1221 slot.className = 'time-slot'; 1222 slot.textContent = `${String(hour).padStart(2, '0')}:00`; 1223 1224 const slotTime = new Date(selectedDate); 1225 slotTime.setHours(hour, 0, 0, 0); 1226 1227 if (slotTime < now) { 1228 slot.classList.add('ts-past'); 1229 } else if (BUSY_SLOTS.some(s => s.date === dateVal && s.hours.includes(hour))) { 1230 slot.classList.add('ts-busy'); 1231 } else { 1232 slot.classList.add('ts-available'); 1233 slot.addEventListener('click', () => { 1234 tripTime.value = `${String(hour).padStart(2, '0')}:00`; 1235 renderTimeSlots(); 1236 calculatePrice(); 1237 }); 1238 } 1239 1240 if (tripTime.value === `${String(hour).padStart(2, '0')}:00`) { 1241 slot.classList.add('ts-selected'); 1242 } 1243 1244 container.appendChild(slot); 1245 } 1246} 1247 1248// --- CUSTOM LOCATION TOGGLE --- 1249function initCustomLocation() { 1250 const pickupSel = $('#pickupLocation'); 1251 const dropoffSel = $('#dropoffLocation'); 1252 1253 function togglePanel(selectEl, panelId, type) { 1254 selectEl.addEventListener('change', () => { 1255 const val = selectEl.value; 1256 const isCustom = val === 'custom'; 1257 const hasCoords = !isCustom && val && LOCATION_COORDS[val]; 1258 const showPanel = isCustom || hasCoords; 1259 const panel = document.getElementById(panelId); 1260 if (panel) { 1261 panel.style.display = showPanel ? 'block' : 'none'; 1262 if (showPanel) { 1263 const mapInstance = type === 'pickup' ? pickupMapInstance : dropoffMapInstance; 1264 if (!mapInstance) initMapPicker(type); 1265 setTimeout(() => { 1266 const mi = type === 'pickup' ? pickupMapInstance : dropoffMapInstance; 1267 if (mi) mi.invalidateSize(); 1268 }, 200); 1269 1270 if (hasCoords) { 1271 // Predefined location: show marker + address on map 1272 const coords = LOCATION_COORDS[val]; 1273 const addr = LOCATION_ADDRESSES[val] || LOCATION_NAMES[val] || ''; 1274 const input = document.getElementById(type === 'pickup' ? 'pickupCustom' : 'dropoffCustom'); 1275 if (input) input.value = addr; 1276 setTimeout(() => { 1277 const mi = type === 'pickup' ? pickupMapInstance : dropoffMapInstance; 1278 if (mi && coords) { 1279 let marker = type === 'pickup' ? pickupMarker : dropoffMarker; 1280 if (marker) { 1281 marker.setLatLng(coords); 1282 } else { 1283 marker = L.marker(coords).addTo(mi); 1284 if (type === 'pickup') pickupMarker = marker; 1285 else dropoffMarker = marker; 1286 } 1287 mi.setView(coords, 14); 1288 } 1289 }, 300); 1290 } else { 1291 // Custom: focus the search input 1292 const input = document.getElementById(type === 'pickup' ? 'pickupCustom' : 'dropoffCustom'); 1293 if (input) setTimeout(() => input.focus(), 250); 1294 } 1295 } 1296 } 1297 calculatePrice(); 1298 });
1299 } 1300 1301 togglePanel(pickupSel, 'pickupPanel', 'pickup'); 1302 togglePanel(dropoffSel, 'dropoffPanel', 'dropoff'); 1303 1304 // Address search handlers 1305 initAddressSearch('pickup'); 1306 initAddressSearch('dropoff'); 1307} 1308 1309// --- ADDRESS SEARCH (forward geocoding) --- 1310function initAddressSearch(type) { 1311 const input = document.getElementById(type === 'pickup' ? 'pickupCustom' : 'dropoffCustom'); 1312 const btn = document.getElementById(type + 'SearchBtn'); 1313 1314 if (input) { 1315 input.addEventListener('keydown', (e) => { 1316 if (e.key === 'Enter') { 1317 e.preventDefault(); 1318 if (input.value.trim().length >= 3) forwardGeocode(input.value, type); 1319 } 1320 }); 1321 } 1322 if (btn) { 1323 btn.addEventListener('click', () => { 1324 if (input && input.value.trim().length >= 3) forwardGeocode(input.value, type); 1325 }); 1326 } 1327} 1328 1329function forwardGeocode(query, type) { 1330 const input = document.getElementById(type === 'pickup' ? 'pickupCustom' : 'dropoffCustom'); 1331 const btn = document.getElementById(type + 'SearchBtn'); 1332 1333 if (btn) btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; 1334 1335 const lang = currentLang || 'en'; 1336 1337 fetch(`https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=${encodeURIComponent(query)}&limit=5&countrycodes=it&accept-language=${lang},it,en`) 1338 .then(r => r.json()) 1339 .then(data => { 1340 if (btn) btn.innerHTML = '<i class="fas fa-search"></i>'; 1341 if (data && data.length > 0) { 1342 const lat = parseFloat(data[0].lat); 1343 const lng = parseFloat(data[0].lon); 1344 1345 // Store structured address for professional labels 1346 if (data[0].address) { 1347 customLocationDetails[type] = data[0].address; 1348 } 1349 // Store Nominatim class/type for airport/port detection 1350 customLocationType[type] = { 1351 class: data[0].class || '', 1352 type: data[0].type || '', 1353 display_name: data[0].display_name || '' 1354 }; 1355 1356 // Place/move marker 1357 const map = type === 'pickup' ? pickupMapInstance : dropoffMapInstance; 1358 let marker = type === 'pickup' ? pickupMarker : dropoffMarker; 1359 1360 if (map) { 1361 if (marker) { 1362 marker.setLatLng([lat, lng]); 1363 } else { 1364 marker = L.marker([lat, lng]).addTo(map); 1365 if (type === 'pickup') pickupMarker = marker; 1366 else dropoffMarker = marker; 1367 }
1368 map.setView([lat, lng], 14); 1369 } 1370 1371 if (input) input.value = data[0].display_name; 1372 updateFlightShipField(); 1373 calculatePrice(); 1374 } else { 1375 // No results - flash red border 1376 if (input) { 1377 input.style.borderColor = '#e74c3c'; 1378 setTimeout(() => { input.style.borderColor = ''; }, 2000); 1379 } 1380 } 1381 }) 1382 .catch(() => { 1383 if (btn) btn.innerHTML = '<i class="fas fa-search"></i>'; 1384 }); 1385} 1386 1387// --- DESTINATION PRE-FILL (with map + coords) --- 1388function initDestinationLinks() { 1389 document.querySelectorAll('.dest-book-btn').forEach(btn => { 1390 btn.addEventListener('click', (e) => { 1391 e.preventDefault(); 1392 const dest = btn.dataset.dest; 1393 if (!dest || !dropoff) return; 1394 1395 // All destinations use custom location with map + predefined coords 1396 const coords = LOCATION_COORDS[dest]; 1397 const address = LOCATION_ADDRESSES[dest] || LOCATION_NAMES[dest] || dest; 1398 1399 // Set dropoff to 'custom' to open map panel 1400 dropoff.value = 'custom'; 1401 dropoff.dispatchEvent(new Event('change')); 1402 1403 // Wait for panel to open, then place marker 1404 setTimeout(() => { 1405 const input = $('#dropoffCustom'); 1406 if (input) input.value = address; 1407 1408 // Init map if not already 1409 if (!dropoffMapInstance) initMapPicker('dropoff'); 1410 1411 setTimeout(() => { 1412 if (coords && dropoffMapInstance) { 1413 // Place/move marker at predefined coords 1414 if (dropoffMarker) { 1415 dropoffMarker.setLatLng(coords); 1416 } else { 1417 dropoffMarker = L.marker(coords).addTo(dropoffMapInstance); 1418 } 1419 dropoffMapInstance.setView(coords, 13); 1420 dropoffMapInstance.invalidateSize(); 1421 } 1422 calculatePrice(); 1423 }, 300); 1424 }, 250); 1425 1426 // Scroll to calculator section 1427 const calcTitle = document.querySelector('.booking-form-title'); 1428 if (calcTitle) { 1429 const topbar = document.querySelector('#topbar'); 1430 const navbar = document.querySelector('#navbar'); 1431 const offset = (topbar ? topbar.offsetHeight : 0) + (navbar ? navbar.offsetHeight : 0) + 16; 1432 const targetPos = calcTitle.getBoundingClientRect().top + window.scrollY - offset; 1433 window.scrollTo({ top: targetPos, behavior: 'smooth' }); 1434 } 1435 }); 1436 }); 1437} 1438 1439// --- MAP PICKER (Leaflet / OpenStreetMap) --- 1440let pickupMapInstance = null, dropoffMapInstance = null; 1441let pickupMarker = null, dropoffMarker = null; 1442 1443 1444function initMapPicker(type) { 1445 const mapId = type + 'Map'; 1446 const mapEl = document.getElementById(mapId); 1447 if (!mapEl || !window.L) return; 1448 1449 const map = L.map(mapId, { tap: false }).setView([41.95, 12.01], 9); 1450 1451 // Street + Satellite layers 1452 const streets = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', { 1453 attribution: '\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> \u00a9 <a href="https://carto.com/">CARTO</a>', 1454 maxZoom: 19 1455 }); 1456 const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { 1457 attribution: '\u00a9 Esri', 1458 maxZoom: 19 1459 }); 1460 streets.addTo(map); 1461 L.control.layers({ 'Street': streets, 'Satellite': satellite }, null, { position: 'topright' }).addTo(map); 1462 1463 map.on('click', function(e) { 1464 const { lat, lng } = e.latlng; 1465 // If user clicks map while a predefined location is selected, switch to 'custom' 1466 // so calculatePrice uses marker coords instead of fixed price lookup 1467 const sel = type === 'pickup' ? pickup : dropoff; 1468 if (sel.value && sel.value !== 'custom') { 1469 sel.value = 'custom'; 1470 } 1471 // Use global marker refs to avoid duplicates 1472 let marker = type === 'pickup' ? pickupMarker : dropoffMarker; 1473 if (marker) { 1474 marker.setLatLng([lat, lng]); 1475 } else { 1476 marker = L.marker([lat, lng]).addTo(map); 1477 if (type === 'pickup') pickupMarker = marker;
1478 else dropoffMarker = marker; 1479 } 1480 reverseGeocode(lat, lng, type); 1481 calculatePrice(); 1482 }); 1483 1484 if (type === 'pickup') pickupMapInstance = map; 1485 else dropoffMapInstance = map; 1486 setTimeout(() => map.invalidateSize(), 100); 1487} 1488 1489function reverseGeocode(lat, lng, type) { 1490 const input = document.getElementById(type === 'pickup' ? 'pickupCustom' : 'dropoffCustom'); 1491 if (!input) return; 1492 1493 const lang = currentLang || 'en'; 1494 1495 fetch('https://nominatim.openstreetmap.org/reverse?format=json&lat=' + lat + '&lon=' + lng + '&zoom=18&addressdetails=1', { 1496 headers: { 'Accept-Language': lang + ',it,en' } 1497 }) 1498 .then(function(r) { return r.json(); }) 1499 .then(function(data) { 1500 if (data.display_name) { 1501 input.value = data.display_name; 1502 } 1503 if (data.address) { 1504 customLocationDetails[type] = data.address; 1505 } 1506 customLocationType[type] = { 1507 class: data.class || '', 1508 type: data.type || '', 1509 display_name: data.display_name || '' 1510 }; 1511 updateFlightShipField(); 1512 }) 1513 .catch(function() { 1514 input.value = lat.toFixed(5) + ', ' + lng.toFixed(5); 1515 }); 1516} 1517 1518// --- CLICKABLE PRICING TABLE ROUTES --- 1519function initRouteButtons() { 1520 document.querySelectorAll('.route-row').forEach(row => { 1521 row.addEventListener('click', () => { 1522 const from = row.dataset.from; 1523 const to = row.dataset.to; 1524 if (!from || !to) return; 1525
1526 // Close any open map panels first 1527 const pickupPanel = document.getElementById('pickupPanel'); 1528 const dropoffPanel = document.getElementById('dropoffPanel'); 1529 if (pickupPanel) pickupPanel.style.display = 'none'; 1530 if (dropoffPanel) dropoffPanel.style.display = 'none'; 1531 1532 // Set dropdowns 1533 pickup.value = from; 1534 dropoff.value = to; 1535 1536 // Trigger change events for map panels + price 1537 pickup.dispatchEvent(new Event('change')); 1538 dropoff.dispatchEvent(new Event('change')); 1539 1540 // Scroll to calculator section 1541 const calcTitle = document.querySelector('.booking-form-title'); 1542 if (calcTitle) { 1543 const topbar = document.querySelector('#topbar'); 1544 const navbar = document.querySelector('#navbar'); 1545 const offset = (topbar ? topbar.offsetHeight : 0) + (navbar ? navbar.offsetHeight : 0) + 16; 1546 const targetPos = calcTitle.getBoundingClientRect().top + window.scrollY - offset; 1547 window.scrollTo({ top: targetPos, behavior: 'smooth' }); 1548 } 1549 }); 1550 }); 1551} 1552 1553// --- EVENT LISTENERS --- 1554function init() { 1555 initDate(); 1556 initNav(); 1557 initSmoothScroll(); 1558 initFleetGallery(); 1559 initFleetLightbox(); 1560 initFleetCardThumbs(); 1561 initFleetDragScroll(); 1562 initFAQ(); 1563 initAnimations(); 1564 initLanguage(); 1565 initCustomLocation(); 1566 initDestinationLinks(); 1567 initRouteButtons(); 1568 initLuggage(); 1569 renderTimeSlots(); 1570 1571 // Time slots toggle (folded by default) 1572 const tsToggle = document.getElementById('timeSlotsToggle'); 1573 const tsPanel = document.getElementById('timeSlotsCollapsible'); 1574 if (tsToggle && tsPanel) { 1575 tsToggle.addEventListener('click', function() { 1576 const isOpen = tsPanel.style.display !== 'none'; 1577 tsPanel.style.display = isOpen ? 'none' : 'block'; 1578 tsToggle.classList.toggle('open', !isOpen); 1579 }); 1580 } 1581 1582 // Price recalculation on any change 1583 [pickup, dropoff, passengers, tripTime].forEach(el => { 1584 el.addEventListener('change', calculatePrice); 1585 }); 1586 if (childSeatCheckbox) childSeatCheckbox.addEventListener('change', calculatePrice); 1587 1588 // Flight/ship field visibility on location change 1589 pickup.addEventListener('change', updateFlightShipField); 1590 dropoff.addEventListener('change', updateFlightShipField); 1591 1592 // Payment method toggle 1593 initPaymentToggle(); 1594 1595 // Re-render time slots when date changes 1596 tripDate.addEventListener('change', renderTimeSlots); 1597 1598 // Dynamic WhatsApp links â update floating/mobile bar buttons with booking data 1599 const DEFAULT_WA_URL = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent("Hello! I'd like to book a transfer.")}`; 1600 function updateDynamicWhatsAppLinks() { 1601 const links = document.querySelectorAll('[data-wa-dynamic]'); 1602 if (!links.length) return; 1603 // Check if any meaningful booking field is filled 1604 const hasFrom = pickup.value && pickup.value !== ''; 1605 const hasTo = dropoff.value && dropoff.value !== ''; 1606 const hasDate = tripDate.value && tripDate.value !== ''; 1607 if (hasFrom || hasTo || hasDate) { 1608 const msg = buildWhatsAppMessage(); 1609 const url = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent(msg)}`; 1610 links.forEach(function(a) { a.href = url; }); 1611 } else { 1612 links.forEach(function(a) { a.href = DEFAULT_WA_URL; }); 1613 } 1614 } 1615 [pickup, dropoff, passengers, tripDate, tripTime].forEach(function(el) { 1616 el.addEventListener('change', updateDynamicWhatsAppLinks); 1617 }); 1618 if (childSeatCheckbox) childSeatCheckbox.addEventListener('change', updateDynamicWhatsAppLinks); 1619 // Also update on custom address input changes 1620 var pickupCustomEl = document.getElementById('pickupCustom'); 1621 var dropoffCustomEl = document.getElementById('dropoffCustom'); 1622 if (pickupCustomEl) pickupCustomEl.addEventListener('change', updateDynamicWhatsAppLinks); 1623 if (dropoffCustomEl) dropoffCustomEl.addEventListener('change', updateDynamicWhatsAppLinks); 1624 1625 // URL parameter pre-fill (for landing page deep links) 1626 const params = new URLSearchParams(window.location.search); 1627 const paramFrom = params.get('from'); 1628 const paramTo = params.get('to'); 1629 if (paramFrom || paramTo) { 1630 if (paramFrom) pickup.value = paramFrom; 1631 if (paramTo) dropoff.value = paramTo; 1632 pickup.dispatchEvent(new Event('change'));
1633 dropoff.dispatchEvent(new Event('change')); 1634 setTimeout(() => { 1635 document.getElementById('booking')?.scrollIntoView({ behavior: 'smooth' }); 1636 }, 300); 1637 } 1638 1639 // Booking buttons 1640 bookingForm.addEventListener('submit', bookViaWhatsApp); 1641 $('#btnEmail').addEventListener('click', sendEmailInquiry); 1642 1643 // Preview modal close 1644 $('#previewClose').addEventListener('click', closePreview); 1645 $('#previewCancel').addEventListener('click', closePreview); 1646 $('#previewOverlay').addEventListener('click', (e) => { 1647 if (e.target === $('#previewOverlay')) closePreview(); 1648 }); 1649 document.addEventListener('keydown', (e) => { 1650 if (e.key === 'Escape' && $('#previewOverlay').classList.contains('active')) closePreview(); 1651 }); 1652} 1653 1654document.addEventListener('DOMContentLoaded', init);
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.