templates/website/page/index.html.twig line 1
{% extends '@web/base.html.twig' %}{% block title %}TNT — Explore Locations{% endblock %}{# ── Override stylesheets: suppress Bootstrap/main.css, load only what map needs ── #}{% block stylesheets %}<link rel="stylesheet" href="{{ asset('css/all.min.css') }}"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"><style>:root {--navy: #0B1F3A;--ocean: #1A3A5C;--aegean: #2E7D9E;--mint: #48C9B0;--amber: #E8A020;--terra: #D4572A;--parchment: #F5F2EC;--stone: #717D7E;--panel: #0f2a45;--panel2: #162e48;--border: #1e3a56;/* An unlit rating star. Must read as an empty star, not as nothingat all — --border is a panel edge and disappears against it. */--star-off: #9BA7B6;}/* ── Reset everything the base injects ── */*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }html, body {height: 100%;width: 100%;overflow: hidden !important;background: var(--navy);color: var(--parchment);font-family: 'DM Sans', sans-serif;}/* ── Hide header, footer, #content wrapper from base ── */body > header,body > footer,body > nav,#content {display: none !important;}/* ── Map app layout ── */.map-app {display: grid;grid-template-rows: 60px 1fr;position: fixed;inset: 0;/* Size from inset, NOT 100vw/100vh. On a phone 100vh is the tallviewport (URL bar expanded), so the grid ran below the visiblearea and everything anchored to the bottom was cut off; 100vwignores a scrollbar and can push the row sideways. inset:0 givesexactly the visible viewport on both axes. */width: 100%;height: 100%;overflow: hidden;z-index: 9999;background: var(--navy);}/* ── Map header ── */.map-header {display: flex;align-items: center;justify-content: space-between;gap: 12px;min-width: 0;padding: 0 28px;background: var(--navy);border-bottom: 1px solid var(--border);z-index: 400;}.map-logo {font-family: 'Bebas Neue', sans-serif;font-size: 30px;letter-spacing: 5px;color: var(--parchment);text-decoration: none;display: flex;align-items: center;line-height: 1;}.map-logo .acc { color: var(--mint); }.map-logo .dot {width: 6px;height: 6px;background: var(--amber);border-radius: 50%;margin-left: 5px;margin-bottom: 14px;display: inline-block;flex-shrink: 0;}.map-header { position: relative; }.map-controls {display: flex;align-items: center;gap: 10px;}/* ── Hamburger (hidden on desktop) ── */.map-burger {display: none;flex-direction: column;justify-content: center;gap: 5px;width: 42px;height: 38px;padding: 0 10px;background: var(--panel);border: 1px solid var(--border);border-radius: 8px;cursor: pointer;}.map-burger span {display: block;height: 2px;width: 100%;background: var(--parchment);border-radius: 2px;transition: transform 0.25s, opacity 0.2s;}.map-burger[aria-expanded="true"] { border-color: var(--mint); }.map-burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }.map-burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }.map-burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }.map-count {font-size: 12px;color: var(--stone);white-space: nowrap;margin-right: 4px;}.map-count strong { color: var(--mint); }.map-select {background: var(--panel);color: var(--parchment);border: 1px solid var(--border);border-radius: 8px;padding: 7px 30px 7px 13px;font-family: 'DM Sans', sans-serif;font-size: 13px;cursor: pointer;outline: none;appearance: none;-webkit-appearance: none;background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2348C9B0' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat: no-repeat;background-position: right 10px center;transition: border-color 0.15s;}.map-select:focus { border-color: var(--mint); }.map-select option { background: var(--ocean); }.map-back {display: none;align-items: center;gap: 6px;font-size: 13px;color: var(--stone);text-decoration: none;padding: 6px 12px;border: 1px solid var(--border);border-radius: 8px;background: var(--panel);transition: color 0.15s, border-color 0.15s;white-space: nowrap;}.map-back:hover { color: var(--parchment); border-color: var(--aegean); }/* ── Language switcher ── */.map-lang {display: flex; align-items: center; gap: 4px;padding: 4px 6px; border: 1px solid var(--border);border-radius: 8px; background: var(--panel);}.map-lang-ico { font-size: 13px; line-height: 1; margin-right: 2px; }.map-lang-btn {min-width: 30px; height: 26px; padding: 0 7px; border-radius: 6px;border: 1px solid transparent; background: none; color: var(--stone);font-family: 'DM Sans', sans-serif; font-size: 12px; font-weight: 700;letter-spacing: .5px; cursor: pointer; white-space: nowrap;transition: color .15s, border-color .15s, background .15s;}.map-lang-btn:hover { color: var(--parchment); border-color: var(--aegean); }/* TNT_I18N adds .active to whichever data-set-lang matches the current one. */.map-lang-btn.active { color: var(--mint); border-color: var(--mint); background: var(--ocean); }/* ── Auth (sign in / register / account) ── */.map-auth { display: flex; align-items: center; gap: 8px; }.map-auth::before {content: ''; width: 1px; align-self: stretch; min-height: 22px;background: var(--border); margin: 0 2px;}.auth-btn {display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600;text-decoration: none; padding: 8px 15px; border-radius: 8px; white-space: nowrap;border: 1px solid transparent; cursor: pointer; transition: filter .15s, border-color .15s, color .15s;}.auth-in { color: var(--parchment); border-color: var(--border); background: var(--panel); }.auth-in:hover { border-color: var(--aegean); }.auth-up {color: #2a1c00; background: linear-gradient(135deg, var(--amber), #f0b84a);box-shadow: 0 2px 12px rgba(232,160,32,.28);}.auth-up:hover { filter: brightness(1.06); }.auth-acct {display: flex; align-items: center; gap: 9px; padding: 5px 13px 5px 6px; border-radius: 999px;border: 1px solid var(--border); background: var(--panel); color: var(--parchment);text-decoration: none; transition: border-color .15s;}.auth-acct:hover { border-color: var(--mint); }.auth-avatar {width: 26px; height: 26px; border-radius: 50%; flex: none; display: grid; place-items: center;font-size: 12px; font-weight: 700; color: #06231d;background: linear-gradient(135deg, var(--aegean), var(--mint));}.auth-name { font-size: 13px; font-weight: 500; }.auth-out {display: grid; place-items: center; width: 34px; height: 34px; border-radius: 8px;border: 1px solid var(--border); background: var(--panel); color: var(--stone);text-decoration: none; font-size: 15px; transition: color .15s, border-color .15s;}.auth-out:hover { color: var(--terra); border-color: var(--terra); }/* ── Live guide toggle ── */.map-live {display: flex;align-items: center;gap: 8px;font-family: 'DM Sans', sans-serif;font-size: 13px;color: var(--parchment);padding: 7px 15px;border: 1px solid var(--border);border-radius: 8px;background: var(--panel);cursor: pointer;transition: border-color 0.15s, background 0.15s;white-space: nowrap;}.map-live:hover { border-color: var(--mint); }.map-live .live-dot {width: 9px;height: 9px;border-radius: 50%;background: var(--stone);flex-shrink: 0;transition: background 0.2s;}.map-live.active { border-color: var(--mint); background: var(--ocean); }.map-live.active .live-dot {background: var(--terra);box-shadow: 0 0 0 0 rgba(212, 87, 42, 0.6);animation: live-pulse 1.6s infinite;}@keyframes live-pulse {0% { box-shadow: 0 0 0 0 rgba(212, 87, 42, 0.6); }70% { box-shadow: 0 0 0 7px rgba(212, 87, 42, 0); }100% { box-shadow: 0 0 0 0 rgba(212, 87, 42, 0); }}/* ── User position marker with heading arrow (Yandex custom layout) ── */.user-heading {width: 30px;height: 30px;margin: -15px 0 0 -15px; /* centre the layout on the GPS point */position: relative;pointer-events: none;}.user-heading .uh-dot {position: absolute;left: 50%; top: 50%;width: 14px; height: 14px;margin: -7px 0 0 -7px;border-radius: 50%;background: #4285F4;border: 2px solid #fff;box-shadow: 0 0 0 3px rgba(66,133,244,0.30), 0 1px 4px rgba(0,0,0,0.5);}.user-heading .uh-arrow {position: absolute;left: 50%; top: -1px;margin-left: -6px;width: 0; height: 0;border-left: 6px solid transparent;border-right: 6px solid transparent;border-bottom: 10px solid #4285F4;filter: drop-shadow(0 0 1px #fff);transition: opacity 0.2s;}/* ── Now playing banner ── */#tnt-nowplaying {position: fixed;top: 74px;right: 20px;z-index: 500;display: none;flex-direction: column;gap: 10px;width: 320px;max-width: calc(100vw - 40px);padding: 12px 14px;background: var(--panel);border: 1px solid var(--border);border-left: 3px solid var(--mint);border-radius: 12px;box-shadow: 0 12px 40px rgba(0,0,0,0.5);}#tnt-nowplaying.show { display: flex; }.np-row { display: flex; align-items: center; gap: 12px; }#np-audio { width: 100%; height: 34px; }.np-pulse {width: 36px;height: 36px;flex-shrink: 0;border-radius: 50%;background: var(--mint);color: var(--navy);display: flex;align-items: center;justify-content: center;}.np-meta { min-width: 0; flex: 1; }.np-eyebrow {font-size: 10px;letter-spacing: 1.5px;text-transform: uppercase;color: var(--mint);font-weight: 500;margin-bottom: 3px;}.np-title {font-size: 14px;color: var(--parchment);font-weight: 500;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;}.np-close {flex-shrink: 0;width: 26px;height: 26px;border-radius: 50%;border: 1px solid var(--border);background: var(--panel2);color: var(--stone);cursor: pointer;font-size: 12px;transition: background 0.15s, color 0.15s;}.np-close:hover { background: var(--terra); color: var(--parchment); }/* ── Toast (permission / errors) ── */#tnt-toast {position: fixed;bottom: 28px;left: 50%;transform: translateX(-50%) translateY(20px);z-index: 600;background: var(--panel);border: 1px solid var(--border);color: var(--parchment);font-size: 13px;padding: 11px 18px;border-radius: 10px;box-shadow: 0 8px 30px rgba(0,0,0,0.5);opacity: 0;pointer-events: none;transition: opacity 0.25s, transform 0.25s;}#tnt-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }/* ── Live GPS status pill ── */#tnt-status {position: fixed;bottom: 20px;left: 50%;transform: translateX(-50%);z-index: 550;display: none;max-width: calc(100vw - 24px);padding: 8px 14px;background: var(--panel);border: 1px solid var(--border);border-radius: 20px;font-size: 12px;line-height: 1.4;color: var(--parchment);box-shadow: 0 6px 24px rgba(0,0,0,0.5);text-align: center;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;}#tnt-status.show { display: block; }#tnt-status .st-ok { color: var(--mint); }#tnt-status .st-warn { color: var(--amber); }/* ── Map canvas ── */#map { width: 100%; height: 100%; display: block; }/* ── Watermark ── */#tnt-watermark {position: fixed;bottom: 28px;left: 28px;z-index: 300;pointer-events: none;user-select: none;display: flex;align-items: center;gap: 10px;opacity: 0.9;filter: drop-shadow(0 2px 10px rgba(0,0,0,0.9));}#wm-img { height: 38px; width: auto; display: none; pointer-events: none; }#wm-fallback {font-family: 'Bebas Neue', sans-serif;font-size: 22px;letter-spacing: 3px;color: var(--parchment);border-left: 3px solid var(--mint);padding-left: 10px;line-height: 1;pointer-events: none;}#wm-fallback .acc { color: var(--mint); }/* ── Backdrop ── */#tnt-backdrop {position: fixed;inset: 0;background: rgba(8, 20, 38, 0.82);z-index: 10000;display: flex;align-items: flex-end;justify-content: center;opacity: 0;pointer-events: none;transition: opacity 0.25s ease;}#tnt-backdrop.open { opacity: 1; pointer-events: all; }/* ── Modal ── */#tnt-modal {background: var(--panel);border-radius: 18px 18px 0 0;border-top: 3px solid var(--mint);width: 100%;max-width: 620px;max-height: 80vh;overflow-y: auto;transform: translateY(48px);transition: transform 0.3s cubic-bezier(.22,.68,0,1.3);}#tnt-backdrop.open #tnt-modal { transform: translateY(0); }#tnt-modal::-webkit-scrollbar { width: 4px; }#tnt-modal::-webkit-scrollbar-thumb { background: var(--aegean); border-radius: 2px; }.m-header { padding: 24px 24px 0; position: relative; }.m-close {position: absolute;top: 16px;right: 16px;width: 32px;height: 32px;border: 1px solid var(--border);background: var(--panel2);color: var(--stone);border-radius: 50%;font-size: 15px;cursor: pointer;display: flex;align-items: center;justify-content: center;transition: background 0.15s, color 0.15s, border-color 0.15s;}.m-close:hover { background: var(--terra); color: var(--parchment); border-color: var(--terra); }.m-category {font-size: 10px;letter-spacing: 2.5px;text-transform: uppercase;color: var(--mint);font-weight: 500;margin-bottom: 6px;}.m-title {font-family: 'Bebas Neue', sans-serif;font-size: 32px;letter-spacing: 1px;line-height: 1.1;color: var(--parchment);padding-right: 40px;}.m-address {margin-top: 8px;font-size: 13px;color: var(--stone);display: flex;align-items: center;gap: 6px;}.m-divider { height: 1px; background: var(--border); margin: 18px 24px; }.m-body { padding: 0 24px 24px; }.m-tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }.m-tab {background: var(--panel2);border: 1px solid var(--border);border-radius: 20px;padding: 5px 16px;font-size: 12px;font-family: 'DM Sans', sans-serif;color: var(--stone);cursor: pointer;transition: all 0.15s;letter-spacing: 0.5px;}.m-tab:hover { border-color: var(--aegean); color: var(--parchment); }.m-tab.active { background: var(--mint); border-color: var(--mint); color: var(--navy); font-weight: 500; }.m-panel { display: none; }.m-panel.active { display: block; }.m-item {background: var(--panel2);border: 1px solid var(--border);border-radius: 10px;padding: 16px;margin-bottom: 10px;}.m-item:last-child { margin-bottom: 0; }.m-item-title { font-size: 14px; font-weight: 500; color: var(--parchment); margin-bottom: 4px; }.m-item-lang { font-size: 10px; color: var(--amber); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 8px; font-weight: 500; }.m-item-text { font-size: 13px; color: var(--stone); line-height: 1.65; }.m-item audio, .m-item video { width: 100%; margin-top: 10px; border-radius: 8px; }.m-empty { text-align: center; padding: 36px 0; color: var(--stone); font-size: 13px; }/* ── Welcome / language chooser ── */#tnt-welcome {position: fixed;inset: 0;z-index: 11000;display: flex;align-items: center;justify-content: center;padding: 20px;background: rgba(8, 20, 38, 0.92);}#tnt-welcome.hidden { display: none; }.w-card {width: 100%;max-width: 380px;padding: 34px 28px;text-align: center;background: var(--panel);border: 1px solid var(--border);border-top: 3px solid var(--mint);border-radius: 18px;box-shadow: 0 24px 70px rgba(0,0,0,0.6);}.w-logo {font-family: 'Bebas Neue', sans-serif;font-size: 38px;letter-spacing: 6px;color: var(--parchment);display: inline-flex;align-items: center;line-height: 1;margin-bottom: 18px;}.w-logo .acc { color: var(--mint); }.w-logo .dot {width: 6px; height: 6px;background: var(--amber);border-radius: 50%;margin-left: 5px; margin-bottom: 16px;display: inline-block;}.w-title {font-family: 'Bebas Neue', sans-serif;font-size: 27px;letter-spacing: 1px;color: var(--parchment);margin-bottom: 8px;}.w-sub { font-size: 13px; color: var(--stone); margin-bottom: 24px; }.w-langs { display: flex; flex-direction: column; gap: 10px; }.w-lang {display: flex;align-items: center;justify-content: center;gap: 10px;padding: 14px;font-family: 'DM Sans', sans-serif;font-size: 15px;color: var(--parchment);background: var(--panel2);border: 1px solid var(--border);border-radius: 10px;cursor: pointer;transition: border-color 0.15s, background 0.15s;}.w-lang:hover { border-color: var(--mint); background: var(--ocean); }.w-lang.current { border-color: var(--mint); background: var(--ocean); }.w-lang.current::after { content: '✓'; color: var(--mint); margin-left: 4px; font-weight: 700; }.w-flag { font-size: 20px; }/* ═══ Responsive: collapse header controls into a menu on small screens ═══ */@media (max-width: 720px) {.map-header { padding: 0 16px; }.map-logo { font-size: 26px; letter-spacing: 4px; }.map-burger { display: flex; }/* Turn the inline control row into a dropdown panel below the header.It is a full-width sheet under the 60px header, and it SCROLLS:with the language, live-guide and auth rows the menu is easilytaller than a phone screen, and without a max-height + overflowthe last items were simply cut off the bottom with no way toreach them. */.map-controls {position: absolute;top: 60px;left: 0;right: 0;width: auto;max-width: 100%;flex-direction: column;align-items: stretch;gap: 12px;padding: 16px;padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));background: var(--navy);border-bottom: 1px solid var(--border);box-shadow: 0 16px 40px rgba(0,0,0,0.55);display: none;max-height: calc(100vh - 60px);max-height: calc(100dvh - 60px);overflow-y: auto;overscroll-behavior: contain;-webkit-overflow-scrolling: touch;z-index: 450;}.map-controls.open { display: flex; }.map-controls > * { min-width: 0; }.map-controls .map-select { width: 100%; font-size: 15px; padding: 11px 34px 11px 14px; }.map-controls .map-live { justify-content: center; font-size: 15px; padding: 11px 15px; }.map-controls .map-back { justify-content: center; font-size: 14px; padding: 10px 12px; order: 5; }.map-controls .map-count { order: -1; font-size: 13px; text-align: center; }/* Language pills: full width, split evenly, sized for a thumb. */.map-controls .map-lang { padding: 6px; order: 4; }.map-controls .map-lang-btn { flex: 1; height: 38px; font-size: 14px; }/* Auth group: a row of pills does not fit a phone's width, so stackit. The ::before divider is a vertical rule for the horizontaldesktop bar — meaningless (and ugly) once stacked. */.map-auth { flex-direction: column; align-items: stretch; gap: 10px; order: 6; }.map-auth::before { display: none; }.map-auth .auth-btn,.map-auth .auth-acct { justify-content: center; width: 100%; padding: 11px 15px; }.map-auth .auth-out { width: 100%; height: 42px; }/* Keep the now-playing banner clear of the open menu */#tnt-nowplaying { top: 68px; right: 12px; left: 12px; width: auto; max-width: none; }}/* ═══════════════════════════════════════════════════════════════════ORDER SHEET — the booking panel.Mobile: a drag-to-resize sheet pinned to the bottom of the screen.Desktop (≥ 900px): the same markup floats as a left-hand panel.═══════════════════════════════════════════════════════════════════ */.sheet {position: fixed;left: 0;right: 0;bottom: 0;z-index: 800;display: flex;flex-direction: column;background: var(--panel);border-top: 1px solid var(--border);border-radius: 18px 18px 0 0;box-shadow: 0 -12px 40px rgba(0,0,0,0.5);/* Sheet height is driven by --sheet-h, which the drag handler sets. */height: var(--sheet-h, 46vh);max-height: 88vh;transition: height 260ms cubic-bezier(0.32, 0.72, 0, 1);/* Respect the iOS home indicator. */padding-bottom: env(safe-area-inset-bottom, 0);}/* dvh where it exists, so the resting height matches what the draghandler computes from window.innerHeight — with the URL bar showing,88vh is taller than the screen and the sheet grew up under theheader. @supports rather than a second declaration on purpose: anunsupported unit inside var() is invalid at computed-value time, soit would fall back to `auto`, not to the vh line above it. */@supports (height: 100dvh) {.sheet {height: var(--sheet-h, 46dvh);max-height: 88dvh;}}/* While a finger is down we follow it directly — no transition lag. */.sheet.dragging { transition: none; }.sheet.hidden { display: none; }/* Grab handle */.sheet-grab {flex: none;padding: 10px 0 6px;display: flex;justify-content: center;cursor: grab;touch-action: none;}.sheet-grab:active { cursor: grabbing; }.sheet-grab::before {content: '';width: 40px;height: 4px;border-radius: 2px;background: #35516e;}/* Header: back arrow + title */.sheet-head {flex: none;display: flex;align-items: center;gap: 10px;padding: 2px 18px 12px;}.sheet-back {flex: none;width: 30px;height: 30px;display: none;align-items: center;justify-content: center;border: 0;border-radius: 50%;background: var(--panel2);color: var(--parchment);cursor: pointer;}.sheet-back:hover { background: var(--ocean); }.sheet-back.show { display: flex; }.sheet-title {font-family: 'Bebas Neue', sans-serif;font-size: 22px;letter-spacing: 1.6px;line-height: 1;color: var(--parchment);}.sheet-sub {font-size: 12px;color: var(--stone);margin-top: 3px;}/* Scrolling content */.sheet-body {flex: 1;/* Without min-height:0 a flex:1 child refuses to shrink below itscontent, so a long tour list pushed the CTA out of the sheetinstead of scrolling inside it. */min-height: 0;overflow-y: auto;overscroll-behavior: contain;-webkit-overflow-scrolling: touch;padding: 0 18px 18px;}/* Sticky footer button */.sheet-foot {flex: none;padding: 12px 18px 16px;border-top: 1px solid var(--border);background: var(--panel);border-radius: 0 0 18px 18px;}.sheet-foot.hidden { display: none; }.sheet-cta {width: 100%;padding: 15px 18px;border: 0;border-radius: 12px;background: var(--mint);color: #06251f;font-family: 'DM Sans', sans-serif;font-size: 16px;font-weight: 500;cursor: pointer;display: flex;align-items: center;justify-content: center;gap: 8px;transition: filter 140ms ease, opacity 140ms ease;}.sheet-cta:hover:not(:disabled) { filter: brightness(1.08); }.sheet-cta:disabled { opacity: 0.45; cursor: not-allowed; }.sheet-cta .cta-price { margin-left: auto; font-weight: 700; }.sheet-cta.ghost {background: transparent;border: 1px solid var(--border);color: var(--stone);}.sheet-cta.ghost:hover:not(:disabled) { color: var(--terra); border-color: var(--terra); }/* ── Route cards ────────────────────────────────────────────────── */.rt-card {position: relative;display: flex;align-items: center;gap: 12px;width: 100%;text-align: left;padding: 14px 14px 14px 16px;margin-bottom: 10px;border: 1px solid var(--border);border-radius: 14px;background: var(--panel2);color: var(--parchment);cursor: pointer;font-family: 'DM Sans', sans-serif;transition: border-color 140ms ease, background 140ms ease;}.rt-card:hover { border-color: var(--aegean); background: var(--ocean); }.rt-card.active { border-color: var(--mint); background: var(--ocean); }/* The colour stripe matches the road painted on the map. */.rt-card::before {content: '';position: absolute;left: 0;top: 12px;bottom: 12px;width: 4px;border-radius: 0 3px 3px 0;background: var(--rt-color, var(--mint));}.rt-main { flex: 1; min-width: 0; }.rt-name {font-size: 15px;font-weight: 500;margin-bottom: 4px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;}.rt-meta {font-size: 12px;color: var(--stone);display: flex;align-items: center;gap: 6px;flex-wrap: wrap;}.rt-meta .dot-sep { width: 3px; height: 3px; border-radius: 50%; background: var(--stone); }.rt-price {flex: none;font-size: 15px;font-weight: 700;color: var(--amber);white-space: nowrap;}.rt-desc {font-size: 13px;line-height: 1.55;color: #9fb3c8;margin-bottom: 14px;}/* ── Stop list (the route's itinerary) ──────────────────────────── */.stops { list-style: none; margin: 0 0 8px; padding: 0; }.stop {position: relative;display: flex;gap: 12px;padding: 0 0 16px 0;}/* Vertical line joining the stop bullets. */.stop:not(:last-child)::before {content: '';position: absolute;left: 9px;top: 18px;bottom: 0;width: 2px;background: var(--border);}.stop-dot {flex: none;width: 20px;height: 20px;border-radius: 50%;background: var(--panel2);border: 2px solid var(--rt-color, var(--mint));display: flex;align-items: center;justify-content: center;font-size: 10px;font-weight: 700;color: var(--parchment);z-index: 1;}.stop-body { flex: 1; min-width: 0; padding-top: 1px; }.stop-name { font-size: 14px; line-height: 1.35; }.stop-time { font-size: 11px; color: var(--stone); margin-top: 2px; }/* ── Tuk cards ──────────────────────────────────────────────────── */.tuk-card {display: flex;align-items: center;gap: 12px;width: 100%;text-align: left;padding: 12px;margin-bottom: 10px;border: 1px solid var(--border);border-radius: 14px;background: var(--panel2);color: var(--parchment);cursor: pointer;font-family: 'DM Sans', sans-serif;transition: border-color 140ms ease, background 140ms ease;}.tuk-card:hover { border-color: var(--aegean); background: var(--ocean); }.tuk-card.active { border-color: var(--mint); background: var(--ocean); }.tuk-avatar {flex: none;width: 42px;height: 42px;border-radius: 50%;display: flex;align-items: center;justify-content: center;font-weight: 700;font-size: 14px;color: #06251f;background: var(--tuk-color, var(--amber));}.tuk-main { flex: 1; min-width: 0; }.tuk-name {font-size: 14px;font-weight: 500;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;}.tuk-meta {font-size: 12px;color: var(--stone);margin-top: 3px;display: flex;align-items: center;gap: 6px;flex-wrap: wrap;}.tuk-star { color: var(--amber); }.tuk-eta { flex: none; text-align: right; }.tuk-eta-val {font-size: 15px;font-weight: 700;color: var(--mint);white-space: nowrap;}.tuk-eta-lbl { font-size: 11px; color: var(--stone); }/* ── Payment options ────────────────────────────────────────────── */.pay-opt {display: flex;align-items: center;gap: 12px;width: 100%;text-align: left;padding: 14px 12px;margin-bottom: 8px;border: 1px solid var(--border);border-radius: 12px;background: var(--panel2);color: var(--parchment);cursor: pointer;font-family: 'DM Sans', sans-serif;font-size: 14px;transition: border-color 140ms ease, background 140ms ease;}.pay-opt:hover { border-color: var(--aegean); }.pay-opt.active { border-color: var(--mint); background: var(--ocean); }.pay-ico { flex: none; font-size: 18px; width: 24px; text-align: center; }.pay-main { flex: 1; min-width: 0; }.pay-lbl { font-size: 14px; }.pay-sub { font-size: 11px; color: var(--stone); margin-top: 2px; }/* Radio tick */.pay-tick {flex: none;width: 18px;height: 18px;border-radius: 50%;border: 2px solid var(--border);display: flex;align-items: center;justify-content: center;}.pay-opt.active .pay-tick { border-color: var(--mint); }.pay-opt.active .pay-tick::after {content: '';width: 8px;height: 8px;border-radius: 50%;background: var(--mint);}.pay-opt.expired { opacity: 0.5; cursor: not-allowed; }/* Summary rows above the order button */.sum-row {display: flex;justify-content: space-between;gap: 12px;font-size: 13px;padding: 7px 0;border-bottom: 1px dashed var(--border);}.sum-row:last-child { border-bottom: 0; }.sum-k { color: var(--stone); }.sum-v { color: var(--parchment); text-align: right; }.sum-v.big { font-size: 16px; font-weight: 700; color: var(--amber); }/* ── Schedule (advance booking) form ────────────────────────────── */.sched-field { margin-bottom: 12px; }.sched-field > label {display: block; font-size: 12px; color: var(--stone);margin-bottom: 6px; text-transform: uppercase; letter-spacing: .4px;}.sched-row { display: flex; gap: 10px; }.sched-row .sched-field { flex: 1; }.sched-input {width: 100%; padding: 12px 13px; border-radius: 11px;border: 1px solid var(--border); background: var(--ocean);color: var(--parchment); font-size: 15px; font-family: inherit;}.sched-input:focus { outline: none; border-color: var(--mint); }.sched-input::-webkit-calendar-picker-indicator { filter: invert(1); opacity: .6; }textarea.sched-input { resize: vertical; min-height: 62px; }.sched-note {font-size: 12px; color: var(--stone); line-height: 1.5; margin: 2px 0 14px;}/* ── Guest checkout (ordering without an account) ────────────────── */.guest-box, .card-box {background: var(--ocean); border: 1px solid var(--border);border-radius: 14px; padding: 14px 13px 4px; margin-bottom: 14px;}.card-box { margin-top: -4px; }.guest-lbl {font-size: 13px; font-weight: 700; color: var(--parchment);margin-bottom: 12px;}.guest-lbl small {display: block; font-size: 12px; font-weight: 400;color: var(--stone); margin-top: 3px; line-height: 1.45;}.guest-box .sched-input { background: var(--navy); }.guest-alt { font-size: 12px; color: var(--stone); padding: 2px 0 12px; }.guest-alt a { color: var(--mint); text-decoration: none; font-weight: 500; }.guest-alt a:hover { text-decoration: underline; }.guest-signin {width: 100%; margin: 2px 0 12px; padding: 12px; border-radius: 11px;border: 1px solid var(--mint); background: rgba(72,201,176,.12);color: var(--mint); font-family: inherit; font-size: 14px;font-weight: 700; cursor: pointer;}.guest-signin:hover { background: rgba(72,201,176,.2); }/* ── Live order status ──────────────────────────────────────────── */.ord-status {display: flex;align-items: center;gap: 10px;padding: 12px;border-radius: 12px;background: var(--ocean);border: 1px solid var(--border);margin-bottom: 14px;}.ord-pulse {flex: none;width: 10px;height: 10px;border-radius: 50%;background: var(--mint);animation: ordPulse 1.6s ease-out infinite;}@keyframes ordPulse {0% { box-shadow: 0 0 0 0 rgba(72,201,176,0.6); }100% { box-shadow: 0 0 0 12px rgba(72,201,176,0); }}.ord-txt { flex: 1; font-size: 14px; }.ord-ref { font-size: 11px; color: var(--stone); margin-top: 2px; letter-spacing: 1px; }/* Post-tour rating */.rate-box {margin-top: 14px;padding: 16px 12px;border-radius: 12px;background: var(--ocean);border: 1px solid var(--border);text-align: center;}.rate-box.done { background: rgba(72,201,176,0.10); }.rate-q { font-size: 14px; color: var(--parchment); margin-bottom: 10px; }.rate-stars { display: flex; justify-content: center; gap: 8px; }.rate-star {/* Unlit stars used to be var(--border) — all but invisible on thedark panel, so the widget read as "no stars here" and a 4/5 scorelooked like a star had gone missing. Light silver instead: clearlyan empty star, clearly still a star. */font-size: 32px; line-height: 1; cursor: pointer; color: var(--star-off);transition: color .12s, transform .12s; user-select: none;}.rate-stars:not(.readonly) .rate-star:hover { transform: scale(1.12); }.rate-star.on { color: var(--amber); }.rate-stars.readonly .rate-star { cursor: default; font-size: 26px; }.rate-skip {display: block; margin: 12px auto 0; background: none; border: none;color: var(--stone); font-size: 13px; cursor: pointer; text-decoration: underline;}/* States: empty / loading / error */.sheet-msg {padding: 26px 10px;text-align: center;font-size: 13px;color: var(--stone);line-height: 1.6;}.sheet-msg a { color: var(--mint); }.sheet-err {padding: 10px 12px;margin-bottom: 12px;border-radius: 10px;font-size: 13px;background: rgba(212,87,42,0.14);border: 1px solid var(--terra);color: #ffb59a;}.sheet-err.hidden { display: none; }.spin {width: 22px;height: 22px;margin: 0 auto 10px;border: 2px solid var(--border);border-top-color: var(--mint);border-radius: 50%;animation: spin 700ms linear infinite;}@keyframes spin { to { transform: rotate(360deg); } }/* ── Party size ──────────────────────────────────────────────────A stepper rather than a number input: three is the whole range, andtapping ± beats summoning a numeric keyboard on a phone. */.pax {display: flex; align-items: center; gap: 12px; margin: 12px 0 4px;padding: 12px 14px; background: var(--panel2); border: 1px solid var(--border);border-radius: 13px;}.pax-lbl { font-size: 14px; font-weight: 500; }.pax-lbl small { display: block; font-size: 11px; color: var(--stone); font-weight: 400; margin-top: 2px; }.pax-ctl { margin-left: auto; display: flex; align-items: center; gap: 4px; }.pax-btn {width: 34px; height: 34px; border-radius: 10px; border: 1px solid var(--border);background: var(--panel); color: var(--parch); font-size: 19px; line-height: 1;cursor: pointer; font-family: inherit; display: grid; place-items: center;transition: border-color .12s, color .12s;}.pax-btn:hover:not(:disabled) { border-color: var(--mint); color: var(--mint); }.pax-btn:disabled { opacity: .35; cursor: default; }.pax-n { min-width: 30px; text-align: center; font-size: 18px; font-weight: 700; }/* The whole party-size price table, so the rule is visible up front. */.pax-table { display: flex; gap: 6px; margin: 8px 0 2px; flex-wrap: wrap; }.pax-cell {flex: 1 1 0; min-width: 78px; text-align: center; padding: 8px 6px;border: 1px solid var(--border); border-radius: 11px; background: var(--panel2);cursor: pointer; font-family: inherit; color: var(--parch); transition: border-color .12s, background .12s;/* Stack the label over the price and centre both, so cells ofdiffering text lengths still line up across the row. */display: flex; flex-direction: column; justify-content: center; align-items: center;}.pax-cell.on { border-color: var(--mint); background: var(--ocean); }/* The two lines swap emphasis: the party size leads, big and light, andthe price sits under it as the quiet caption. */.pax-cell-n { font-size: 14px; font-weight: 300; color: var(--parch); line-height: 1.2; }.pax-cell.on .pax-cell-n { color: var(--mint); }.pax-cell-p {font-size: 11px; font-weight: 700; color: var(--stone);text-transform: uppercase; letter-spacing: .4px; margin-top: 3px;}.pax-cell.on .pax-cell-p { color: var(--mint); }/* ── Tip after the tour ──────────────────────────────────────────── */.tip-box {margin-top: 12px; padding: 14px; background: var(--panel2);border: 1px solid var(--border); border-radius: 14px; text-align: center;}.tip-q { font-size: 14px; font-weight: 500; margin-bottom: 10px; }.tip-opts { display: flex; gap: 7px; justify-content: center; flex-wrap: wrap; }.tip-opt {flex: 1 1 0; min-width: 68px; padding: 10px 8px; border-radius: 11px;border: 1px solid var(--border); background: var(--panel); color: var(--parch);font-size: 13px; font-weight: 700; cursor: pointer; font-family: inherit;transition: border-color .12s, background .12s, color .12s;}.tip-opt:hover { border-color: var(--mint); color: var(--mint); }.tip-opt.on { border-color: var(--mint); background: var(--ocean); color: var(--mint); }.tip-custom { display: flex; gap: 7px; margin-top: 9px; }.tip-custom input {flex: 1; padding: 10px 12px; border-radius: 11px; border: 1px solid var(--border);background: var(--navy); color: var(--parch); font-size: 14px; font-family: inherit;text-align: center;}.tip-custom input:focus { outline: none; border-color: var(--mint); }.tip-skip { background: none; border: none; color: var(--stone); font-size: 12px; cursor: pointer; font-family: inherit; margin-top: 9px; }.tip-box.done .tip-amount { font-size: 22px; font-weight: 700; color: var(--mint); font-family: 'Bebas Neue', sans-serif; letter-spacing: 1px; }/* ── Custom route drawing ────────────────────────────────────────── */.rt-card.draw { border-style: dashed; }.rt-card.draw .rt-name { color: var(--amber); }.draw-hint {padding: 12px 14px; background: var(--panel2); border: 1px dashed var(--border);border-radius: 13px; font-size: 13px; color: var(--stone); line-height: 1.5;}.draw-list { list-style: none; margin: 12px 0 0; padding: 0; }.draw-item {display: flex; align-items: center; gap: 10px; padding: 9px 0;border-bottom: 1px solid var(--border); font-size: 14px;}.draw-item:last-child { border-bottom: none; }.draw-dot {width: 24px; height: 24px; border-radius: 50%; flex: none; display: grid;place-items: center; font-size: 11px; font-weight: 700; color: #1b1030;background: var(--cust, #9B7BD4);}.draw-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.draw-del {margin-left: auto; background: none; border: none; color: var(--stone);font-size: 15px; cursor: pointer; padding: 4px 6px; border-radius: 8px;}.draw-del:hover { color: var(--terra); }.draw-acts { display: flex; gap: 8px; margin-top: 12px; }.draw-acts button {flex: 1; padding: 11px; border-radius: 11px; border: 1px solid var(--border);background: var(--panel2); color: var(--parch); font-size: 13px; font-weight: 700;cursor: pointer; font-family: inherit;}.draw-acts button:hover:not(:disabled) { border-color: var(--mint); color: var(--mint); }.draw-acts button:disabled { opacity: .4; cursor: default; }/* Stop picker: our own places, searchable. A custom tour is assembledfrom these — never from an arbitrary point on the map. */.pick-search { margin-top: 14px; }.pick-list { list-style: none; margin: 8px 0 0; padding: 0; }.pick-list li { margin-bottom: 6px; }.pick-item {width: 100%; display: flex; align-items: center; gap: 10px;padding: 11px 13px; border: 1px solid var(--border); border-radius: 12px;background: var(--panel2); color: var(--parch); font-family: inherit;font-size: 14px; text-align: left; cursor: pointer;transition: border-color .12s, background .12s;}.pick-item:hover { border-color: var(--mint); background: var(--ocean); }.pick-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.pick-cat {flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .4px;color: var(--stone); border: 1px solid var(--border); border-radius: 999px; padding: 2px 7px;}.pick-plus { flex: none; font-size: 18px; line-height: 1; color: var(--mint); font-weight: 700; }.pick-empty { padding: 18px; text-align: center; color: var(--stone); font-size: 13px; }/* Floating "tap the map" cue, shown over the map while drawing. */.draw-badge {position: absolute; top: 74px; left: 50%; transform: translateX(-50%);z-index: 9998; background: rgba(11,31,58,.94); border: 1px solid var(--cust, #9B7BD4);color: var(--parch); padding: 8px 15px; border-radius: 999px; font-size: 12px;font-weight: 700; box-shadow: 0 6px 20px rgba(0,0,0,.5); pointer-events: none;white-space: nowrap; max-width: calc(100vw - 32px); overflow: hidden; text-overflow: ellipsis;}/* ── Desktop: float the sheet as a left-hand panel ───────────────── */@media (min-width: 900px) {.sheet {left: 20px;right: auto;bottom: 20px;width: 380px;/* Sits below the 60px header, above the bottom margin. */height: auto;max-height: calc(100vh - 100px);border-radius: 16px;border: 1px solid var(--border);box-shadow: 0 20px 60px rgba(0,0,0,0.55);}/* Dragging is a touch affordance; on desktop the panel just sits. */.sheet-grab { display: none; }.sheet-head { padding-top: 16px; }.sheet-foot { border-radius: 0 0 16px 16px; }.sheet-body { max-height: 60vh; }}/* Small phones: give the map a little more room. */@media (max-width: 380px) {.sheet-body { padding: 0 14px 14px; }.sheet-head { padding: 2px 14px 10px; }.sheet-foot { padding: 10px 14px 14px; }.rt-price { font-size: 14px; }/* The party-size table and the tip row are the two places wherefour-ish equal columns stop fitting; let them wrap instead ofsqueezing the numbers illegibly small. */.pax { padding: 10px 12px; gap: 8px; }.pax-cell { min-width: 0; padding: 7px 4px; }.pax-cell-p { font-size: 13px; }.tip-box { padding: 12px; }.tip-opt { min-width: 0; flex-basis: calc(50% - 4px); }.draw-badge { top: 68px; font-size: 11px; padding: 7px 12px; }}</style>{% endblock %}{# ── Override header — hide site header on map page ── #}{% block header %}{% endblock %}{# ── Override footer — hide site footer on map page ── #}{% block footer %}{% endblock %}{# ── Override body — inject full-screen map app ── #}{% block body %}<div class="map-app"><header class="map-header"><a href="{{ path('app_map') }}" class="map-logo">TN<span class="acc">T</span><span class="dot"></span></a><div class="map-controls" id="map-controls"><a href="{{ path('app_map') }}" class="map-back"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M9 2L4 7l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg><span data-i18n="nav.back">Back</span></a><span class="map-count"><strong id="vis-count">{{ locations|length }}</strong> <span data-i18n="nav.locations">locations</span></span><select class="map-select" id="filter-city"><option value="" data-i18n="nav.allCities">All cities</option>{% for city in cities %}<option value="{{ city.id }}">{{ city.name }}</option>{% endfor %}</select><select class="map-select" id="filter-cat"><option value="" data-i18n="nav.allCategories">All categories</option>{% set cats = [] %}{% for loc in locations %}{% if loc.category and loc.category not in cats %}{% set cats = cats|merge([loc.category]) %}<option value="{{ loc.category }}">{{ loc.category|capitalize }}</option>{% endif %}{% endfor %}</select><select class="map-select" id="filter-radius" title="How close before the audio plays"><option value="100" selected data-i18n="radius.drive">🚗 Driving · 100 m</option><option value="50" data-i18n="radius.walk">🚶 Walking · 50 m</option></select>{# Language switcher. These used to be one "🌐 Language" buttoncarrying class .map-back — which is display:none, so it wasinvisible at every screen size and the only way to changelanguage was the first-visit welcome screen. Three pills,auto-wired by TNT_I18N through data-set-lang (which alsomarks the active one). #}<div class="map-lang" role="group" aria-label="Language"><span class="map-lang-ico" aria-hidden="true">🌐</span><button type="button" class="map-lang-btn" data-set-lang="en" title="English">EN</button><button type="button" class="map-lang-btn" data-set-lang="hy" title="Հայերեն">ՀՅ</button><button type="button" class="map-lang-btn" data-set-lang="ru" title="Русский">RU</button></div><button type="button" class="map-live" id="live-toggle" aria-pressed="false"><span class="live-dot"></span><span class="live-label" data-i18n="nav.liveGuide">Live guide</span></button><div class="map-auth">{% if app.user %}<a href="{{ path('app_account') }}" class="auth-acct" title="My account" data-i18n-title="nav.myAccount"><span class="auth-avatar">{{ app.user.name|split(' ')|map(w => w|first)|join('')|upper|slice(0, 2) }}</span><span class="auth-name">{{ app.user.name|split(' ')|first }}</span></a><a href="{{ path('app_logout') }}" class="auth-out" title="Sign out" data-i18n-title="nav.signout">⎋</a>{% else %}<a href="{{ path('app_login') }}" class="auth-btn auth-in" data-i18n="nav.signin">Sign in</a><a href="{{ path('app_register') }}" class="auth-btn auth-up" data-i18n="nav.register">Register</a>{% endif %}</div></div><button type="button" class="map-burger" id="map-burger"aria-label="Menu" aria-controls="map-controls" aria-expanded="false"><span></span><span></span><span></span></button></header><div id="map"></div>{# ── Order sheet — route → tuk → payment ──Lives inside .map-app on purpose: that element sets z-index 9999 andso opens a stacking context. A sheet outside it would be paintedunder the map and swallow every tap. Inside, it stacks above the mappanes, while the modal (10000) and welcome screen (11000) still winover the whole map-app. #}<div class="sheet hidden" id="sheet" aria-live="polite"><div class="sheet-grab" id="sheet-grab" role="button" tabindex="0" aria-label="Resize panel"></div><div class="sheet-head"><button type="button" class="sheet-back" id="sheet-back" aria-label="Back"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M9 2L4 7l5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button><div><div class="sheet-title" id="sheet-title" data-i18n="sheet.chooseTour">Choose your tour</div><div class="sheet-sub" id="sheet-sub"></div></div></div><div class="sheet-body" id="sheet-body"></div><div class="sheet-foot hidden" id="sheet-foot"><button type="button" class="sheet-cta" id="sheet-cta"></button></div></div></div>{# ── Live "now playing" banner ── #}<div id="tnt-nowplaying" aria-live="polite"><div class="np-row"><div class="np-pulse"><svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M3 10v4h4l5 5V5L7 10H3z" fill="currentColor"/><path d="M16 8a5 5 0 010 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div><div class="np-meta"><div class="np-eyebrow"><span data-i18n="guide.nowPlaying">Now playing</span> · <span id="np-dist"></span></div><div class="np-title" id="np-title"></div></div><button type="button" class="np-close" id="np-close" title="Stop">✕</button></div><audio id="np-audio" preload="auto" controls></audio></div>{# Live GPS status pill (diagnostics) #}<div id="tnt-status" aria-live="polite"></div>{# Toast #}<div id="tnt-toast"></div>{# Watermark #}<div id="tnt-watermark"><img id="wm-img" src="{{ asset('img/tnt-watermark.png') }}" alt="TNT"><div id="wm-fallback">TN<span class="acc">T</span></div></div>{# Modal #}<div id="tnt-backdrop"><div id="tnt-modal"><div class="m-header"><button class="m-close" id="m-close-btn">✕</button><div class="m-category" id="m-cat"></div><div class="m-title" id="m-title"></div><div class="m-address"><svg width="11" height="14" viewBox="0 0 11 14" fill="none"><path d="M5.5 0C3.01 0 1 2.01 1 4.5c0 3.38 4.5 8.5 4.5 8.5S10 7.88 10 4.5C10 2.01 7.99 0 5.5 0z" fill="#717D7E"/></svg><span id="m-addr"></span></div></div><div class="m-divider"></div><div class="m-body" id="m-body"></div></div></div>{# Welcome / language chooser — shown on first open #}<div id="tnt-welcome"><div class="w-card"><div class="w-logo">TN<span class="acc">T</span><span class="dot"></span></div><div class="w-title" data-i18n="welcome.title">Welcome to Tuk-n-Tour</div><div class="w-sub" data-i18n="welcome.sub">Choose your language to start exploring</div><div class="w-langs"><button type="button" class="w-lang" data-lang="en"><span class="w-flag">🇬🇧</span><span>English</span></button><button type="button" class="w-lang" data-lang="hy"><span class="w-flag">🇦🇲</span><span>Հայերեն</span></button><button type="button" class="w-lang" data-lang="ru"><span class="w-flag">🇷🇺</span><span>Русский</span></button></div></div></div>{# Hide the welcome popup synchronously (before paint) when a language isalready stored, so returning visitors never see it flash. #}<script>try {if (localStorage.getItem('tnt_lang')) {document.getElementById('tnt-welcome').classList.add('hidden');}} catch (e) {}</script>{# Booking API wiring. __REF__ is swapped for a real booking reference client-side. #}<script>var TAXI = {routesUrl: "{{ path('api_routes') }}",tuksUrl: "{{ path('api_tuks') }}",tuksMapUrl: "{{ path('api_tuks_map') }}",createUrl: "{{ path('api_booking_create') }}",activeUrl: "{{ path('api_booking_active') }}",showUrlTpl: "{{ path('api_booking_show', {reference: '__REF__'}) }}",cancelUrlTpl:"{{ path('api_booking_cancel', {reference: '__REF__'}) }}",rateUrlTpl: "{{ path('api_booking_rate', {reference: '__REF__'}) }}",tipUrlTpl: "{{ path('api_booking_tip', {reference: '__REF__'}) }}",cardsUrl: "{{ path('api_cards') }}",// Chat + in-app calls. The module appends its own suffixes to the base.commBase: "{{ path('comm_thread', {reference: '__REF__'})|replace({'/thread': ''}) }}",commIceUrl: "{{ path('comm_ice') }}",reservationsUrl: "{{ path('api_reservation_create') }}",customUrl: "{{ path('api_reservation_custom') }}",resShowTpl: "{{ path('api_reservation_show', {reference: '__REF__'}) }}",loginUrl: "{{ path('app_login') }}",// Used to rebuild the header once a guest checkout has registered// and signed the visitor in without a page reload.accountUrl: "{{ path('app_account') }}",logoutUrl: "{{ path('app_logout') }}",csrf: "{{ csrf_token('taxi_api') }}",isLoggedIn: {{ app.user ? 'true' : 'false' }},// Pricing and tipping rules live on the server; these are the copies// the sheet renders with. Every amount is re-checked server-side, so// a tampered value here buys nothing.maxPassengers: {{ constant('App\\Service\\Pricing\\TourPricing::MAX_PASSENGERS') }},tipPresets: {{ constant('App\\Entity\\Booking::TIP_PRESETS_AMD')|json_encode|raw }},tipMin: {{ constant('App\\Entity\\Booking::TIP_MIN_AMD') }},tipMax: {{ constant('App\\Entity\\Booking::TIP_MAX_AMD') }},customMinPts: {{ constant('App\\Entity\\TourReservation::CUSTOM_MIN_POINTS') }},customMaxPts: {{ constant('App\\Entity\\TourReservation::CUSTOM_MAX_POINTS') }},customColor: "{{ constant('App\\Entity\\TourReservation::CUSTOM_COLOR') }}",// Set when the account page links back to a scheduled tour// (/map?res=TNT-R-XXXXXX) so the map can paint that line.showReservation: {{ app.request.query.get('res') ? ('"' ~ app.request.query.get('res')|e('js') ~ '"')|raw : 'null' }}};// Real-time push (Mercure). Null when logged out or no hub is configured;// the order view then simply keeps polling.var MERCURE = {url: {{ mercure_url|default(null)|json_encode|raw }},topic: {{ mercure_topic|default(null)|json_encode|raw }}};</script>{# Location data #}<script>var LOCS = {{ locations|map(loc => {id: loc.id,name: loc.name,nameHy: loc.nameHy,nameRu: loc.nameRu,address: loc.address,lat: loc.latitude,lng: loc.longitude,category: loc.category,cityId: loc.city.id,details: loc.details|filter(d => d.isActive)|map(d => {type: d.type,title: d.title,content: d.content,filePath: d.filePath,language: d.language,sort: d.sortOrder})|sort((a,b) => a.sort <=> b.sort)})|json_encode|raw }};var NEARBY_URL = "{{ path('app_map_nearby') }}";</script>{% endblock %}{# ── Override javascripts — suppress jQuery/Bootstrap, load only Yandex Maps ── #}{% block javascripts %}<script src="https://api-maps.yandex.ru/2.1/?apikey={{ yandex_api_key }}&lang=en_US"></script>{# Passenger↔driver chat and in-app voice calls. Shared verbatim with /driver. #}<script src="{{ asset('js/tnt-comm.js') }}"></script><script>// ── Language chooser ──// LANG is global and read by openModal() and the live guide. Content// (text/audio/video) is filtered to the chosen language.var LANG = null;var LANG_NAMES = { en: 'English', hy: 'Հայերեն', ru: 'Русский' };(function () {var wrap = document.getElementById('tnt-welcome');try { LANG = localStorage.getItem('tnt_lang'); } catch (e) {}// Highlight the currently chosen language inside the welcome chooser.// Must run on every change (not just page load) so re-opening the// chooser shows the active choice without needing a refresh.function markWelcome() {wrap.querySelectorAll('.w-lang').forEach(function (b) {b.classList.toggle('current', b.dataset.lang === LANG);});}function choose(l) {LANG = l;wrap.classList.add('hidden');// TNT_I18N.set persists tnt_lang, applies data-i18n to the static// chrome, and fires tnt:langchange — which re-renders the JS views// (booking sheet) and re-opens any active location modal.if (window.TNT_I18N) {window.TNT_I18N.set(l);} else {try { localStorage.setItem('tnt_lang', l); } catch (e) {}if (typeof reopenActiveModal === 'function') reopenActiveModal();}}wrap.addEventListener('click', function (e) {var b = e.target.closest('.w-lang');if (b) choose(b.dataset.lang);});// Keep LANG and the open location modal in sync whenever the// language changes (from this chooser or the header's data-set-lang// pills). The booking sheet re-renders itself via its own listener// registered inside that IIFE (LANG is shared and updated here// first, since this listener is registered earlier).document.addEventListener('tnt:langchange', function (e) {LANG = (e.detail && e.detail.lang) || LANG;markWelcome();if (typeof reopenActiveModal === 'function') reopenActiveModal();});// Only ask when no language is set yet. On return visits the stored// choice is used and the welcome screen is skipped — the header's// EN / ՀՅ / RU pills change it from then on. Highlight the stored// choice anyway, in case the chooser is shown again.if (LANG && LANG_NAMES[LANG]) {wrap.classList.add('hidden');}markWelcome();}());// ── Watermark ──(function () {var img = document.getElementById('wm-img');var fb = document.getElementById('wm-fallback');var t = new Image();t.onload = function () { img.src = t.src; img.style.display = 'block'; fb.style.display = 'none'; };t.onerror = function () { img.style.display = 'none'; fb.style.display = 'flex'; };t.src = img.getAttribute('src');}());// ── Modal ──var backdrop = document.getElementById('tnt-backdrop');document.getElementById('m-close-btn').addEventListener('click', function () {backdrop.classList.remove('open');});backdrop.addEventListener('click', function (e) {if (e.target === backdrop) backdrop.classList.remove('open');});document.addEventListener('keydown', function (e) {if (e.key === 'Escape') backdrop.classList.remove('open');});// Localized display name for a LOCS entry (Armenian/Russian when chosen// & present; falls back to the default name).function locName(loc) {if (LANG === 'hy' && loc && loc.nameHy) return loc.nameHy;if (LANG === 'ru' && loc && loc.nameRu) return loc.nameRu;return loc ? loc.name : '';}// Localized name looked up by id (for server responses that carry only id).function locNameById(id, fallback) {if (typeof LOCS !== 'undefined') {for (var i = 0; i < LOCS.length; i++) {if (LOCS[i].id === id) return locName(LOCS[i]);}}return fallback;}var activeModalLoc = null; // remembered so we can re-render on language changefunction openModal(loc, opts) {opts = opts || {};activeModalLoc = loc;document.getElementById('m-cat').textContent = loc.category || '';document.getElementById('m-title').textContent = locName(loc);document.getElementById('m-addr').textContent = loc.address || '';var body = document.getElementById('m-body');body.innerHTML = '';// Only show content in the chosen language. Language-neutral items// (those without a language) are always shown.var details = (loc.details || []).filter(function (d) {return !LANG || !d.language || d.language === LANG;});if (!details.length) {var langName = (LANG && LANG_NAMES[LANG]) ? LANG_NAMES[LANG] : 'this language';body.innerHTML = '<div class="m-empty">No content available in ' + esc(langName) + '.</div>';backdrop.classList.add('open');return;}var types = details.map(function (d) { return d.type; }).filter(function (v, i, a) { return a.indexOf(v) === i; });// Which tab opens first: caller's choice if available, else the first.var activeType = (opts.activeType && types.indexOf(opts.activeType) !== -1)? opts.activeType : types[0];var tabs = document.createElement('div');tabs.className = 'm-tabs';types.forEach(function (type, i) {var btn = document.createElement('button');btn.className = 'm-tab' + (type === activeType ? ' active' : '');btn.textContent = type.charAt(0).toUpperCase() + type.slice(1);btn.dataset.type = type;tabs.appendChild(btn);});body.appendChild(tabs);tabs.addEventListener('click', function (e) {var btn = e.target.closest('.m-tab');if (!btn) return;body.querySelectorAll('.m-tab').forEach(function (t) { t.classList.remove('active'); });body.querySelectorAll('.m-panel').forEach(function (p) { p.classList.remove('active'); });btn.classList.add('active');body.querySelector('.m-panel[data-type="' + btn.dataset.type + '"]').classList.add('active');});types.forEach(function (type, i) {var panel = document.createElement('div');panel.className = 'm-panel' + (type === activeType ? ' active' : '');panel.dataset.type = type;details.filter(function (d) { return d.type === type; }).forEach(function (d) {var item = document.createElement('div');item.className = 'm-item';var html = '<div class="m-item-title">' + esc(d.title) + '</div>';if (d.language) html += '<div class="m-item-lang">' + esc(d.language) + '</div>';if (type === 'text') {html += '<div class="m-item-text">' + esc(d.content || '') + '</div>';} else if (type === 'audio') {if (d.filePath) html += '<audio controls><source src="' + esc(d.filePath) + '"></audio>';if (d.content) html += '<div class="m-item-text" style="margin-top:8px">' + esc(d.content) + '</div>';} else if (type === 'video') {if (d.filePath) html += '<video controls><source src="' + esc(d.filePath) + '"></video>';if (d.content) html += '<div class="m-item-text" style="margin-top:8px">' + esc(d.content) + '</div>';}item.innerHTML = html;panel.appendChild(item);});body.appendChild(panel);});backdrop.classList.add('open');// Autoplay the media in the opened tab (used by the live GPS guide).// If the browser blocks the freshly-created player, tell the caller// so it can fall back to an already-unlocked audio element.if (opts.autoplay) {var media = body.querySelector('.m-panel.active audio, .m-panel.active video');if (media) {var pp = media.play();if (pp && pp.catch) {pp.catch(function () { if (opts.onBlocked) opts.onBlocked(); });}} else if (opts.onBlocked) {opts.onBlocked();}}}// Re-render the currently open modal (e.g. after the language changes).function reopenActiveModal() {if (activeModalLoc && backdrop.classList.contains('open')) {openModal(activeModalLoc, {});}}// ── Map ──var map, clusterer, allPM = [], filterCity = '', filterCat = '';ymaps.ready(function () {map = new ymaps.Map('map', {center: [40.1872, 44.5152],zoom: 12,controls: ['zoomControl', 'fullscreenControl'],}, { suppressMapOpenBlock: true });map.panes.get('ground').getElement().style.filter ='invert(90%) hue-rotate(180deg) saturate(0.6) brightness(0.85)';clusterer = new ymaps.Clusterer({preset: 'islands#invertedBlueClusterIcons',groupByCoordinates: false,});LOCS.forEach(function (loc) {if (!loc.lat || !loc.lng) return;var pm = new ymaps.Placemark([loc.lat, loc.lng], {},{ preset: 'islands#blueDotIcon', iconColor: '#48C9B0' });pm.events.add('click', function () {// While a custom tour is being put together, a pin is a// choice rather than a guide entry: hand it to the booking// sheet instead of opening the modal. TNT_PICK is the// bridge the sheet sets up (it lives in its own closure).if (window.TNT_PICK && window.TNT_PICK.active) {window.TNT_PICK.pick(loc);return;}openModal(loc);});allPM.push({ pm: pm, loc: loc });clusterer.add(pm);});map.geoObjects.add(clusterer);document.getElementById('vis-count').textContent = allPM.length;fitTo(allPM.map(function (x) { return x.pm; }));});function fitTo(pms) {if (!pms.length) return;if (pms.length === 1) {map.setCenter(pms[0].geometry.getCoordinates(), 15, { duration: 400 });return;}var b = pms.reduce(function (acc, pm) {var c = pm.geometry.getCoordinates();return [[Math.min(acc[0][0], c[0]), Math.min(acc[0][1], c[1])],[Math.max(acc[1][0], c[0]), Math.max(acc[1][1], c[1])],];}, [[90, 180], [-90, -180]]);map.setBounds(b, { checkZoomRange: true, zoomMargin: 80, duration: 400 });}document.getElementById('filter-city').addEventListener('change', function () {filterCity = this.value; applyFilters();});document.getElementById('filter-cat').addEventListener('change', function () {filterCat = this.value; applyFilters();});function applyFilters() {clusterer.removeAll();var vis = allPM.filter(function (x) {return (!filterCity || String(x.loc.cityId) === filterCity) &&(!filterCat || x.loc.category === filterCat);});clusterer.add(vis.map(function (x) { return x.pm; }));document.getElementById('vis-count').textContent = vis.length;fitTo(vis.map(function (x) { return x.pm; }));}function esc(s) {return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}// ── Responsive header menu ──(function () {var burger = document.getElementById('map-burger');var controls = document.getElementById('map-controls');if (!burger || !controls) return;function setOpen(open) {controls.classList.toggle('open', open);burger.setAttribute('aria-expanded', open ? 'true' : 'false');}burger.addEventListener('click', function (e) {e.stopPropagation();setOpen(!controls.classList.contains('open'));});// Close when tapping outside the open paneldocument.addEventListener('click', function (e) {if (!controls.classList.contains('open')) return;if (!controls.contains(e.target) && e.target !== burger) setOpen(false);});// Close on Escapedocument.addEventListener('keydown', function (e) {if (e.key === 'Escape') setOpen(false);});}());// ═══════════════════════════════════════════════════════════════════// LIVE GUIDE — GPS drive-by autoplay// Watches the visitor's position, asks the server for a nearby// location and autoplays its mp3 audio guide on arrival.// ═══════════════════════════════════════════════════════════════════(function () {var SILENT = 'data:audio/wav;base64,UklGRnQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YVAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA==';var MIN_MOVE_M = 25; // don't re-query until moved this farvar MIN_GAP_MS = 4000; // …or at least this long since last queryvar radiusSel = document.getElementById('filter-radius');var RADIUS_M = radiusSel ? parseInt(radiusSel.value, 10) : 100;var lastFix = null; // last GPS fix, so we can re-query on range changeif (radiusSel) {radiusSel.addEventListener('change', function () {RADIUS_M = parseInt(this.value, 10) || 100;if (lastFix && watchId !== null) {lastQuery = { lat: null, lng: null, at: 0 }; // force a fresh queryquery(lastFix.lat, lastFix.lng, lastFix.acc);}});}var toggle = document.getElementById('live-toggle');var banner = document.getElementById('tnt-nowplaying');var audio = document.getElementById('np-audio');var npTitle = document.getElementById('np-title');var npDist = document.getElementById('np-dist');var npClose = document.getElementById('np-close');var toastEl = document.getElementById('tnt-toast');var statusEl = document.getElementById('tnt-status');var controlsPanel = document.getElementById('map-controls');function setStatus(html) { statusEl.innerHTML = html; }var watchId = null;var userPM = null; // Yandex placemark for the visitorvar userLayout = null; // cached custom icon layout classvar lastQuery = { lat: null, lng: null, at: 0 };var currentLoc = null; // id of the location currently playingvar inFlight = false;var travelHeading = null; // degrees, 0 = north, direction of travelvar headingAnchor = null; // point used to derive heading from motionvar toastTimer;function toast(msg) {toastEl.textContent = msg;toastEl.classList.add('show');clearTimeout(toastTimer);toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 3200);}// Metres between two lat/lng points (Haversine, mirrors the backend).function distM(aLat, aLng, bLat, bLng) {var R = 6371000, toRad = Math.PI / 180;var dLat = (bLat - aLat) * toRad, dLng = (bLng - aLng) * toRad;var s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +Math.cos(aLat * toRad) * Math.cos(bLat * toRad) *Math.sin(dLng / 2) * Math.sin(dLng / 2);return 2 * R * Math.asin(Math.sqrt(s));}// Initial compass bearing from point A to point B (degrees, 0 = north).function bearing(aLat, aLng, bLat, bLng) {var toRad = Math.PI / 180, toDeg = 180 / Math.PI;var φ1 = aLat * toRad, φ2 = bLat * toRad, Δλ = (bLng - aLng) * toRad;var y = Math.sin(Δλ) * Math.cos(φ2);var x = Math.cos(φ1) * Math.sin(φ2) -Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);return (Math.atan2(y, x) * toDeg + 360) % 360;}// Signed angle of `to` relative to `from`, in [-180,180]. + = to the right.function angleDiff(from, to) {return ((to - from + 540) % 360) - 180;}// Next location to hint at: the closest one ahead in the direction of// travel; if the heading is unknown, the nearest overall. Name is// localized to the chosen language.function pickAhead(lat, lng) {if (typeof LOCS === 'undefined') return null;var best = null, bestAny = null;for (var i = 0; i < LOCS.length; i++) {var L = LOCS[i];if (!L.lat || !L.lng || L.id === currentLoc) continue;var d = distM(lat, lng, +L.lat, +L.lng);if (!bestAny || d < bestAny.d) bestAny = { loc: L, d: d };if (travelHeading != null) {var diff = angleDiff(travelHeading, bearing(lat, lng, +L.lat, +L.lng));if (Math.abs(diff) > 75) continue; // behind / off to the sideif (!best || d < best.d) best = { loc: L, d: d, diff: diff };}}var chosen = best || bestAny;if (!chosen) return null;var arrow = best? (Math.abs(best.diff) <= 20 ? '↑' : (best.diff > 0 ? '↗' : '↖')): '⟶';return { name: locName(chosen.loc), dist: chosen.d, arrow: arrow };}// Fallback player (always unlocked by the initial tap): used only when// the modal's freshly-created <audio> is blocked from autoplaying (iOS).function playFallback(aud) {audio.src = aud.filePath;audio.currentTime = 0;var bp = audio.play();if (bp && bp.catch) bp.catch(function () {toast(window.t('guide.tapPlay'));});}function startGuide(loc, aud) {currentLoc = loc.id;// Stop whatever the previous location was playing before switching.try { audio.pause(); } catch (e) {}var prevMedia = document.querySelectorAll('#tnt-modal audio, #tnt-modal video');for (var k = 0; k < prevMedia.length; k++) {try { prevMedia[k].pause(); } catch (e) {}}// Find the full location (with all details) already loaded on the page.var full = null;if (typeof LOCS !== 'undefined') {for (var i = 0; i < LOCS.length; i++) {if (LOCS[i].id === loc.id) { full = LOCS[i]; break; }}}if (full && typeof openModal === 'function') {// Open the detail modal on the AUDIO tab and play it there.openModal(full, {activeType: 'audio',autoplay: true,onBlocked: function () { playFallback(aud); }});} else {// No modal data — show the compact banner and play there instead.npTitle.textContent = (aud && aud.title) ? aud.title : loc.name;npDist.textContent = loc.name + ' · ' + Math.round(loc.distance) + ' m';banner.classList.add('show');playFallback(aud);}}function stopGuide() {try { audio.pause(); } catch (e) {}banner.classList.remove('show');currentLoc = null;}npClose.addEventListener('click', stopGuide);audio.addEventListener('ended', function () {// Keep the location "played" so we don't loop it; just hide banner.banner.classList.remove('show');});// Anti-echo: if any *other* audio starts (e.g. the modal's own player),// pause the banner audio so the guide isn't heard twice.document.addEventListener('play', function (e) {if (e.target !== audio && e.target.tagName === 'AUDIO') {try { audio.pause(); } catch (_) {}}}, true);function query(lat, lng, acc) {if (inFlight) return;inFlight = true;var url = NEARBY_URL + '?lat=' + encodeURIComponent(lat) +'&lng=' + encodeURIComponent(lng) + '&radius=' + RADIUS_M +(LANG ? '&lang=' + encodeURIComponent(LANG) : '');fetch(url, { headers: {'X-Requested-With': 'XMLHttpRequest','ngrok-skip-browser-warning': 'true'} }).then(function (r) { return r.json(); }).then(function (data) {inFlight = false;var accTxt = ' · ' + lat.toFixed(5) + ', ' + lng.toFixed(5) +(acc ? ' ±' + Math.round(acc) + 'm' : '');if (data && data.found) {var locLabel = locNameById(data.location.id, data.location.name);if (!data.audio || !data.audio.filePath) {setStatus('<span class="st-warn">' + esc(locLabel) +'</span> is here but has no audio guide' + accTxt);return;}if (data.location.id === currentLoc) {setStatus('<span class="st-ok">▶ ' + esc(locLabel) +'</span> · ' + Math.round(data.location.distance) + 'm' + accTxt);return; // already playing this one}startGuide(data.location, data.audio);setStatus('<span class="st-ok">▶ ' + esc(locLabel) +'</span> · ' + Math.round(data.location.distance) + 'm' + accTxt);return;}// Between locations — hint at the next place in the direction// of travel; fall back to nearest-by-distance until a heading// is known.var name = null, dist = null, arrow = '⟶';var ahead = pickAhead(lat, lng);if (ahead) {name = ahead.name; dist = Math.round(ahead.dist); arrow = ahead.arrow;} else if (data && data.nearest) {name = data.nearest.name; dist = Math.round(data.nearest.distance);}if (name !== null) {var remain = Math.max(0, dist - RADIUS_M);setStatus('Next ' + arrow + ' <b>' + esc(name) + '</b> · ~' + dist + 'm ' +'<span class="st-warn">(plays in ~' + remain + 'm)</span>' + accTxt);} else {setStatus('No locations found' + accTxt);}}).catch(function () {inFlight = false;setStatus('<span class="st-warn">Network error contacting server</span>');});}function drawUserMarker(lat, lng) {if (typeof map === 'undefined' || !map || typeof ymaps === 'undefined') return;if (!userLayout) {userLayout = ymaps.templateLayoutFactory.createClass('<div class="user-heading" style="transform:rotate($[properties.rotation]deg)">' +'<div class="uh-arrow" style="opacity:$[properties.arrowOpacity]"></div>' +'<div class="uh-dot"></div>' +'</div>');}// Rotation follows the travel heading; hide the arrow until we know it.var props = {rotation: (travelHeading == null ? 0 : Math.round(travelHeading)),arrowOpacity: (travelHeading == null ? 0 : 1)};if (!userPM) {userPM = new ymaps.Placemark([lat, lng], props, {iconLayout: userLayout,iconShape: { type: 'Circle', coordinates: [0, 0], radius: 15 }});map.geoObjects.add(userPM);map.setCenter([lat, lng], Math.max(map.getZoom(), 15), { duration: 400 });} else {userPM.geometry.setCoordinates([lat, lng]);userPM.properties.set(props);}}function onPosition(pos) {var lat = pos.coords.latitude,lng = pos.coords.longitude,acc = pos.coords.accuracy;lastFix = { lat: lat, lng: lng, acc: acc };// Direction of travel: use the device heading while actually moving,// otherwise derive it from successive positions (>12 m apart).if (typeof pos.coords.heading === 'number' && !isNaN(pos.coords.heading) &&(pos.coords.speed == null || pos.coords.speed > 0.5)) {travelHeading = pos.coords.heading;} else if (headingAnchor) {if (distM(headingAnchor.lat, headingAnchor.lng, lat, lng) > 12) {travelHeading = bearing(headingAnchor.lat, headingAnchor.lng, lat, lng);headingAnchor = { lat: lat, lng: lng };}} else {headingAnchor = { lat: lat, lng: lng };}// 1) Query the server FIRST — audio must never be blocked by the map.var now = Date.now();var moved = lastQuery.lat === null ? Infinity :distM(lastQuery.lat, lastQuery.lng, lat, lng);if (moved >= MIN_MOVE_M || (now - lastQuery.at) >= MIN_GAP_MS) {lastQuery = { lat: lat, lng: lng, at: now };query(lat, lng, acc);}// 2) Draw the visitor marker (guarded so it can never break the flow).try { drawUserMarker(lat, lng); } catch (e) {}}function onError(err) {if (err && err.code === 1) { // PERMISSION_DENIEDstopWatching();toast(window.t('guide.denied'));return;}// POSITION_UNAVAILABLE / TIMEOUT — keep watching, just report it.setStatus('<span class="st-warn">Waiting for a GPS fix…</span>');}function stopWatching() {if (watchId !== null) {navigator.geolocation.clearWatch(watchId);watchId = null;}toggle.classList.remove('active');toggle.setAttribute('aria-pressed', 'false');toggle.querySelector('.live-label').textContent = window.t('nav.liveGuide');if (userPM && typeof map !== 'undefined' && map) {map.geoObjects.remove(userPM);userPM = null;}lastQuery = { lat: null, lng: null, at: 0 };statusEl.classList.remove('show');}toggle.addEventListener('click', function () {if (watchId !== null) { stopWatching(); return; }if (!('geolocation' in navigator)) {toast(window.t('guide.noGeo'));return;}// Unlock audio inside this user gesture so later autoplay works.audio.src = SILENT;var up = audio.play();if (up && up.catch) up.catch(function () {});toggle.classList.add('active');toggle.setAttribute('aria-pressed', 'true');toggle.querySelector('.live-label').textContent = window.t('guide.stop');// Close the mobile menu so the map + status are visible.if (controlsPanel) {controlsPanel.classList.remove('open');var b = document.getElementById('map-burger');if (b) b.setAttribute('aria-expanded', 'false'); // reset ✕ back to ☰}statusEl.classList.add('show');setStatus('Acquiring GPS…');watchId = navigator.geolocation.watchPosition(onPosition, onError, {enableHighAccuracy: true,maximumAge: 2000,timeout: 15000});});}());// ═══════════════════════════════════════════════════════════════════// ORDER SHEET — book a tuk-tuk//// route → paint the road → pick a tuk → cash or card → order.// The sheet is a small state machine: `state.step` decides what// render() draws, and every step knows how to go back.// ═══════════════════════════════════════════════════════════════════(function () {var STEP_ROUTES = 'routes'; // the three toursvar STEP_ROUTE = 'route'; // one tour's itineraryvar STEP_TUKS = 'tuks'; // nearby tuksvar STEP_PAY = 'pay'; // cash / card + ordervar STEP_ACTIVE = 'active'; // live ordervar STEP_SCHEDULE = 'schedule'; // advance-booking formvar STEP_SCHEDULE_DONE = 'scheduleDone'; // request submittedvar STEP_CUSTOM = 'custom'; // drawing your own line on the mapvar STEP_CUSTOM_FORM = 'customForm'; // when/who for that drawn linevar STEP_RESERVATION = 'reservation'; // viewing a scheduled tour// Snap positions for the drag handle, as fractions of the viewport.var SNAP = [0.28, 0.5, 0.88];var POLL_MS = 4000; // how often a live order re-checks the servervar el = {sheet: document.getElementById('sheet'),grab: document.getElementById('sheet-grab'),back: document.getElementById('sheet-back'),title: document.getElementById('sheet-title'),sub: document.getElementById('sheet-sub'),body: document.getElementById('sheet-body'),foot: document.getElementById('sheet-foot'),cta: document.getElementById('sheet-cta')};var state = {step: STEP_ROUTES,routes: [],route: null, // selected route objecttuks: [],tuk: null, // selected tuk objectcards: [],pay: 'cash', // 'cash' | 'card'card: null, // selected card objectbooking: null, // live bookinguserPos: null, // [lat, lng] once GPS answersbusy: false, // a request is in flighterror: null,ratingPending: 0, // stars chosen on the post-tour rating widgetpassengers: 1, // party size — drives the price (1…TAXI.maxPassengers)tipPending: 0, // amount chosen on the post-tour tip widgettipDone: false,// hides the widget after a tip is sentdraw: [], // stops chosen for a custom line, in orderpicker: '', // search text in the stop pickercustom: null, // the custom form's saved valuescustomTable: [], // price table for a drawn tour, from /api/routesreservation: null,// last created / opened reservationguest: null, // name/email/phone typed by a signed-out visitornewCard: null, // card details typed at checkout, before savingaccountExists: false // that email is already taken — offer sign-in};// Map objects we own, so we can clean up without touching the// location clusterer the rest of the page manages.var routeLine = null;var stopPMs = [];var drawLine = null; // the polyline of a custom line being drawnvar drawPMs = []; // its numbered markersvar drawBadge = null; // the "tap the map" cue over the mapvar tukPMs = []; // tuks shown for selection during bookingvar ambientPMs = []; // the whole live fleet, shown while browsingvar ambientTimer = null;var pollTimer = null;var es = null; // Mercure EventSource, when the hub is reachablevar liveOn = false; // true once the push stream is connected// Tuk marker colours: free tuks are amber, ones on a tour are red, and// the tuk a passenger is ordering is mint.var TUK_FREE_COLOR = '#E8A020';var TUK_BUSY_COLOR = '#D4572A';var TUK_SELECTED_COLOR = '#48C9B0';// ── Helpers ──────────────────────────────────────────────────// Translation shortcut for this scope. NOT named `t` on purpose:// renderTuks() already uses `t` as its tuk loop variable, so shadowing// it here would break the sheet. Always translate via L(...) below.function L(k, p) { return window.TNT_I18N ? window.TNT_I18N.t(k, p) : k; }function amd(n) {return Number(n).toLocaleString('en-US') + ' ֏';}// ── Party-size pricing ───────────────────────────────────────//// The server ships the whole table with every route// (priceTable: [{passengers, priceAmd}, …]), so the sheet never// re-derives the surcharge rules — it just looks the price up. The// fallbacks below only matter for an old cached payload.function maxPax() {return TAXI.maxPassengers || 3;}function priceFor(route, pax) {if (!route) return 0;var table = route.priceTable || [];for (var i = 0; i < table.length; i++) {if (table[i].passengers === pax) return table[i].priceAmd;}return route.priceAmd || 0;}// What the sheet should currently quote for the selected route.function currentPrice() {return priceFor(state.route, state.passengers);}function paxWord(n) {return L(n === 1 ? 'sheet.passenger' : 'sheet.passengers');}function setPassengers(n) {var next = Math.max(1, Math.min(n, maxPax()));if (next === state.passengers) return;// The stepper lives inside the schedule / custom forms too, and// changing it re-renders them — so keep whatever has been typed// before the markup is thrown away.captureForms();state.passengers = next;state.error = null;render();}/*** Read any open form back into state, so a re-render restores it.*/function captureForms() {var date = document.getElementById('sc-date');if (date) {state.sched = {date: date.value,time: val('sc-time'),pickup: val('sc-pickup'),note: val('sc-note')};}var title = document.getElementById('cu-title');if (title) {state.custom = {title: title.value,date: val('cu-date'),time: val('cu-time'),pickup: val('cu-pickup'),note: val('cu-note')};}// Guest checkout: switching cash↔card re-renders the payment// step, so the details typed so far have to survive it.var guName = document.getElementById('gu-name');if (guName) {state.guest = {name: guName.value,email: val('gu-email'),phone: val('gu-phone')};}var cdNum = document.getElementById('cd-number');if (cdNum) {state.newCard = {number: cdNum.value,exp: val('cd-exp'),holder: val('cd-holder')};}}function val(id) {var e = document.getElementById(id);return e ? e.value : '';}/*** The ± stepper plus the full price table. Both are rendered* together so the passenger can see not just what they pay but why* — and can jump straight to a party size by tapping its cell.*/function paxHtml(route) {var html ='<div class="pax">' +'<div class="pax-lbl">' + esc(L('pax.title')) +'<small>' + esc(L('pax.hint', { n: maxPax() })) + '</small>' +'</div>' +'<div class="pax-ctl">' +'<button type="button" class="pax-btn" data-pax-step="-1"' +(state.passengers <= 1 ? ' disabled' : '') + ' aria-label="' + esc(L('pax.fewer')) + '">−</button>' +'<span class="pax-n">' + state.passengers + '</span>' +'<button type="button" class="pax-btn" data-pax-step="1"' +(state.passengers >= maxPax() ? ' disabled' : '') + ' aria-label="' + esc(L('pax.more')) + '">+</button>' +'</div>' +'</div>' +'<div class="pax-table">';for (var n = 1; n <= maxPax(); n++) {html +='<button type="button" class="pax-cell' + (n === state.passengers ? ' on' : '') + '" data-pax="' + n + '">' +'<span class="pax-cell-n">' + n + ' ' + esc(paxWord(n)) + '</span>' +'<span class="pax-cell-p">' + esc(amd(priceFor(route, n))) + '</span>' +'</button>';}return html + '</div>';}// Wire whatever pax controls the current step happened to render.function wirePax(root) {root.querySelectorAll('[data-pax-step]').forEach(function (b) {b.addEventListener('click', function () {setPassengers(state.passengers + parseInt(b.dataset.paxStep, 10));});});root.querySelectorAll('[data-pax]').forEach(function (b) {b.addEventListener('click', function () {setPassengers(parseInt(b.dataset.pax, 10));});});}function mins(n) {if (n < 60) return n + ' ' + L('sheet.min');var h = Math.floor(n / 60), m = n % 60;return m ? h + ' h ' + m + ' ' + L('sheet.min') : h + ' h';}function metres(m) {if (m === null || m === undefined) return '';return m < 1000 ? Math.round(m) + ' m' : (m / 1000).toFixed(1) + ' km';}// Route/stop names follow the language chosen on the welcome screen.function tr(obj) {if (LANG === 'hy' && obj && obj.nameHy) return obj.nameHy;if (LANG === 'ru' && obj && obj.nameRu) return obj.nameRu;return obj ? obj.name : '';}// Localized route description (same rule as tr, for the description fields).function trDesc(obj) {if (LANG === 'hy' && obj && obj.descriptionHy) return obj.descriptionHy;if (LANG === 'ru' && obj && obj.descriptionRu) return obj.descriptionRu;return obj ? (obj.description || '') : '';}function initials(name) {return String(name || '?').trim().split(/\s+/).slice(0, 2).map(function (w) { return w.charAt(0).toUpperCase(); }).join('');}function api(url, opts) {opts = opts || {};var headers = { 'Accept': 'application/json' };if (opts.body) {headers['Content-Type'] = 'application/json';headers['X-CSRF-Token'] = TAXI.csrf;}return fetch(url, {method: opts.method || 'GET',headers: headers,body: opts.body ? JSON.stringify(opts.body) : undefined,credentials: 'same-origin'}).then(function (res) {return res.json().catch(function () { return {}; }).then(function (data) {if (!res.ok) {throw new Error(data.error || ('Request failed (' + res.status + ')'));}return data;});});}// ── Map painting ─────────────────────────────────────────────function clearRoute() {if (routeLine) { map.geoObjects.remove(routeLine); routeLine = null; }stopPMs.forEach(function (pm) { map.geoObjects.remove(pm); });stopPMs = [];}/*** Show or hide the browse-mode pins for every location in the city.** All 76 of them on top of a route drown out the road and its* stops, so once a tour is chosen we take them off the map and put* them back when the passenger returns to the tour list. The* clusterer itself is left intact — the page's own filters keep* managing its contents.*/function setPoiVisible(on) {if (!clusterer) return;var present = false;map.geoObjects.each(function (o) { if (o === clusterer) present = true; });if (on && !present) map.geoObjects.add(clusterer);if (!on && present) map.geoObjects.remove(clusterer);}function clearTuks() {tukPMs.forEach(function (pm) { map.geoObjects.remove(pm); });tukPMs = [];}// A tuk marker in the same style as the driver app: a 🛺 in a coloured// disc. One layout class, reused; colour and heading ride in as// placemark properties so we never rebuild the class per tuk.var tukLayoutClass = null;function tukLayout() {if (!tukLayoutClass && typeof ymaps !== 'undefined') {tukLayoutClass = ymaps.templateLayoutFactory.createClass('<div class="tnt-tuk-mark" style="transform:rotate($[properties.heading]deg);' +'width:34px;height:34px;margin:-17px 0 0 -17px;background:$[properties.tukColor];' +'border:3px solid #0B1F3A;border-radius:50%;box-shadow:0 3px 10px rgba(0,0,0,.5);' +'display:flex;align-items:center;justify-content:center;font-size:16px">🛺</div>');}return tukLayoutClass;}function tukPlacemark(t, opts) {opts = opts || {};var color = opts.color || (t.status === 'busy' ? TUK_BUSY_COLOR : TUK_FREE_COLOR);return new ymaps.Placemark([t.lat, t.lng], {heading: t.heading || 0,tukColor: color,hintContent: (t.driver ? t.driver.name + ' · ' : '') + t.code}, {iconLayout: tukLayout(),iconShape: { type: 'Circle', coordinates: [0, 0], radius: 17 },zIndex: opts.zIndex || 750});}// ── Ambient fleet (every live tuk, shown only while browsing) ──function clearAmbient() {ambientPMs.forEach(function (pm) { map.geoObjects.remove(pm); });ambientPMs = [];}function drawAmbientTuks(tuks) {clearAmbient();tuks.forEach(function (t) {if (!t.lat || !t.lng) return;var pm = tukPlacemark(t, { zIndex: t.status === 'busy' ? 730 : 735 });ambientPMs.push(pm);map.geoObjects.add(pm);});}// Only paint the ambient fleet on the tour-list step; a booking's// route / selected-tuk / active views own the map at every other step.function refreshAmbient() {if (!map || state.step !== STEP_ROUTES) { clearAmbient(); return; }api(TAXI.tuksMapUrl).then(function (data) {if (state.step !== STEP_ROUTES) return; // step changed mid-fetchdrawAmbientTuks(data.tuks || []);}).catch(function () {});}function startAmbient() {refreshAmbient();if (ambientTimer) clearInterval(ambientTimer);ambientTimer = setInterval(refreshAmbient, 12000);}/*** Draw the driving road through every stop of a route, in the* route's own colour, plus a numbered marker per stop.** The road is drawn from geometry the server resolved ahead of time* (see app:route:geometry). We deliberately do NOT use Yandex's* multiRouter: it answers HTTP 401 unless the account's key is* signed up for the Router API, and fails silently when it isn't.* If a route has no stored geometry yet, we join the stops with a* straight line so the map still shows the shape of the tour.*/function paintRoute(route) {clearRoute();var pts = (route.points || []).filter(function (p) { return p.lat && p.lng; });if (!pts.length) return;var road = (route.geometry && route.geometry.length)? route.geometry: pts.map(function (p) { return [p.lat, p.lng]; });routeLine = new ymaps.Polyline(road, {}, {strokeColor: route.color,strokeWidth: 6,strokeOpacity: 0.92,zIndex: 690});map.geoObjects.add(routeLine);// Frame the road inside the part of the map the sheet leaves// visible — useMapMargin honours the reserved area.try {map.setBounds(routeLine.geometry.getBounds(), {checkZoomRange: true,useMapMargin: true,zoomMargin: 30,duration: 400});} catch (e) {}pts.forEach(function (p, i) {var pm = new ymaps.Placemark([p.lat, p.lng], {iconContent: String(i + 1),hintContent: tr(p)}, {preset: 'islands#circleIcon',iconColor: route.color,zIndex: 700});// Tapping a stop opens the existing audio/text guide for it.pm.events.add('click', function () {var loc = null;for (var k = 0; k < LOCS.length; k++) {if (LOCS[k].id === p.id) { loc = LOCS[k]; break; }}if (loc) openModal(loc);});stopPMs.push(pm);map.geoObjects.add(pm);});}/*** Show the fleet. Selecting a marker selects that tuk in the sheet,* so map and list stay in step.*/function paintTuks(tuks) {clearTuks();tuks.forEach(function (t) {if (!t.lat || !t.lng) return;var selected = state.tuk && state.tuk.id === t.id;var pm = tukPlacemark(t, {color: selected ? TUK_SELECTED_COLOR : TUK_FREE_COLOR,zIndex: selected ? 760 : 755});pm.events.add('click', function () {selectTuk(t);});tukPMs.push(pm);map.geoObjects.add(pm);});}/*** Keep the map's usable centre clear of the sheet, so a route is* fitted into the part of the map that is actually visible rather* than centred under the panel.** The margin manager has no "clear everything" call: addArea() hands* back an accessor and removeArea() takes it, so we hold on to ours.*/var marginArea = null;function syncMapMargin() {if (!map || !map.margin) return;if (marginArea) {try { map.margin.removeArea(marginArea); } catch (e) {}marginArea = null;}if (el.sheet.classList.contains('hidden')) return;var r = el.sheet.getBoundingClientRect();try {marginArea = window.innerWidth >= 900// Desktop: the panel hugs the left edge.? map.margin.addArea({ left: 0, top: 0, width: r.width + 20, height: '100%' })// Mobile: the sheet covers the bottom.: map.margin.addArea({ left: 0, bottom: 0, width: '100%', height: r.height });} catch (e) {marginArea = null;}}// ── Rendering ────────────────────────────────────────────────function setFoot(label, opts) {opts = opts || {};if (!label) {el.foot.classList.add('hidden');return;}el.foot.classList.remove('hidden');el.cta.disabled = !!opts.disabled;el.cta.className = 'sheet-cta' + (opts.ghost ? ' ghost' : '');el.cta.innerHTML = opts.price? esc(label) + '<span class="cta-price">' + esc(opts.price) + '</span>': esc(label);el.cta.onclick = opts.onClick || null;}function errorHtml() {return state.error? '<div class="sheet-err">' + esc(state.error) + '</div>': '';}function render() {el.back.classList.toggle('show',state.step !== STEP_ROUTES &&state.step !== STEP_ACTIVE &&state.step !== STEP_SCHEDULE_DONE &&state.step !== STEP_RESERVATION);// The "tap the map" cue belongs to the drawing step only.if (state.step !== STEP_CUSTOM) hideDrawBadge();if (state.step === STEP_ROUTES) renderRoutes();else if (state.step === STEP_ROUTE) renderRoute();else if (state.step === STEP_TUKS) renderTuks();else if (state.step === STEP_PAY) renderPay();else if (state.step === STEP_ACTIVE) renderActive();else if (state.step === STEP_SCHEDULE) renderSchedule();else if (state.step === STEP_SCHEDULE_DONE) renderScheduleDone();else if (state.step === STEP_CUSTOM) renderCustom();else if (state.step === STEP_CUSTOM_FORM) renderCustomForm();else if (state.step === STEP_RESERVATION) renderReservation();syncComm();}/*** Keep the chat/call channel following the live order.** Attached from the moment a driver is on the job and dropped the* moment the tour ends, so the panel can never outlive the order it* belongs to. Both calls are idempotent, so calling this on every* render costs nothing.*/function syncComm() {if (!window.TNT_COMM) return;var b = state.booking;var live = b && b.driver &&b.status !== 'completed' && b.status !== 'cancelled';if (live) TNT_COMM.attach(b.reference);else TNT_COMM.detach();}function renderRoutes() {el.title.textContent = L('sheet.chooseTour');el.sub.textContent = state.routes.length? L('sheet.routesIn', { n: state.routes.length }): '';if (!state.routes.length) {el.body.innerHTML = '<div class="sheet-msg"><div class="spin"></div>' + esc(L('sheet.loadingRoutes')) + '</div>';setFoot(null);return;}var html = errorHtml();state.routes.forEach(function (r) {html +='<button type="button" class="rt-card' + (state.route && state.route.id === r.id ? ' active' : '') + '"' +' style="--rt-color:' + esc(r.color) + '" data-route="' + r.id + '">' +'<div class="rt-main">' +'<div class="rt-name">' + esc(tr(r)) + '</div>' +'<div class="rt-meta">' +'<span>' + esc(mins(r.durationMinutes)) + '</span>' +'<span class="dot-sep"></span>' +'<span>' + r.stopCount + ' ' + esc(L('sheet.stops')) + '</span>' +'</div>' +'</div>' +'<div class="rt-price">' + esc(L('sheet.from')) + ' ' + esc(amd(priceFor(r, 1))) + '</div>' +'</button>';});// Not one of ours: let the passenger draw their own line and// have the back office price and confirm it.html +='<button type="button" class="rt-card draw" style="--rt-color:' + esc(TAXI.customColor) + '" id="rt-draw">' +'<div class="rt-main">' +'<div class="rt-name">✏️ ' + esc(L('cust.entry')) + '</div>' +'<div class="rt-meta"><span>' + esc(L('cust.entrySub')) + '</span></div>' +'</div>' +'</button>';el.body.innerHTML = html;setFoot(null);el.body.querySelectorAll('.rt-card[data-route]').forEach(function (btn) {btn.addEventListener('click', function () {var id = parseInt(btn.dataset.route, 10);var r = state.routes.filter(function (x) { return x.id === id; })[0];if (r) selectRoute(r);});});var drawBtn = document.getElementById('rt-draw');if (drawBtn) drawBtn.addEventListener('click', goToCustom);}function renderRoute() {var r = state.route;el.title.textContent = tr(r);el.sub.textContent = mins(r.durationMinutes) + ' · ' + r.stopCount + ' ' + L('sheet.stops');var html = errorHtml();var desc = trDesc(r);if (desc) {html += '<div class="rt-desc">' + esc(desc) + '</div>';}html += '<ul class="stops" style="--rt-color:' + esc(r.color) + '">';r.points.forEach(function (p, i) {html +='<li class="stop">' +'<span class="stop-dot">' + (i + 1) + '</span>' +'<div class="stop-body">' +'<div class="stop-name">' + esc(tr(p)) + '</div>' +(p.stopMinutes? '<div class="stop-time">' + esc(L('sheet.minStop', { n: p.stopMinutes })) + '</div>': '') +'</div>' +'</li>';});html += '</ul>';// How many are riding decides the price, so it is chosen here —// before a tuk is picked — and the footer quotes that total.html += paxHtml(r);// Advance-booking option: order a tuk now, or schedule the tour// for a future date and let the back office confirm it.html += '<button type="button" class="sheet-cta ghost" id="rt-schedule" style="margin-top:10px">📅 ' +esc(L('sched.later')) + '</button>';el.body.innerHTML = html;wirePax(el.body);var schedBtn = document.getElementById('rt-schedule');if (schedBtn) schedBtn.addEventListener('click', goToSchedule);setFoot(L('sheet.chooseTuk'), {price: amd(currentPrice()),onClick: goToTuks});}// ── Advance booking (schedule for later) ─────────────────────// Local YYYY-MM-DD (not UTC, so "today" is the passenger's today).function ymd(dt) {return dt.getFullYear() + '-' +String(dt.getMonth() + 1).padStart(2, '0') + '-' +String(dt.getDate()).padStart(2, '0');}function goToSchedule() {state.step = STEP_SCHEDULE;state.error = null;clearTuks();render();snapTo(1);}function renderSchedule() {var r = state.route;el.title.textContent = L('sched.title');el.sub.textContent = tr(r) + ' · ' + amd(currentPrice());var d = state.sched || {};var today = ymd(new Date());var defDate = d.date || ymd(new Date(Date.now() + 864e5)); // tomorrowvar defTime = d.time || '10:00';el.body.innerHTML = errorHtml() +'<div class="sched-row">' +'<div class="sched-field"><label>' + esc(L('sched.date')) + '</label>' +'<input type="date" id="sc-date" class="sched-input" value="' + esc(defDate) + '" min="' + esc(today) + '"></div>' +'<div class="sched-field"><label>' + esc(L('sched.time')) + '</label>' +'<input type="time" id="sc-time" class="sched-input" value="' + esc(defTime) + '"></div>' +'</div>' +// Same stepper as the route step, so the quoted total moves// with the party size here too.paxHtml(r) +'<div class="sched-field" style="margin-top:10px"><label>' + esc(L('sched.pickup')) + '</label>' +'<input type="text" id="sc-pickup" class="sched-input" placeholder="' + esc(L('sched.pickupPh')) + '" value="' + esc(d.pickup || '') + '"></div>' +'<div class="sched-field"><label>' + esc(L('sched.note')) + '</label>' +'<textarea id="sc-note" class="sched-input" placeholder="' + esc(L('sched.notePh')) + '">' + esc(d.note || '') + '</textarea></div>' +'<div class="sched-note">' + esc(L('sched.hint')) + '</div>';wirePax(el.body);setFoot(state.busy ? L('sched.submitting') : L('sched.submit'), {price: amd(currentPrice()),disabled: state.busy,onClick: placeReservation});}function placeReservation() {if (state.busy) return;if (!TAXI.isLoggedIn) { window.location.href = TAXI.loginUrl; return; }// Keep what they typed so a validation error never wipes the form.captureForms();var d = state.sched || {};var date = d.date;var time = d.time;var pax = state.passengers;var pickup = d.pickup;var note = d.note;if (!date || !time) { state.error = L('sched.needDate'); render(); return; }state.busy = true;state.error = null;render();api(TAXI.reservationsUrl, {method: 'POST',body: {routeId: state.route.id,scheduledAt: date + 'T' + time,passengers: pax,pickupAddress: pickup,note: note}}).then(function (data) {state.busy = false;state.reservation = data.reservation;state.sched = null;state.step = STEP_SCHEDULE_DONE;render();snapTo(1);}).catch(function (e) {state.busy = false;state.error = e.message;render();});}function renderScheduleDone() {var rv = state.reservation || {};el.title.textContent = L('sched.doneTitle');el.sub.textContent = rv.reference || '';var when = rv.scheduledAt ? new Date(rv.scheduledAt) : null;var whenTxt = when ? when.toLocaleDateString() + ' · ' +when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';var pax = rv.passengers || 1;el.body.innerHTML ='<div class="sheet-msg" style="text-align:center">' +'<div style="font-size:40px;line-height:1;margin-bottom:8px">📅</div>' +esc(rv.kind === 'custom' ? L('cust.doneMsg') : L('sched.doneMsg')) +'</div>' +'<div style="margin-top:8px">' +// tourName covers both kinds: one of our lines, or the// name the passenger gave their own drawn route.'<div class="sum-row"><span class="sum-k">' + esc(L('sum.route')) + '</span><span class="sum-v">' + esc(rv.tourName || '') + '</span></div>' +'<div class="sum-row"><span class="sum-k">' + esc(L('sched.when')) + '</span><span class="sum-v">' + esc(whenTxt) + '</span></div>' +'<div class="sum-row"><span class="sum-k">' + esc(L('pax.title')) + '</span><span class="sum-v">' + pax + ' ' + esc(paxWord(pax)) + '</span></div>' +// A custom tour carries no quote: it is priced by hand// during review and the figure arrives with the// approval email.(rv.kind === 'custom'? '<div class="sched-note" style="margin-top:10px">' + esc(L('cust.priceLater')) + '</div>': '<div class="sum-row"><span class="sum-k">' + esc(L('sheet.total')) + '</span><span class="sum-v big">' + esc(amd(rv.priceAmd || 0)) + '</span></div>') +'</div>';setFoot(L('sheet.bookAnother'), { onClick: resetToRoutes });}// ── Custom tour: build a line from our own stops ─────────────//// Same review-then-approve lifecycle as scheduling one of our// lines; the difference is that there is no TourRoute behind it,// just an ordered pick of places from our catalogue. Stops are// chosen by name — from the list here or by tapping a pin on the// map — never by dropping an arbitrary coordinate, so every tour we// are asked for is one we actually cover. Only the ids travel to// the server; it reads the names and coordinates back off the// Location rows and snaps the road between them.function clearDraw() {if (drawLine) { map.geoObjects.remove(drawLine); drawLine = null; }drawPMs.forEach(function (pm) { map.geoObjects.remove(pm); });drawPMs = [];hideDrawBadge();}function showDrawBadge(text) {if (!drawBadge) {drawBadge = document.createElement('div');drawBadge.className = 'draw-badge';// Inside .map-app, or the map's event pane eats it (that// element opens a stacking context at z-index 9999).var host = el.sheet.parentNode;if (host) host.appendChild(drawBadge);}drawBadge.textContent = text;drawBadge.style.display = '';}function hideDrawBadge() {if (drawBadge) drawBadge.style.display = 'none';}/*** Repaint the line being drawn: a dashed polyline through the taps,* plus a numbered marker for each.*/function paintDraw() {clearDraw();if (!state.draw.length) return;var coords = state.draw.map(function (p) { return [p.lat, p.lng]; });if (coords.length > 1) {drawLine = new ymaps.Polyline(coords, {}, {strokeColor: TAXI.customColor,strokeWidth: 5,strokeOpacity: 0.9,strokeStyle: 'shortdash',zIndex: 690});map.geoObjects.add(drawLine);}state.draw.forEach(function (p, i) {var pm = new ymaps.Placemark([p.lat, p.lng], {iconContent: String(i + 1),hintContent: p.name || L('cust.stopN', { n: i + 1 })}, {preset: 'islands#circleIcon',iconColor: TAXI.customColor,zIndex: 700});drawPMs.push(pm);map.geoObjects.add(pm);});}function goToCustom() {state.step = STEP_CUSTOM;state.route = null;state.error = null;state.picker = ''; // the stop-search box starts emptyclearRoute();clearTuks();clearAmbient();// Our pins stay on: they ARE the choices, and tapping one adds// it to the tour.setPoiVisible(true);setPicking(true);paintDraw();render();snapTo(1);}/*** While picking, a tap on one of our map pins adds that place to the* tour instead of opening its guide. The bridge is a global because* the pin handlers are wired up in the page's own scope, outside* this sheet's closure.*/function setPicking(on) {window.TNT_PICK = window.TNT_PICK || {};window.TNT_PICK.active = !!on;window.TNT_PICK.pick = addStop;}function addStop(loc) {if (!loc || !loc.lat || !loc.lng) return;if (state.draw.length >= (TAXI.customMaxPts || 12)) {state.error = L('cust.tooMany', { n: TAXI.customMaxPts || 12 });render();return;}// The same place twice in a row is not a second stop.var last = state.draw[state.draw.length - 1];if (last && last.id === loc.id) return;state.draw.push({ id: loc.id, name: locName(loc), lat: loc.lat, lng: loc.lng });state.error = null;state.picker = '';paintDraw();render();}function renderCustom() {var min = TAXI.customMinPts || 2;var n = state.draw.length;var q = (state.picker || '').trim().toLowerCase();el.title.textContent = L('cust.title');el.sub.textContent = n? n + ' ' + L(n === 1 ? 'cust.stop' : 'cust.stops'): L('cust.pickToStart');showDrawBadge(n < min ? L('cust.badgeNeed', { n: min - n }) : L('cust.badgeOk'));var html = errorHtml() +'<div class="draw-hint">' + esc(n ? L('cust.hintShort') : L('cust.hint')) + '</div>';// The tour so far, in visiting order.if (n) {html += '<ul class="draw-list" style="--cust:' + esc(TAXI.customColor) + '">';state.draw.forEach(function (p, i) {html +='<li class="draw-item">' +'<span class="draw-dot">' + (i + 1) + '</span>' +'<span class="draw-name">' + esc(p.name) + '</span>' +'<button type="button" class="draw-del" data-drop="' + i + '" aria-label="' + esc(L('cust.remove')) + '">✕</button>' +'</li>';});html += '</ul>';html +='<div class="draw-acts">' +'<button type="button" id="dr-undo">↩ ' + esc(L('cust.undo')) + '</button>' +'<button type="button" id="dr-clear">' + esc(L('cust.clear')) + '</button>' +'</div>';}// Places to add, filtered by the search box.html +='<div class="pick-search">' +'<input type="text" id="cu-search" class="sched-input" autocomplete="off"' +' placeholder="' + esc(L('cust.searchPh')) + '" value="' + esc(state.picker || '') + '">' +'</div>';var matches = pickableStops(q);if (!matches.length) {html += '<div class="pick-empty">' + esc(L('cust.noMatches')) + '</div>';} else {html += '<ul class="pick-list">';matches.forEach(function (loc) {html +='<li><button type="button" class="pick-item" data-add="' + loc.id + '">' +'<span class="pick-name">' + esc(locName(loc)) + '</span>' +(loc.category ? '<span class="pick-cat">' + esc(loc.category) + '</span>' : '') +'<span class="pick-plus">+</span>' +'</button></li>';});html += '</ul>';}el.body.innerHTML = html;el.body.querySelectorAll('[data-drop]').forEach(function (b) {b.addEventListener('click', function () {state.draw.splice(parseInt(b.dataset.drop, 10), 1);paintDraw();render();});});el.body.querySelectorAll('[data-add]').forEach(function (b) {b.addEventListener('click', function () {addStop(locById(parseInt(b.dataset.add, 10)));});});var undo = document.getElementById('dr-undo');if (undo) undo.addEventListener('click', function () {state.draw.pop();paintDraw();render();});var clear = document.getElementById('dr-clear');if (clear) clear.addEventListener('click', function () {state.draw = [];paintDraw();render();});// Filter as they type. Re-rendering the whole sheet would steal// focus mid-word, so only the list below the box is redrawn.var search = document.getElementById('cu-search');if (search) {search.addEventListener('input', function () {state.picker = search.value;refreshPickList();});}setFoot(L('cust.continue'), {disabled: n < min,onClick: goToCustomForm});}function locById(id) {if (typeof LOCS === 'undefined') return null;for (var i = 0; i < LOCS.length; i++) {if (LOCS[i].id === id) return LOCS[i];}return null;}/*** Our mappable places, minus the ones already in the tour, matched* against the search box in whichever language they are shown in.*/function pickableStops(q) {if (typeof LOCS === 'undefined') return [];var chosen = {};state.draw.forEach(function (p) { chosen[p.id] = true; });return LOCS.filter(function (loc) {if (!loc.lat || !loc.lng || chosen[loc.id]) return false;if (!q) return true;return (locName(loc) + ' ' + (loc.address || '') + ' ' + (loc.category || '')).toLowerCase().indexOf(q) !== -1;}).sort(function (a, b) {return locName(a).localeCompare(locName(b));});}function refreshPickList() {var list = el.body.querySelector('.pick-list');var none = el.body.querySelector('.pick-empty');var q = (state.picker || '').trim().toLowerCase();var matches = pickableStops(q);if (!matches.length) {if (list) list.remove();if (!none) {el.body.insertAdjacentHTML('beforeend','<div class="pick-empty">' + esc(L('cust.noMatches')) + '</div>');}return;}if (none) none.remove();var html = matches.map(function (loc) {return '<li><button type="button" class="pick-item" data-add="' + loc.id + '">' +'<span class="pick-name">' + esc(locName(loc)) + '</span>' +(loc.category ? '<span class="pick-cat">' + esc(loc.category) + '</span>' : '') +'<span class="pick-plus">+</span>' +'</button></li>';}).join('');if (list) {list.innerHTML = html;} else {el.body.insertAdjacentHTML('beforeend', '<ul class="pick-list">' + html + '</ul>');list = el.body.querySelector('.pick-list');}list.querySelectorAll('[data-add]').forEach(function (b) {b.addEventListener('click', function () {addStop(locById(parseInt(b.dataset.add, 10)));});});}function goToCustomForm() {state.step = STEP_CUSTOM_FORM;state.error = null;hideDrawBadge();setPicking(false); // pins go back to opening their guiderender();snapTo(1);}function renderCustomForm() {var c = state.custom || {};var today = ymd(new Date());var defDate = c.date || ymd(new Date(Date.now() + 864e5));var defTime = c.time || '10:00';el.title.textContent = L('cust.formTitle');el.sub.textContent = state.draw.length + ' ' + L('cust.stops');el.body.innerHTML = errorHtml() +'<div class="sched-field"><label>' + esc(L('cust.name')) + '</label>' +'<input type="text" id="cu-title" class="sched-input" maxlength="120" placeholder="' + esc(L('cust.namePh')) + '" value="' + esc(c.title || '') + '"></div>' +'<div class="sched-row">' +'<div class="sched-field"><label>' + esc(L('sched.date')) + '</label>' +'<input type="date" id="cu-date" class="sched-input" value="' + esc(defDate) + '" min="' + esc(today) + '"></div>' +'<div class="sched-field"><label>' + esc(L('sched.time')) + '</label>' +'<input type="time" id="cu-time" class="sched-input" value="' + esc(defTime) + '"></div>' +'</div>' +customPaxHtml() +'<div class="sched-field" style="margin-top:10px"><label>' + esc(L('sched.pickup')) + '</label>' +'<input type="text" id="cu-pickup" class="sched-input" placeholder="' + esc(L('sched.pickupPh')) + '" value="' + esc(c.pickup || '') + '"></div>' +'<div class="sched-field"><label>' + esc(L('sched.note')) + '</label>' +'<textarea id="cu-note" class="sched-input" placeholder="' + esc(L('cust.notePh')) + '">' + esc(c.note || '') + '</textarea></div>' +'<div class="sched-note">' + esc(L('cust.priceHint')) + '</div>';wirePax(el.body);setFoot(state.busy ? L('sched.submitting') : L('sched.submit'), {disabled: state.busy,onClick: placeCustomReservation});}/*** Party size for a custom tour: the count, and nothing else.** No price is shown anywhere in this flow. A tour we have never run* before cannot be quoted from a table — the back office works it* out from the stops and the day, and the passenger gets the figure* by email when the request is approved. Showing a table price here* would be a promise we might not keep.*/function customPaxHtml() {return '' +'<div class="pax">' +'<div class="pax-lbl">' + esc(L('pax.title')) +'<small>' + esc(L('pax.hint', { n: maxPax() })) + '</small>' +'</div>' +'<div class="pax-ctl">' +'<button type="button" class="pax-btn" data-pax-step="-1"' +(state.passengers <= 1 ? ' disabled' : '') +' aria-label="' + esc(L('pax.fewer')) + '">−</button>' +'<span class="pax-n">' + state.passengers + '</span>' +'<button type="button" class="pax-btn" data-pax-step="1"' +(state.passengers >= maxPax() ? ' disabled' : '') +' aria-label="' + esc(L('pax.more')) + '">+</button>' +'</div>' +'</div>';}function placeCustomReservation() {if (state.busy) return;if (!TAXI.isLoggedIn) { window.location.href = TAXI.loginUrl; return; }captureForms();var c = state.custom || {};if (!c.date || !c.time) { state.error = L('sched.needDate'); render(); return; }state.busy = true;state.error = null;render();api(TAXI.customUrl, {method: 'POST',body: {// Ids only: the server reads each stop's name and// coordinates off the Location row itself.locationIds: state.draw.map(function (p) { return p.id; }),title: c.title,scheduledAt: c.date + 'T' + c.time,passengers: state.passengers,pickupAddress: c.pickup,note: c.note}}).then(function (data) {state.busy = false;state.reservation = data.reservation;state.custom = null;state.draw = [];state.step = STEP_SCHEDULE_DONE;clearDraw();// Paint the snapped road back, so the passenger sees the// actual streets their tour will follow.paintReservation(data.reservation);render();snapTo(1);}).catch(function (e) {state.busy = false;state.error = e.message;render();});}// ── Viewing a scheduled tour (from /map?res=REF) ─────────────/*** Paint a reservation's line. A custom one carries its own snapped* geometry; one of our lines is already in state.routes.*/function paintReservation(rv) {if (!rv) return;if (rv.kind === 'custom' && rv.custom) {var pts = rv.custom.points || [];if (!pts.length) return;paintRoute({color: TAXI.customColor,geometry: rv.custom.geometry,points: pts.map(function (p, i) {return { id: null, lat: p.lat, lng: p.lng, name: p.label || L('cust.stopN', { n: i + 1 }) };})});return;}if (rv.route) {var full = state.routes.filter(function (r) { return r.id === rv.route.id; })[0];if (full) paintRoute(full);}}function renderReservation() {var rv = state.reservation || {};var pax = rv.passengers || 1;var when = rv.scheduledAt ? new Date(rv.scheduledAt) : null;el.title.textContent = rv.tourName || L('cust.title');el.sub.textContent = rv.reference || '';var statusTxt = L('res.' + rv.status);if (statusTxt === 'res.' + rv.status) statusTxt = rv.statusLabel;var html = errorHtml() +'<div class="ord-status">' +'<span class="ord-pulse"></span>' +'<span class="ord-txt">' + esc(statusTxt) +'<span class="ord-ref">' + esc(rv.reference || '') + '</span>' +'</span>' +'</div>' +'<div style="margin-top:8px">' +'<div class="sum-row"><span class="sum-k">' + esc(L('sched.when')) + '</span><span class="sum-v">' +esc(when ? when.toLocaleDateString() + ' · ' + when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '') +'</span></div>' +'<div class="sum-row"><span class="sum-k">' + esc(L('pax.title')) + '</span><span class="sum-v">' + pax + ' ' + esc(paxWord(pax)) + '</span></div>' +(rv.pickupAddress? '<div class="sum-row"><span class="sum-k">' + esc(L('sched.pickup')) + '</span><span class="sum-v">' + esc(rv.pickupAddress) + '</span></div>': '') +// Until it is approved a custom tour has no agreed// price, so quoting one here would be misleading.(rv.kind === 'custom' && rv.status !== 'approved'? '<div class="sched-note" style="margin-top:10px">' + esc(L('cust.priceLater')) + '</div>': '<div class="sum-row"><span class="sum-k">' + esc(L('sheet.total')) + '</span><span class="sum-v big">' + esc(amd(rv.priceAmd || 0)) + '</span></div>') +'</div>';el.body.innerHTML = html;setFoot(L('sheet.bookAnother'), { onClick: resetToRoutes });}function renderTuks() {el.title.textContent = L('sheet.chooseTuk');if (state.busy) {el.sub.textContent = '';el.body.innerHTML = '<div class="sheet-msg"><div class="spin"></div>' + esc(L('sheet.finding')) + '</div>';setFoot(null);return;}el.sub.textContent = state.tuks.length? L('sheet.availableNow', { n: state.tuks.length }): '';if (!state.tuks.length) {el.body.innerHTML = errorHtml() +'<div class="sheet-msg">' +L('sheet.noTuksOnline') +'</div>';setFoot(L('sheet.retry'), { ghost: true, onClick: goToTuks });return;}var html = errorHtml();state.tuks.forEach(function (t) {var d = t.driver;html +='<button type="button" class="tuk-card' + (state.tuk && state.tuk.id === t.id ? ' active' : '') + '"' +' style="--tuk-color:' + esc(t.color) + '" data-tuk="' + t.id + '">' +'<div class="tuk-avatar">' + esc(initials(d ? d.name : t.code)) + '</div>' +'<div class="tuk-main">' +'<div class="tuk-name">' + esc(d ? d.name : t.code) + '</div>' +'<div class="tuk-meta">' +(d ? '<span class="tuk-star">★</span><span>' + d.rating.toFixed(1) + '</span><span class="dot-sep"></span>' : '') +'<span>' + esc(t.code) + '</span>' +'<span class="dot-sep"></span>' +'<span>' + t.seats + ' ' + esc(L('sheet.seats')) + '</span>' +'</div>' +'</div>' +'<div class="tuk-eta">' +(t.eta !== null? '<div class="tuk-eta-val">' + t.eta + ' ' + esc(L('sheet.min')) + '</div>' +'<div class="tuk-eta-lbl">' + esc(metres(t.distance)) + '</div>': '<div class="tuk-eta-lbl">' + esc(L('sheet.nearby')) + '</div>') +'</div>' +'</button>';});el.body.innerHTML = html;el.body.querySelectorAll('.tuk-card').forEach(function (btn) {btn.addEventListener('click', function () {var id = parseInt(btn.dataset.tuk, 10);var t = state.tuks.filter(function (x) { return x.id === id; })[0];if (t) selectTuk(t);});});setFoot(L('sheet.continuePay'), {disabled: !state.tuk,onClick: goToPay});}function renderPay() {el.title.textContent = L('sheet.payment');el.sub.textContent = state.tuk && state.tuk.driver ? state.tuk.driver.name + ' · ' + state.tuk.code : '';var html = errorHtml();// Signed out? Take the three details the order needs and let the// server make the account. Nothing here blocks the booking: the// visitor types name, email and phone and orders in the same tap.if (!TAXI.isLoggedIn) {var g = state.guest || {};html +='<div class="guest-box">' +'<div class="guest-lbl">' + esc(L('guest.title')) +'<small>' + esc(L('guest.hint')) + '</small>' +'</div>' +'<div class="sched-field"><label for="gu-name">' + esc(L('auth.fullName')) + '</label>' +'<input type="text" id="gu-name" class="sched-input" autocomplete="name"' +' placeholder="' + esc(L('guest.namePh')) + '" value="' + esc(g.name || '') + '"></div>' +'<div class="sched-field"><label for="gu-email">' + esc(L('auth.email')) + '</label>' +'<input type="email" id="gu-email" class="sched-input" autocomplete="email"' +' placeholder="you@example.com" value="' + esc(g.email || '') + '"></div>' +'<div class="sched-field"><label for="gu-phone">' + esc(L('auth.phone')) + '</label>' +'<input type="tel" id="gu-phone" class="sched-input" autocomplete="tel"' +' placeholder="+374 XX XXXXXX" value="' + esc(g.phone || '') + '"></div>' +// Only shown once the server says so, so an ordinary// typo never nags anyone about signing in.(state.accountExists? '<button type="button" class="guest-signin" id="gu-signin">' +esc(L('guest.signinInstead')) +'</button>': '<div class="guest-alt">' + esc(L('guest.haveAccount')) +' <a href="#" id="gu-signin">' + esc(L('nav.signin')) + '</a></div>') +'</div>';}// Cashhtml +='<button type="button" class="pay-opt' + (state.pay === 'cash' ? ' active' : '') + '" data-pay="cash">' +'<span class="pay-ico">💵</span>' +'<span class="pay-main">' +'<span class="pay-lbl">' + esc(L('pay.cash')) + '</span>' +'<span class="pay-sub">' + esc(L('pay.cashSub')) + '</span>' +'</span>' +'<span class="pay-tick"></span>' +'</button>';// Saved cardsstate.cards.forEach(function (c) {var on = state.pay === 'card' && state.card && state.card.id === c.id;html +='<button type="button" class="pay-opt' + (on ? ' active' : '') + (c.expired ? ' expired' : '') + '"' +' data-pay="card" data-card="' + c.id + '"' + (c.expired ? ' disabled' : '') + '>' +'<span class="pay-ico">💳</span>' +'<span class="pay-main">' +'<span class="pay-lbl">' + esc(c.label + ' ' + c.masked) + '</span>' +'<span class="pay-sub">' + (c.expired ? esc(L('acc.expired')) : esc(L('acc.expires')) + ' ' + String(c.expMonth).padStart(2, '0') + '/' + String(c.expYear).slice(-2)) + '</span>' +'</span>' +'<span class="pay-tick"></span>' +'</button>';});// Pay by card with a card that is not on the account yet. This is// the only card option a guest has; a signed-in passenger gets it// under their saved ones as "use another card".var newCardOn = state.pay === 'card' && !state.card;var nc = state.newCard || {};html +='<button type="button" class="pay-opt' + (newCardOn ? ' active' : '') + '" data-pay="card" data-card="new">' +'<span class="pay-ico">💳</span>' +'<span class="pay-main">' +'<span class="pay-lbl">' + esc(state.cards.length ? L('pay.anotherCard') : L('pay.card')) + '</span>' +'<span class="pay-sub">' + esc(L('pay.cardSub')) + '</span>' +'</span>' +'<span class="pay-tick"></span>' +'</button>';if (newCardOn) {html +='<div class="card-box">' +'<div class="sched-field"><label for="cd-number">' + esc(L('acc.cardNumber')) + '</label>' +'<input type="text" id="cd-number" class="sched-input" inputmode="numeric" autocomplete="cc-number"' +' placeholder="4242 4242 4242 4242" value="' + esc(nc.number || '') + '"></div>' +'<div class="sched-row">' +'<div class="sched-field"><label for="cd-exp">' + esc(L('pay.expiry')) + '</label>' +'<input type="text" id="cd-exp" class="sched-input" inputmode="numeric" autocomplete="cc-exp"' +' placeholder="MM/YY" maxlength="5" value="' + esc(nc.exp || '') + '"></div>' +'<div class="sched-field"><label for="cd-holder">' + esc(L('acc.holder')) + '</label>' +'<input type="text" id="cd-holder" class="sched-input" autocomplete="cc-name"' +' placeholder="AS ON CARD" value="' + esc(nc.holder || '') + '"></div>' +'</div>' +// Says what the app actually does today: no gateway is// wired up, so the fare is settled with the driver and// only the brand and last four digits are kept.'<div class="sched-note">' + esc(L('pay.cardNote')) + '</div>' +'</div>';}// Summaryhtml +='<div style="margin-top:14px">' +'<div class="sum-row"><span class="sum-k">' + esc(L('sum.route')) + '</span><span class="sum-v">' + esc(tr(state.route)) + '</span></div>' +'<div class="sum-row"><span class="sum-k">' + esc(L('sum.duration')) + '</span><span class="sum-v">' + esc(mins(state.route.durationMinutes)) + '</span></div>' +'<div class="sum-row"><span class="sum-k">' + esc(L('sum.tuk')) + '</span><span class="sum-v">' + esc(state.tuk.code) + (state.tuk.eta !== null ? ' · ' + esc(L('sheet.minAway', { n: state.tuk.eta })) : '') + '</span></div>' +'<div class="sum-row"><span class="sum-k">' + esc(L('pax.title')) + '</span><span class="sum-v">' + state.passengers + ' ' + esc(paxWord(state.passengers)) + '</span></div>' +'<div class="sum-row"><span class="sum-k">' + esc(L('sheet.total')) + '</span><span class="sum-v big">' + esc(amd(currentPrice())) + '</span></div>' +'</div>';el.body.innerHTML = html;el.body.querySelectorAll('.pay-opt').forEach(function (btn) {if (btn.disabled) return;btn.addEventListener('click', function () {// Switching option re-renders, which throws the inputs// away — keep what has been typed first.captureForms();state.pay = btn.dataset.pay;if (state.pay === 'card') {var id = parseInt(btn.dataset.card, 10);state.card = state.cards.filter(function (c) { return c.id === id; })[0] || null;} else {state.card = null;}state.error = null;render();});});var signin = document.getElementById('gu-signin');if (signin) {signin.addEventListener('click', function (e) {e.preventDefault();captureForms();goToLogin();});}setFoot(state.busy ? L('sheet.ordering') : L('sheet.orderTuk'), {price: amd(currentPrice()),disabled: state.busy,onClick: placeOrder});}/*** Off to the sign-in page, leaving a note to ourselves about the tour* being ordered so coming back does not mean starting over. Used by* the guest form's "I already have an account" way out.*//*** The other end of goToLogin(): put the visitor back on the tour they* were ordering. Returns true when it took over the boot sequence.** It stops at the tuk list rather than the payment step on purpose —* whichever tuk was free before the detour may not be free now, so* the list is re-fetched and they pick again.*/function resumePendingOrder() {var raw = null;try {raw = sessionStorage.getItem('tnt.pendingOrder');sessionStorage.removeItem('tnt.pendingOrder');} catch (e) {return false;}if (!raw) return false;var saved;try { saved = JSON.parse(raw); } catch (e) { return false; }var route = state.routes.filter(function (r) { return r.id === saved.routeId; })[0];if (!route) return false;// Set before selectRoute, which does not touch the party size.state.passengers = Math.max(1, Math.min(saved.passengers || 1, maxPax()));selectRoute(route);goToTuks();return true;}function goToLogin() {try {sessionStorage.setItem('tnt.pendingOrder', JSON.stringify({routeId: state.route ? state.route.id : null,passengers: state.passengers}));} catch (e) {// Private mode, quota, a browser that says no — the sign-in// still has to happen, they just land on the tour list.}window.location.href = TAXI.loginUrl;}function renderActive() {var b = state.booking;el.title.textContent = L('sheet.yourTuk');el.sub.textContent = tr(b.route);var d = b.driver;var t = b.tuk;// Translate the status client-side from booking.status rather than// trusting the server's English statusLabel.var statusTxt = L('status.' + b.status);if (statusTxt === 'status.' + b.status) statusTxt = b.statusLabel;var html =errorHtml() +'<div class="ord-status">' +'<span class="ord-pulse"></span>' +'<span class="ord-txt">' + esc(statusTxt) +'<span class="ord-ref">' + esc(b.reference) + '</span>' +'</span>' +'</div>';if (d) {html +='<div class="tuk-card" style="--tuk-color:' + esc(t ? t.color : '#E8A020') + '; cursor:default">' +'<div class="tuk-avatar">' + esc(initials(d.name)) + '</div>' +'<div class="tuk-main">' +'<div class="tuk-name">' + esc(d.name) + '</div>' +'<div class="tuk-meta">' +'<span class="tuk-star">★</span><span>' + d.rating.toFixed(1) + '</span>' +'<span class="dot-sep"></span>' +'<span>' + esc(t ? t.code : '') + '</span>' +(t && t.plate ? '<span class="dot-sep"></span><span>' + esc(t.plate) + '</span>' : '') +'</div>' +'</div>' +'</div>';// Contact is in-system only — chat and the in-app voice// call. The driver's phone number is never shown and the// old tel: fallback is gone. `d.phone` is still the gate:// the server releases it over exactly the window in which// the comm channel is open (accepted → in_progress).if (d.phone && window.TNT_COMM) {html += TNT_COMM.actionsHtml();}}html +='<div style="margin-top:6px">' +'<div class="sum-row"><span class="sum-k">' + esc(L('sheet.payment')) + '</span><span class="sum-v">' +(b.paymentMethod === 'cash' ? esc(L('pay.cash')) : esc(b.card ? b.card.label + ' ' + b.card.masked : L('pay.card'))) +'</span></div>' +'<div class="sum-row"><span class="sum-k">' + esc(L('sheet.total')) + '</span><span class="sum-v big">' + esc(amd(b.priceAmd)) + '</span></div>' +'</div>';// After a completed tour, invite a rating and a tip (each once).if (b.status === 'completed') {html += ratingHtml(b, d);html += tipHtml(b, d);}el.body.innerHTML = html;wireTip(el.body);if (window.TNT_COMM) TNT_COMM.wireActions(el.body);// Wire the interactive stars, if the widget is showing.var stars = el.body.querySelectorAll('.rate-star');stars.forEach(function (s) {s.addEventListener('click', function () {state.ratingPending = parseInt(s.dataset.v, 10);paintStars();});s.addEventListener('mouseenter', function () { paintStars(parseInt(s.dataset.v, 10)); });});if (stars.length) {var wrap = el.body.querySelector('.rate-stars');if (wrap) wrap.addEventListener('mouseleave', function () { paintStars(); });}var skip = el.body.querySelector('.rate-skip');if (skip) skip.addEventListener('click', resetToRoutes);refreshActiveFoot();}/*** The live order's footer button, which changes with the order's* state: cancel while it can still be called off, then rate, then* tip, then "book another". Split out of renderActive so the tip* input can refresh the button without re-rendering (and stealing* focus from) the field being typed into.*/function refreshActiveFoot() {var b = state.booking;if (!b) { setFoot(null); return; }if (b.cancellable) {setFoot(state.busy ? L('sheet.cancelling') : L('sheet.cancelOrder'), {ghost: true,disabled: state.busy,onClick: cancelOrder});return;}if (b.status === 'completed' && b.rateable) {setFoot(state.busy ? L('rate.sending') : L('rate.submit'), {disabled: state.busy || state.ratingPending < 1,onClick: submitRating});return;}// Rated (or rating skipped) and not yet tipped: offer the tip.if (b.status === 'completed' && b.tippable && !state.tipDone) {setFoot(state.busy ? L('tip.sending') : L('tip.submit'), {price: state.tipPending > 0 ? amd(state.tipPending) : null,disabled: state.busy || state.tipPending < (TAXI.tipMin || 1),onClick: submitTip});return;}if (b.status === 'completed' || b.status === 'cancelled') {setFoot(L('sheet.bookAnother'), { onClick: resetToRoutes });return;}setFoot(null);}/*** The post-tour rating block: interactive stars while the tour is* unrated, a read-back once a score has been given.*/function ratingHtml(b, d) {var name = d ? d.name.split(' ')[0] : null;if (b.rating) {var filled = '';for (var i = 1; i <= 5; i++) {filled += '<span class="rate-star' + (i <= b.rating ? ' on' : '') + '">★</span>';}var thanks = name ? L('rate.thanks', { name: name }) : L('rate.thanksGeneric');return '<div class="rate-box done">' +'<div class="rate-q">' + esc(thanks) + '</div>' +'<div class="rate-stars readonly">' + filled + '</div>' +'</div>';}// Render the pending choice into the markup rather than relying// on paintStars() to light it afterwards: anything that// re-renders the sheet — submitting, a poll landing, tapping a// tip amount — rebuilds this block, and stars painted only by// the event handler would silently go dark each time.var stars = '';for (var j = 1; j <= 5; j++) {stars += '<span class="rate-star' + (j <= state.ratingPending ? ' on' : '') +'" data-v="' + j + '" role="button" tabindex="0">★</span>';}var q = name ? L('rate.q', { name: name }) : L('rate.qGeneric');return '<div class="rate-box">' +'<div class="rate-q">' + esc(q) + '</div>' +'<div class="rate-stars">' + stars + '</div>' +'<button type="button" class="rate-skip">' + esc(L('rate.skip')) + '</button>' +'</div>';}/*** The post-tour tip block: preset amounts plus a free entry while* the tour is untipped, a thank-you read-back once one is given.** The tip is a separate, optional gesture from the fare — it is* never rolled into the total the passenger was quoted, and the* amount is re-checked server-side before it is recorded.*/function tipHtml(b, d) {var name = d ? d.name.split(' ')[0] : null;if (b.tipAmd) {return '<div class="tip-box done">' +'<div class="tip-q">' + esc(name ? L('tip.gave', { name: name }) : L('tip.gaveGeneric')) + '</div>' +'<div class="tip-amount">' + esc(amd(b.tipAmd)) + '</div>' +'</div>';}// Dismissed for this order — do not nag.if (state.tipDone) return '';var presets = b.tipPresets || TAXI.tipPresets || [];var opts = '';presets.forEach(function (v) {opts += '<button type="button" class="tip-opt' + (state.tipPending === v ? ' on' : '') +'" data-tip="' + v + '">' + esc(amd(v)) + '</button>';});return '<div class="tip-box">' +'<div class="tip-q">' + esc(name ? L('tip.q', { name: name }) : L('tip.qGeneric')) + '</div>' +'<div class="tip-opts">' + opts + '</div>' +'<div class="tip-custom">' +'<input type="number" id="tip-other" inputmode="numeric" min="' + (TAXI.tipMin || 0) +'" max="' + (TAXI.tipMax || 0) + '" placeholder="' + esc(L('tip.other')) + '"' +(state.tipPending && presets.indexOf(state.tipPending) === -1 ? ' value="' + state.tipPending + '"' : '') + '>' +'</div>' +'<button type="button" class="tip-skip">' + esc(L('tip.skip')) + '</button>' +'</div>';}function wireTip(root) {root.querySelectorAll('[data-tip]').forEach(function (b) {b.addEventListener('click', function () {state.tipPending = parseInt(b.dataset.tip, 10);var other = document.getElementById('tip-other');if (other) other.value = '';render();});});var other = root.querySelector('#tip-other');if (other) {// Typed amounts must not re-render on every keystroke (the// input would lose focus), so only the footer is refreshed.other.addEventListener('input', function () {state.tipPending = parseInt(other.value, 10) || 0;root.querySelectorAll('[data-tip]').forEach(function (b) { b.classList.remove('on'); });refreshActiveFoot();});}var skip = root.querySelector('.tip-skip');if (skip) {skip.addEventListener('click', function () {state.tipDone = true;state.tipPending = 0;render();});}}function submitTip() {var min = TAXI.tipMin || 0;var max = TAXI.tipMax || 0;if (state.busy || !state.booking) return;if (state.tipPending < min || state.tipPending > max) {state.error = L('tip.range', { min: amd(min), max: amd(max) });render();return;}state.busy = true;state.error = null;render();api(TAXI.tipUrlTpl.replace('__REF__', encodeURIComponent(state.booking.reference)), {method: 'POST',// Card tips would need the card picker; cash keeps the// gesture one tap, matching how the fare is settled today.body: { amountAmd: state.tipPending, method: 'cash' }}).then(function (data) {state.busy = false;state.booking = data.booking;state.tipPending = 0;render();}).catch(function (e) {state.busy = false;state.error = e.message;render();});}// Repaint the stars to reflect a hover value or the pending choice.function paintStars(hover) {var v = hover || state.ratingPending;el.body.querySelectorAll('.rate-stars:not(.readonly) .rate-star').forEach(function (s) {s.classList.toggle('on', parseInt(s.dataset.v, 10) <= v);});}function submitRating() {if (state.busy || !state.booking || state.ratingPending < 1) return;state.busy = true;state.error = null;render();api(TAXI.rateUrlTpl.replace('__REF__', encodeURIComponent(state.booking.reference)), {method: 'POST',body: { rating: state.ratingPending }}).then(function (data) {state.busy = false;state.booking = data.booking;state.ratingPending = 0;render();}).catch(function (e) {state.busy = false;state.error = e.message;render();});}// ── Transitions ──────────────────────────────────────────────function selectRoute(route) {state.route = route;state.tuk = null;state.error = null;state.step = STEP_ROUTE;clearTuks();clearAmbient(); // hide the ambient fleet during the bookingsetPoiVisible(false); // let the road be the only thing on the mappaintRoute(route);render();snapTo(1);}function selectTuk(tuk) {state.tuk = tuk;state.error = null;// Re-colour the markers so the map shows the selection too.paintTuks(state.tuks);if (state.step === STEP_TUKS) render();}function goToTuks() {state.step = STEP_TUKS;state.busy = true;state.error = null;render();// Ask for the tuks nearest the passenger; without GPS, fall back// to the route's first stop, which is where the tour begins.locate().then(function (pos) {var origin = pos || firstStop();var url = TAXI.tuksUrl;if (origin) {url += '?lat=' + encodeURIComponent(origin[0]) + '&lng=' + encodeURIComponent(origin[1]);}return api(url);}).then(function (data) {state.tuks = data.tuks || [];state.busy = false;// Pre-select the closest, the way a taxi app does.if (state.tuks.length && !state.tuk) state.tuk = state.tuks[0];paintTuks(state.tuks);render();}).catch(function (e) {state.busy = false;state.tuks = [];state.error = e.message;render();});}function goToPay() {if (!state.tuk) return;state.step = STEP_PAY;state.error = null;render();// A signed-out visitor has no saved cards to fetch — they type// theirs into the step itself.if (!TAXI.isLoggedIn || state.cards.length) return;// Load saved cards so they can be picked alongside cash.api(TAXI.cardsUrl).then(function (data) {state.cards = data.cards || [];var def = state.cards.filter(function (c) { return c.default && !c.expired; })[0];if (def) { state.pay = 'card'; state.card = def; }if (state.step === STEP_PAY) render();}).catch(function () {// Cards are optional — cash still works, so stay quiet.});}function placeOrder() {if (state.busy) return;// Whatever is typed into the payment step has to survive both the// busy re-render below and any error that sends us back here.captureForms();var body = {routeId: state.route.id,tukId: state.tuk.id,paymentMethod: state.pay,cardId: state.card ? state.card.id : null,passengers: state.passengers};// Guest checkout: the server registers and signs them in as part// of placing the order. Checked here first so an empty field costs// a message rather than a round-trip.if (!TAXI.isLoggedIn) {var g = state.guest || {};if (!String(g.name || '').trim() ||!String(g.email || '').trim() ||!String(g.phone || '').trim()) {state.error = L('guest.needAll');render();return;}if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(g.email).trim())) {state.error = L('guest.badEmail');render();return;}body.guest = { name: g.name, email: g.email, phone: g.phone };}// A card that is not saved on the account yet — the only kind a// guest can have. The number never touches our storage: the// server keeps the brand and last four digits and forgets it.if (state.pay === 'card' && !state.card) {var card = parseCardFields(state.newCard || {});if (!card) {state.error = L('pay.badCard');render();return;}body.card = card;}state.busy = true;state.error = null;render();var origin = state.userPos || firstStop();body.pickupLat = origin ? origin[0] : null;body.pickupLng = origin ? origin[1] : null;api(TAXI.createUrl, {method: 'POST',body: body}).then(function (data) {state.busy = false;state.booking = data.booking;state.step = STEP_ACTIVE;// Registered and signed in on the way through. From here the// sheet behaves exactly as it does for anyone else.if (data.account && data.account.created) {onGuestRegistered(data.account);}state.guest = null;state.newCard = null;clearTuks();render();snapTo(1);startTracking();}).catch(function (e) {state.busy = false;state.error = e.message;// The email is already an account: nothing to fix in the form,// so offer the way in instead.if (/already uses this email/i.test(e.message)) {state.accountExists = true;render();return;}// "Tuk was just taken" — send them back to pick another.if (/just taken|not found/i.test(e.message)) {state.tuk = null;goToTuks();return;}render();});}/*** Turn the checkout card fields into what /api/bookings expects, or* null when they are not filled in well enough to bother sending.* Deliberately forgiving about spacing and about "MM/YY" vs "MM/YYYY";* the real validation (Luhn, expiry) belongs to the server.*/function parseCardFields(nc) {var number = String(nc.number || '').replace(/\D/g, '');var exp = String(nc.exp || '').trim();var m = exp.match(/^(\d{1,2})\s*[\/\-.]?\s*(\d{2}|\d{4})$/);if (number.length < 12 || !m) return null;return {number: number,expMonth: parseInt(m[1], 10),expYear: parseInt(m[2], 10),holderName: String(nc.holder || '').trim()};}/*** The account the server just made for a guest. Everything the sheet* gates on `isLoggedIn` — polling, the chat channel, the account link* in the header — is switched on here rather than after a reload, so* the order they just placed keeps working in this same page.*/var commStarted = TAXI.isLoggedIn;function onGuestRegistered(account) {// Signing in gave the session a new CSRF token; the one this// page was rendered with is dead. Swap it in before anything// else, or the next POST from this page is a 403.if (account.csrf) TAXI.csrf = account.csrf;if (account.signedIn === false) return;TAXI.isLoggedIn = true;state.accountExists = false;// Chat and calls with the driver, which boot skipped for a// logged-out visitor. Push (Mercure) needs a cookie only issued// on a page render, so this session polls instead — which is// exactly what the module falls back to.if (window.TNT_COMM && !commStarted) {commStarted = true;TNT_COMM.init({base: TAXI.commBase,iceUrl: TAXI.commIceUrl,csrf: TAXI.csrf,role: 'passenger',mount: document.querySelector('.map-app')});}// Swap "Sign in / Register" in the header for the account they// now have, so the page stops contradicting itself.var auth = document.querySelector('.map-auth');if (auth) {auth.innerHTML ='<a href="' + esc(TAXI.accountUrl) + '" class="auth-acct" title="' + esc(L('nav.myAccount')) + '">' +'<span class="auth-avatar">' + esc(initials(state.guest && state.guest.name)) + '</span>' +'<span class="auth-name">' + esc(String((state.guest && state.guest.name) || '').split(' ')[0]) + '</span>' +'</a>' +'<a href="' + esc(TAXI.logoutUrl) + '" class="auth-out" title="' + esc(L('nav.signout')) + '">⎋</a>';}}function cancelOrder() {if (state.busy || !state.booking) return;state.busy = true;render();api(TAXI.cancelUrlTpl.replace('__REF__', encodeURIComponent(state.booking.reference)), {method: 'POST',body: { reason: 'Cancelled by passenger' }}).then(function (data) {state.busy = false;state.booking = data.booking;stopPolling();stopLive();render();}).catch(function (e) {state.busy = false;state.error = e.message;render();});}function resetToRoutes() {stopPolling();stopLive();state.step = STEP_ROUTES;state.route = null;state.tuk = null;state.tuks = [];state.booking = null;state.error = null;// Start the next booking from a clean slate rather than// inheriting the last party size, tip or half-drawn line.state.passengers = 1;state.tipPending = 0;state.tipDone = false;state.draw = [];state.picker = '';state.custom = null;state.sched = null;state.reservation = null;setPicking(false);clearRoute();clearTuks();clearDraw();setPoiVisible(true); // back to browsing every locationrefreshAmbient(); // and the live fleet comes backrender();snapTo(1);}function goBack() {state.error = null;if (state.step === STEP_ROUTE) { resetToRoutes(); return; }if (state.step === STEP_TUKS) { state.step = STEP_ROUTE; clearTuks(); render(); return; }if (state.step === STEP_PAY) { state.step = STEP_TUKS; paintTuks(state.tuks); render(); return; }if (state.step === STEP_SCHEDULE) { state.step = STEP_ROUTE; render(); return; }if (state.step === STEP_CUSTOM) { resetToRoutes(); return; }// Back out of the custom form to the stop picker, keeping the// stops already chosen.if (state.step === STEP_CUSTOM_FORM) {captureForms();state.step = STEP_CUSTOM;setPicking(true);setPoiVisible(true);paintDraw();render();snapTo(1);return;}}function firstStop() {if (!state.route || !state.route.points.length) return null;var p = state.route.points[0];return [p.lat, p.lng];}/*** One-shot geolocation. Never rejects: a passenger who declines GPS* still gets to order, just without proximity sorting.*/function locate() {if (state.userPos) return Promise.resolve(state.userPos);if (!navigator.geolocation) return Promise.resolve(null);return new Promise(function (resolve) {var done = false;var finish = function (val) {if (done) return;done = true;resolve(val);};navigator.geolocation.getCurrentPosition(function (p) {state.userPos = [p.coords.latitude, p.coords.longitude];finish(state.userPos);},function () { finish(null); },{ enableHighAccuracy: true, timeout: 6000, maximumAge: 30000 });// Belt and braces: some browsers never fire either callback.setTimeout(function () { finish(null); }, 6500);});}// ── Live order polling ───────────────────────────────────────// Re-read the authoritative booking and reflect it on screen. Used by// both the poll loop and a Mercure status push.function refetchBooking() {if (!state.booking) return Promise.resolve();return api(TAXI.showUrlTpl.replace('__REF__', encodeURIComponent(state.booking.reference))).then(function (data) {state.booking = data.booking;// Follow the tuk in on the map.if (data.booking.tuk && data.booking.tuk.lat) {paintTuks([data.booking.tuk]);}if (state.step === STEP_ACTIVE) render();if (data.booking.status === 'completed' || data.booking.status === 'cancelled') {stopPolling();stopLive();}}).catch(function () {// A blip should not kill the order view; the next tick retries.});}function startPolling() {stopPolling();pollTimer = setInterval(function () {if (!state.booking) { stopPolling(); return; }refetchBooking();}, POLL_MS);}function stopPolling() {if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }}// ── Live updates over Mercure, with polling as the floor ──────//// We start polling AND try to open an EventSource. If the hub answers,// onopen stops the poll loop and pushes take over — position updates// move the tuk directly; any other event re-reads the booking. If the// hub never answers (none configured, or down), the EventSource errors// and polling simply carries on. Nothing to configure, no dead ends.function startLive() {if (liveOn || es) return;if (!window.EventSource || !MERCURE || !MERCURE.url || !MERCURE.topic) return;try {es = new EventSource(MERCURE.url + '?topic=' + encodeURIComponent(MERCURE.topic),{ withCredentials: true });} catch (e) { es = null; return; }es.onopen = function () {liveOn = true;stopPolling();if (window.TNT_COMM) TNT_COMM.setLive(true);};es.onmessage = function (ev) {var msg;try { msg = JSON.parse(ev.data); } catch (e) { msg = {}; }// Chat and call events ride the same stream; hand them to the// comm module rather than re-reading the booking for them.if (msg.event === 'chat' || msg.event === 'chat_read' || msg.event === 'call') {if (window.TNT_COMM) TNT_COMM.handlePush(msg);return;}if (msg.event === 'position' && state.booking && state.booking.tuk) {state.booking.tuk.lat = msg.lat;state.booking.tuk.lng = msg.lng;state.booking.tuk.heading = msg.heading;paintTuks([state.booking.tuk]);} else {refetchBooking();}};es.onerror = function () {// Never reached the hub, or the stream dropped. Fall back to// polling; EventSource keeps retrying and onopen will stop it.liveOn = false;if (window.TNT_COMM) TNT_COMM.setLive(false);if (!pollTimer && state.booking) startPolling();};}function stopLive() {if (es) { es.close(); es = null; }liveOn = false;}// Convenience: begin following the current order by whatever transport// is available.function startTracking() {startPolling();startLive();}// Don't poll a tab nobody is looking at.document.addEventListener('visibilitychange', function () {if (document.hidden) {stopPolling();} else if (state.booking && state.step === STEP_ACTIVE &&state.booking.status !== 'completed' && state.booking.status !== 'cancelled') {// Resume tracking; if the push stream is live, polling stays off.if (liveOn) { refetchBooking(); } else { startPolling(); }}});// ── Drag to resize ───────────────────────────────────────────function snapTo(i) {if (window.innerWidth >= 900) return; // desktop panel: fixed heightvar h = Math.round(SNAP[i] * window.innerHeight);el.sheet.style.setProperty('--sheet-h', h + 'px');setTimeout(syncMapMargin, 280);}(function initDrag() {var startY = 0, startH = 0, active = false;el.grab.addEventListener('pointerdown', function (e) {if (window.innerWidth >= 900) return;active = true;startY = e.clientY;startH = el.sheet.getBoundingClientRect().height;el.sheet.classList.add('dragging');el.grab.setPointerCapture(e.pointerId);});el.grab.addEventListener('pointermove', function (e) {if (!active) return;// Dragging up (smaller clientY) makes the sheet taller.var h = startH + (startY - e.clientY);h = Math.max(120, Math.min(h, window.innerHeight * 0.88));el.sheet.style.setProperty('--sheet-h', h + 'px');});function end(e) {if (!active) return;active = false;el.sheet.classList.remove('dragging');if (e.pointerId !== undefined && el.grab.hasPointerCapture(e.pointerId)) {el.grab.releasePointerCapture(e.pointerId);}// Snap to whichever stop is closest to where they let go.var h = el.sheet.getBoundingClientRect().height / window.innerHeight;var best = 0;SNAP.forEach(function (s, i) {if (Math.abs(s - h) < Math.abs(SNAP[best] - h)) best = i;});snapTo(best);}el.grab.addEventListener('pointerup', end);el.grab.addEventListener('pointercancel', end);}());el.back.addEventListener('click', goBack);window.addEventListener('resize', function () {el.sheet.style.removeProperty('--sheet-h');syncMapMargin();});// Re-render the booking sheet when the language changes. The chooser// IIFE's listener (registered earlier) has already updated the shared// LANG global by the time this fires, so tr()/L() pick up the new value.document.addEventListener('tnt:langchange', function () {if (!el.sheet.classList.contains('hidden')) render();});// ── Boot ─────────────────────────────────────────────────────// Chat + calls. Mounted inside .map-app on purpose: that element// opens a stacking context (z-index 9999), so anything meant to sit// over the map must live inside it or the map's event pane eats// every click. The call overlay is position:fixed on <body> and// deliberately escapes this — it must cover the modal too.if (window.TNT_COMM && TAXI.isLoggedIn) {TNT_COMM.init({base: TAXI.commBase,iceUrl: TAXI.commIceUrl,csrf: TAXI.csrf,role: 'passenger',mount: document.querySelector('.map-app')});}ymaps.ready(function () {api(TAXI.routesUrl).then(function (data) {state.routes = data.routes || [];state.customTable = data.customPriceTable || [];el.sheet.classList.remove('hidden');render();snapTo(1);syncMapMargin();if (!TAXI.isLoggedIn) return;// Back from signing in half-way through a guest checkout:// pick the tour back up instead of dropping them on the list.if (resumePendingOrder()) return;// Arriving from the account page's "View on map" link:// show that scheduled tour instead of the tour list.if (TAXI.showReservation) {return api(TAXI.resShowTpl.replace('__REF__', encodeURIComponent(TAXI.showReservation))).then(function (res) {if (!res.reservation) return;state.reservation = res.reservation;state.step = STEP_RESERVATION;setPoiVisible(false);paintReservation(res.reservation);render();snapTo(1);}).catch(function () {// A stale or foreign reference just falls back// to the normal tour list.});}// Restore a live order across a page reload.return api(TAXI.activeUrl).then(function (res) {if (!res.booking) return;state.booking = res.booking;state.step = STEP_ACTIVE;var r = state.routes.filter(function (x) { return x.id === res.booking.route.id; })[0];if (r) { state.route = r; setPoiVisible(false); paintRoute(r); }if (res.booking.tuk && res.booking.tuk.lat) paintTuks([res.booking.tuk]);render();startTracking();});}).then(function () {// Start the live fleet loop last: refreshAmbient only paints on// the tour-list step, so a restored active order is left alone.startAmbient();}).catch(function (e) {// This catch covers the whole boot chain, so a programming// error anywhere in it would otherwise be shown to the// visitor as if the tour list had failed to load. Keep the// banner for real request failures, but always leave the// original on the console.if (window.console) { console.error('[tnt] boot failed', e); }state.error = e.message;el.sheet.classList.remove('hidden');render();startAmbient();});});}());</script>{% endblock %}