PageSourceSearch

https://azure-sandy-phi.vercel.app/assets/js/tour.js

js azure-sandy-phi.vercel.app collected 2026-10-03 06:09:35 UTC 33,486 bytes, 716 lines download raw bytes

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.