1/* ============================================================ 2 AZURE â ÑÑÑаниÑа одного ÑÑÑа 3 ============================================================ */ 4(() => { 5'use strict'; 6const $ = (s, r = document) => r.querySelector(s); 7const $$ = (s, r = document) => [...r.querySelectorAll(s)]; 8const T = (ru, en) => AZI18N.t(ru, en); 9 10AZI18N.addDict({ 11 't.plan':'Day by day','t.inc':'What the price includes','t.book':'Book now', 12 't.ask':'Ask a travel designer','t.note':'No prepayment. Free cancellation up to 30 days before departure.', 13 't.more':'More tours in this <i>destination</i>','tours.all':'All tours', 14 'card.per':'per person','t.notfound':'Tour not found', 15}); 16 17const INCLUDED = { 18 ru:['ÐеÑелÑÑ ÑÑда и обÑаÑно','ÐÑоживание в оÑеле вÑбÑанной каÑегоÑии','ÐавÑÑаки, ÑаÑÑÑ Ð¾Ð±ÐµÐ´Ð¾Ð² и Ñжинов по пÑогÑамме', 19 'ÐÑе ÑÑанÑÑеÑÑ Ð½Ð° маÑÑÑÑÑе','Ðид, говоÑÑÑий на ваÑем ÑзÑке, на вÑÑм маÑÑÑÑÑе','ÐÑ Ð¾Ð´Ð½Ñе билеÑÑ Ð¿Ð¾ пÑогÑамме','СÑÑÐ°Ñ Ð¾Ð²ÐºÐ°'], 20 en:['Return flights','Accommodation in the chosen hotel category','Breakfasts plus meals listed in the programme', 21 'All transfers on the route','An English-speaking guide throughout','Entry tickets per the programme','Insurance'], 22}; 23const EXCLUDED = { 24 ru:['ÐиÑнÑе ÑаÑÑ Ð¾Ð´Ñ Ð¸ ÑÑвениÑÑ','ÐÐ±ÐµÐ´Ñ Ð¸ ÑÐ¶Ð¸Ð½Ñ Ð²Ð½Ðµ пÑогÑаммÑ','ÐополниÑелÑнÑе ÑкÑкÑÑÑии по желаниÑ','ЧаевÑе'], 25 en:['Personal expenses and souvenirs','Meals outside the programme','Optional extra excursions','Tips'], 26}; 27 28const id = new URLSearchParams(location.search).get('id') || AZ.TOURS[0].id; 29const t = AZ.tour(id) || AZ.TOURS[0]; 30const d = AZ.dest(t.dest); 31 32const CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m5 13 4 4L19 7"/></svg>`; 33const CROSS = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>`; 34 35const paint = () => { 36 const c = t[AZ.lang()], L = AZ.lang(); 37 document.title = `${c.name} â AZURE`; 38 39 $('#tHeroPic').style.backgroundImage = `url('assets/img/${t.img}.jpg')`; 40 $('#tCrumb').textContent = c.name; 41 $('#tName').textContent = c.name; 42 $('#tGeo').textContent = c.geo; 43 44 const badge = $('#tBadge'); 45 if (t.badge) { badge.hidden = false; badge.textContent = t.badge[L]; } else badge.hidden = true; 46 47 $('#tFacts').innerHTML = [ 48 `${t.nights} ${T('дней','days')}`, 49 `${T('до','up to')} ${t.group} ${T('гоÑÑей','guests')}`, 50 `${t.hotel}â ${T('оÑелÑ','hotel')}`, 51 `${T('ÑейÑинг','rating')} ${t.rate.toFixed(1).replace('.', L==='en'?'.':',')}`, 52 ].map(x => `<li>${AZR.esc(x)}</li>`).join(''); 53 54 $('#tLead').textContent = c.lead; 55 $('#tPlan').innerHTML = c.plan.map((step, i) => 56 `<li class="plan__it" data-reveal style="--d:${i*50}ms"> 57 <span class="plan__n">${T('ÐенÑ','Day')} ${i + 1}</span> 58 <span class="plan__txt">${AZR.esc(step)}</span> 59 </li>`).join(''); 60 61 $('#tYes').innerHTML = INCLUDED[L].map(x => `<li>${CHECK}${AZR.esc(x)}</li>`).join(''); 62 $('#tNo').innerHTML = EXCLUDED[L].map(x => `<li>${CROSS}${AZR.esc(x)}</li>`).join(''); 63 64 $('#tPrice').textContent = AZR.eur(t.price); 65 $('#tMeta').innerHTML = [ 66 [T('ÐапÑавление','Destination'), d ? d[L].name : 'â'], 67 [T('ÐлиÑелÑноÑÑÑ','Length'), `${t.nights} ${T('дней','days')}`], 68 [T('Ð Ð°Ð·Ð¼ÐµÑ Ð³ÑÑппÑ','Group size'), `${T('до','up to')} ${t.group}`], 69 [T('ÐÑелÑ','Hotel'), `${t.hotel}â `], 70 ].map(([k, v]) => `<li><span>${AZR.esc(k)}</span><b>${AZR.esc(v)}</b></li>`).join(''); 71 72 const more = AZ.TOURS.filter(x => x.dest === t.dest && x.id !== t.id); 73 $('#tMoreGrid').innerHTML = (more.length ? more : AZ.TOURS.filter(x => x.id !== t.id).slice(0, 3)) 74 .map(AZR.tourCard).join(''); 75 76 AZFav.sync($('#tMoreGrid')); 77 AZReveal(); 78 paintWeather(); 79}; 80 81/* погода в напÑавлении â Open-Meteo */ 82let wx = null; 83const paintWeather = () => { 84 const box = $('#tWx'); 85 if (!wx || !box) return; 86 box.hidden = false;
87 box.innerHTML = `<span class="book__wx-ico">${wx.icon}</span> 88 <span><b>${wx.temp}°C</b><small>${AZR.esc(wx[AZ.lang()])} · ${T('ÑейÑаÑ','now')}</small></span>`; 89}; 90 91if (d) { 92 AZLive.loadWeather([{ id:d.id, lat:d.lat, lon:d.lon }]) 93 .then(r => { wx = r[0]; paintWeather(); }) 94 .catch(e => console.warn('Ðогода недоÑÑÑпна:', e.message)); 95} 96 97/* ============================================================ 98 ÐеÑелÑÑ â Travelpayouts ÑеÑез наÑÑ ÑÑнкÑÐ¸Ñ /api/flights 99 ============================================================ */ 100AZI18N.addDict({ 101 'fl.tag':'Flights','fl.title':'What it costs <i>to get there</i>','fl.from':'Departing from', 102 'fl.direct':'Direct','fl.look':'See tickets','fl.cal':'When it is cheaper to fly', 103 'fl.best':'Cheapest','fl.plus':'plus flights from', 104 'fl.rt':'Return','fl.ow':'One way','fl.pax':'Travelling', 105 'fl.len':'Prices for trips of {n} days â to match the tour', 106 'fl.all':'Show the cheapest','fl.picked':'Selected date','fl.est':'estimate for', 107 'fl.relaxed':'No prices for a {n}-day trip right now, showing all dates',
108 'fl.note':'Prices from Aviasales, updated every half hour. Booking happens on their side; we do not add a mark-up.', 109 'fl.demo':'Demo figures: the Travelpayouts token is not connected yet, so these prices are made up.', 110 'fl.none':'No flight prices for this route right now.', 111}); 112 113/* логоÑÐ¸Ð¿Ñ Ð¾ÑдаÑÑ CDN Travelpayouts, клÑÑ Ð½Ðµ нÑжен */ 114const logo = code => `https://pics.avs.io/80/40/${code}.png`; 115 116const AIRLINES = { TP:'TAP Portugal', IB:'Iberia', AF:'Air France', KL:'KLM', LH:'Lufthansa', 117 BA:'British Airways', A3:'Aegean', VY:'Vueling', FR:'Ryanair', U2:'easyJet', 118 LX:'SWISS', AZ:'ITA Airways', EK:'Emirates', QR:'Qatar Airways', TK:'Turkish Airlines', 119 AT:'Royal Air Maroc', JL:'Japan Airlines', NH:'ANA' }; 120 121let originIata = null; 122let flights = null; 123let tripType = 'rt'; // rt â ÑÑда-обÑаÑно, ow â в Ð¾Ð´Ð½Ñ ÑÑоÑÐ¾Ð½Ñ 124let paxCount = 2; // обÑий ÑÑÑÑÑик: и Ð´Ð»Ñ ÑÑÑлки, и Ð´Ð»Ñ Ð±Ñони 125 126const transfersText = n => n === 0 127 ? T('ÐÑÑмой', 'Direct') 128 : T(`${n} ${n === 1 ? 'пеÑеÑадка' : 'пеÑеÑадки'}`, `${n} stop${n === 1 ? '' : 's'}`); 129 130const durText = min => { 131 if (!min) return ''; 132 const h = Math.floor(min / 60), m = min % 60; 133 return T(`${h} Ñ${m ? ' ' + m + ' мин' : ''} в пÑÑи`, `${h}h${m ? ' ' + m + 'm' : ''} en route`); 134}; 135 136const paxText = n => T( 137 `${n} ${n === 1 ? 'Ñеловек' : n < 5 ? 'Ñеловека' : 'Ñеловек'}`, 138 `${n} ${n === 1 ? 'traveller' : 'travellers'}`); 139 140const dateShort = iso => { 141 const dt = new Date(iso); 142 if (isNaN(dt)) return ''; 143 return dt.toLocaleDateString(AZ.lang() === 'en' ? 'en-GB' : 'ru-RU', 144 { day: 'numeric', month: 'short' }); 145}; 146 147const paintOrigins = () => { 148 $('#flyOrigin').innerHTML = AZ.CONFIG.origins.map(o => 149 `<option value="${o.iata}"${o.iata === originIata ? ' selected' : ''}>${AZR.esc(o[AZ.lang()])}</option>`).join(''); 150 $('#flyPax').innerHTML = Array.from({ length: 6 }, (_, i) => i + 1).map(n => 151 `<option value="${n}"${n === paxCount ? ' selected' : ''}>${AZR.esc(paxText(n))}</option>`).join(''); 152 $$('.fly__seg button').forEach(b => b.classList.toggle('is-on', b.dataset.trip === tripType)); 153}; 154 155/* ââ ÑоÑÑоÑние вÑбоÑа Ð´Ð°Ñ ââ */ 156let depDate = null; // даÑа вÑлеÑа, вÑбиÑÐ°ÐµÑ Ñеловек 157let retDate = null; // даÑа возвÑаÑа, Ñоже вÑбиÑÐ°ÐµÑ Ñеловек 158let pairQuote = null; // Ñена на вÑбÑаннÑÑ Ð¿Ð°ÑÑ (или ближайÑаÑ) 159let quoteBusy = false; 160 161/* меÑÑÑÑ ÐºÑÑиÑÑем на клиенÑе â лиÑÑание не должно ждаÑÑ ÑеÑÑ */ 162const monthCache = new Map(); 163const cacheKey = m => `${originIata}|${d.iata}|${tripType}|${paxCount}|${m}`; 164 165const todayISO = () => new Date().toISOString().slice(0, 10); 166const thisMonth = () => todayISO().slice(0, 7); 167 168/* ============================================================ 169 СпиÑок пÑедложений 170 ============================================================ */ 171const paintFlights = () => { 172 const sec = $('#flySec'); 173 if (!flights) { sec.hidden = true; return; } 174 sec.hidden = false; 175 paintOrigins(); 176 177 $('#flyDemo').hidden = !flights.demo; 178 if (flights.demo) $('#flyDemo').textContent = T( 179 'Ðемо-ÑиÑÑÑ: Ñокен Travelpayouts еÑÑ Ð½Ðµ подклÑÑÑн, ÑÑи ÑÐµÐ½Ñ Ð²ÑдÑманÑ.', 180 'Demo figures: the Travelpayouts token is not connected yet, so these prices are made up.'); 181 182 const rows = pairRows(); 183 if (!rows.length) { 184 $('#flyList').innerHTML = `<p class="fly__empty"> 185 ${T('Ðо ÑÑÐ¾Ð¼Ñ Ð¼Ð°ÑÑÑÑÑÑ ÑейÑÐ°Ñ Ð½ÐµÑ ÑÐ¾Ñ ÑанÑннÑÑ Ñен.', 'No cached prices for this route right now.')} 186 ${flights.searchLink ? `<a class="fly__go" href="${flights.searchLink}" target="_blank" rel="noopener sponsored" style="margin-top:14px"> 187 ${T('ÐоиÑкаÑÑ Ð½Ð° Aviasales', 'Search on Aviasales')} 188 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg> 189 </a>` : ''} 190 </p>`; 191 } else { 192 $('#flyList').innerHTML = rows.map((o, i) => flightRow(o, i)).join('') 193 + (depDate ? `<button class="fly__reset" type="button" id="flyReset"> 194 ${T('ÐоказаÑÑ ÑамÑе деÑÑвÑе', 'Show the cheapest')} 195 </button>` : ''); 196 } 197
198 paintCalendar(); 199 paintSidebarFlight(); 200 paintNote(); 201 AZReveal(sec); 202}; 203 204/* какие каÑÑоÑки показÑваÑÑ: вÑбÑаннÑÑ Ð¿Ð°ÑÑ Ð¸Ð»Ð¸ ÑÑÐ¾Ð¹ÐºÑ ÑамÑÑ Ð´ÐµÑÑвÑÑ */ 205function pairRows() { 206 if (quoteBusy) return []; 207 if (depDate && pairQuote?.found) { 208 return [{ 209 date: pairQuote.depart, 210 price: pairQuote.price, 211 transfers: pairQuote.transfers, 212 airline: null, flightNumber: null, durationMin: null, 213 departureAt: pairQuote.depart, 214 returnAt: pairQuote.back || null, 215 link: pairQuote.link, 216 inexact: !pairQuote.exact, 217 }]; 218 } 219 if (depDate && !flights.oneWay && !retDate) { 220 const day = (flights.days || []).find(x => x.date === depDate); 221 if (day) return [day]; 222 return []; 223 } 224 if (depDate && flights.oneWay) { 225 const day = (flights.days || []).find(x => x.date === depDate); 226 if (day) return [day]; 227 return []; 228 } 229 return (flights.offers || []).slice(0, 3); 230} 231 232function flightRow(o, i) { 233 const c = paxCount > 1 234 ? `â ${AZR.eur(o.price * paxCount)}` 235 : AZR.eur(o.price); 236 const sub = paxCount > 1 237 ? `${T('за', 'for')} ${paxText(paxCount)} · ${AZR.eur(o.price)} ${T('за одного', 'each')}` 238 : (flights.oneWay ? T('в Ð¾Ð´Ð½Ñ ÑÑоÑонÑ', 'one way') : T('ÑÑда-обÑаÑно', 'return')); 239 240 return `<a class="fly__it${i === 0 ? ' is-best' : ''}${o.inexact ? ' is-near' : ''}" 241 href="${o.link}" target="_blank" rel="noopener sponsored"> 242 <span class="fly__route"> 243 <b>${AZR.esc(flights.origin)}</b> 244 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg> 245 <b>${AZR.esc(flights.destination)}</b> 246 </span> 247 ${o.airline ? `<span class="fly__logo"> 248 <img src="${logo(o.airline)}" alt="" loading="lazy" width="40" height="20" 249 onerror="this.replaceWith(Object.assign(document.createElement('b'),{textContent:'${AZR.esc(o.airline)}',className:'fly__code'}))"> 250 </span>` : '<span class="fly__logo fly__logo--none" aria-hidden="true"></span>'} 251 <span class="fly__mid"> 252 <span class="fly__airline">${o.airline 253 ? AZR.esc((AIRLINES[o.airline] || o.airline) + ' ' + (o.flightNumber || '')) 254 : AZR.esc(durText(o.durationMin) || T('ÐеÑелÑÑ', 'Flight'))}</span> 255 <span class="fly__meta">${AZR.esc(transfersText(o.transfers))}${ 256 o.departureAt ? ` · ${AZR.esc(dateShort(o.departureAt))}` : ''}${ 257 o.returnAt ? ` â ${AZR.esc(dateShort(o.returnAt))}` : ''}</span> 258 </span> 259 <span class="fly__price"><b>${c}</b><small>${AZR.esc(sub)}</small></span> 260 <span class="fly__go">${T('СмоÑÑеÑÑ Ð±Ð¸Ð»ÐµÑÑ', 'See tickets')} 261 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg> 262 </span> 263 </a>`; 264} 265 266/* ============================================================ 267 ÐалендаÑÑ: обе даÑÑ Ð²ÑбиÑÐ°ÐµÑ Ñеловек 268 ============================================================ */ 269let viewMonth = thisMonth(); 270 271const shiftMonth = (ym, delta) => { 272 const [y, m] = ym.split('-').map(Number); 273 return new Date(Date.UTC(y, m - 1 + delta, 1)).toISOString().slice(0, 7); 274}; 275const monthTitle = ym => { 276 const [y, m] = ym.split('-').map(Number); 277 return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString( 278 AZ.lang() === 'en' ? 'en-GB' : 'ru-RU', { month: 'long', year: 'numeric', timeZone: 'UTC' }); 279}; 280 281const WD_RU = ['пн','вÑ','ÑÑ','ÑÑ','пÑ','Ñб','вÑ']; 282const WD_EN = ['Mo','Tu','We','Th','Fr','Sa','Su']; 283 284const paintCalendar = () => { 285 $('#flyCal').hidden = false; 286 $('#flyMonth').textContent = monthTitle(viewMonth); 287 $('#flyPrev').disabled = viewMonth <= thisMonth(); 288 289 const days = flights?.days || []; 290 const byDate = new Map(days.map(x => [x.date, x])); 291 const min = days.length ? Math.min(...days.map(x => x.price)) : 0; 292 293 const [y, m] = viewMonth.split('-').map(Number); 294 const total = new Date(Date.UTC(y, m, 0)).getUTCDate(); 295 const lead = (new Date(Date.UTC(y, m - 1, 1)).getUTCDay() + 6) % 7; 296 const today = todayISO(); 297 298 const cells = Array.from({ length: lead }, () => '<span class="fly__cell is-empty"></span>'); 299 300 for (let n = 1; n <= total; n++) { 301 const date = `${viewMonth}-${String(n).padStart(2, '0')}`; 302 const info = byDate.get(date); 303 const past = date < today; 304 const isDep = depDate === date; 305 const isRet = retDate === date; 306 const inSpan = depDate && retDate && date > depDate && date < retDate; 307 const cheap = info && info.price === min && !isDep && !isRet; 308 309 /* кликабелÑÐ½Ñ Ð²Ñе бÑдÑÑие дни, а не ÑолÑко Ñе, где еÑÑÑ ÐºÑÑ ÑÐµÐ½Ñ */
310 const cls = ['fly__cell', past ? 'is-past' : 'is-open', 311 info ? 'has-price' : '', isDep ? 'is-dep' : '', isRet ? 'is-ret' : '', 312 inSpan ? 'is-span' : '', cheap ? 'is-cheap' : ''].filter(Boolean).join(' '); 313 314 if (past) { 315 cells.push(`<span class="${cls}"><span class="fly__num">${n}</span></span>`); 316 continue; 317 } 318 cells.push(`<button type="button" class="${cls}" data-date="${date}" 319 aria-pressed="${isDep || isRet}"> 320 <span class="fly__num">${n}</span> 321 ${info ? `<span class="fly__cost">${AZR.eur(info.price)}</span>` : ''} 322 ${isDep ? `<span class="fly__tag">${T('ÑÑда','out')}</span>` 323 : isRet ? `<span class="fly__tag">${T('обÑаÑно','back')}</span>` 324 : cheap ? `<span class="fly__tag fly__tag--cheap">${T('деÑевле','cheapest')}</span>` : ''} 325 </button>`); 326 } 327 328 $('#flyGrid').innerHTML = 329 (AZ.lang() === 'en' ? WD_EN : WD_RU).map(w => `<span class="fly__wd">${w}</span>`).join('') 330 + cells.join(''); 331}; 332 333/* ââ подпиÑÑ Ð¿Ð¾Ð´ календаÑÑм ââ */ 334const paintNote = () => { 335 const hint = $('#flyLegend'); 336 const parts = []; 337 338 if (quoteBusy) parts.push(T('ÐÑем ÑенÑâ¦', 'Looking up the priceâ¦')); 339 else if (flights.oneWay && depDate) 340 parts.push(T(`ÐÑÐ»ÐµÑ ${dateShort(depDate)}.`, `Departing ${dateShort(depDate)}.`)); 341 else if (depDate && retDate) { 342 const n = Math.round((Date.parse(retDate) - Date.parse(depDate)) / 86400000); 343 parts.push(T(`ТÑда ${dateShort(depDate)}, обÑаÑно ${dateShort(retDate)} â ${n} ${n === 1 ? 'ноÑÑ' : n < 5 ? 'ноÑи' : 'ноÑей'}.`, 344 `Out ${dateShort(depDate)}, back ${dateShort(retDate)} â ${n} night${n === 1 ? '' : 's'}.`)); 345 if (pairQuote && pairQuote.found && !pairQuote.exact) 346 parts.push(T(`Ðа ÑÑи даÑÑ ÑÐµÐ½Ñ Ð² кÑÑе Ð½ÐµÑ â показÑваем ближайÑÑÑ: ${dateShort(pairQuote.depart)} â ${dateShort(pairQuote.back)}. СÑÑлка ведÑÑ Ð½Ð° ваÑи даÑÑ.`, 347 `No cached price for those exact dates â showing the nearest: ${dateShort(pairQuote.depart)} â ${dateShort(pairQuote.back)}. The link opens your dates.`)); 348 else if (pairQuote && !pairQuote.found) 349 parts.push(T('Ð¦ÐµÐ½Ñ Ð½Ð° ÑÑи даÑÑ Ð¿Ð¾Ð´ÑÐºÐ°Ð¶ÐµÑ Ð¿Ð¾Ð¸Ñк â нажмиÑе «СмоÑÑеÑÑ Ð±Ð¸Ð»ÐµÑÑ».', 350 'The search will price those dates â hit âSee ticketsâ.')); 351 } 352 else if (depDate) 353 parts.push(T(`ТÑда ${dateShort(depDate)}. ÐÑбеÑиÑе даÑÑ Ð²Ð¾Ð·Ð²ÑаÑа.`, 354 `Out ${dateShort(depDate)}. Now pick a return date.`)); 355 else 356 parts.push(flights.oneWay 357 ? T('ÐÑбеÑиÑе даÑÑ Ð²ÑлеÑа.', 'Pick a departure date.') 358 : T('ÐÑбеÑиÑе даÑÑ Ð²ÑлеÑа, заÑем даÑÑ Ð²Ð¾Ð·Ð²ÑаÑа.', 'Pick a departure date, then a return date.')); 359 360 /* «26 авг.» Ñже Ñ ÑоÑкой â ÑбиÑаем задвоение в конÑе пÑедложений */ 361 hint.textContent = parts.join(' ').replace(/\.\.+/g, '.'); 362}; 363 364/* ââ ÑÑÑока пеÑелÑÑа в каÑÑоÑке бÑони ââ */ 365const paintSidebarFlight = () => { 366 const box = $('#tFly'); 367 const best = pairRows()[0] || flights?.offers?.[0]; 368 if (!box) return; 369 if (!best) { box.hidden = true; return; } 370 const from = AZ.CONFIG.origins.find(o => o.iata === originIata); 371 const city = from ? (AZ.lang() === 'en' ? from.en : from.from) : originIata; 372 box.hidden = false; 373 box.innerHTML = `<span>${T('плÑÑ Ð¿ÐµÑелÑÑ Ð¸Ð·', 'plus flights from')} ${AZR.esc(city)}</span> 374 <b>${T('оÑ', 'from')} ${AZR.eur(best.price)}</b>`; 375}; 376 377/* ============================================================ 378 ÐагÑÑзка 379 ============================================================ */ 380const fetchMonth = async (month) => { 381 const key = cacheKey(month); 382 if (monthCache.has(key)) return monthCache.get(key); 383 const q = new URLSearchParams({ origin: originIata, destination: d.iata, 384 currency: 'eur', pax: String(paxCount), month }); 385 if (tripType === 'ow') q.set('oneWay', '1'); 386 const r = await fetch(`${AZ.CONFIG.flightsApi}?${q}`); 387 const data = await r.json(); 388 if (data.ok) monthCache.set(key, data); 389 return data; 390}; 391 392const loadFlights = async (keepDates = false) => { 393 if (!d?.iata) return; 394 if (!keepDates) { depDate = null; retDate = null; pairQuote = null; } 395 396 const key = cacheKey(viewMonth); 397 if (!monthCache.has(key)) $('#flyGrid')?.classList.add('is-loading'); 398 399 try { 400 const data = await fetchMonth(viewMonth); 401 flights = data.ok ? data : null; 402 } catch (e) { 403 console.warn('Ð¦ÐµÐ½Ñ Ð½Ð° пеÑелÑÑ Ð½ÐµÐ´Ð¾ÑÑÑпнÑ:', e.message); 404 flights = null; 405 } 406 $('#flyGrid')?.classList.remove('is-loading'); 407 paintFlights(); 408 409 /* ÑледÑÑÑий меÑÑÑ Ð¿Ð¾Ð´ÑÑгиваем заÑанее â лиÑÑание бÑÐ´ÐµÑ Ð¼Ð³Ð½Ð¾Ð²ÐµÐ½Ð½Ñм */ 410 fetchMonth(shiftMonth(viewMonth, 1)).catch(() => {}); 411}; 412 413/* ââ Ñена на вÑбÑаннÑÑ Ð¿Ð°ÑÑ Ð´Ð°Ñ ââ */ 414const loadQuote = async () => { 415 if (!depDate || !retDate) return; 416 quoteBusy = true; pairQuote = null; 417 paintFlights(); 418 try { 419 const q = new URLSearchParams({ origin: originIata, destination: d.iata, 420 currency: 'eur', pax: String(paxCount), depart: depDate, back: retDate }); 421 const r = await fetch(`${AZ.CONFIG.flightsApi}?${q}`); 422 pairQuote = await r.json(); 423 } catch (e) { 424 console.warn('Цена на паÑÑ Ð´Ð°Ñ Ð½ÐµÐ´Ð¾ÑÑÑпна:', e.message); 425 pairQuote = { ok: true, found: false }; 426 } 427 quoteBusy = false;
428 paintFlights(); 429}; 430 431/* ââ вÑÐ±Ð¾Ñ Ð´Ð°Ñ Ð² календаÑе ââ */ 432$('#flySec')?.addEventListener('click', e => { 433 const cell = e.target.closest('.fly__cell.is-open'); 434 if (cell) { 435 const date = cell.dataset.date; 436 if (tripType === 'ow') { 437 depDate = depDate === date ? null : date; 438 retDate = null; pairQuote = null; 439 paintFlights(); 440 return; 441 } 442 /* пеÑвÑй клик â ÑÑда, вÑоÑой â обÑаÑно, ÑÑеÑий наÑÐ¸Ð½Ð°ÐµÑ Ð·Ð°Ð½Ð¾Ð²Ð¾ */ 443 if (!depDate || retDate || date <= depDate) { 444 depDate = date; retDate = null; pairQuote = null; 445 paintFlights(); 446 } else { 447 retDate = date; 448 loadQuote(); 449 } 450 return; 451 } 452 if (e.target.closest('#flyReset')) { 453 depDate = null; retDate = null; pairQuote = null; 454 paintFlights(); 455 } 456}); 457 458if (d?.iata) { 459 /* гоÑод вÑлеÑа запоминаем â он не должен ÑбÑаÑÑваÑÑÑÑ Ð¿Ñи пеÑÐµÑ Ð¾Ð´Ðµ Ð¼ÐµÐ¶Ð´Ñ ÑÑÑами */ 460 let saved = null; 461 try { saved = localStorage.getItem('az-origin'); } catch {} 462 originIata = AZ.CONFIG.origins.some(o => o.iata === saved) ? saved : AZ.CONFIG.origins[0].iata; 463 464 paintOrigins(); 465 466 $('#flyOrigin').addEventListener('change', e => { 467 originIata = e.target.value; 468 try { localStorage.setItem('az-origin', originIata); } catch {} 469 loadFlights(); 470 }); 471 472 $('#flyPax').addEventListener('change', async e => { 473 paxCount = Number(e.target.value); 474 syncPax(); 475 await loadFlights(true); 476 if (depDate && retDate) loadQuote(); // ÑÑÑлка и Ñена завиÑÑÑ Ð¾Ñ ÑиÑла лÑдей 477 }); 478 479 $('#flyPrev').addEventListener('click', () => { 480 const prev = shiftMonth(viewMonth, -1); 481 if (prev < new Date().toISOString().slice(0, 7)) return; 482 viewMonth = prev; loadFlights(true); 483 }); 484 $('#flyNext').addEventListener('click', () => { 485 viewMonth = shiftMonth(viewMonth, 1); 486 loadFlights(true); 487 }); 488 489 $$('.fly__seg button').forEach(b => b.addEventListener('click', () => { 490 if (b.dataset.trip === tripType) return; 491 tripType = b.dataset.trip; 492 viewMonth = new Date().toISOString().slice(0, 7); 493 $$('.fly__seg button').forEach(x => x.classList.toggle('is-on', x === b)); 494 loadFlights(); 495 })); 496 497 loadFlights(); 498} 499 500/* ÑиÑло паÑÑажиÑов обÑее Ð´Ð»Ñ Ð±Ð»Ð¾ÐºÐ° пеÑелÑÑа и ÑоÑÐ¼Ñ Ð±Ñони */ 501function syncPax() { 502 const sel = $('#bkPax'); 503 if (sel && Number(sel.value) !== paxCount && paxCount <= t.group) { 504 sel.value = String(paxCount); 505 sel.dispatchEvent(new Event('change')); 506 } 507 const fly = $('#flyPax'); 508 if (fly && Number(fly.value) !== paxCount) fly.value = String(paxCount); 509} 510 511/* ============================================================ 512 ÐÑакÑиÑеÑÐºÐ°Ñ Ð¸Ð½ÑоÑмаÑÐ¸Ñ Ð¿Ð¾ ÑÑÑане â Warnely (CC BY 4.0) 513 ============================================================ */ 514AZI18N.addDict({ 515 'pf.tag':'Before you go','pf.title':'Useful to know<br>about the <i>country</i>', 516 'pf.lead':'Updated automatically from official sources â UK FCDO and US State Department advisories.', 517}); 518 519/* ÐºÐ¾Ð´Ñ Ð¸Ð· оÑвеÑа: ÑозеÑка, напÑÑжение, ÑаÑовой поÑÑ, ÑÑоÑона движениÑ, вода */ 520const WATER = { s:['Ðожно пиÑÑ Ð¸Ð·-под кÑана','Tap water is safe'], 521 c:['ÐÑÑÑе бÑÑилиÑованнаÑ','Bottled water advised'], 522 u:['Ðз-под кÑана не пиÑÑ','Do not drink tap water'] }; 523const DRIVE = { R:['ÐÑавоÑÑоÑоннее','Right-hand'], L:['ÐевоÑÑоÑоннее','Left-hand'] }; 524const TIER = { 525 'Very Safe':['ÐÑÐµÐ½Ñ Ð±ÐµÐ·Ð¾Ð¿Ð°Ñно','Very safe'], 526 'Safe':['ÐезопаÑно','Safe'], 527 'Exercise Caution':['СоблÑдаÑÑ Ð¾ÑÑоÑожноÑÑÑ','Exercise caution'], 528 'Exercise Normal Caution':['ÐбÑÑÐ½Ð°Ñ Ð¾ÑÑоÑожноÑÑÑ','Normal caution'], 529 'Exercise Increased Caution':['ÐовÑÑÐµÐ½Ð½Ð°Ñ Ð¾ÑÑоÑожноÑÑÑ','Increased caution'], 530 'Reconsider Travel':['СÑÐ¾Ð¸Ñ Ð¿ÐµÑеÑмоÑÑеÑÑ Ð¿Ð¾ÐµÐ·Ð´ÐºÑ','Reconsider travel'],
531 'Do Not Travel':['ÐÑ Ð°ÑÑ Ð½Ðµ ÑÑоиÑ','Do not travel'], 532}; 533const tierText = v => TIER[v] ? T(...TIER[v]) : v; 534 535let country = null; 536 537const paintFacts = () => { 538 const sec = $('#factsSec'); 539 if (!country) { sec.hidden = true; return; } 540 sec.hidden = false; 541 542 const L = AZ.lang(), q = country.quick_facts || {}; 543 const tier = tierText(country.risk_tier); 544 /* название ÑÑÑÐ°Ð½Ñ Ð±ÐµÑÑм ÑÐ²Ð¾Ñ â Ñ Warnely оно ÑолÑко по-английÑки */ 545 const cname = d?.land?.[L] || d?.[L]?.country || country.name; 546 /* 0 â безопаÑнее вÑего, 100 â опаÑнее вÑего */ 547 const level = country.risk_score <= 20 ? 'ok' : country.risk_score <= 35 ? 'mid' : 'high'; 548 549 $('#pfSafety').innerHTML = ` 550 <div class="safety__top"> 551 <span class="safety__flag" aria-hidden="true">${country.flag || ''}</span> 552 <span> 553 <span class="safety__country">${AZR.esc(cname)}</span> 554 <span class="safety__tier safety__tier--${level}">${AZR.esc(tier)}</span> 555 </span> 556 <span class="safety__score safety__score--${level}"> 557 <b>${country.risk_score}</b><small>${T('из 100', 'of 100')}</small> 558 </span> 559 </div> 560 ${L === 'en' && country.summary 561 ? `<p class="safety__txt">${AZR.esc(country.summary)}</p>` : ''} 562 <div class="safety__bar"><i class="safety__bar--${level}" style="left:${Math.min(98, Math.max(2, country.risk_score))}%"></i></div> 563 <p class="safety__scale"><span>${T('безопаÑнее','safer')}</span><span>${T('опаÑнее','riskier')}</span></p> 564 ${country.advisories?.fcdo?.url 565 ? `<a class="safety__link" href="${country.advisories.fcdo.url}" target="_blank" rel="noopener"> 566 ${T('ÐÑиÑиалÑнÑе ÑекомендаÑии ÐÐÐ ÐеликобÑиÑании', 'Official UK FCDO advice')} 567 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg> 568 </a>` : ''}`; 569 570 const rows = [ 571 ['ð', T('РозеÑки','Sockets'), `${q.p || 'â'} · ${q.v || ''}`], 572 ['ð', T('ЧаÑовой поÑÑ','Time zone'), q.tz || 'â'], 573 ['ð', T('Ðвижение','Driving'), q.d && DRIVE[q.d] ? T(...DRIVE[q.d]) : 'â'], 574 ['ð§', T('Ðода','Water'), q.w && WATER[q.w] ? T(...WATER[q.w]) : 'â'], 575 ]; 576 if (country.peace_index?.rank) 577 rows.push(['ð', T('ÐÐ½Ð´ÐµÐºÑ Ð¼Ð¸Ñа','Peace index'), 578 `${country.peace_index.rank} ${T('меÑÑо','place')} · ${country.peace_index.year}`]); 579 if (country.womens_safety?.rating) 580 rows.push(['ð©', T('ÐезопаÑноÑÑÑ Ð´Ð»Ñ Ð¶ÐµÐ½Ñин','Safety for women'), 581 tierText(country.womens_safety.rating)]); 582 583 $('#pfFacts').innerHTML = rows.map(([ico, k, v]) => 584 `<li><span class="facts__ico" aria-hidden="true">${ico}</span> 585 <span><span class="facts__k">${AZR.esc(k)}</span> 586 <span class="facts__v">${AZR.esc(v)}</span></span></li>`).join(''); 587 588 $('#pfSrc').innerHTML = T( 589 `ÐÑÑоÑник: <a href="https://warnely.com" target="_blank" rel="noopener">Warnely</a>, лиÑÐµÐ½Ð·Ð¸Ñ CC BY 4.0. Сводка по даннÑм FCDO, ÐоÑдепаÑÑаменÑа СШРи Global Peace Index.`, 590 `Source: <a href="https://warnely.com" target="_blank" rel="noopener">Warnely</a>, CC BY 4.0. Composite of FCDO, US State Department and Global Peace Index data.`); 591 592 AZReveal(sec); 593}; 594 595if (d?.iso) { 596 AZLive.loadCountry(d.iso) 597 .then(c => { country = c; paintFacts(); }) 598 .catch(e => console.warn('ÐаннÑе по ÑÑÑане недоÑÑÑпнÑ:', e.message)); 599} 600 601/* ============================================================ 602 ÐодалÑное окно бÑони 603 ============================================================ */ 604AZI18N.addDict({ 605 'bk.title':'Booking enquiry', 606 'bk.lead':'Leave your contacts â a travel designer will confirm dates and places in the group.', 607 'bk.when':'Departure date','bk.pax':'How many people','bk.msg':'Anything to note', 608 'bk.msgph':'What matters on this trip','bk.total':'Estimated total','bk.send':'Send enquiry', 609 'bk.note':'No prepayment. We will be in touch within one business day.', 610 'bk.okh':'Enquiry sent','bk.close':'Close','bk.sum':'What we received', 611}); 612 613const MONTHS = [['СенÑÑбÑÑ 2026','September 2026'],['ÐкÑÑбÑÑ 2026','October 2026'], 614 ['ÐоÑбÑÑ 2026','November 2026'],['ÐекабÑÑ 2026','December 2026'], 615 ['Ðибкие даÑÑ','Flexible dates']]; 616 617const bk = $('#bkForm'); 618const modal = $('#bookModal'); 619 620const paxLabel = n => n === 1 621 ? T('1 Ñеловек', '1 person') 622 : T(`${n} ${n < 5 ? 'Ñеловека' : 'Ñеловек'}`, `${n} people`); 623 624const paintBooking = () => { 625 const c = t[AZ.lang()], L = AZ.lang(); 626 $('#bkPic').style.backgroundImage = `url('assets/img/${t.img}.jpg')`; 627 $('#bkName').textContent = c.name; 628 $('#bkGeo').textContent = c.geo; 629 $('#bkUnit').textContent = AZR.eur(t.price); 630 631 const when = $('#bkWhen'), pax = $('#bkPax'); 632 const keepWhen = when.value, keepPax = pax.value; 633 when.innerHTML = MONTHS.map((m, i) => `<option value="${i}">${AZR.esc(T(m[0], m[1]))}</option>`).join(''); 634 pax.innerHTML = Array.from({ length: t.group }, (_, i) => 635 `<option value="${i + 1}">${AZR.esc(paxLabel(i + 1))}</option>`).join(''); 636 when.value = keepWhen || '0'; 637 pax.value = keepPax || '2'; 638 paintTotal(); 639}; 640 641const paintTotal = () => {
642 const n = Number($('#bkPax').value || 1); 643 $('#bkTotal').textContent = AZR.eur(t.price * n); 644}; 645$('#bkPax')?.addEventListener('change', () => { 646 paintTotal(); 647 const n = Number($('#bkPax').value); 648 if (n !== paxCount && n <= 6) { paxCount = n; syncPax(); loadFlights(true); } 649}); 650 651bk?.addEventListener('submit', async e => { 652 e.preventDefault(); 653 const bad = [...bk.querySelectorAll('[required]')].filter(i => !i.value.trim()); 654 if (bad.length) { bad[0].focus(); bad[0].reportValidity?.(); return; } 655 656 const L = AZ.lang(); 657 const n = Number($('#bkPax').value); 658 const whenTxt = $('#bkWhen').selectedOptions[0].textContent; 659 660 /* вÑÑ, ÑÑо ÑÑ Ð¾Ð´Ð¸Ñ Ð½Ð° поÑÑÑ Ð¸Ð»Ð¸ в CRM */ 661 const payload = { 662 subject: `ÐаÑвка на ÑÑÑ: ${t.ru.name}`, 663 tour_id: t.id, 664 tour: t[L].name, 665 direction: d ? d[L].name : '', 666 geo: t[L].geo, 667 nights: t.nights, 668 group_max: t.group, 669 hotel: `${t.hotel}â `, 670 departure: whenTxt, 671 people: n, 672 price_per_person: t.price, 673 total: t.price * n, 674 currency: AZ.CONFIG.currency, 675 name: $('#bkName2').value.trim(), 676 contact: $('#bkContact').value.trim(), 677 message: $('#bkMsg').value.trim(), 678 page: location.href, 679 }; 680 681 const btn = $('.f-send span', bk); 682 const was = btn.textContent; 683 btn.textContent = T('ÐÑпÑавлÑемâ¦', 'Sendingâ¦'); 684 685 const res = await AZForm.send(payload); 686 btn.textContent = was; 687 688 $('#bkOkText').textContent = res.demo 689 ? T('ФоÑма ÑабоÑÐ°ÐµÑ Ð² демо-Ñежиме: даннÑе не оÑпÑавленÑ. ÐодÑÑавÑÑе адÑÐµÑ Ð¿ÑиÑма заÑвок в assets/js/data.js â и они наÑнÑÑ Ð¿ÑÐ¸Ñ Ð¾Ð´Ð¸ÑÑ Ð½Ð° поÑÑÑ.', 690 'The form runs in demo mode: nothing was sent. Add a form endpoint in assets/js/data.js and enquiries will start arriving by e-mail.') 691 : res.ok 692 ? T('СпаÑибо! ТÑÑвел-Ð´Ð¸Ð·Ð°Ð¹Ð½ÐµÑ ÑвÑжеÑÑÑ Ñ Ð²Ð°Ð¼Ð¸ в ÑеÑение ÑабоÑего днÑ.', 693 'Thank you! A travel designer will contact you within one business day.') 694 : T('Ðе ÑдалоÑÑ Ð¾ÑпÑавиÑÑ Ð·Ð°ÑвкÑ. ÐапиÑиÑе нам на [email protected] â Ð¼Ñ Ð¾ÑвеÑим.', 695 'The enquiry could not be sent. Please write to [email protected] and we will reply.'); 696 697 const rows = [ 698 [T('ТÑÑ','Tour'), payload.tour], 699 [T('ÐапÑавление','Destination'), payload.direction], 700 [T('ÐлиÑелÑноÑÑÑ','Length'), `${t.nights} ${T('дней','days')}`], 701 [T('ÐÑезд','Departure'), payload.departure], 702 [T('Человек','People'), String(n)], 703 [T('Цена за Ñеловека','Price per person'), AZR.eur(t.price)], 704 [T('ÐÑого','Total'), AZR.eur(payload.total)], 705 [T('ÐонÑакÑ','Contact'), payload.contact], 706 ]; 707 $('#bkPayload').innerHTML = `<h5>${T('ЧÑо полÑÑил менеджеÑ','What the manager received')}</h5><dl>` 708 + rows.map(([k, v]) => `<dt>${AZR.esc(k)}</dt><dd>${AZR.esc(v)}</dd>`).join('') + '</dl>'; 709 710 modal.classList.add('is-sent'); 711}); 712 713paint(); 714paintBooking(); 715AZI18N.onLang(() => { paint(); paintBooking(); paintFacts(); paintFlights(); }); 716})();
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.