templates/website/page/index.html.twig line 1

  1. {% extends '@web/base.html.twig' %}
  2. {% block title %}TNT — Explore Locations{% endblock %}
  3. {# ── Override stylesheets: suppress Bootstrap/main.css, load only what map needs ── #}
  4. {% block stylesheets %}
  5.     <link rel="stylesheet" href="{{ asset('css/all.min.css') }}">
  6.     <link rel="preconnect" href="https://fonts.googleapis.com">
  7.     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  8.     <link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
  9.     <style>
  10.         :root {
  11.             --navy:      #0B1F3A;
  12.             --ocean:     #1A3A5C;
  13.             --aegean:    #2E7D9E;
  14.             --mint:      #48C9B0;
  15.             --amber:     #E8A020;
  16.             --terra:     #D4572A;
  17.             --parchment: #F5F2EC;
  18.             --stone:     #717D7E;
  19.             --panel:     #0f2a45;
  20.             --panel2:    #162e48;
  21.             --border:    #1e3a56;
  22.             /* An unlit rating star. Must read as an empty star, not as nothing
  23.                at all — --border is a panel edge and disappears against it. */
  24.             --star-off:  #9BA7B6;
  25.         }
  26.         /* ── Reset everything the base injects ── */
  27.         *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  28.         html, body {
  29.             height: 100%;
  30.             width: 100%;
  31.             overflow: hidden !important;
  32.             background: var(--navy);
  33.             color: var(--parchment);
  34.             font-family: 'DM Sans', sans-serif;
  35.         }
  36.         /* ── Hide header, footer, #content wrapper from base ── */
  37.         body > header,
  38.         body > footer,
  39.         body > nav,
  40.         #content {
  41.             display: none !important;
  42.         }
  43.         /* ── Map app layout ── */
  44.         .map-app {
  45.             display: grid;
  46.             grid-template-rows: 60px 1fr;
  47.             position: fixed;
  48.             inset: 0;
  49.             /* Size from inset, NOT 100vw/100vh. On a phone 100vh is the tall
  50.                viewport (URL bar expanded), so the grid ran below the visible
  51.                area and everything anchored to the bottom was cut off; 100vw
  52.                ignores a scrollbar and can push the row sideways. inset:0 gives
  53.                exactly the visible viewport on both axes. */
  54.             width: 100%;
  55.             height: 100%;
  56.             overflow: hidden;
  57.             z-index: 9999;
  58.             background: var(--navy);
  59.         }
  60.         /* ── Map header ── */
  61.         .map-header {
  62.             display: flex;
  63.             align-items: center;
  64.             justify-content: space-between;
  65.             gap: 12px;
  66.             min-width: 0;
  67.             padding: 0 28px;
  68.             background: var(--navy);
  69.             border-bottom: 1px solid var(--border);
  70.             z-index: 400;
  71.         }
  72.         .map-logo {
  73.             font-family: 'Bebas Neue', sans-serif;
  74.             font-size: 30px;
  75.             letter-spacing: 5px;
  76.             color: var(--parchment);
  77.             text-decoration: none;
  78.             display: flex;
  79.             align-items: center;
  80.             line-height: 1;
  81.         }
  82.         .map-logo .acc { color: var(--mint); }
  83.         .map-logo .dot {
  84.             width: 6px;
  85.             height: 6px;
  86.             background: var(--amber);
  87.             border-radius: 50%;
  88.             margin-left: 5px;
  89.             margin-bottom: 14px;
  90.             display: inline-block;
  91.             flex-shrink: 0;
  92.         }
  93.         .map-header { position: relative; }
  94.         .map-controls {
  95.             display: flex;
  96.             align-items: center;
  97.             gap: 10px;
  98.         }
  99.         /* ── Hamburger (hidden on desktop) ── */
  100.         .map-burger {
  101.             display: none;
  102.             flex-direction: column;
  103.             justify-content: center;
  104.             gap: 5px;
  105.             width: 42px;
  106.             height: 38px;
  107.             padding: 0 10px;
  108.             background: var(--panel);
  109.             border: 1px solid var(--border);
  110.             border-radius: 8px;
  111.             cursor: pointer;
  112.         }
  113.         .map-burger span {
  114.             display: block;
  115.             height: 2px;
  116.             width: 100%;
  117.             background: var(--parchment);
  118.             border-radius: 2px;
  119.             transition: transform 0.25s, opacity 0.2s;
  120.         }
  121.         .map-burger[aria-expanded="true"] { border-color: var(--mint); }
  122.         .map-burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
  123.         .map-burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
  124.         .map-burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
  125.         .map-count {
  126.             font-size: 12px;
  127.             color: var(--stone);
  128.             white-space: nowrap;
  129.             margin-right: 4px;
  130.         }
  131.         .map-count strong { color: var(--mint); }
  132.         .map-select {
  133.             background: var(--panel);
  134.             color: var(--parchment);
  135.             border: 1px solid var(--border);
  136.             border-radius: 8px;
  137.             padding: 7px 30px 7px 13px;
  138.             font-family: 'DM Sans', sans-serif;
  139.             font-size: 13px;
  140.             cursor: pointer;
  141.             outline: none;
  142.             appearance: none;
  143.             -webkit-appearance: none;
  144.             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");
  145.             background-repeat: no-repeat;
  146.             background-position: right 10px center;
  147.             transition: border-color 0.15s;
  148.         }
  149.         .map-select:focus { border-color: var(--mint); }
  150.         .map-select option { background: var(--ocean); }
  151.         .map-back {
  152.             display: none;
  153.             align-items: center;
  154.             gap: 6px;
  155.             font-size: 13px;
  156.             color: var(--stone);
  157.             text-decoration: none;
  158.             padding: 6px 12px;
  159.             border: 1px solid var(--border);
  160.             border-radius: 8px;
  161.             background: var(--panel);
  162.             transition: color 0.15s, border-color 0.15s;
  163.             white-space: nowrap;
  164.         }
  165.         .map-back:hover { color: var(--parchment); border-color: var(--aegean); }
  166.         /* ── Language switcher ── */
  167.         .map-lang {
  168.             display: flex; align-items: center; gap: 4px;
  169.             padding: 4px 6px; border: 1px solid var(--border);
  170.             border-radius: 8px; background: var(--panel);
  171.         }
  172.         .map-lang-ico { font-size: 13px; line-height: 1; margin-right: 2px; }
  173.         .map-lang-btn {
  174.             min-width: 30px; height: 26px; padding: 0 7px; border-radius: 6px;
  175.             border: 1px solid transparent; background: none; color: var(--stone);
  176.             font-family: 'DM Sans', sans-serif; font-size: 12px; font-weight: 700;
  177.             letter-spacing: .5px; cursor: pointer; white-space: nowrap;
  178.             transition: color .15s, border-color .15s, background .15s;
  179.         }
  180.         .map-lang-btn:hover { color: var(--parchment); border-color: var(--aegean); }
  181.         /* TNT_I18N adds .active to whichever data-set-lang matches the current one. */
  182.         .map-lang-btn.active { color: var(--mint); border-color: var(--mint); background: var(--ocean); }
  183.         /* ── Auth (sign in / register / account) ── */
  184.         .map-auth { display: flex; align-items: center; gap: 8px; }
  185.         .map-auth::before {
  186.             content: ''; width: 1px; align-self: stretch; min-height: 22px;
  187.             background: var(--border); margin: 0 2px;
  188.         }
  189.         .auth-btn {
  190.             display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600;
  191.             text-decoration: none; padding: 8px 15px; border-radius: 8px; white-space: nowrap;
  192.             border: 1px solid transparent; cursor: pointer; transition: filter .15s, border-color .15s, color .15s;
  193.         }
  194.         .auth-in { color: var(--parchment); border-color: var(--border); background: var(--panel); }
  195.         .auth-in:hover { border-color: var(--aegean); }
  196.         .auth-up {
  197.             color: #2a1c00; background: linear-gradient(135deg, var(--amber), #f0b84a);
  198.             box-shadow: 0 2px 12px rgba(232,160,32,.28);
  199.         }
  200.         .auth-up:hover { filter: brightness(1.06); }
  201.         .auth-acct {
  202.             display: flex; align-items: center; gap: 9px; padding: 5px 13px 5px 6px; border-radius: 999px;
  203.             border: 1px solid var(--border); background: var(--panel); color: var(--parchment);
  204.             text-decoration: none; transition: border-color .15s;
  205.         }
  206.         .auth-acct:hover { border-color: var(--mint); }
  207.         .auth-avatar {
  208.             width: 26px; height: 26px; border-radius: 50%; flex: none; display: grid; place-items: center;
  209.             font-size: 12px; font-weight: 700; color: #06231d;
  210.             background: linear-gradient(135deg, var(--aegean), var(--mint));
  211.         }
  212.         .auth-name { font-size: 13px; font-weight: 500; }
  213.         .auth-out {
  214.             display: grid; place-items: center; width: 34px; height: 34px; border-radius: 8px;
  215.             border: 1px solid var(--border); background: var(--panel); color: var(--stone);
  216.             text-decoration: none; font-size: 15px; transition: color .15s, border-color .15s;
  217.         }
  218.         .auth-out:hover { color: var(--terra); border-color: var(--terra); }
  219.         /* ── Live guide toggle ── */
  220.         .map-live {
  221.             display: flex;
  222.             align-items: center;
  223.             gap: 8px;
  224.             font-family: 'DM Sans', sans-serif;
  225.             font-size: 13px;
  226.             color: var(--parchment);
  227.             padding: 7px 15px;
  228.             border: 1px solid var(--border);
  229.             border-radius: 8px;
  230.             background: var(--panel);
  231.             cursor: pointer;
  232.             transition: border-color 0.15s, background 0.15s;
  233.             white-space: nowrap;
  234.         }
  235.         .map-live:hover { border-color: var(--mint); }
  236.         .map-live .live-dot {
  237.             width: 9px;
  238.             height: 9px;
  239.             border-radius: 50%;
  240.             background: var(--stone);
  241.             flex-shrink: 0;
  242.             transition: background 0.2s;
  243.         }
  244.         .map-live.active { border-color: var(--mint); background: var(--ocean); }
  245.         .map-live.active .live-dot {
  246.             background: var(--terra);
  247.             box-shadow: 0 0 0 0 rgba(212, 87, 42, 0.6);
  248.             animation: live-pulse 1.6s infinite;
  249.         }
  250.         @keyframes live-pulse {
  251.             0%   { box-shadow: 0 0 0 0 rgba(212, 87, 42, 0.6); }
  252.             70%  { box-shadow: 0 0 0 7px rgba(212, 87, 42, 0); }
  253.             100% { box-shadow: 0 0 0 0 rgba(212, 87, 42, 0); }
  254.         }
  255.         /* ── User position marker with heading arrow (Yandex custom layout) ── */
  256.         .user-heading {
  257.             width: 30px;
  258.             height: 30px;
  259.             margin: -15px 0 0 -15px;   /* centre the layout on the GPS point */
  260.             position: relative;
  261.             pointer-events: none;
  262.         }
  263.         .user-heading .uh-dot {
  264.             position: absolute;
  265.             left: 50%; top: 50%;
  266.             width: 14px; height: 14px;
  267.             margin: -7px 0 0 -7px;
  268.             border-radius: 50%;
  269.             background: #4285F4;
  270.             border: 2px solid #fff;
  271.             box-shadow: 0 0 0 3px rgba(66,133,244,0.30), 0 1px 4px rgba(0,0,0,0.5);
  272.         }
  273.         .user-heading .uh-arrow {
  274.             position: absolute;
  275.             left: 50%; top: -1px;
  276.             margin-left: -6px;
  277.             width: 0; height: 0;
  278.             border-left: 6px solid transparent;
  279.             border-right: 6px solid transparent;
  280.             border-bottom: 10px solid #4285F4;
  281.             filter: drop-shadow(0 0 1px #fff);
  282.             transition: opacity 0.2s;
  283.         }
  284.         /* ── Now playing banner ── */
  285.         #tnt-nowplaying {
  286.             position: fixed;
  287.             top: 74px;
  288.             right: 20px;
  289.             z-index: 500;
  290.             display: none;
  291.             flex-direction: column;
  292.             gap: 10px;
  293.             width: 320px;
  294.             max-width: calc(100vw - 40px);
  295.             padding: 12px 14px;
  296.             background: var(--panel);
  297.             border: 1px solid var(--border);
  298.             border-left: 3px solid var(--mint);
  299.             border-radius: 12px;
  300.             box-shadow: 0 12px 40px rgba(0,0,0,0.5);
  301.         }
  302.         #tnt-nowplaying.show { display: flex; }
  303.         .np-row { display: flex; align-items: center; gap: 12px; }
  304.         #np-audio { width: 100%; height: 34px; }
  305.         .np-pulse {
  306.             width: 36px;
  307.             height: 36px;
  308.             flex-shrink: 0;
  309.             border-radius: 50%;
  310.             background: var(--mint);
  311.             color: var(--navy);
  312.             display: flex;
  313.             align-items: center;
  314.             justify-content: center;
  315.         }
  316.         .np-meta { min-width: 0; flex: 1; }
  317.         .np-eyebrow {
  318.             font-size: 10px;
  319.             letter-spacing: 1.5px;
  320.             text-transform: uppercase;
  321.             color: var(--mint);
  322.             font-weight: 500;
  323.             margin-bottom: 3px;
  324.         }
  325.         .np-title {
  326.             font-size: 14px;
  327.             color: var(--parchment);
  328.             font-weight: 500;
  329.             white-space: nowrap;
  330.             overflow: hidden;
  331.             text-overflow: ellipsis;
  332.         }
  333.         .np-close {
  334.             flex-shrink: 0;
  335.             width: 26px;
  336.             height: 26px;
  337.             border-radius: 50%;
  338.             border: 1px solid var(--border);
  339.             background: var(--panel2);
  340.             color: var(--stone);
  341.             cursor: pointer;
  342.             font-size: 12px;
  343.             transition: background 0.15s, color 0.15s;
  344.         }
  345.         .np-close:hover { background: var(--terra); color: var(--parchment); }
  346.         /* ── Toast (permission / errors) ── */
  347.         #tnt-toast {
  348.             position: fixed;
  349.             bottom: 28px;
  350.             left: 50%;
  351.             transform: translateX(-50%) translateY(20px);
  352.             z-index: 600;
  353.             background: var(--panel);
  354.             border: 1px solid var(--border);
  355.             color: var(--parchment);
  356.             font-size: 13px;
  357.             padding: 11px 18px;
  358.             border-radius: 10px;
  359.             box-shadow: 0 8px 30px rgba(0,0,0,0.5);
  360.             opacity: 0;
  361.             pointer-events: none;
  362.             transition: opacity 0.25s, transform 0.25s;
  363.         }
  364.         #tnt-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
  365.         /* ── Live GPS status pill ── */
  366.         #tnt-status {
  367.             position: fixed;
  368.             bottom: 20px;
  369.             left: 50%;
  370.             transform: translateX(-50%);
  371.             z-index: 550;
  372.             display: none;
  373.             max-width: calc(100vw - 24px);
  374.             padding: 8px 14px;
  375.             background: var(--panel);
  376.             border: 1px solid var(--border);
  377.             border-radius: 20px;
  378.             font-size: 12px;
  379.             line-height: 1.4;
  380.             color: var(--parchment);
  381.             box-shadow: 0 6px 24px rgba(0,0,0,0.5);
  382.             text-align: center;
  383.             white-space: nowrap;
  384.             overflow: hidden;
  385.             text-overflow: ellipsis;
  386.         }
  387.         #tnt-status.show { display: block; }
  388.         #tnt-status .st-ok   { color: var(--mint); }
  389.         #tnt-status .st-warn { color: var(--amber); }
  390.         /* ── Map canvas ── */
  391.         #map { width: 100%; height: 100%; display: block; }
  392.         /* ── Watermark ── */
  393.         #tnt-watermark {
  394.             position: fixed;
  395.             bottom: 28px;
  396.             left: 28px;
  397.             z-index: 300;
  398.             pointer-events: none;
  399.             user-select: none;
  400.             display: flex;
  401.             align-items: center;
  402.             gap: 10px;
  403.             opacity: 0.9;
  404.             filter: drop-shadow(0 2px 10px rgba(0,0,0,0.9));
  405.         }
  406.         #wm-img { height: 38px; width: auto; display: none; pointer-events: none; }
  407.         #wm-fallback {
  408.             font-family: 'Bebas Neue', sans-serif;
  409.             font-size: 22px;
  410.             letter-spacing: 3px;
  411.             color: var(--parchment);
  412.             border-left: 3px solid var(--mint);
  413.             padding-left: 10px;
  414.             line-height: 1;
  415.             pointer-events: none;
  416.         }
  417.         #wm-fallback .acc { color: var(--mint); }
  418.         /* ── Backdrop ── */
  419.         #tnt-backdrop {
  420.             position: fixed;
  421.             inset: 0;
  422.             background: rgba(8, 20, 38, 0.82);
  423.             z-index: 10000;
  424.             display: flex;
  425.             align-items: flex-end;
  426.             justify-content: center;
  427.             opacity: 0;
  428.             pointer-events: none;
  429.             transition: opacity 0.25s ease;
  430.         }
  431.         #tnt-backdrop.open { opacity: 1; pointer-events: all; }
  432.         /* ── Modal ── */
  433.         #tnt-modal {
  434.             background: var(--panel);
  435.             border-radius: 18px 18px 0 0;
  436.             border-top: 3px solid var(--mint);
  437.             width: 100%;
  438.             max-width: 620px;
  439.             max-height: 80vh;
  440.             overflow-y: auto;
  441.             transform: translateY(48px);
  442.             transition: transform 0.3s cubic-bezier(.22,.68,0,1.3);
  443.         }
  444.         #tnt-backdrop.open #tnt-modal { transform: translateY(0); }
  445.         #tnt-modal::-webkit-scrollbar { width: 4px; }
  446.         #tnt-modal::-webkit-scrollbar-thumb { background: var(--aegean); border-radius: 2px; }
  447.         .m-header { padding: 24px 24px 0; position: relative; }
  448.         .m-close {
  449.             position: absolute;
  450.             top: 16px;
  451.             right: 16px;
  452.             width: 32px;
  453.             height: 32px;
  454.             border: 1px solid var(--border);
  455.             background: var(--panel2);
  456.             color: var(--stone);
  457.             border-radius: 50%;
  458.             font-size: 15px;
  459.             cursor: pointer;
  460.             display: flex;
  461.             align-items: center;
  462.             justify-content: center;
  463.             transition: background 0.15s, color 0.15s, border-color 0.15s;
  464.         }
  465.         .m-close:hover { background: var(--terra); color: var(--parchment); border-color: var(--terra); }
  466.         .m-category {
  467.             font-size: 10px;
  468.             letter-spacing: 2.5px;
  469.             text-transform: uppercase;
  470.             color: var(--mint);
  471.             font-weight: 500;
  472.             margin-bottom: 6px;
  473.         }
  474.         .m-title {
  475.             font-family: 'Bebas Neue', sans-serif;
  476.             font-size: 32px;
  477.             letter-spacing: 1px;
  478.             line-height: 1.1;
  479.             color: var(--parchment);
  480.             padding-right: 40px;
  481.         }
  482.         .m-address {
  483.             margin-top: 8px;
  484.             font-size: 13px;
  485.             color: var(--stone);
  486.             display: flex;
  487.             align-items: center;
  488.             gap: 6px;
  489.         }
  490.         .m-divider { height: 1px; background: var(--border); margin: 18px 24px; }
  491.         .m-body { padding: 0 24px 24px; }
  492.         .m-tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
  493.         .m-tab {
  494.             background: var(--panel2);
  495.             border: 1px solid var(--border);
  496.             border-radius: 20px;
  497.             padding: 5px 16px;
  498.             font-size: 12px;
  499.             font-family: 'DM Sans', sans-serif;
  500.             color: var(--stone);
  501.             cursor: pointer;
  502.             transition: all 0.15s;
  503.             letter-spacing: 0.5px;
  504.         }
  505.         .m-tab:hover { border-color: var(--aegean); color: var(--parchment); }
  506.         .m-tab.active { background: var(--mint); border-color: var(--mint); color: var(--navy); font-weight: 500; }
  507.         .m-panel { display: none; }
  508.         .m-panel.active { display: block; }
  509.         .m-item {
  510.             background: var(--panel2);
  511.             border: 1px solid var(--border);
  512.             border-radius: 10px;
  513.             padding: 16px;
  514.             margin-bottom: 10px;
  515.         }
  516.         .m-item:last-child { margin-bottom: 0; }
  517.         .m-item-title { font-size: 14px; font-weight: 500; color: var(--parchment); margin-bottom: 4px; }
  518.         .m-item-lang { font-size: 10px; color: var(--amber); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 8px; font-weight: 500; }
  519.         .m-item-text { font-size: 13px; color: var(--stone); line-height: 1.65; }
  520.         .m-item audio, .m-item video { width: 100%; margin-top: 10px; border-radius: 8px; }
  521.         .m-empty { text-align: center; padding: 36px 0; color: var(--stone); font-size: 13px; }
  522.         /* ── Welcome / language chooser ── */
  523.         #tnt-welcome {
  524.             position: fixed;
  525.             inset: 0;
  526.             z-index: 11000;
  527.             display: flex;
  528.             align-items: center;
  529.             justify-content: center;
  530.             padding: 20px;
  531.             background: rgba(8, 20, 38, 0.92);
  532.         }
  533.         #tnt-welcome.hidden { display: none; }
  534.         .w-card {
  535.             width: 100%;
  536.             max-width: 380px;
  537.             padding: 34px 28px;
  538.             text-align: center;
  539.             background: var(--panel);
  540.             border: 1px solid var(--border);
  541.             border-top: 3px solid var(--mint);
  542.             border-radius: 18px;
  543.             box-shadow: 0 24px 70px rgba(0,0,0,0.6);
  544.         }
  545.         .w-logo {
  546.             font-family: 'Bebas Neue', sans-serif;
  547.             font-size: 38px;
  548.             letter-spacing: 6px;
  549.             color: var(--parchment);
  550.             display: inline-flex;
  551.             align-items: center;
  552.             line-height: 1;
  553.             margin-bottom: 18px;
  554.         }
  555.         .w-logo .acc { color: var(--mint); }
  556.         .w-logo .dot {
  557.             width: 6px; height: 6px;
  558.             background: var(--amber);
  559.             border-radius: 50%;
  560.             margin-left: 5px; margin-bottom: 16px;
  561.             display: inline-block;
  562.         }
  563.         .w-title {
  564.             font-family: 'Bebas Neue', sans-serif;
  565.             font-size: 27px;
  566.             letter-spacing: 1px;
  567.             color: var(--parchment);
  568.             margin-bottom: 8px;
  569.         }
  570.         .w-sub { font-size: 13px; color: var(--stone); margin-bottom: 24px; }
  571.         .w-langs { display: flex; flex-direction: column; gap: 10px; }
  572.         .w-lang {
  573.             display: flex;
  574.             align-items: center;
  575.             justify-content: center;
  576.             gap: 10px;
  577.             padding: 14px;
  578.             font-family: 'DM Sans', sans-serif;
  579.             font-size: 15px;
  580.             color: var(--parchment);
  581.             background: var(--panel2);
  582.             border: 1px solid var(--border);
  583.             border-radius: 10px;
  584.             cursor: pointer;
  585.             transition: border-color 0.15s, background 0.15s;
  586.         }
  587.         .w-lang:hover { border-color: var(--mint); background: var(--ocean); }
  588.         .w-lang.current { border-color: var(--mint); background: var(--ocean); }
  589.         .w-lang.current::after { content: '✓'; color: var(--mint); margin-left: 4px; font-weight: 700; }
  590.         .w-flag { font-size: 20px; }
  591.         /* ═══ Responsive: collapse header controls into a menu on small screens ═══ */
  592.         @media (max-width: 720px) {
  593.             .map-header { padding: 0 16px; }
  594.             .map-logo { font-size: 26px; letter-spacing: 4px; }
  595.             .map-burger { display: flex; }
  596.             /* Turn the inline control row into a dropdown panel below the header.
  597.                It is a full-width sheet under the 60px header, and it SCROLLS:
  598.                with the language, live-guide and auth rows the menu is easily
  599.                taller than a phone screen, and without a max-height + overflow
  600.                the last items were simply cut off the bottom with no way to
  601.                reach them. */
  602.             .map-controls {
  603.                 position: absolute;
  604.                 top: 60px;
  605.                 left: 0;
  606.                 right: 0;
  607.                 width: auto;
  608.                 max-width: 100%;
  609.                 flex-direction: column;
  610.                 align-items: stretch;
  611.                 gap: 12px;
  612.                 padding: 16px;
  613.                 padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  614.                 background: var(--navy);
  615.                 border-bottom: 1px solid var(--border);
  616.                 box-shadow: 0 16px 40px rgba(0,0,0,0.55);
  617.                 display: none;
  618.                 max-height: calc(100vh - 60px);
  619.                 max-height: calc(100dvh - 60px);
  620.                 overflow-y: auto;
  621.                 overscroll-behavior: contain;
  622.                 -webkit-overflow-scrolling: touch;
  623.                 z-index: 450;
  624.             }
  625.             .map-controls.open { display: flex; }
  626.             .map-controls > * { min-width: 0; }
  627.             .map-controls .map-select { width: 100%; font-size: 15px; padding: 11px 34px 11px 14px; }
  628.             .map-controls .map-live   { justify-content: center; font-size: 15px; padding: 11px 15px; }
  629.             .map-controls .map-back   { justify-content: center; font-size: 14px; padding: 10px 12px; order: 5; }
  630.             .map-controls .map-count  { order: -1; font-size: 13px; text-align: center; }
  631.             /* Language pills: full width, split evenly, sized for a thumb. */
  632.             .map-controls .map-lang { padding: 6px; order: 4; }
  633.             .map-controls .map-lang-btn { flex: 1; height: 38px; font-size: 14px; }
  634.             /* Auth group: a row of pills does not fit a phone's width, so stack
  635.                it. The ::before divider is a vertical rule for the horizontal
  636.                desktop bar — meaningless (and ugly) once stacked. */
  637.             .map-auth { flex-direction: column; align-items: stretch; gap: 10px; order: 6; }
  638.             .map-auth::before { display: none; }
  639.             .map-auth .auth-btn,
  640.             .map-auth .auth-acct { justify-content: center; width: 100%; padding: 11px 15px; }
  641.             .map-auth .auth-out  { width: 100%; height: 42px; }
  642.             /* Keep the now-playing banner clear of the open menu */
  643.             #tnt-nowplaying { top: 68px; right: 12px; left: 12px; width: auto; max-width: none; }
  644.         }
  645.         /* ═══════════════════════════════════════════════════════════════════
  646.            ORDER SHEET — the booking panel.
  647.            Mobile: a drag-to-resize sheet pinned to the bottom of the screen.
  648.            Desktop (≥ 900px): the same markup floats as a left-hand panel.
  649.            ═══════════════════════════════════════════════════════════════════ */
  650.         .sheet {
  651.             position: fixed;
  652.             left: 0;
  653.             right: 0;
  654.             bottom: 0;
  655.             z-index: 800;
  656.             display: flex;
  657.             flex-direction: column;
  658.             background: var(--panel);
  659.             border-top: 1px solid var(--border);
  660.             border-radius: 18px 18px 0 0;
  661.             box-shadow: 0 -12px 40px rgba(0,0,0,0.5);
  662.             /* Sheet height is driven by --sheet-h, which the drag handler sets. */
  663.             height: var(--sheet-h, 46vh);
  664.             max-height: 88vh;
  665.             transition: height 260ms cubic-bezier(0.32, 0.72, 0, 1);
  666.             /* Respect the iOS home indicator. */
  667.             padding-bottom: env(safe-area-inset-bottom, 0);
  668.         }
  669.         /* dvh where it exists, so the resting height matches what the drag
  670.            handler computes from window.innerHeight — with the URL bar showing,
  671.            88vh is taller than the screen and the sheet grew up under the
  672.            header. @supports rather than a second declaration on purpose: an
  673.            unsupported unit inside var() is invalid at computed-value time, so
  674.            it would fall back to `auto`, not to the vh line above it. */
  675.         @supports (height: 100dvh) {
  676.             .sheet {
  677.                 height: var(--sheet-h, 46dvh);
  678.                 max-height: 88dvh;
  679.             }
  680.         }
  681.         /* While a finger is down we follow it directly — no transition lag. */
  682.         .sheet.dragging { transition: none; }
  683.         .sheet.hidden   { display: none; }
  684.         /* Grab handle */
  685.         .sheet-grab {
  686.             flex: none;
  687.             padding: 10px 0 6px;
  688.             display: flex;
  689.             justify-content: center;
  690.             cursor: grab;
  691.             touch-action: none;
  692.         }
  693.         .sheet-grab:active { cursor: grabbing; }
  694.         .sheet-grab::before {
  695.             content: '';
  696.             width: 40px;
  697.             height: 4px;
  698.             border-radius: 2px;
  699.             background: #35516e;
  700.         }
  701.         /* Header: back arrow + title */
  702.         .sheet-head {
  703.             flex: none;
  704.             display: flex;
  705.             align-items: center;
  706.             gap: 10px;
  707.             padding: 2px 18px 12px;
  708.         }
  709.         .sheet-back {
  710.             flex: none;
  711.             width: 30px;
  712.             height: 30px;
  713.             display: none;
  714.             align-items: center;
  715.             justify-content: center;
  716.             border: 0;
  717.             border-radius: 50%;
  718.             background: var(--panel2);
  719.             color: var(--parchment);
  720.             cursor: pointer;
  721.         }
  722.         .sheet-back:hover { background: var(--ocean); }
  723.         .sheet-back.show  { display: flex; }
  724.         .sheet-title {
  725.             font-family: 'Bebas Neue', sans-serif;
  726.             font-size: 22px;
  727.             letter-spacing: 1.6px;
  728.             line-height: 1;
  729.             color: var(--parchment);
  730.         }
  731.         .sheet-sub {
  732.             font-size: 12px;
  733.             color: var(--stone);
  734.             margin-top: 3px;
  735.         }
  736.         /* Scrolling content */
  737.         .sheet-body {
  738.             flex: 1;
  739.             /* Without min-height:0 a flex:1 child refuses to shrink below its
  740.                content, so a long tour list pushed the CTA out of the sheet
  741.                instead of scrolling inside it. */
  742.             min-height: 0;
  743.             overflow-y: auto;
  744.             overscroll-behavior: contain;
  745.             -webkit-overflow-scrolling: touch;
  746.             padding: 0 18px 18px;
  747.         }
  748.         /* Sticky footer button */
  749.         .sheet-foot {
  750.             flex: none;
  751.             padding: 12px 18px 16px;
  752.             border-top: 1px solid var(--border);
  753.             background: var(--panel);
  754.             border-radius: 0 0 18px 18px;
  755.         }
  756.         .sheet-foot.hidden { display: none; }
  757.         .sheet-cta {
  758.             width: 100%;
  759.             padding: 15px 18px;
  760.             border: 0;
  761.             border-radius: 12px;
  762.             background: var(--mint);
  763.             color: #06251f;
  764.             font-family: 'DM Sans', sans-serif;
  765.             font-size: 16px;
  766.             font-weight: 500;
  767.             cursor: pointer;
  768.             display: flex;
  769.             align-items: center;
  770.             justify-content: center;
  771.             gap: 8px;
  772.             transition: filter 140ms ease, opacity 140ms ease;
  773.         }
  774.         .sheet-cta:hover:not(:disabled) { filter: brightness(1.08); }
  775.         .sheet-cta:disabled { opacity: 0.45; cursor: not-allowed; }
  776.         .sheet-cta .cta-price { margin-left: auto; font-weight: 700; }
  777.         .sheet-cta.ghost {
  778.             background: transparent;
  779.             border: 1px solid var(--border);
  780.             color: var(--stone);
  781.         }
  782.         .sheet-cta.ghost:hover:not(:disabled) { color: var(--terra); border-color: var(--terra); }
  783.         /* ── Route cards ────────────────────────────────────────────────── */
  784.         .rt-card {
  785.             position: relative;
  786.             display: flex;
  787.             align-items: center;
  788.             gap: 12px;
  789.             width: 100%;
  790.             text-align: left;
  791.             padding: 14px 14px 14px 16px;
  792.             margin-bottom: 10px;
  793.             border: 1px solid var(--border);
  794.             border-radius: 14px;
  795.             background: var(--panel2);
  796.             color: var(--parchment);
  797.             cursor: pointer;
  798.             font-family: 'DM Sans', sans-serif;
  799.             transition: border-color 140ms ease, background 140ms ease;
  800.         }
  801.         .rt-card:hover { border-color: var(--aegean); background: var(--ocean); }
  802.         .rt-card.active { border-color: var(--mint); background: var(--ocean); }
  803.         /* The colour stripe matches the road painted on the map. */
  804.         .rt-card::before {
  805.             content: '';
  806.             position: absolute;
  807.             left: 0;
  808.             top: 12px;
  809.             bottom: 12px;
  810.             width: 4px;
  811.             border-radius: 0 3px 3px 0;
  812.             background: var(--rt-color, var(--mint));
  813.         }
  814.         .rt-main { flex: 1; min-width: 0; }
  815.         .rt-name {
  816.             font-size: 15px;
  817.             font-weight: 500;
  818.             margin-bottom: 4px;
  819.             white-space: nowrap;
  820.             overflow: hidden;
  821.             text-overflow: ellipsis;
  822.         }
  823.         .rt-meta {
  824.             font-size: 12px;
  825.             color: var(--stone);
  826.             display: flex;
  827.             align-items: center;
  828.             gap: 6px;
  829.             flex-wrap: wrap;
  830.         }
  831.         .rt-meta .dot-sep { width: 3px; height: 3px; border-radius: 50%; background: var(--stone); }
  832.         .rt-price {
  833.             flex: none;
  834.             font-size: 15px;
  835.             font-weight: 700;
  836.             color: var(--amber);
  837.             white-space: nowrap;
  838.         }
  839.         .rt-desc {
  840.             font-size: 13px;
  841.             line-height: 1.55;
  842.             color: #9fb3c8;
  843.             margin-bottom: 14px;
  844.         }
  845.         /* ── Stop list (the route's itinerary) ──────────────────────────── */
  846.         .stops { list-style: none; margin: 0 0 8px; padding: 0; }
  847.         .stop {
  848.             position: relative;
  849.             display: flex;
  850.             gap: 12px;
  851.             padding: 0 0 16px 0;
  852.         }
  853.         /* Vertical line joining the stop bullets. */
  854.         .stop:not(:last-child)::before {
  855.             content: '';
  856.             position: absolute;
  857.             left: 9px;
  858.             top: 18px;
  859.             bottom: 0;
  860.             width: 2px;
  861.             background: var(--border);
  862.         }
  863.         .stop-dot {
  864.             flex: none;
  865.             width: 20px;
  866.             height: 20px;
  867.             border-radius: 50%;
  868.             background: var(--panel2);
  869.             border: 2px solid var(--rt-color, var(--mint));
  870.             display: flex;
  871.             align-items: center;
  872.             justify-content: center;
  873.             font-size: 10px;
  874.             font-weight: 700;
  875.             color: var(--parchment);
  876.             z-index: 1;
  877.         }
  878.         .stop-body { flex: 1; min-width: 0; padding-top: 1px; }
  879.         .stop-name { font-size: 14px; line-height: 1.35; }
  880.         .stop-time { font-size: 11px; color: var(--stone); margin-top: 2px; }
  881.         /* ── Tuk cards ──────────────────────────────────────────────────── */
  882.         .tuk-card {
  883.             display: flex;
  884.             align-items: center;
  885.             gap: 12px;
  886.             width: 100%;
  887.             text-align: left;
  888.             padding: 12px;
  889.             margin-bottom: 10px;
  890.             border: 1px solid var(--border);
  891.             border-radius: 14px;
  892.             background: var(--panel2);
  893.             color: var(--parchment);
  894.             cursor: pointer;
  895.             font-family: 'DM Sans', sans-serif;
  896.             transition: border-color 140ms ease, background 140ms ease;
  897.         }
  898.         .tuk-card:hover  { border-color: var(--aegean); background: var(--ocean); }
  899.         .tuk-card.active { border-color: var(--mint); background: var(--ocean); }
  900.         .tuk-avatar {
  901.             flex: none;
  902.             width: 42px;
  903.             height: 42px;
  904.             border-radius: 50%;
  905.             display: flex;
  906.             align-items: center;
  907.             justify-content: center;
  908.             font-weight: 700;
  909.             font-size: 14px;
  910.             color: #06251f;
  911.             background: var(--tuk-color, var(--amber));
  912.         }
  913.         .tuk-main { flex: 1; min-width: 0; }
  914.         .tuk-name {
  915.             font-size: 14px;
  916.             font-weight: 500;
  917.             white-space: nowrap;
  918.             overflow: hidden;
  919.             text-overflow: ellipsis;
  920.         }
  921.         .tuk-meta {
  922.             font-size: 12px;
  923.             color: var(--stone);
  924.             margin-top: 3px;
  925.             display: flex;
  926.             align-items: center;
  927.             gap: 6px;
  928.             flex-wrap: wrap;
  929.         }
  930.         .tuk-star { color: var(--amber); }
  931.         .tuk-eta  { flex: none; text-align: right; }
  932.         .tuk-eta-val {
  933.             font-size: 15px;
  934.             font-weight: 700;
  935.             color: var(--mint);
  936.             white-space: nowrap;
  937.         }
  938.         .tuk-eta-lbl { font-size: 11px; color: var(--stone); }
  939.         /* ── Payment options ────────────────────────────────────────────── */
  940.         .pay-opt {
  941.             display: flex;
  942.             align-items: center;
  943.             gap: 12px;
  944.             width: 100%;
  945.             text-align: left;
  946.             padding: 14px 12px;
  947.             margin-bottom: 8px;
  948.             border: 1px solid var(--border);
  949.             border-radius: 12px;
  950.             background: var(--panel2);
  951.             color: var(--parchment);
  952.             cursor: pointer;
  953.             font-family: 'DM Sans', sans-serif;
  954.             font-size: 14px;
  955.             transition: border-color 140ms ease, background 140ms ease;
  956.         }
  957.         .pay-opt:hover  { border-color: var(--aegean); }
  958.         .pay-opt.active { border-color: var(--mint); background: var(--ocean); }
  959.         .pay-ico { flex: none; font-size: 18px; width: 24px; text-align: center; }
  960.         .pay-main { flex: 1; min-width: 0; }
  961.         .pay-lbl { font-size: 14px; }
  962.         .pay-sub { font-size: 11px; color: var(--stone); margin-top: 2px; }
  963.         /* Radio tick */
  964.         .pay-tick {
  965.             flex: none;
  966.             width: 18px;
  967.             height: 18px;
  968.             border-radius: 50%;
  969.             border: 2px solid var(--border);
  970.             display: flex;
  971.             align-items: center;
  972.             justify-content: center;
  973.         }
  974.         .pay-opt.active .pay-tick { border-color: var(--mint); }
  975.         .pay-opt.active .pay-tick::after {
  976.             content: '';
  977.             width: 8px;
  978.             height: 8px;
  979.             border-radius: 50%;
  980.             background: var(--mint);
  981.         }
  982.         .pay-opt.expired { opacity: 0.5; cursor: not-allowed; }
  983.         /* Summary rows above the order button */
  984.         .sum-row {
  985.             display: flex;
  986.             justify-content: space-between;
  987.             gap: 12px;
  988.             font-size: 13px;
  989.             padding: 7px 0;
  990.             border-bottom: 1px dashed var(--border);
  991.         }
  992.         .sum-row:last-child { border-bottom: 0; }
  993.         .sum-k { color: var(--stone); }
  994.         .sum-v { color: var(--parchment); text-align: right; }
  995.         .sum-v.big { font-size: 16px; font-weight: 700; color: var(--amber); }
  996.         /* ── Schedule (advance booking) form ────────────────────────────── */
  997.         .sched-field { margin-bottom: 12px; }
  998.         .sched-field > label {
  999.             display: block; font-size: 12px; color: var(--stone);
  1000.             margin-bottom: 6px; text-transform: uppercase; letter-spacing: .4px;
  1001.         }
  1002.         .sched-row { display: flex; gap: 10px; }
  1003.         .sched-row .sched-field { flex: 1; }
  1004.         .sched-input {
  1005.             width: 100%; padding: 12px 13px; border-radius: 11px;
  1006.             border: 1px solid var(--border); background: var(--ocean);
  1007.             color: var(--parchment); font-size: 15px; font-family: inherit;
  1008.         }
  1009.         .sched-input:focus { outline: none; border-color: var(--mint); }
  1010.         .sched-input::-webkit-calendar-picker-indicator { filter: invert(1); opacity: .6; }
  1011.         textarea.sched-input { resize: vertical; min-height: 62px; }
  1012.         .sched-note {
  1013.             font-size: 12px; color: var(--stone); line-height: 1.5; margin: 2px 0 14px;
  1014.         }
  1015.         /* ── Guest checkout (ordering without an account) ────────────────── */
  1016.         .guest-box, .card-box {
  1017.             background: var(--ocean); border: 1px solid var(--border);
  1018.             border-radius: 14px; padding: 14px 13px 4px; margin-bottom: 14px;
  1019.         }
  1020.         .card-box { margin-top: -4px; }
  1021.         .guest-lbl {
  1022.             font-size: 13px; font-weight: 700; color: var(--parchment);
  1023.             margin-bottom: 12px;
  1024.         }
  1025.         .guest-lbl small {
  1026.             display: block; font-size: 12px; font-weight: 400;
  1027.             color: var(--stone); margin-top: 3px; line-height: 1.45;
  1028.         }
  1029.         .guest-box .sched-input { background: var(--navy); }
  1030.         .guest-alt { font-size: 12px; color: var(--stone); padding: 2px 0 12px; }
  1031.         .guest-alt a { color: var(--mint); text-decoration: none; font-weight: 500; }
  1032.         .guest-alt a:hover { text-decoration: underline; }
  1033.         .guest-signin {
  1034.             width: 100%; margin: 2px 0 12px; padding: 12px; border-radius: 11px;
  1035.             border: 1px solid var(--mint); background: rgba(72,201,176,.12);
  1036.             color: var(--mint); font-family: inherit; font-size: 14px;
  1037.             font-weight: 700; cursor: pointer;
  1038.         }
  1039.         .guest-signin:hover { background: rgba(72,201,176,.2); }
  1040.         /* ── Live order status ──────────────────────────────────────────── */
  1041.         .ord-status {
  1042.             display: flex;
  1043.             align-items: center;
  1044.             gap: 10px;
  1045.             padding: 12px;
  1046.             border-radius: 12px;
  1047.             background: var(--ocean);
  1048.             border: 1px solid var(--border);
  1049.             margin-bottom: 14px;
  1050.         }
  1051.         .ord-pulse {
  1052.             flex: none;
  1053.             width: 10px;
  1054.             height: 10px;
  1055.             border-radius: 50%;
  1056.             background: var(--mint);
  1057.             animation: ordPulse 1.6s ease-out infinite;
  1058.         }
  1059.         @keyframes ordPulse {
  1060.             0%   { box-shadow: 0 0 0 0 rgba(72,201,176,0.6); }
  1061.             100% { box-shadow: 0 0 0 12px rgba(72,201,176,0); }
  1062.         }
  1063.         .ord-txt { flex: 1; font-size: 14px; }
  1064.         .ord-ref { font-size: 11px; color: var(--stone); margin-top: 2px; letter-spacing: 1px; }
  1065.         /* Post-tour rating */
  1066.         .rate-box {
  1067.             margin-top: 14px;
  1068.             padding: 16px 12px;
  1069.             border-radius: 12px;
  1070.             background: var(--ocean);
  1071.             border: 1px solid var(--border);
  1072.             text-align: center;
  1073.         }
  1074.         .rate-box.done { background: rgba(72,201,176,0.10); }
  1075.         .rate-q { font-size: 14px; color: var(--parchment); margin-bottom: 10px; }
  1076.         .rate-stars { display: flex; justify-content: center; gap: 8px; }
  1077.         .rate-star {
  1078.             /* Unlit stars used to be var(--border) — all but invisible on the
  1079.                dark panel, so the widget read as "no stars here" and a 4/5 score
  1080.                looked like a star had gone missing. Light silver instead: clearly
  1081.                an empty star, clearly still a star. */
  1082.             font-size: 32px; line-height: 1; cursor: pointer; color: var(--star-off);
  1083.             transition: color .12s, transform .12s; user-select: none;
  1084.         }
  1085.         .rate-stars:not(.readonly) .rate-star:hover { transform: scale(1.12); }
  1086.         .rate-star.on { color: var(--amber); }
  1087.         .rate-stars.readonly .rate-star { cursor: default; font-size: 26px; }
  1088.         .rate-skip {
  1089.             display: block; margin: 12px auto 0; background: none; border: none;
  1090.             color: var(--stone); font-size: 13px; cursor: pointer; text-decoration: underline;
  1091.         }
  1092.         /* States: empty / loading / error */
  1093.         .sheet-msg {
  1094.             padding: 26px 10px;
  1095.             text-align: center;
  1096.             font-size: 13px;
  1097.             color: var(--stone);
  1098.             line-height: 1.6;
  1099.         }
  1100.         .sheet-msg a { color: var(--mint); }
  1101.         .sheet-err {
  1102.             padding: 10px 12px;
  1103.             margin-bottom: 12px;
  1104.             border-radius: 10px;
  1105.             font-size: 13px;
  1106.             background: rgba(212,87,42,0.14);
  1107.             border: 1px solid var(--terra);
  1108.             color: #ffb59a;
  1109.         }
  1110.         .sheet-err.hidden { display: none; }
  1111.         .spin {
  1112.             width: 22px;
  1113.             height: 22px;
  1114.             margin: 0 auto 10px;
  1115.             border: 2px solid var(--border);
  1116.             border-top-color: var(--mint);
  1117.             border-radius: 50%;
  1118.             animation: spin 700ms linear infinite;
  1119.         }
  1120.         @keyframes spin { to { transform: rotate(360deg); } }
  1121.         /* ── Party size ──────────────────────────────────────────────────
  1122.            A stepper rather than a number input: three is the whole range, and
  1123.            tapping ± beats summoning a numeric keyboard on a phone. */
  1124.         .pax {
  1125.             display: flex; align-items: center; gap: 12px; margin: 12px 0 4px;
  1126.             padding: 12px 14px; background: var(--panel2); border: 1px solid var(--border);
  1127.             border-radius: 13px;
  1128.         }
  1129.         .pax-lbl { font-size: 14px; font-weight: 500; }
  1130.         .pax-lbl small { display: block; font-size: 11px; color: var(--stone); font-weight: 400; margin-top: 2px; }
  1131.         .pax-ctl { margin-left: auto; display: flex; align-items: center; gap: 4px; }
  1132.         .pax-btn {
  1133.             width: 34px; height: 34px; border-radius: 10px; border: 1px solid var(--border);
  1134.             background: var(--panel); color: var(--parch); font-size: 19px; line-height: 1;
  1135.             cursor: pointer; font-family: inherit; display: grid; place-items: center;
  1136.             transition: border-color .12s, color .12s;
  1137.         }
  1138.         .pax-btn:hover:not(:disabled) { border-color: var(--mint); color: var(--mint); }
  1139.         .pax-btn:disabled { opacity: .35; cursor: default; }
  1140.         .pax-n { min-width: 30px; text-align: center; font-size: 18px; font-weight: 700; }
  1141.         /* The whole party-size price table, so the rule is visible up front. */
  1142.         .pax-table { display: flex; gap: 6px; margin: 8px 0 2px; flex-wrap: wrap; }
  1143.         .pax-cell {
  1144.             flex: 1 1 0; min-width: 78px; text-align: center; padding: 8px 6px;
  1145.             border: 1px solid var(--border); border-radius: 11px; background: var(--panel2);
  1146.             cursor: pointer; font-family: inherit; color: var(--parch); transition: border-color .12s, background .12s;
  1147.             /* Stack the label over the price and centre both, so cells of
  1148.                differing text lengths still line up across the row. */
  1149.             display: flex; flex-direction: column; justify-content: center; align-items: center;
  1150.         }
  1151.         .pax-cell.on { border-color: var(--mint); background: var(--ocean); }
  1152.         /* The two lines swap emphasis: the party size leads, big and light, and
  1153.            the price sits under it as the quiet caption. */
  1154.         .pax-cell-n { font-size: 14px; font-weight: 300; color: var(--parch); line-height: 1.2; }
  1155.         .pax-cell.on .pax-cell-n { color: var(--mint); }
  1156.         .pax-cell-p {
  1157.             font-size: 11px; font-weight: 700; color: var(--stone);
  1158.             text-transform: uppercase; letter-spacing: .4px; margin-top: 3px;
  1159.         }
  1160.         .pax-cell.on .pax-cell-p { color: var(--mint); }
  1161.         /* ── Tip after the tour ──────────────────────────────────────────── */
  1162.         .tip-box {
  1163.             margin-top: 12px; padding: 14px; background: var(--panel2);
  1164.             border: 1px solid var(--border); border-radius: 14px; text-align: center;
  1165.         }
  1166.         .tip-q { font-size: 14px; font-weight: 500; margin-bottom: 10px; }
  1167.         .tip-opts { display: flex; gap: 7px; justify-content: center; flex-wrap: wrap; }
  1168.         .tip-opt {
  1169.             flex: 1 1 0; min-width: 68px; padding: 10px 8px; border-radius: 11px;
  1170.             border: 1px solid var(--border); background: var(--panel); color: var(--parch);
  1171.             font-size: 13px; font-weight: 700; cursor: pointer; font-family: inherit;
  1172.             transition: border-color .12s, background .12s, color .12s;
  1173.         }
  1174.         .tip-opt:hover { border-color: var(--mint); color: var(--mint); }
  1175.         .tip-opt.on { border-color: var(--mint); background: var(--ocean); color: var(--mint); }
  1176.         .tip-custom { display: flex; gap: 7px; margin-top: 9px; }
  1177.         .tip-custom input {
  1178.             flex: 1; padding: 10px 12px; border-radius: 11px; border: 1px solid var(--border);
  1179.             background: var(--navy); color: var(--parch); font-size: 14px; font-family: inherit;
  1180.             text-align: center;
  1181.         }
  1182.         .tip-custom input:focus { outline: none; border-color: var(--mint); }
  1183.         .tip-skip { background: none; border: none; color: var(--stone); font-size: 12px; cursor: pointer; font-family: inherit; margin-top: 9px; }
  1184.         .tip-box.done .tip-amount { font-size: 22px; font-weight: 700; color: var(--mint); font-family: 'Bebas Neue', sans-serif; letter-spacing: 1px; }
  1185.         /* ── Custom route drawing ────────────────────────────────────────── */
  1186.         .rt-card.draw { border-style: dashed; }
  1187.         .rt-card.draw .rt-name { color: var(--amber); }
  1188.         .draw-hint {
  1189.             padding: 12px 14px; background: var(--panel2); border: 1px dashed var(--border);
  1190.             border-radius: 13px; font-size: 13px; color: var(--stone); line-height: 1.5;
  1191.         }
  1192.         .draw-list { list-style: none; margin: 12px 0 0; padding: 0; }
  1193.         .draw-item {
  1194.             display: flex; align-items: center; gap: 10px; padding: 9px 0;
  1195.             border-bottom: 1px solid var(--border); font-size: 14px;
  1196.         }
  1197.         .draw-item:last-child { border-bottom: none; }
  1198.         .draw-dot {
  1199.             width: 24px; height: 24px; border-radius: 50%; flex: none; display: grid;
  1200.             place-items: center; font-size: 11px; font-weight: 700; color: #1b1030;
  1201.             background: var(--cust, #9B7BD4);
  1202.         }
  1203.         .draw-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  1204.         .draw-del {
  1205.             margin-left: auto; background: none; border: none; color: var(--stone);
  1206.             font-size: 15px; cursor: pointer; padding: 4px 6px; border-radius: 8px;
  1207.         }
  1208.         .draw-del:hover { color: var(--terra); }
  1209.         .draw-acts { display: flex; gap: 8px; margin-top: 12px; }
  1210.         .draw-acts button {
  1211.             flex: 1; padding: 11px; border-radius: 11px; border: 1px solid var(--border);
  1212.             background: var(--panel2); color: var(--parch); font-size: 13px; font-weight: 700;
  1213.             cursor: pointer; font-family: inherit;
  1214.         }
  1215.         .draw-acts button:hover:not(:disabled) { border-color: var(--mint); color: var(--mint); }
  1216.         .draw-acts button:disabled { opacity: .4; cursor: default; }
  1217.         /* Stop picker: our own places, searchable. A custom tour is assembled
  1218.            from these — never from an arbitrary point on the map. */
  1219.         .pick-search { margin-top: 14px; }
  1220.         .pick-list { list-style: none; margin: 8px 0 0; padding: 0; }
  1221.         .pick-list li { margin-bottom: 6px; }
  1222.         .pick-item {
  1223.             width: 100%; display: flex; align-items: center; gap: 10px;
  1224.             padding: 11px 13px; border: 1px solid var(--border); border-radius: 12px;
  1225.             background: var(--panel2); color: var(--parch); font-family: inherit;
  1226.             font-size: 14px; text-align: left; cursor: pointer;
  1227.             transition: border-color .12s, background .12s;
  1228.         }
  1229.         .pick-item:hover { border-color: var(--mint); background: var(--ocean); }
  1230.         .pick-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  1231.         .pick-cat {
  1232.             flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .4px;
  1233.             color: var(--stone); border: 1px solid var(--border); border-radius: 999px; padding: 2px 7px;
  1234.         }
  1235.         .pick-plus { flex: none; font-size: 18px; line-height: 1; color: var(--mint); font-weight: 700; }
  1236.         .pick-empty { padding: 18px; text-align: center; color: var(--stone); font-size: 13px; }
  1237.         /* Floating "tap the map" cue, shown over the map while drawing. */
  1238.         .draw-badge {
  1239.             position: absolute; top: 74px; left: 50%; transform: translateX(-50%);
  1240.             z-index: 9998; background: rgba(11,31,58,.94); border: 1px solid var(--cust, #9B7BD4);
  1241.             color: var(--parch); padding: 8px 15px; border-radius: 999px; font-size: 12px;
  1242.             font-weight: 700; box-shadow: 0 6px 20px rgba(0,0,0,.5); pointer-events: none;
  1243.             white-space: nowrap; max-width: calc(100vw - 32px); overflow: hidden; text-overflow: ellipsis;
  1244.         }
  1245.         /* ── Desktop: float the sheet as a left-hand panel ───────────────── */
  1246.         @media (min-width: 900px) {
  1247.             .sheet {
  1248.                 left: 20px;
  1249.                 right: auto;
  1250.                 bottom: 20px;
  1251.                 width: 380px;
  1252.                 /* Sits below the 60px header, above the bottom margin. */
  1253.                 height: auto;
  1254.                 max-height: calc(100vh - 100px);
  1255.                 border-radius: 16px;
  1256.                 border: 1px solid var(--border);
  1257.                 box-shadow: 0 20px 60px rgba(0,0,0,0.55);
  1258.             }
  1259.             /* Dragging is a touch affordance; on desktop the panel just sits. */
  1260.             .sheet-grab { display: none; }
  1261.             .sheet-head { padding-top: 16px; }
  1262.             .sheet-foot { border-radius: 0 0 16px 16px; }
  1263.             .sheet-body { max-height: 60vh; }
  1264.         }
  1265.         /* Small phones: give the map a little more room. */
  1266.         @media (max-width: 380px) {
  1267.             .sheet-body { padding: 0 14px 14px; }
  1268.             .sheet-head { padding: 2px 14px 10px; }
  1269.             .sheet-foot { padding: 10px 14px 14px; }
  1270.             .rt-price   { font-size: 14px; }
  1271.             /* The party-size table and the tip row are the two places where
  1272.                four-ish equal columns stop fitting; let them wrap instead of
  1273.                squeezing the numbers illegibly small. */
  1274.             .pax        { padding: 10px 12px; gap: 8px; }
  1275.             .pax-cell   { min-width: 0; padding: 7px 4px; }
  1276.             .pax-cell-p { font-size: 13px; }
  1277.             .tip-box    { padding: 12px; }
  1278.             .tip-opt    { min-width: 0; flex-basis: calc(50% - 4px); }
  1279.             .draw-badge { top: 68px; font-size: 11px; padding: 7px 12px; }
  1280.         }
  1281.     </style>
  1282. {% endblock %}
  1283. {# ── Override header — hide site header on map page ── #}
  1284. {% block header %}{% endblock %}
  1285. {# ── Override footer — hide site footer on map page ── #}
  1286. {% block footer %}{% endblock %}
  1287. {# ── Override body — inject full-screen map app ── #}
  1288. {% block body %}
  1289.     <div class="map-app">
  1290.         <header class="map-header">
  1291.             <a href="{{ path('app_map') }}" class="map-logo">
  1292.                 TN<span class="acc">T</span><span class="dot"></span>
  1293.             </a>
  1294.             <div class="map-controls" id="map-controls">
  1295.                 <a href="{{ path('app_map') }}" class="map-back">
  1296.                     <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
  1297.                         <path d="M9 2L4 7l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  1298.                     </svg>
  1299.                     <span data-i18n="nav.back">Back</span>
  1300.                 </a>
  1301.                 <span class="map-count"><strong id="vis-count">{{ locations|length }}</strong> <span data-i18n="nav.locations">locations</span></span>
  1302.                 <select class="map-select" id="filter-city">
  1303.                     <option value="" data-i18n="nav.allCities">All cities</option>
  1304.                     {% for city in cities %}
  1305.                         <option value="{{ city.id }}">{{ city.name }}</option>
  1306.                     {% endfor %}
  1307.                 </select>
  1308.                 <select class="map-select" id="filter-cat">
  1309.                     <option value="" data-i18n="nav.allCategories">All categories</option>
  1310.                     {% set cats = [] %}
  1311.                     {% for loc in locations %}
  1312.                         {% if loc.category and loc.category not in cats %}
  1313.                             {% set cats = cats|merge([loc.category]) %}
  1314.                             <option value="{{ loc.category }}">{{ loc.category|capitalize }}</option>
  1315.                         {% endif %}
  1316.                     {% endfor %}
  1317.                 </select>
  1318.                 <select class="map-select" id="filter-radius" title="How close before the audio plays">
  1319.                     <option value="100" selected data-i18n="radius.drive">🚗 Driving · 100 m</option>
  1320.                     <option value="50" data-i18n="radius.walk">🚶 Walking · 50 m</option>
  1321.                 </select>
  1322.                 {# Language switcher. These used to be one "🌐 Language" button
  1323.                    carrying class .map-back — which is display:none, so it was
  1324.                    invisible at every screen size and the only way to change
  1325.                    language was the first-visit welcome screen. Three pills,
  1326.                    auto-wired by TNT_I18N through data-set-lang (which also
  1327.                    marks the active one). #}
  1328.                 <div class="map-lang" role="group" aria-label="Language">
  1329.                     <span class="map-lang-ico" aria-hidden="true">🌐</span>
  1330.                     <button type="button" class="map-lang-btn" data-set-lang="en" title="English">EN</button>
  1331.                     <button type="button" class="map-lang-btn" data-set-lang="hy" title="Հայերեն">ՀՅ</button>
  1332.                     <button type="button" class="map-lang-btn" data-set-lang="ru" title="Русский">RU</button>
  1333.                 </div>
  1334.                 <button type="button" class="map-live" id="live-toggle" aria-pressed="false">
  1335.                     <span class="live-dot"></span>
  1336.                     <span class="live-label" data-i18n="nav.liveGuide">Live guide</span>
  1337.                 </button>
  1338.                 <div class="map-auth">
  1339.                     {% if app.user %}
  1340.                         <a href="{{ path('app_account') }}" class="auth-acct" title="My account" data-i18n-title="nav.myAccount">
  1341.                             <span class="auth-avatar">{{ app.user.name|split(' ')|map(w => w|first)|join('')|upper|slice(0, 2) }}</span>
  1342.                             <span class="auth-name">{{ app.user.name|split(' ')|first }}</span>
  1343.                         </a>
  1344.                         <a href="{{ path('app_logout') }}" class="auth-out" title="Sign out" data-i18n-title="nav.signout">⎋</a>
  1345.                     {% else %}
  1346.                         <a href="{{ path('app_login') }}" class="auth-btn auth-in" data-i18n="nav.signin">Sign in</a>
  1347.                         <a href="{{ path('app_register') }}" class="auth-btn auth-up" data-i18n="nav.register">Register</a>
  1348.                     {% endif %}
  1349.                 </div>
  1350.             </div>
  1351.             <button type="button" class="map-burger" id="map-burger"
  1352.                     aria-label="Menu" aria-controls="map-controls" aria-expanded="false">
  1353.                 <span></span><span></span><span></span>
  1354.             </button>
  1355.         </header>
  1356.         <div id="map"></div>
  1357.         {# ── Order sheet — route → tuk → payment ──
  1358.            Lives inside .map-app on purpose: that element sets z-index 9999 and
  1359.            so opens a stacking context. A sheet outside it would be painted
  1360.            under the map and swallow every tap. Inside, it stacks above the map
  1361.            panes, while the modal (10000) and welcome screen (11000) still win
  1362.            over the whole map-app. #}
  1363.         <div class="sheet hidden" id="sheet" aria-live="polite">
  1364.             <div class="sheet-grab" id="sheet-grab" role="button" tabindex="0" aria-label="Resize panel"></div>
  1365.             <div class="sheet-head">
  1366.                 <button type="button" class="sheet-back" id="sheet-back" aria-label="Back">
  1367.                     <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
  1368.                         <path d="M9 2L4 7l5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
  1369.                     </svg>
  1370.                 </button>
  1371.                 <div>
  1372.                     <div class="sheet-title" id="sheet-title" data-i18n="sheet.chooseTour">Choose your tour</div>
  1373.                     <div class="sheet-sub" id="sheet-sub"></div>
  1374.                 </div>
  1375.             </div>
  1376.             <div class="sheet-body" id="sheet-body"></div>
  1377.             <div class="sheet-foot hidden" id="sheet-foot">
  1378.                 <button type="button" class="sheet-cta" id="sheet-cta"></button>
  1379.             </div>
  1380.         </div>
  1381.     </div>
  1382.     {# ── Live "now playing" banner ── #}
  1383.     <div id="tnt-nowplaying" aria-live="polite">
  1384.         <div class="np-row">
  1385.             <div class="np-pulse">
  1386.                 <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
  1387.                     <path d="M3 10v4h4l5 5V5L7 10H3z" fill="currentColor"/>
  1388.                     <path d="M16 8a5 5 0 010 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
  1389.                 </svg>
  1390.             </div>
  1391.             <div class="np-meta">
  1392.                 <div class="np-eyebrow"><span data-i18n="guide.nowPlaying">Now playing</span> · <span id="np-dist"></span></div>
  1393.                 <div class="np-title" id="np-title"></div>
  1394.             </div>
  1395.             <button type="button" class="np-close" id="np-close" title="Stop">✕</button>
  1396.         </div>
  1397.         <audio id="np-audio" preload="auto" controls></audio>
  1398.     </div>
  1399.     {# Live GPS status pill (diagnostics) #}
  1400.     <div id="tnt-status" aria-live="polite"></div>
  1401.     {# Toast #}
  1402.     <div id="tnt-toast"></div>
  1403.     {# Watermark #}
  1404.     <div id="tnt-watermark">
  1405.         <img id="wm-img" src="{{ asset('img/tnt-watermark.png') }}" alt="TNT">
  1406.         <div id="wm-fallback">TN<span class="acc">T</span></div>
  1407.     </div>
  1408.     {# Modal #}
  1409.     <div id="tnt-backdrop">
  1410.         <div id="tnt-modal">
  1411.             <div class="m-header">
  1412.                 <button class="m-close" id="m-close-btn">✕</button>
  1413.                 <div class="m-category" id="m-cat"></div>
  1414.                 <div class="m-title" id="m-title"></div>
  1415.                 <div class="m-address">
  1416.                     <svg width="11" height="14" viewBox="0 0 11 14" fill="none">
  1417.                         <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"/>
  1418.                     </svg>
  1419.                     <span id="m-addr"></span>
  1420.                 </div>
  1421.             </div>
  1422.             <div class="m-divider"></div>
  1423.             <div class="m-body" id="m-body"></div>
  1424.         </div>
  1425.     </div>
  1426.     {# Welcome / language chooser — shown on first open #}
  1427.     <div id="tnt-welcome">
  1428.         <div class="w-card">
  1429.             <div class="w-logo">TN<span class="acc">T</span><span class="dot"></span></div>
  1430.             <div class="w-title" data-i18n="welcome.title">Welcome to Tuk-n-Tour</div>
  1431.             <div class="w-sub" data-i18n="welcome.sub">Choose your language to start exploring</div>
  1432.             <div class="w-langs">
  1433.                 <button type="button" class="w-lang" data-lang="en">
  1434.                     <span class="w-flag">🇬🇧</span><span>English</span>
  1435.                 </button>
  1436.                 <button type="button" class="w-lang" data-lang="hy">
  1437.                     <span class="w-flag">🇦🇲</span><span>Հայերեն</span>
  1438.                 </button>
  1439.                 <button type="button" class="w-lang" data-lang="ru">
  1440.                     <span class="w-flag">🇷🇺</span><span>Русский</span>
  1441.                 </button>
  1442.             </div>
  1443.         </div>
  1444.     </div>
  1445.     {# Hide the welcome popup synchronously (before paint) when a language is
  1446.        already stored, so returning visitors never see it flash. #}
  1447.     <script>
  1448.         try {
  1449.             if (localStorage.getItem('tnt_lang')) {
  1450.                 document.getElementById('tnt-welcome').classList.add('hidden');
  1451.             }
  1452.         } catch (e) {}
  1453.     </script>
  1454.     {# Booking API wiring. __REF__ is swapped for a real booking reference client-side. #}
  1455.     <script>
  1456.         var TAXI = {
  1457.             routesUrl:   "{{ path('api_routes') }}",
  1458.             tuksUrl:     "{{ path('api_tuks') }}",
  1459.             tuksMapUrl:  "{{ path('api_tuks_map') }}",
  1460.             createUrl:   "{{ path('api_booking_create') }}",
  1461.             activeUrl:   "{{ path('api_booking_active') }}",
  1462.             showUrlTpl:  "{{ path('api_booking_show', {reference: '__REF__'}) }}",
  1463.             cancelUrlTpl:"{{ path('api_booking_cancel', {reference: '__REF__'}) }}",
  1464.             rateUrlTpl:  "{{ path('api_booking_rate', {reference: '__REF__'}) }}",
  1465.             tipUrlTpl:   "{{ path('api_booking_tip', {reference: '__REF__'}) }}",
  1466.             cardsUrl:    "{{ path('api_cards') }}",
  1467.             // Chat + in-app calls. The module appends its own suffixes to the base.
  1468.             commBase:    "{{ path('comm_thread', {reference: '__REF__'})|replace({'/thread': ''}) }}",
  1469.             commIceUrl:  "{{ path('comm_ice') }}",
  1470.             reservationsUrl: "{{ path('api_reservation_create') }}",
  1471.             customUrl:   "{{ path('api_reservation_custom') }}",
  1472.             resShowTpl:  "{{ path('api_reservation_show', {reference: '__REF__'}) }}",
  1473.             loginUrl:    "{{ path('app_login') }}",
  1474.             // Used to rebuild the header once a guest checkout has registered
  1475.             // and signed the visitor in without a page reload.
  1476.             accountUrl:  "{{ path('app_account') }}",
  1477.             logoutUrl:   "{{ path('app_logout') }}",
  1478.             csrf:        "{{ csrf_token('taxi_api') }}",
  1479.             isLoggedIn:  {{ app.user ? 'true' : 'false' }},
  1480.             // Pricing and tipping rules live on the server; these are the copies
  1481.             // the sheet renders with. Every amount is re-checked server-side, so
  1482.             // a tampered value here buys nothing.
  1483.             maxPassengers: {{ constant('App\\Service\\Pricing\\TourPricing::MAX_PASSENGERS') }},
  1484.             tipPresets:    {{ constant('App\\Entity\\Booking::TIP_PRESETS_AMD')|json_encode|raw }},
  1485.             tipMin:        {{ constant('App\\Entity\\Booking::TIP_MIN_AMD') }},
  1486.             tipMax:        {{ constant('App\\Entity\\Booking::TIP_MAX_AMD') }},
  1487.             customMinPts:  {{ constant('App\\Entity\\TourReservation::CUSTOM_MIN_POINTS') }},
  1488.             customMaxPts:  {{ constant('App\\Entity\\TourReservation::CUSTOM_MAX_POINTS') }},
  1489.             customColor:   "{{ constant('App\\Entity\\TourReservation::CUSTOM_COLOR') }}",
  1490.             // Set when the account page links back to a scheduled tour
  1491.             // (/map?res=TNT-R-XXXXXX) so the map can paint that line.
  1492.             showReservation: {{ app.request.query.get('res') ? ('"' ~ app.request.query.get('res')|e('js') ~ '"')|raw : 'null' }}
  1493.         };
  1494.         // Real-time push (Mercure). Null when logged out or no hub is configured;
  1495.         // the order view then simply keeps polling.
  1496.         var MERCURE = {
  1497.             url:   {{ mercure_url|default(null)|json_encode|raw }},
  1498.             topic: {{ mercure_topic|default(null)|json_encode|raw }}
  1499.         };
  1500.     </script>
  1501.     {# Location data #}
  1502.     <script>
  1503.         var LOCS = {{ locations|map(loc => {
  1504.             id:       loc.id,
  1505.             name:     loc.name,
  1506.             nameHy:   loc.nameHy,
  1507.             nameRu:   loc.nameRu,
  1508.             address:  loc.address,
  1509.             lat:      loc.latitude,
  1510.             lng:      loc.longitude,
  1511.             category: loc.category,
  1512.             cityId:   loc.city.id,
  1513.             details:  loc.details|filter(d => d.isActive)|map(d => {
  1514.                 type:     d.type,
  1515.                 title:    d.title,
  1516.                 content:  d.content,
  1517.                 filePath: d.filePath,
  1518.                 language: d.language,
  1519.                 sort:     d.sortOrder
  1520.             })|sort((a,b) => a.sort <=> b.sort)
  1521.         })|json_encode|raw }};
  1522.         var NEARBY_URL = "{{ path('app_map_nearby') }}";
  1523.     </script>
  1524. {% endblock %}
  1525. {# ── Override javascripts — suppress jQuery/Bootstrap, load only Yandex Maps ── #}
  1526. {% block javascripts %}
  1527.     <script src="https://api-maps.yandex.ru/2.1/?apikey={{ yandex_api_key }}&lang=en_US"></script>
  1528.     {# Passenger↔driver chat and in-app voice calls. Shared verbatim with /driver. #}
  1529.     <script src="{{ asset('js/tnt-comm.js') }}"></script>
  1530.     <script>
  1531.         // ── Language chooser ──
  1532.         // LANG is global and read by openModal() and the live guide. Content
  1533.         // (text/audio/video) is filtered to the chosen language.
  1534.         var LANG = null;
  1535.         var LANG_NAMES = { en: 'English', hy: 'Հայերեն', ru: 'Русский' };
  1536.         (function () {
  1537.             var wrap = document.getElementById('tnt-welcome');
  1538.             try { LANG = localStorage.getItem('tnt_lang'); } catch (e) {}
  1539.             // Highlight the currently chosen language inside the welcome chooser.
  1540.             // Must run on every change (not just page load) so re-opening the
  1541.             // chooser shows the active choice without needing a refresh.
  1542.             function markWelcome() {
  1543.                 wrap.querySelectorAll('.w-lang').forEach(function (b) {
  1544.                     b.classList.toggle('current', b.dataset.lang === LANG);
  1545.                 });
  1546.             }
  1547.             function choose(l) {
  1548.                 LANG = l;
  1549.                 wrap.classList.add('hidden');
  1550.                 // TNT_I18N.set persists tnt_lang, applies data-i18n to the static
  1551.                 // chrome, and fires tnt:langchange — which re-renders the JS views
  1552.                 // (booking sheet) and re-opens any active location modal.
  1553.                 if (window.TNT_I18N) {
  1554.                     window.TNT_I18N.set(l);
  1555.                 } else {
  1556.                     try { localStorage.setItem('tnt_lang', l); } catch (e) {}
  1557.                     if (typeof reopenActiveModal === 'function') reopenActiveModal();
  1558.                 }
  1559.             }
  1560.             wrap.addEventListener('click', function (e) {
  1561.                 var b = e.target.closest('.w-lang');
  1562.                 if (b) choose(b.dataset.lang);
  1563.             });
  1564.             // Keep LANG and the open location modal in sync whenever the
  1565.             // language changes (from this chooser or the header's data-set-lang
  1566.             // pills). The booking sheet re-renders itself via its own listener
  1567.             // registered inside that IIFE (LANG is shared and updated here
  1568.             // first, since this listener is registered earlier).
  1569.             document.addEventListener('tnt:langchange', function (e) {
  1570.                 LANG = (e.detail && e.detail.lang) || LANG;
  1571.                 markWelcome();
  1572.                 if (typeof reopenActiveModal === 'function') reopenActiveModal();
  1573.             });
  1574.             // Only ask when no language is set yet. On return visits the stored
  1575.             // choice is used and the welcome screen is skipped — the header's
  1576.             // EN / ՀՅ / RU pills change it from then on. Highlight the stored
  1577.             // choice anyway, in case the chooser is shown again.
  1578.             if (LANG && LANG_NAMES[LANG]) {
  1579.                 wrap.classList.add('hidden');
  1580.             }
  1581.             markWelcome();
  1582.         }());
  1583.         // ── Watermark ──
  1584.         (function () {
  1585.             var img = document.getElementById('wm-img');
  1586.             var fb  = document.getElementById('wm-fallback');
  1587.             var t   = new Image();
  1588.             t.onload  = function () { img.src = t.src; img.style.display = 'block'; fb.style.display = 'none'; };
  1589.             t.onerror = function () { img.style.display = 'none'; fb.style.display = 'flex'; };
  1590.             t.src = img.getAttribute('src');
  1591.         }());
  1592.         // ── Modal ──
  1593.         var backdrop = document.getElementById('tnt-backdrop');
  1594.         document.getElementById('m-close-btn').addEventListener('click', function () {
  1595.             backdrop.classList.remove('open');
  1596.         });
  1597.         backdrop.addEventListener('click', function (e) {
  1598.             if (e.target === backdrop) backdrop.classList.remove('open');
  1599.         });
  1600.         document.addEventListener('keydown', function (e) {
  1601.             if (e.key === 'Escape') backdrop.classList.remove('open');
  1602.         });
  1603.         // Localized display name for a LOCS entry (Armenian/Russian when chosen
  1604.         // & present; falls back to the default name).
  1605.         function locName(loc) {
  1606.             if (LANG === 'hy' && loc && loc.nameHy) return loc.nameHy;
  1607.             if (LANG === 'ru' && loc && loc.nameRu) return loc.nameRu;
  1608.             return loc ? loc.name : '';
  1609.         }
  1610.         // Localized name looked up by id (for server responses that carry only id).
  1611.         function locNameById(id, fallback) {
  1612.             if (typeof LOCS !== 'undefined') {
  1613.                 for (var i = 0; i < LOCS.length; i++) {
  1614.                     if (LOCS[i].id === id) return locName(LOCS[i]);
  1615.                 }
  1616.             }
  1617.             return fallback;
  1618.         }
  1619.         var activeModalLoc = null;   // remembered so we can re-render on language change
  1620.         function openModal(loc, opts) {
  1621.             opts = opts || {};
  1622.             activeModalLoc = loc;
  1623.             document.getElementById('m-cat').textContent   = loc.category || '';
  1624.             document.getElementById('m-title').textContent = locName(loc);
  1625.             document.getElementById('m-addr').textContent  = loc.address || '';
  1626.             var body = document.getElementById('m-body');
  1627.             body.innerHTML = '';
  1628.             // Only show content in the chosen language. Language-neutral items
  1629.             // (those without a language) are always shown.
  1630.             var details = (loc.details || []).filter(function (d) {
  1631.                 return !LANG || !d.language || d.language === LANG;
  1632.             });
  1633.             if (!details.length) {
  1634.                 var langName = (LANG && LANG_NAMES[LANG]) ? LANG_NAMES[LANG] : 'this language';
  1635.                 body.innerHTML = '<div class="m-empty">No content available in ' + esc(langName) + '.</div>';
  1636.                 backdrop.classList.add('open');
  1637.                 return;
  1638.             }
  1639.             var types = details
  1640.                 .map(function (d) { return d.type; })
  1641.                 .filter(function (v, i, a) { return a.indexOf(v) === i; });
  1642.             // Which tab opens first: caller's choice if available, else the first.
  1643.             var activeType = (opts.activeType && types.indexOf(opts.activeType) !== -1)
  1644.                 ? opts.activeType : types[0];
  1645.             var tabs = document.createElement('div');
  1646.             tabs.className = 'm-tabs';
  1647.             types.forEach(function (type, i) {
  1648.                 var btn = document.createElement('button');
  1649.                 btn.className = 'm-tab' + (type === activeType ? ' active' : '');
  1650.                 btn.textContent = type.charAt(0).toUpperCase() + type.slice(1);
  1651.                 btn.dataset.type = type;
  1652.                 tabs.appendChild(btn);
  1653.             });
  1654.             body.appendChild(tabs);
  1655.             tabs.addEventListener('click', function (e) {
  1656.                 var btn = e.target.closest('.m-tab');
  1657.                 if (!btn) return;
  1658.                 body.querySelectorAll('.m-tab').forEach(function (t) { t.classList.remove('active'); });
  1659.                 body.querySelectorAll('.m-panel').forEach(function (p) { p.classList.remove('active'); });
  1660.                 btn.classList.add('active');
  1661.                 body.querySelector('.m-panel[data-type="' + btn.dataset.type + '"]').classList.add('active');
  1662.             });
  1663.             types.forEach(function (type, i) {
  1664.                 var panel = document.createElement('div');
  1665.                 panel.className = 'm-panel' + (type === activeType ? ' active' : '');
  1666.                 panel.dataset.type = type;
  1667.                 details.filter(function (d) { return d.type === type; }).forEach(function (d) {
  1668.                     var item = document.createElement('div');
  1669.                     item.className = 'm-item';
  1670.                     var html = '<div class="m-item-title">' + esc(d.title) + '</div>';
  1671.                     if (d.language) html += '<div class="m-item-lang">' + esc(d.language) + '</div>';
  1672.                     if (type === 'text') {
  1673.                         html += '<div class="m-item-text">' + esc(d.content || '') + '</div>';
  1674.                     } else if (type === 'audio') {
  1675.                         if (d.filePath) html += '<audio controls><source src="' + esc(d.filePath) + '"></audio>';
  1676.                         if (d.content)  html += '<div class="m-item-text" style="margin-top:8px">' + esc(d.content) + '</div>';
  1677.                     } else if (type === 'video') {
  1678.                         if (d.filePath) html += '<video controls><source src="' + esc(d.filePath) + '"></video>';
  1679.                         if (d.content)  html += '<div class="m-item-text" style="margin-top:8px">' + esc(d.content) + '</div>';
  1680.                     }
  1681.                     item.innerHTML = html;
  1682.                     panel.appendChild(item);
  1683.                 });
  1684.                 body.appendChild(panel);
  1685.             });
  1686.             backdrop.classList.add('open');
  1687.             // Autoplay the media in the opened tab (used by the live GPS guide).
  1688.             // If the browser blocks the freshly-created player, tell the caller
  1689.             // so it can fall back to an already-unlocked audio element.
  1690.             if (opts.autoplay) {
  1691.                 var media = body.querySelector('.m-panel.active audio, .m-panel.active video');
  1692.                 if (media) {
  1693.                     var pp = media.play();
  1694.                     if (pp && pp.catch) {
  1695.                         pp.catch(function () { if (opts.onBlocked) opts.onBlocked(); });
  1696.                     }
  1697.                 } else if (opts.onBlocked) {
  1698.                     opts.onBlocked();
  1699.                 }
  1700.             }
  1701.         }
  1702.         // Re-render the currently open modal (e.g. after the language changes).
  1703.         function reopenActiveModal() {
  1704.             if (activeModalLoc && backdrop.classList.contains('open')) {
  1705.                 openModal(activeModalLoc, {});
  1706.             }
  1707.         }
  1708.         // ── Map ──
  1709.         var map, clusterer, allPM = [], filterCity = '', filterCat = '';
  1710.         ymaps.ready(function () {
  1711.             map = new ymaps.Map('map', {
  1712.                 center: [40.1872, 44.5152],
  1713.                 zoom: 12,
  1714.                 controls: ['zoomControl', 'fullscreenControl'],
  1715.             }, { suppressMapOpenBlock: true });
  1716.             map.panes.get('ground').getElement().style.filter =
  1717.                 'invert(90%) hue-rotate(180deg) saturate(0.6) brightness(0.85)';
  1718.             clusterer = new ymaps.Clusterer({
  1719.                 preset: 'islands#invertedBlueClusterIcons',
  1720.                 groupByCoordinates: false,
  1721.             });
  1722.             LOCS.forEach(function (loc) {
  1723.                 if (!loc.lat || !loc.lng) return;
  1724.                 var pm = new ymaps.Placemark(
  1725.                     [loc.lat, loc.lng], {},
  1726.                     { preset: 'islands#blueDotIcon', iconColor: '#48C9B0' }
  1727.                 );
  1728.                 pm.events.add('click', function () {
  1729.                     // While a custom tour is being put together, a pin is a
  1730.                     // choice rather than a guide entry: hand it to the booking
  1731.                     // sheet instead of opening the modal. TNT_PICK is the
  1732.                     // bridge the sheet sets up (it lives in its own closure).
  1733.                     if (window.TNT_PICK && window.TNT_PICK.active) {
  1734.                         window.TNT_PICK.pick(loc);
  1735.                         return;
  1736.                     }
  1737.                     openModal(loc);
  1738.                 });
  1739.                 allPM.push({ pm: pm, loc: loc });
  1740.                 clusterer.add(pm);
  1741.             });
  1742.             map.geoObjects.add(clusterer);
  1743.             document.getElementById('vis-count').textContent = allPM.length;
  1744.             fitTo(allPM.map(function (x) { return x.pm; }));
  1745.         });
  1746.         function fitTo(pms) {
  1747.             if (!pms.length) return;
  1748.             if (pms.length === 1) {
  1749.                 map.setCenter(pms[0].geometry.getCoordinates(), 15, { duration: 400 });
  1750.                 return;
  1751.             }
  1752.             var b = pms.reduce(function (acc, pm) {
  1753.                 var c = pm.geometry.getCoordinates();
  1754.                 return [
  1755.                     [Math.min(acc[0][0], c[0]), Math.min(acc[0][1], c[1])],
  1756.                     [Math.max(acc[1][0], c[0]), Math.max(acc[1][1], c[1])],
  1757.                 ];
  1758.             }, [[90, 180], [-90, -180]]);
  1759.             map.setBounds(b, { checkZoomRange: true, zoomMargin: 80, duration: 400 });
  1760.         }
  1761.         document.getElementById('filter-city').addEventListener('change', function () {
  1762.             filterCity = this.value; applyFilters();
  1763.         });
  1764.         document.getElementById('filter-cat').addEventListener('change', function () {
  1765.             filterCat = this.value; applyFilters();
  1766.         });
  1767.         function applyFilters() {
  1768.             clusterer.removeAll();
  1769.             var vis = allPM.filter(function (x) {
  1770.                 return (!filterCity || String(x.loc.cityId) === filterCity) &&
  1771.                     (!filterCat  || x.loc.category === filterCat);
  1772.             });
  1773.             clusterer.add(vis.map(function (x) { return x.pm; }));
  1774.             document.getElementById('vis-count').textContent = vis.length;
  1775.             fitTo(vis.map(function (x) { return x.pm; }));
  1776.         }
  1777.         function esc(s) {
  1778.             return String(s)
  1779.                 .replace(/&/g,'&amp;').replace(/</g,'&lt;')
  1780.                 .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  1781.         }
  1782.         // ── Responsive header menu ──
  1783.         (function () {
  1784.             var burger   = document.getElementById('map-burger');
  1785.             var controls = document.getElementById('map-controls');
  1786.             if (!burger || !controls) return;
  1787.             function setOpen(open) {
  1788.                 controls.classList.toggle('open', open);
  1789.                 burger.setAttribute('aria-expanded', open ? 'true' : 'false');
  1790.             }
  1791.             burger.addEventListener('click', function (e) {
  1792.                 e.stopPropagation();
  1793.                 setOpen(!controls.classList.contains('open'));
  1794.             });
  1795.             // Close when tapping outside the open panel
  1796.             document.addEventListener('click', function (e) {
  1797.                 if (!controls.classList.contains('open')) return;
  1798.                 if (!controls.contains(e.target) && e.target !== burger) setOpen(false);
  1799.             });
  1800.             // Close on Escape
  1801.             document.addEventListener('keydown', function (e) {
  1802.                 if (e.key === 'Escape') setOpen(false);
  1803.             });
  1804.         }());
  1805.         // ═══════════════════════════════════════════════════════════════════
  1806.         //  LIVE GUIDE — GPS drive-by autoplay
  1807.         //  Watches the visitor's position, asks the server for a nearby
  1808.         //  location and autoplays its mp3 audio guide on arrival.
  1809.         // ═══════════════════════════════════════════════════════════════════
  1810.         (function () {
  1811.             var SILENT = 'data:audio/wav;base64,UklGRnQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YVAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA==';
  1812.             var MIN_MOVE_M  = 25;     // don't re-query until moved this far
  1813.             var MIN_GAP_MS  = 4000;   // …or at least this long since last query
  1814.             var radiusSel = document.getElementById('filter-radius');
  1815.             var RADIUS_M  = radiusSel ? parseInt(radiusSel.value, 10) : 100;
  1816.             var lastFix   = null;     // last GPS fix, so we can re-query on range change
  1817.             if (radiusSel) {
  1818.                 radiusSel.addEventListener('change', function () {
  1819.                     RADIUS_M = parseInt(this.value, 10) || 100;
  1820.                     if (lastFix && watchId !== null) {
  1821.                         lastQuery = { lat: null, lng: null, at: 0 };  // force a fresh query
  1822.                         query(lastFix.lat, lastFix.lng, lastFix.acc);
  1823.                     }
  1824.                 });
  1825.             }
  1826.             var toggle  = document.getElementById('live-toggle');
  1827.             var banner  = document.getElementById('tnt-nowplaying');
  1828.             var audio   = document.getElementById('np-audio');
  1829.             var npTitle = document.getElementById('np-title');
  1830.             var npDist  = document.getElementById('np-dist');
  1831.             var npClose = document.getElementById('np-close');
  1832.             var toastEl = document.getElementById('tnt-toast');
  1833.             var statusEl = document.getElementById('tnt-status');
  1834.             var controlsPanel = document.getElementById('map-controls');
  1835.             function setStatus(html) { statusEl.innerHTML = html; }
  1836.             var watchId       = null;
  1837.             var userPM        = null;   // Yandex placemark for the visitor
  1838.             var userLayout    = null;   // cached custom icon layout class
  1839.             var lastQuery     = { lat: null, lng: null, at: 0 };
  1840.             var currentLoc    = null;   // id of the location currently playing
  1841.             var inFlight      = false;
  1842.             var travelHeading = null;   // degrees, 0 = north, direction of travel
  1843.             var headingAnchor = null;   // point used to derive heading from motion
  1844.             var toastTimer;
  1845.             function toast(msg) {
  1846.                 toastEl.textContent = msg;
  1847.                 toastEl.classList.add('show');
  1848.                 clearTimeout(toastTimer);
  1849.                 toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 3200);
  1850.             }
  1851.             // Metres between two lat/lng points (Haversine, mirrors the backend).
  1852.             function distM(aLat, aLng, bLat, bLng) {
  1853.                 var R = 6371000, toRad = Math.PI / 180;
  1854.                 var dLat = (bLat - aLat) * toRad, dLng = (bLng - aLng) * toRad;
  1855.                 var s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
  1856.                     Math.cos(aLat * toRad) * Math.cos(bLat * toRad) *
  1857.                     Math.sin(dLng / 2) * Math.sin(dLng / 2);
  1858.                 return 2 * R * Math.asin(Math.sqrt(s));
  1859.             }
  1860.             // Initial compass bearing from point A to point B (degrees, 0 = north).
  1861.             function bearing(aLat, aLng, bLat, bLng) {
  1862.                 var toRad = Math.PI / 180, toDeg = 180 / Math.PI;
  1863.                 var φ1 = aLat * toRad, φ2 = bLat * toRad, Δλ = (bLng - aLng) * toRad;
  1864.                 var y = Math.sin(Δλ) * Math.cos(φ2);
  1865.                 var x = Math.cos(φ1) * Math.sin(φ2) -
  1866.                     Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
  1867.                 return (Math.atan2(y, x) * toDeg + 360) % 360;
  1868.             }
  1869.             // Signed angle of `to` relative to `from`, in [-180,180]. + = to the right.
  1870.             function angleDiff(from, to) {
  1871.                 return ((to - from + 540) % 360) - 180;
  1872.             }
  1873.             // Next location to hint at: the closest one ahead in the direction of
  1874.             // travel; if the heading is unknown, the nearest overall. Name is
  1875.             // localized to the chosen language.
  1876.             function pickAhead(lat, lng) {
  1877.                 if (typeof LOCS === 'undefined') return null;
  1878.                 var best = null, bestAny = null;
  1879.                 for (var i = 0; i < LOCS.length; i++) {
  1880.                     var L = LOCS[i];
  1881.                     if (!L.lat || !L.lng || L.id === currentLoc) continue;
  1882.                     var d = distM(lat, lng, +L.lat, +L.lng);
  1883.                     if (!bestAny || d < bestAny.d) bestAny = { loc: L, d: d };
  1884.                     if (travelHeading != null) {
  1885.                         var diff = angleDiff(travelHeading, bearing(lat, lng, +L.lat, +L.lng));
  1886.                         if (Math.abs(diff) > 75) continue;       // behind / off to the side
  1887.                         if (!best || d < best.d) best = { loc: L, d: d, diff: diff };
  1888.                     }
  1889.                 }
  1890.                 var chosen = best || bestAny;
  1891.                 if (!chosen) return null;
  1892.                 var arrow = best
  1893.                     ? (Math.abs(best.diff) <= 20 ? '↑' : (best.diff > 0 ? '↗' : '↖'))
  1894.                     : '⟶';
  1895.                 return { name: locName(chosen.loc), dist: chosen.d, arrow: arrow };
  1896.             }
  1897.             // Fallback player (always unlocked by the initial tap): used only when
  1898.             // the modal's freshly-created <audio> is blocked from autoplaying (iOS).
  1899.             function playFallback(aud) {
  1900.                 audio.src = aud.filePath;
  1901.                 audio.currentTime = 0;
  1902.                 var bp = audio.play();
  1903.                 if (bp && bp.catch) bp.catch(function () {
  1904.                     toast(window.t('guide.tapPlay'));
  1905.                 });
  1906.             }
  1907.             function startGuide(loc, aud) {
  1908.                 currentLoc = loc.id;
  1909.                 // Stop whatever the previous location was playing before switching.
  1910.                 try { audio.pause(); } catch (e) {}
  1911.                 var prevMedia = document.querySelectorAll('#tnt-modal audio, #tnt-modal video');
  1912.                 for (var k = 0; k < prevMedia.length; k++) {
  1913.                     try { prevMedia[k].pause(); } catch (e) {}
  1914.                 }
  1915.                 // Find the full location (with all details) already loaded on the page.
  1916.                 var full = null;
  1917.                 if (typeof LOCS !== 'undefined') {
  1918.                     for (var i = 0; i < LOCS.length; i++) {
  1919.                         if (LOCS[i].id === loc.id) { full = LOCS[i]; break; }
  1920.                     }
  1921.                 }
  1922.                 if (full && typeof openModal === 'function') {
  1923.                     // Open the detail modal on the AUDIO tab and play it there.
  1924.                     openModal(full, {
  1925.                         activeType: 'audio',
  1926.                         autoplay:   true,
  1927.                         onBlocked:  function () { playFallback(aud); }
  1928.                     });
  1929.                 } else {
  1930.                     // No modal data — show the compact banner and play there instead.
  1931.                     npTitle.textContent = (aud && aud.title) ? aud.title : loc.name;
  1932.                     npDist.textContent  = loc.name + ' · ' + Math.round(loc.distance) + ' m';
  1933.                     banner.classList.add('show');
  1934.                     playFallback(aud);
  1935.                 }
  1936.             }
  1937.             function stopGuide() {
  1938.                 try { audio.pause(); } catch (e) {}
  1939.                 banner.classList.remove('show');
  1940.                 currentLoc = null;
  1941.             }
  1942.             npClose.addEventListener('click', stopGuide);
  1943.             audio.addEventListener('ended', function () {
  1944.                 // Keep the location "played" so we don't loop it; just hide banner.
  1945.                 banner.classList.remove('show');
  1946.             });
  1947.             // Anti-echo: if any *other* audio starts (e.g. the modal's own player),
  1948.             // pause the banner audio so the guide isn't heard twice.
  1949.             document.addEventListener('play', function (e) {
  1950.                 if (e.target !== audio && e.target.tagName === 'AUDIO') {
  1951.                     try { audio.pause(); } catch (_) {}
  1952.                 }
  1953.             }, true);
  1954.             function query(lat, lng, acc) {
  1955.                 if (inFlight) return;
  1956.                 inFlight = true;
  1957.                 var url = NEARBY_URL + '?lat=' + encodeURIComponent(lat) +
  1958.                     '&lng=' + encodeURIComponent(lng) + '&radius=' + RADIUS_M +
  1959.                     (LANG ? '&lang=' + encodeURIComponent(LANG) : '');
  1960.                 fetch(url, { headers: {
  1961.                     'X-Requested-With': 'XMLHttpRequest',
  1962.                     'ngrok-skip-browser-warning': 'true'
  1963.                 } })
  1964.                     .then(function (r) { return r.json(); })
  1965.                     .then(function (data) {
  1966.                         inFlight = false;
  1967.                         var accTxt = ' · ' + lat.toFixed(5) + ', ' + lng.toFixed(5) +
  1968.                             (acc ? ' ±' + Math.round(acc) + 'm' : '');
  1969.                         if (data && data.found) {
  1970.                             var locLabel = locNameById(data.location.id, data.location.name);
  1971.                             if (!data.audio || !data.audio.filePath) {
  1972.                                 setStatus('<span class="st-warn">' + esc(locLabel) +
  1973.                                     '</span> is here but has no audio guide' + accTxt);
  1974.                                 return;
  1975.                             }
  1976.                             if (data.location.id === currentLoc) {
  1977.                                 setStatus('<span class="st-ok">▶ ' + esc(locLabel) +
  1978.                                     '</span> · ' + Math.round(data.location.distance) + 'm' + accTxt);
  1979.                                 return; // already playing this one
  1980.                             }
  1981.                             startGuide(data.location, data.audio);
  1982.                             setStatus('<span class="st-ok">▶ ' + esc(locLabel) +
  1983.                                 '</span> · ' + Math.round(data.location.distance) + 'm' + accTxt);
  1984.                             return;
  1985.                         }
  1986.                         // Between locations — hint at the next place in the direction
  1987.                         // of travel; fall back to nearest-by-distance until a heading
  1988.                         // is known.
  1989.                         var name = null, dist = null, arrow = '⟶';
  1990.                         var ahead = pickAhead(lat, lng);
  1991.                         if (ahead) {
  1992.                             name = ahead.name; dist = Math.round(ahead.dist); arrow = ahead.arrow;
  1993.                         } else if (data && data.nearest) {
  1994.                             name = data.nearest.name; dist = Math.round(data.nearest.distance);
  1995.                         }
  1996.                         if (name !== null) {
  1997.                             var remain = Math.max(0, dist - RADIUS_M);
  1998.                             setStatus('Next ' + arrow + ' <b>' + esc(name) + '</b> · ~' + dist + 'm ' +
  1999.                                 '<span class="st-warn">(plays in ~' + remain + 'm)</span>' + accTxt);
  2000.                         } else {
  2001.                             setStatus('No locations found' + accTxt);
  2002.                         }
  2003.                     })
  2004.                     .catch(function () {
  2005.                         inFlight = false;
  2006.                         setStatus('<span class="st-warn">Network error contacting server</span>');
  2007.                     });
  2008.             }
  2009.             function drawUserMarker(lat, lng) {
  2010.                 if (typeof map === 'undefined' || !map || typeof ymaps === 'undefined') return;
  2011.                 if (!userLayout) {
  2012.                     userLayout = ymaps.templateLayoutFactory.createClass(
  2013.                         '<div class="user-heading" style="transform:rotate($[properties.rotation]deg)">' +
  2014.                             '<div class="uh-arrow" style="opacity:$[properties.arrowOpacity]"></div>' +
  2015.                             '<div class="uh-dot"></div>' +
  2016.                         '</div>'
  2017.                     );
  2018.                 }
  2019.                 // Rotation follows the travel heading; hide the arrow until we know it.
  2020.                 var props = {
  2021.                     rotation:     (travelHeading == null ? 0 : Math.round(travelHeading)),
  2022.                     arrowOpacity: (travelHeading == null ? 0 : 1)
  2023.                 };
  2024.                 if (!userPM) {
  2025.                     userPM = new ymaps.Placemark([lat, lng], props, {
  2026.                         iconLayout: userLayout,
  2027.                         iconShape:  { type: 'Circle', coordinates: [0, 0], radius: 15 }
  2028.                     });
  2029.                     map.geoObjects.add(userPM);
  2030.                     map.setCenter([lat, lng], Math.max(map.getZoom(), 15), { duration: 400 });
  2031.                 } else {
  2032.                     userPM.geometry.setCoordinates([lat, lng]);
  2033.                     userPM.properties.set(props);
  2034.                 }
  2035.             }
  2036.             function onPosition(pos) {
  2037.                 var lat = pos.coords.latitude,
  2038.                     lng = pos.coords.longitude,
  2039.                     acc = pos.coords.accuracy;
  2040.                 lastFix = { lat: lat, lng: lng, acc: acc };
  2041.                 // Direction of travel: use the device heading while actually moving,
  2042.                 // otherwise derive it from successive positions (>12 m apart).
  2043.                 if (typeof pos.coords.heading === 'number' && !isNaN(pos.coords.heading) &&
  2044.                     (pos.coords.speed == null || pos.coords.speed > 0.5)) {
  2045.                     travelHeading = pos.coords.heading;
  2046.                 } else if (headingAnchor) {
  2047.                     if (distM(headingAnchor.lat, headingAnchor.lng, lat, lng) > 12) {
  2048.                         travelHeading = bearing(headingAnchor.lat, headingAnchor.lng, lat, lng);
  2049.                         headingAnchor = { lat: lat, lng: lng };
  2050.                     }
  2051.                 } else {
  2052.                     headingAnchor = { lat: lat, lng: lng };
  2053.                 }
  2054.                 // 1) Query the server FIRST — audio must never be blocked by the map.
  2055.                 var now = Date.now();
  2056.                 var moved = lastQuery.lat === null ? Infinity :
  2057.                     distM(lastQuery.lat, lastQuery.lng, lat, lng);
  2058.                 if (moved >= MIN_MOVE_M || (now - lastQuery.at) >= MIN_GAP_MS) {
  2059.                     lastQuery = { lat: lat, lng: lng, at: now };
  2060.                     query(lat, lng, acc);
  2061.                 }
  2062.                 // 2) Draw the visitor marker (guarded so it can never break the flow).
  2063.                 try { drawUserMarker(lat, lng); } catch (e) {}
  2064.             }
  2065.             function onError(err) {
  2066.                 if (err && err.code === 1) {           // PERMISSION_DENIED
  2067.                     stopWatching();
  2068.                     toast(window.t('guide.denied'));
  2069.                     return;
  2070.                 }
  2071.                 // POSITION_UNAVAILABLE / TIMEOUT — keep watching, just report it.
  2072.                 setStatus('<span class="st-warn">Waiting for a GPS fix…</span>');
  2073.             }
  2074.             function stopWatching() {
  2075.                 if (watchId !== null) {
  2076.                     navigator.geolocation.clearWatch(watchId);
  2077.                     watchId = null;
  2078.                 }
  2079.                 toggle.classList.remove('active');
  2080.                 toggle.setAttribute('aria-pressed', 'false');
  2081.                 toggle.querySelector('.live-label').textContent = window.t('nav.liveGuide');
  2082.                 if (userPM && typeof map !== 'undefined' && map) {
  2083.                     map.geoObjects.remove(userPM);
  2084.                     userPM = null;
  2085.                 }
  2086.                 lastQuery = { lat: null, lng: null, at: 0 };
  2087.                 statusEl.classList.remove('show');
  2088.             }
  2089.             toggle.addEventListener('click', function () {
  2090.                 if (watchId !== null) { stopWatching(); return; }
  2091.                 if (!('geolocation' in navigator)) {
  2092.                     toast(window.t('guide.noGeo'));
  2093.                     return;
  2094.                 }
  2095.                 // Unlock audio inside this user gesture so later autoplay works.
  2096.                 audio.src = SILENT;
  2097.                 var up = audio.play();
  2098.                 if (up && up.catch) up.catch(function () {});
  2099.                 toggle.classList.add('active');
  2100.                 toggle.setAttribute('aria-pressed', 'true');
  2101.                 toggle.querySelector('.live-label').textContent = window.t('guide.stop');
  2102.                 // Close the mobile menu so the map + status are visible.
  2103.                 if (controlsPanel) {
  2104.                     controlsPanel.classList.remove('open');
  2105.                     var b = document.getElementById('map-burger');
  2106.                     if (b) b.setAttribute('aria-expanded', 'false');  // reset ✕ back to ☰
  2107.                 }
  2108.                 statusEl.classList.add('show');
  2109.                 setStatus('Acquiring GPS…');
  2110.                 watchId = navigator.geolocation.watchPosition(onPosition, onError, {
  2111.                     enableHighAccuracy: true,
  2112.                     maximumAge: 2000,
  2113.                     timeout: 15000
  2114.                 });
  2115.             });
  2116.         }());
  2117.         // ═══════════════════════════════════════════════════════════════════
  2118.         //  ORDER SHEET — book a tuk-tuk
  2119.         //
  2120.         //  route → paint the road → pick a tuk → cash or card → order.
  2121.         //  The sheet is a small state machine: `state.step` decides what
  2122.         //  render() draws, and every step knows how to go back.
  2123.         // ═══════════════════════════════════════════════════════════════════
  2124.         (function () {
  2125.             var STEP_ROUTES = 'routes';   // the three tours
  2126.             var STEP_ROUTE  = 'route';    // one tour's itinerary
  2127.             var STEP_TUKS   = 'tuks';     // nearby tuks
  2128.             var STEP_PAY    = 'pay';      // cash / card + order
  2129.             var STEP_ACTIVE = 'active';   // live order
  2130.             var STEP_SCHEDULE     = 'schedule';      // advance-booking form
  2131.             var STEP_SCHEDULE_DONE = 'scheduleDone'; // request submitted
  2132.             var STEP_CUSTOM       = 'custom';        // drawing your own line on the map
  2133.             var STEP_CUSTOM_FORM  = 'customForm';    // when/who for that drawn line
  2134.             var STEP_RESERVATION  = 'reservation';   // viewing a scheduled tour
  2135.             // Snap positions for the drag handle, as fractions of the viewport.
  2136.             var SNAP = [0.28, 0.5, 0.88];
  2137.             var POLL_MS = 4000;   // how often a live order re-checks the server
  2138.             var el = {
  2139.                 sheet: document.getElementById('sheet'),
  2140.                 grab:  document.getElementById('sheet-grab'),
  2141.                 back:  document.getElementById('sheet-back'),
  2142.                 title: document.getElementById('sheet-title'),
  2143.                 sub:   document.getElementById('sheet-sub'),
  2144.                 body:  document.getElementById('sheet-body'),
  2145.                 foot:  document.getElementById('sheet-foot'),
  2146.                 cta:   document.getElementById('sheet-cta')
  2147.             };
  2148.             var state = {
  2149.                 step:    STEP_ROUTES,
  2150.                 routes:  [],
  2151.                 route:   null,   // selected route object
  2152.                 tuks:    [],
  2153.                 tuk:     null,   // selected tuk object
  2154.                 cards:   [],
  2155.                 pay:     'cash', // 'cash' | 'card'
  2156.                 card:    null,   // selected card object
  2157.                 booking: null,   // live booking
  2158.                 userPos: null,   // [lat, lng] once GPS answers
  2159.                 busy:    false,  // a request is in flight
  2160.                 error:   null,
  2161.                 ratingPending: 0, // stars chosen on the post-tour rating widget
  2162.                 passengers: 1,    // party size — drives the price (1…TAXI.maxPassengers)
  2163.                 tipPending: 0,    // amount chosen on the post-tour tip widget
  2164.                 tipDone:    false,// hides the widget after a tip is sent
  2165.                 draw:    [],      // stops chosen for a custom line, in order
  2166.                 picker:  '',      // search text in the stop picker
  2167.                 custom:  null,    // the custom form's saved values
  2168.                 customTable: [],  // price table for a drawn tour, from /api/routes
  2169.                 reservation: null,// last created / opened reservation
  2170.                 guest:   null,    // name/email/phone typed by a signed-out visitor
  2171.                 newCard: null,    // card details typed at checkout, before saving
  2172.                 accountExists: false // that email is already taken — offer sign-in
  2173.             };
  2174.             // Map objects we own, so we can clean up without touching the
  2175.             // location clusterer the rest of the page manages.
  2176.             var routeLine = null;
  2177.             var stopPMs   = [];
  2178.             var drawLine  = null;       // the polyline of a custom line being drawn
  2179.             var drawPMs   = [];         // its numbered markers
  2180.             var drawBadge = null;       // the "tap the map" cue over the map
  2181.             var tukPMs    = [];         // tuks shown for selection during booking
  2182.             var ambientPMs   = [];      // the whole live fleet, shown while browsing
  2183.             var ambientTimer = null;
  2184.             var pollTimer = null;
  2185.             var es        = null;   // Mercure EventSource, when the hub is reachable
  2186.             var liveOn    = false;  // true once the push stream is connected
  2187.             // Tuk marker colours: free tuks are amber, ones on a tour are red, and
  2188.             // the tuk a passenger is ordering is mint.
  2189.             var TUK_FREE_COLOR     = '#E8A020';
  2190.             var TUK_BUSY_COLOR     = '#D4572A';
  2191.             var TUK_SELECTED_COLOR = '#48C9B0';
  2192.             // ── Helpers ──────────────────────────────────────────────────
  2193.             // Translation shortcut for this scope. NOT named `t` on purpose:
  2194.             // renderTuks() already uses `t` as its tuk loop variable, so shadowing
  2195.             // it here would break the sheet. Always translate via L(...) below.
  2196.             function L(k, p) { return window.TNT_I18N ? window.TNT_I18N.t(k, p) : k; }
  2197.             function amd(n) {
  2198.                 return Number(n).toLocaleString('en-US') + ' ֏';
  2199.             }
  2200.             // ── Party-size pricing ───────────────────────────────────────
  2201.             //
  2202.             // The server ships the whole table with every route
  2203.             // (priceTable: [{passengers, priceAmd}, …]), so the sheet never
  2204.             // re-derives the surcharge rules — it just looks the price up. The
  2205.             // fallbacks below only matter for an old cached payload.
  2206.             function maxPax() {
  2207.                 return TAXI.maxPassengers || 3;
  2208.             }
  2209.             function priceFor(route, pax) {
  2210.                 if (!route) return 0;
  2211.                 var table = route.priceTable || [];
  2212.                 for (var i = 0; i < table.length; i++) {
  2213.                     if (table[i].passengers === pax) return table[i].priceAmd;
  2214.                 }
  2215.                 return route.priceAmd || 0;
  2216.             }
  2217.             // What the sheet should currently quote for the selected route.
  2218.             function currentPrice() {
  2219.                 return priceFor(state.route, state.passengers);
  2220.             }
  2221.             function paxWord(n) {
  2222.                 return L(n === 1 ? 'sheet.passenger' : 'sheet.passengers');
  2223.             }
  2224.             function setPassengers(n) {
  2225.                 var next = Math.max(1, Math.min(n, maxPax()));
  2226.                 if (next === state.passengers) return;
  2227.                 // The stepper lives inside the schedule / custom forms too, and
  2228.                 // changing it re-renders them — so keep whatever has been typed
  2229.                 // before the markup is thrown away.
  2230.                 captureForms();
  2231.                 state.passengers = next;
  2232.                 state.error      = null;
  2233.                 render();
  2234.             }
  2235.             /**
  2236.              * Read any open form back into state, so a re-render restores it.
  2237.              */
  2238.             function captureForms() {
  2239.                 var date = document.getElementById('sc-date');
  2240.                 if (date) {
  2241.                     state.sched = {
  2242.                         date:   date.value,
  2243.                         time:   val('sc-time'),
  2244.                         pickup: val('sc-pickup'),
  2245.                         note:   val('sc-note')
  2246.                     };
  2247.                 }
  2248.                 var title = document.getElementById('cu-title');
  2249.                 if (title) {
  2250.                     state.custom = {
  2251.                         title:  title.value,
  2252.                         date:   val('cu-date'),
  2253.                         time:   val('cu-time'),
  2254.                         pickup: val('cu-pickup'),
  2255.                         note:   val('cu-note')
  2256.                     };
  2257.                 }
  2258.                 // Guest checkout: switching cash↔card re-renders the payment
  2259.                 // step, so the details typed so far have to survive it.
  2260.                 var guName = document.getElementById('gu-name');
  2261.                 if (guName) {
  2262.                     state.guest = {
  2263.                         name:  guName.value,
  2264.                         email: val('gu-email'),
  2265.                         phone: val('gu-phone')
  2266.                     };
  2267.                 }
  2268.                 var cdNum = document.getElementById('cd-number');
  2269.                 if (cdNum) {
  2270.                     state.newCard = {
  2271.                         number: cdNum.value,
  2272.                         exp:    val('cd-exp'),
  2273.                         holder: val('cd-holder')
  2274.                     };
  2275.                 }
  2276.             }
  2277.             function val(id) {
  2278.                 var e = document.getElementById(id);
  2279.                 return e ? e.value : '';
  2280.             }
  2281.             /**
  2282.              * The ± stepper plus the full price table. Both are rendered
  2283.              * together so the passenger can see not just what they pay but why
  2284.              * — and can jump straight to a party size by tapping its cell.
  2285.              */
  2286.             function paxHtml(route) {
  2287.                 var html =
  2288.                     '<div class="pax">' +
  2289.                         '<div class="pax-lbl">' + esc(L('pax.title')) +
  2290.                             '<small>' + esc(L('pax.hint', { n: maxPax() })) + '</small>' +
  2291.                         '</div>' +
  2292.                         '<div class="pax-ctl">' +
  2293.                             '<button type="button" class="pax-btn" data-pax-step="-1"' +
  2294.                                 (state.passengers <= 1 ? ' disabled' : '') + ' aria-label="' + esc(L('pax.fewer')) + '">−</button>' +
  2295.                             '<span class="pax-n">' + state.passengers + '</span>' +
  2296.                             '<button type="button" class="pax-btn" data-pax-step="1"' +
  2297.                                 (state.passengers >= maxPax() ? ' disabled' : '') + ' aria-label="' + esc(L('pax.more')) + '">+</button>' +
  2298.                         '</div>' +
  2299.                     '</div>' +
  2300.                     '<div class="pax-table">';
  2301.                 for (var n = 1; n <= maxPax(); n++) {
  2302.                     html +=
  2303.                         '<button type="button" class="pax-cell' + (n === state.passengers ? ' on' : '') + '" data-pax="' + n + '">' +
  2304.                             '<span class="pax-cell-n">' + n + ' ' + esc(paxWord(n)) + '</span>' +
  2305.                             '<span class="pax-cell-p">' + esc(amd(priceFor(route, n))) + '</span>' +
  2306.                         '</button>';
  2307.                 }
  2308.                 return html + '</div>';
  2309.             }
  2310.             // Wire whatever pax controls the current step happened to render.
  2311.             function wirePax(root) {
  2312.                 root.querySelectorAll('[data-pax-step]').forEach(function (b) {
  2313.                     b.addEventListener('click', function () {
  2314.                         setPassengers(state.passengers + parseInt(b.dataset.paxStep, 10));
  2315.                     });
  2316.                 });
  2317.                 root.querySelectorAll('[data-pax]').forEach(function (b) {
  2318.                     b.addEventListener('click', function () {
  2319.                         setPassengers(parseInt(b.dataset.pax, 10));
  2320.                     });
  2321.                 });
  2322.             }
  2323.             function mins(n) {
  2324.                 if (n < 60) return n + ' ' + L('sheet.min');
  2325.                 var h = Math.floor(n / 60), m = n % 60;
  2326.                 return m ? h + ' h ' + m + ' ' + L('sheet.min') : h + ' h';
  2327.             }
  2328.             function metres(m) {
  2329.                 if (m === null || m === undefined) return '';
  2330.                 return m < 1000 ? Math.round(m) + ' m' : (m / 1000).toFixed(1) + ' km';
  2331.             }
  2332.             // Route/stop names follow the language chosen on the welcome screen.
  2333.             function tr(obj) {
  2334.                 if (LANG === 'hy' && obj && obj.nameHy) return obj.nameHy;
  2335.                 if (LANG === 'ru' && obj && obj.nameRu) return obj.nameRu;
  2336.                 return obj ? obj.name : '';
  2337.             }
  2338.             // Localized route description (same rule as tr, for the description fields).
  2339.             function trDesc(obj) {
  2340.                 if (LANG === 'hy' && obj && obj.descriptionHy) return obj.descriptionHy;
  2341.                 if (LANG === 'ru' && obj && obj.descriptionRu) return obj.descriptionRu;
  2342.                 return obj ? (obj.description || '') : '';
  2343.             }
  2344.             function initials(name) {
  2345.                 return String(name || '?').trim().split(/\s+/).slice(0, 2)
  2346.                     .map(function (w) { return w.charAt(0).toUpperCase(); }).join('');
  2347.             }
  2348.             function api(url, opts) {
  2349.                 opts = opts || {};
  2350.                 var headers = { 'Accept': 'application/json' };
  2351.                 if (opts.body) {
  2352.                     headers['Content-Type'] = 'application/json';
  2353.                     headers['X-CSRF-Token'] = TAXI.csrf;
  2354.                 }
  2355.                 return fetch(url, {
  2356.                     method:      opts.method || 'GET',
  2357.                     headers:     headers,
  2358.                     body:        opts.body ? JSON.stringify(opts.body) : undefined,
  2359.                     credentials: 'same-origin'
  2360.                 }).then(function (res) {
  2361.                     return res.json()
  2362.                         .catch(function () { return {}; })
  2363.                         .then(function (data) {
  2364.                             if (!res.ok) {
  2365.                                 throw new Error(data.error || ('Request failed (' + res.status + ')'));
  2366.                             }
  2367.                             return data;
  2368.                         });
  2369.                 });
  2370.             }
  2371.             // ── Map painting ─────────────────────────────────────────────
  2372.             function clearRoute() {
  2373.                 if (routeLine) { map.geoObjects.remove(routeLine); routeLine = null; }
  2374.                 stopPMs.forEach(function (pm) { map.geoObjects.remove(pm); });
  2375.                 stopPMs = [];
  2376.             }
  2377.             /**
  2378.              * Show or hide the browse-mode pins for every location in the city.
  2379.              *
  2380.              * All 76 of them on top of a route drown out the road and its
  2381.              * stops, so once a tour is chosen we take them off the map and put
  2382.              * them back when the passenger returns to the tour list. The
  2383.              * clusterer itself is left intact — the page's own filters keep
  2384.              * managing its contents.
  2385.              */
  2386.             function setPoiVisible(on) {
  2387.                 if (!clusterer) return;
  2388.                 var present = false;
  2389.                 map.geoObjects.each(function (o) { if (o === clusterer) present = true; });
  2390.                 if (on && !present)  map.geoObjects.add(clusterer);
  2391.                 if (!on && present)  map.geoObjects.remove(clusterer);
  2392.             }
  2393.             function clearTuks() {
  2394.                 tukPMs.forEach(function (pm) { map.geoObjects.remove(pm); });
  2395.                 tukPMs = [];
  2396.             }
  2397.             // A tuk marker in the same style as the driver app: a 🛺 in a coloured
  2398.             // disc. One layout class, reused; colour and heading ride in as
  2399.             // placemark properties so we never rebuild the class per tuk.
  2400.             var tukLayoutClass = null;
  2401.             function tukLayout() {
  2402.                 if (!tukLayoutClass && typeof ymaps !== 'undefined') {
  2403.                     tukLayoutClass = ymaps.templateLayoutFactory.createClass(
  2404.                         '<div class="tnt-tuk-mark" style="transform:rotate($[properties.heading]deg);' +
  2405.                         'width:34px;height:34px;margin:-17px 0 0 -17px;background:$[properties.tukColor];' +
  2406.                         'border:3px solid #0B1F3A;border-radius:50%;box-shadow:0 3px 10px rgba(0,0,0,.5);' +
  2407.                         'display:flex;align-items:center;justify-content:center;font-size:16px">🛺</div>'
  2408.                     );
  2409.                 }
  2410.                 return tukLayoutClass;
  2411.             }
  2412.             function tukPlacemark(t, opts) {
  2413.                 opts = opts || {};
  2414.                 var color = opts.color || (t.status === 'busy' ? TUK_BUSY_COLOR : TUK_FREE_COLOR);
  2415.                 return new ymaps.Placemark([t.lat, t.lng], {
  2416.                     heading:     t.heading || 0,
  2417.                     tukColor:    color,
  2418.                     hintContent: (t.driver ? t.driver.name + ' · ' : '') + t.code
  2419.                 }, {
  2420.                     iconLayout: tukLayout(),
  2421.                     iconShape:  { type: 'Circle', coordinates: [0, 0], radius: 17 },
  2422.                     zIndex:     opts.zIndex || 750
  2423.                 });
  2424.             }
  2425.             // ── Ambient fleet (every live tuk, shown only while browsing) ──
  2426.             function clearAmbient() {
  2427.                 ambientPMs.forEach(function (pm) { map.geoObjects.remove(pm); });
  2428.                 ambientPMs = [];
  2429.             }
  2430.             function drawAmbientTuks(tuks) {
  2431.                 clearAmbient();
  2432.                 tuks.forEach(function (t) {
  2433.                     if (!t.lat || !t.lng) return;
  2434.                     var pm = tukPlacemark(t, { zIndex: t.status === 'busy' ? 730 : 735 });
  2435.                     ambientPMs.push(pm);
  2436.                     map.geoObjects.add(pm);
  2437.                 });
  2438.             }
  2439.             // Only paint the ambient fleet on the tour-list step; a booking's
  2440.             // route / selected-tuk / active views own the map at every other step.
  2441.             function refreshAmbient() {
  2442.                 if (!map || state.step !== STEP_ROUTES) { clearAmbient(); return; }
  2443.                 api(TAXI.tuksMapUrl).then(function (data) {
  2444.                     if (state.step !== STEP_ROUTES) return;   // step changed mid-fetch
  2445.                     drawAmbientTuks(data.tuks || []);
  2446.                 }).catch(function () {});
  2447.             }
  2448.             function startAmbient() {
  2449.                 refreshAmbient();
  2450.                 if (ambientTimer) clearInterval(ambientTimer);
  2451.                 ambientTimer = setInterval(refreshAmbient, 12000);
  2452.             }
  2453.             /**
  2454.              * Draw the driving road through every stop of a route, in the
  2455.              * route's own colour, plus a numbered marker per stop.
  2456.              *
  2457.              * The road is drawn from geometry the server resolved ahead of time
  2458.              * (see app:route:geometry). We deliberately do NOT use Yandex's
  2459.              * multiRouter: it answers HTTP 401 unless the account's key is
  2460.              * signed up for the Router API, and fails silently when it isn't.
  2461.              * If a route has no stored geometry yet, we join the stops with a
  2462.              * straight line so the map still shows the shape of the tour.
  2463.              */
  2464.             function paintRoute(route) {
  2465.                 clearRoute();
  2466.                 var pts = (route.points || []).filter(function (p) { return p.lat && p.lng; });
  2467.                 if (!pts.length) return;
  2468.                 var road = (route.geometry && route.geometry.length)
  2469.                     ? route.geometry
  2470.                     : pts.map(function (p) { return [p.lat, p.lng]; });
  2471.                 routeLine = new ymaps.Polyline(road, {}, {
  2472.                     strokeColor:   route.color,
  2473.                     strokeWidth:   6,
  2474.                     strokeOpacity: 0.92,
  2475.                     zIndex:        690
  2476.                 });
  2477.                 map.geoObjects.add(routeLine);
  2478.                 // Frame the road inside the part of the map the sheet leaves
  2479.                 // visible — useMapMargin honours the reserved area.
  2480.                 try {
  2481.                     map.setBounds(routeLine.geometry.getBounds(), {
  2482.                         checkZoomRange: true,
  2483.                         useMapMargin:   true,
  2484.                         zoomMargin:     30,
  2485.                         duration:       400
  2486.                     });
  2487.                 } catch (e) {}
  2488.                 pts.forEach(function (p, i) {
  2489.                     var pm = new ymaps.Placemark([p.lat, p.lng], {
  2490.                         iconContent: String(i + 1),
  2491.                         hintContent: tr(p)
  2492.                     }, {
  2493.                         preset:    'islands#circleIcon',
  2494.                         iconColor: route.color,
  2495.                         zIndex:    700
  2496.                     });
  2497.                     // Tapping a stop opens the existing audio/text guide for it.
  2498.                     pm.events.add('click', function () {
  2499.                         var loc = null;
  2500.                         for (var k = 0; k < LOCS.length; k++) {
  2501.                             if (LOCS[k].id === p.id) { loc = LOCS[k]; break; }
  2502.                         }
  2503.                         if (loc) openModal(loc);
  2504.                     });
  2505.                     stopPMs.push(pm);
  2506.                     map.geoObjects.add(pm);
  2507.                 });
  2508.             }
  2509.             /**
  2510.              * Show the fleet. Selecting a marker selects that tuk in the sheet,
  2511.              * so map and list stay in step.
  2512.              */
  2513.             function paintTuks(tuks) {
  2514.                 clearTuks();
  2515.                 tuks.forEach(function (t) {
  2516.                     if (!t.lat || !t.lng) return;
  2517.                     var selected = state.tuk && state.tuk.id === t.id;
  2518.                     var pm = tukPlacemark(t, {
  2519.                         color:  selected ? TUK_SELECTED_COLOR : TUK_FREE_COLOR,
  2520.                         zIndex: selected ? 760 : 755
  2521.                     });
  2522.                     pm.events.add('click', function () {
  2523.                         selectTuk(t);
  2524.                     });
  2525.                     tukPMs.push(pm);
  2526.                     map.geoObjects.add(pm);
  2527.                 });
  2528.             }
  2529.             /**
  2530.              * Keep the map's usable centre clear of the sheet, so a route is
  2531.              * fitted into the part of the map that is actually visible rather
  2532.              * than centred under the panel.
  2533.              *
  2534.              * The margin manager has no "clear everything" call: addArea() hands
  2535.              * back an accessor and removeArea() takes it, so we hold on to ours.
  2536.              */
  2537.             var marginArea = null;
  2538.             function syncMapMargin() {
  2539.                 if (!map || !map.margin) return;
  2540.                 if (marginArea) {
  2541.                     try { map.margin.removeArea(marginArea); } catch (e) {}
  2542.                     marginArea = null;
  2543.                 }
  2544.                 if (el.sheet.classList.contains('hidden')) return;
  2545.                 var r = el.sheet.getBoundingClientRect();
  2546.                 try {
  2547.                     marginArea = window.innerWidth >= 900
  2548.                         // Desktop: the panel hugs the left edge.
  2549.                         ? map.margin.addArea({ left: 0, top: 0, width: r.width + 20, height: '100%' })
  2550.                         // Mobile: the sheet covers the bottom.
  2551.                         : map.margin.addArea({ left: 0, bottom: 0, width: '100%', height: r.height });
  2552.                 } catch (e) {
  2553.                     marginArea = null;
  2554.                 }
  2555.             }
  2556.             // ── Rendering ────────────────────────────────────────────────
  2557.             function setFoot(label, opts) {
  2558.                 opts = opts || {};
  2559.                 if (!label) {
  2560.                     el.foot.classList.add('hidden');
  2561.                     return;
  2562.                 }
  2563.                 el.foot.classList.remove('hidden');
  2564.                 el.cta.disabled  = !!opts.disabled;
  2565.                 el.cta.className = 'sheet-cta' + (opts.ghost ? ' ghost' : '');
  2566.                 el.cta.innerHTML = opts.price
  2567.                     ? esc(label) + '<span class="cta-price">' + esc(opts.price) + '</span>'
  2568.                     : esc(label);
  2569.                 el.cta.onclick = opts.onClick || null;
  2570.             }
  2571.             function errorHtml() {
  2572.                 return state.error
  2573.                     ? '<div class="sheet-err">' + esc(state.error) + '</div>'
  2574.                     : '';
  2575.             }
  2576.             function render() {
  2577.                 el.back.classList.toggle('show',
  2578.                     state.step !== STEP_ROUTES &&
  2579.                     state.step !== STEP_ACTIVE &&
  2580.                     state.step !== STEP_SCHEDULE_DONE &&
  2581.                     state.step !== STEP_RESERVATION);
  2582.                 // The "tap the map" cue belongs to the drawing step only.
  2583.                 if (state.step !== STEP_CUSTOM) hideDrawBadge();
  2584.                 if (state.step === STEP_ROUTES)      renderRoutes();
  2585.                 else if (state.step === STEP_ROUTE)  renderRoute();
  2586.                 else if (state.step === STEP_TUKS)   renderTuks();
  2587.                 else if (state.step === STEP_PAY)    renderPay();
  2588.                 else if (state.step === STEP_ACTIVE) renderActive();
  2589.                 else if (state.step === STEP_SCHEDULE)      renderSchedule();
  2590.                 else if (state.step === STEP_SCHEDULE_DONE) renderScheduleDone();
  2591.                 else if (state.step === STEP_CUSTOM)        renderCustom();
  2592.                 else if (state.step === STEP_CUSTOM_FORM)   renderCustomForm();
  2593.                 else if (state.step === STEP_RESERVATION)   renderReservation();
  2594.                 syncComm();
  2595.             }
  2596.             /**
  2597.              * Keep the chat/call channel following the live order.
  2598.              *
  2599.              * Attached from the moment a driver is on the job and dropped the
  2600.              * moment the tour ends, so the panel can never outlive the order it
  2601.              * belongs to. Both calls are idempotent, so calling this on every
  2602.              * render costs nothing.
  2603.              */
  2604.             function syncComm() {
  2605.                 if (!window.TNT_COMM) return;
  2606.                 var b = state.booking;
  2607.                 var live = b && b.driver &&
  2608.                            b.status !== 'completed' && b.status !== 'cancelled';
  2609.                 if (live) TNT_COMM.attach(b.reference);
  2610.                 else      TNT_COMM.detach();
  2611.             }
  2612.             function renderRoutes() {
  2613.                 el.title.textContent = L('sheet.chooseTour');
  2614.                 el.sub.textContent   = state.routes.length
  2615.                     ? L('sheet.routesIn', { n: state.routes.length })
  2616.                     : '';
  2617.                 if (!state.routes.length) {
  2618.                     el.body.innerHTML = '<div class="sheet-msg"><div class="spin"></div>' + esc(L('sheet.loadingRoutes')) + '</div>';
  2619.                     setFoot(null);
  2620.                     return;
  2621.                 }
  2622.                 var html = errorHtml();
  2623.                 state.routes.forEach(function (r) {
  2624.                     html +=
  2625.                         '<button type="button" class="rt-card' + (state.route && state.route.id === r.id ? ' active' : '') + '"' +
  2626.                             ' style="--rt-color:' + esc(r.color) + '" data-route="' + r.id + '">' +
  2627.                             '<div class="rt-main">' +
  2628.                                 '<div class="rt-name">' + esc(tr(r)) + '</div>' +
  2629.                                 '<div class="rt-meta">' +
  2630.                                     '<span>' + esc(mins(r.durationMinutes)) + '</span>' +
  2631.                                     '<span class="dot-sep"></span>' +
  2632.                                     '<span>' + r.stopCount + ' ' + esc(L('sheet.stops')) + '</span>' +
  2633.                                 '</div>' +
  2634.                             '</div>' +
  2635.                             '<div class="rt-price">' + esc(L('sheet.from')) + ' ' + esc(amd(priceFor(r, 1))) + '</div>' +
  2636.                         '</button>';
  2637.                 });
  2638.                 // Not one of ours: let the passenger draw their own line and
  2639.                 // have the back office price and confirm it.
  2640.                 html +=
  2641.                     '<button type="button" class="rt-card draw" style="--rt-color:' + esc(TAXI.customColor) + '" id="rt-draw">' +
  2642.                         '<div class="rt-main">' +
  2643.                             '<div class="rt-name">✏️ ' + esc(L('cust.entry')) + '</div>' +
  2644.                             '<div class="rt-meta"><span>' + esc(L('cust.entrySub')) + '</span></div>' +
  2645.                         '</div>' +
  2646.                     '</button>';
  2647.                 el.body.innerHTML = html;
  2648.                 setFoot(null);
  2649.                 el.body.querySelectorAll('.rt-card[data-route]').forEach(function (btn) {
  2650.                     btn.addEventListener('click', function () {
  2651.                         var id = parseInt(btn.dataset.route, 10);
  2652.                         var r  = state.routes.filter(function (x) { return x.id === id; })[0];
  2653.                         if (r) selectRoute(r);
  2654.                     });
  2655.                 });
  2656.                 var drawBtn = document.getElementById('rt-draw');
  2657.                 if (drawBtn) drawBtn.addEventListener('click', goToCustom);
  2658.             }
  2659.             function renderRoute() {
  2660.                 var r = state.route;
  2661.                 el.title.textContent = tr(r);
  2662.                 el.sub.textContent   = mins(r.durationMinutes) + ' · ' + r.stopCount + ' ' + L('sheet.stops');
  2663.                 var html = errorHtml();
  2664.                 var desc = trDesc(r);
  2665.                 if (desc) {
  2666.                     html += '<div class="rt-desc">' + esc(desc) + '</div>';
  2667.                 }
  2668.                 html += '<ul class="stops" style="--rt-color:' + esc(r.color) + '">';
  2669.                 r.points.forEach(function (p, i) {
  2670.                     html +=
  2671.                         '<li class="stop">' +
  2672.                             '<span class="stop-dot">' + (i + 1) + '</span>' +
  2673.                             '<div class="stop-body">' +
  2674.                                 '<div class="stop-name">' + esc(tr(p)) + '</div>' +
  2675.                                 (p.stopMinutes
  2676.                                     ? '<div class="stop-time">' + esc(L('sheet.minStop', { n: p.stopMinutes })) + '</div>'
  2677.                                     : '') +
  2678.                             '</div>' +
  2679.                         '</li>';
  2680.                 });
  2681.                 html += '</ul>';
  2682.                 // How many are riding decides the price, so it is chosen here —
  2683.                 // before a tuk is picked — and the footer quotes that total.
  2684.                 html += paxHtml(r);
  2685.                 // Advance-booking option: order a tuk now, or schedule the tour
  2686.                 // for a future date and let the back office confirm it.
  2687.                 html += '<button type="button" class="sheet-cta ghost" id="rt-schedule" style="margin-top:10px">📅 ' +
  2688.                     esc(L('sched.later')) + '</button>';
  2689.                 el.body.innerHTML = html;
  2690.                 wirePax(el.body);
  2691.                 var schedBtn = document.getElementById('rt-schedule');
  2692.                 if (schedBtn) schedBtn.addEventListener('click', goToSchedule);
  2693.                 setFoot(L('sheet.chooseTuk'), {
  2694.                     price:   amd(currentPrice()),
  2695.                     onClick: goToTuks
  2696.                 });
  2697.             }
  2698.             // ── Advance booking (schedule for later) ─────────────────────
  2699.             // Local YYYY-MM-DD (not UTC, so "today" is the passenger's today).
  2700.             function ymd(dt) {
  2701.                 return dt.getFullYear() + '-' +
  2702.                     String(dt.getMonth() + 1).padStart(2, '0') + '-' +
  2703.                     String(dt.getDate()).padStart(2, '0');
  2704.             }
  2705.             function goToSchedule() {
  2706.                 state.step  = STEP_SCHEDULE;
  2707.                 state.error = null;
  2708.                 clearTuks();
  2709.                 render();
  2710.                 snapTo(1);
  2711.             }
  2712.             function renderSchedule() {
  2713.                 var r = state.route;
  2714.                 el.title.textContent = L('sched.title');
  2715.                 el.sub.textContent   = tr(r) + ' · ' + amd(currentPrice());
  2716.                 var d        = state.sched || {};
  2717.                 var today    = ymd(new Date());
  2718.                 var defDate  = d.date || ymd(new Date(Date.now() + 864e5)); // tomorrow
  2719.                 var defTime  = d.time || '10:00';
  2720.                 el.body.innerHTML = errorHtml() +
  2721.                     '<div class="sched-row">' +
  2722.                         '<div class="sched-field"><label>' + esc(L('sched.date')) + '</label>' +
  2723.                             '<input type="date" id="sc-date" class="sched-input" value="' + esc(defDate) + '" min="' + esc(today) + '"></div>' +
  2724.                         '<div class="sched-field"><label>' + esc(L('sched.time')) + '</label>' +
  2725.                             '<input type="time" id="sc-time" class="sched-input" value="' + esc(defTime) + '"></div>' +
  2726.                     '</div>' +
  2727.                     // Same stepper as the route step, so the quoted total moves
  2728.                     // with the party size here too.
  2729.                     paxHtml(r) +
  2730.                     '<div class="sched-field" style="margin-top:10px"><label>' + esc(L('sched.pickup')) + '</label>' +
  2731.                         '<input type="text" id="sc-pickup" class="sched-input" placeholder="' + esc(L('sched.pickupPh')) + '" value="' + esc(d.pickup || '') + '"></div>' +
  2732.                     '<div class="sched-field"><label>' + esc(L('sched.note')) + '</label>' +
  2733.                         '<textarea id="sc-note" class="sched-input" placeholder="' + esc(L('sched.notePh')) + '">' + esc(d.note || '') + '</textarea></div>' +
  2734.                     '<div class="sched-note">' + esc(L('sched.hint')) + '</div>';
  2735.                 wirePax(el.body);
  2736.                 setFoot(state.busy ? L('sched.submitting') : L('sched.submit'), {
  2737.                     price:    amd(currentPrice()),
  2738.                     disabled: state.busy,
  2739.                     onClick:  placeReservation
  2740.                 });
  2741.             }
  2742.             function placeReservation() {
  2743.                 if (state.busy) return;
  2744.                 if (!TAXI.isLoggedIn) { window.location.href = TAXI.loginUrl; return; }
  2745.                 // Keep what they typed so a validation error never wipes the form.
  2746.                 captureForms();
  2747.                 var d      = state.sched || {};
  2748.                 var date   = d.date;
  2749.                 var time   = d.time;
  2750.                 var pax    = state.passengers;
  2751.                 var pickup = d.pickup;
  2752.                 var note   = d.note;
  2753.                 if (!date || !time) { state.error = L('sched.needDate'); render(); return; }
  2754.                 state.busy  = true;
  2755.                 state.error = null;
  2756.                 render();
  2757.                 api(TAXI.reservationsUrl, {
  2758.                     method: 'POST',
  2759.                     body: {
  2760.                         routeId:       state.route.id,
  2761.                         scheduledAt:   date + 'T' + time,
  2762.                         passengers:    pax,
  2763.                         pickupAddress: pickup,
  2764.                         note:          note
  2765.                     }
  2766.                 }).then(function (data) {
  2767.                     state.busy        = false;
  2768.                     state.reservation = data.reservation;
  2769.                     state.sched       = null;
  2770.                     state.step        = STEP_SCHEDULE_DONE;
  2771.                     render();
  2772.                     snapTo(1);
  2773.                 }).catch(function (e) {
  2774.                     state.busy  = false;
  2775.                     state.error = e.message;
  2776.                     render();
  2777.                 });
  2778.             }
  2779.             function renderScheduleDone() {
  2780.                 var rv = state.reservation || {};
  2781.                 el.title.textContent = L('sched.doneTitle');
  2782.                 el.sub.textContent   = rv.reference || '';
  2783.                 var when = rv.scheduledAt ? new Date(rv.scheduledAt) : null;
  2784.                 var whenTxt = when ? when.toLocaleDateString() + ' · ' +
  2785.                     when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
  2786.                 var pax = rv.passengers || 1;
  2787.                 el.body.innerHTML =
  2788.                     '<div class="sheet-msg" style="text-align:center">' +
  2789.                         '<div style="font-size:40px;line-height:1;margin-bottom:8px">📅</div>' +
  2790.                         esc(rv.kind === 'custom' ? L('cust.doneMsg') : L('sched.doneMsg')) +
  2791.                     '</div>' +
  2792.                     '<div style="margin-top:8px">' +
  2793.                         // tourName covers both kinds: one of our lines, or the
  2794.                         // name the passenger gave their own drawn route.
  2795.                         '<div class="sum-row"><span class="sum-k">' + esc(L('sum.route')) + '</span><span class="sum-v">' + esc(rv.tourName || '') + '</span></div>' +
  2796.                         '<div class="sum-row"><span class="sum-k">' + esc(L('sched.when')) + '</span><span class="sum-v">' + esc(whenTxt) + '</span></div>' +
  2797.                         '<div class="sum-row"><span class="sum-k">' + esc(L('pax.title')) + '</span><span class="sum-v">' + pax + ' ' + esc(paxWord(pax)) + '</span></div>' +
  2798.                         // A custom tour carries no quote: it is priced by hand
  2799.                         // during review and the figure arrives with the
  2800.                         // approval email.
  2801.                         (rv.kind === 'custom'
  2802.                             ? '<div class="sched-note" style="margin-top:10px">' + esc(L('cust.priceLater')) + '</div>'
  2803.                             : '<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>') +
  2804.                     '</div>';
  2805.                 setFoot(L('sheet.bookAnother'), { onClick: resetToRoutes });
  2806.             }
  2807.             // ── Custom tour: build a line from our own stops ─────────────
  2808.             //
  2809.             // Same review-then-approve lifecycle as scheduling one of our
  2810.             // lines; the difference is that there is no TourRoute behind it,
  2811.             // just an ordered pick of places from our catalogue. Stops are
  2812.             // chosen by name — from the list here or by tapping a pin on the
  2813.             // map — never by dropping an arbitrary coordinate, so every tour we
  2814.             // are asked for is one we actually cover. Only the ids travel to
  2815.             // the server; it reads the names and coordinates back off the
  2816.             // Location rows and snaps the road between them.
  2817.             function clearDraw() {
  2818.                 if (drawLine) { map.geoObjects.remove(drawLine); drawLine = null; }
  2819.                 drawPMs.forEach(function (pm) { map.geoObjects.remove(pm); });
  2820.                 drawPMs = [];
  2821.                 hideDrawBadge();
  2822.             }
  2823.             function showDrawBadge(text) {
  2824.                 if (!drawBadge) {
  2825.                     drawBadge = document.createElement('div');
  2826.                     drawBadge.className = 'draw-badge';
  2827.                     // Inside .map-app, or the map's event pane eats it (that
  2828.                     // element opens a stacking context at z-index 9999).
  2829.                     var host = el.sheet.parentNode;
  2830.                     if (host) host.appendChild(drawBadge);
  2831.                 }
  2832.                 drawBadge.textContent = text;
  2833.                 drawBadge.style.display = '';
  2834.             }
  2835.             function hideDrawBadge() {
  2836.                 if (drawBadge) drawBadge.style.display = 'none';
  2837.             }
  2838.             /**
  2839.              * Repaint the line being drawn: a dashed polyline through the taps,
  2840.              * plus a numbered marker for each.
  2841.              */
  2842.             function paintDraw() {
  2843.                 clearDraw();
  2844.                 if (!state.draw.length) return;
  2845.                 var coords = state.draw.map(function (p) { return [p.lat, p.lng]; });
  2846.                 if (coords.length > 1) {
  2847.                     drawLine = new ymaps.Polyline(coords, {}, {
  2848.                         strokeColor:   TAXI.customColor,
  2849.                         strokeWidth:   5,
  2850.                         strokeOpacity: 0.9,
  2851.                         strokeStyle:   'shortdash',
  2852.                         zIndex:        690
  2853.                     });
  2854.                     map.geoObjects.add(drawLine);
  2855.                 }
  2856.                 state.draw.forEach(function (p, i) {
  2857.                     var pm = new ymaps.Placemark([p.lat, p.lng], {
  2858.                         iconContent: String(i + 1),
  2859.                         hintContent: p.name || L('cust.stopN', { n: i + 1 })
  2860.                     }, {
  2861.                         preset:    'islands#circleIcon',
  2862.                         iconColor: TAXI.customColor,
  2863.                         zIndex:    700
  2864.                     });
  2865.                     drawPMs.push(pm);
  2866.                     map.geoObjects.add(pm);
  2867.                 });
  2868.             }
  2869.             function goToCustom() {
  2870.                 state.step   = STEP_CUSTOM;
  2871.                 state.route  = null;
  2872.                 state.error  = null;
  2873.                 state.picker = '';       // the stop-search box starts empty
  2874.                 clearRoute();
  2875.                 clearTuks();
  2876.                 clearAmbient();
  2877.                 // Our pins stay on: they ARE the choices, and tapping one adds
  2878.                 // it to the tour.
  2879.                 setPoiVisible(true);
  2880.                 setPicking(true);
  2881.                 paintDraw();
  2882.                 render();
  2883.                 snapTo(1);
  2884.             }
  2885.             /**
  2886.              * While picking, a tap on one of our map pins adds that place to the
  2887.              * tour instead of opening its guide. The bridge is a global because
  2888.              * the pin handlers are wired up in the page's own scope, outside
  2889.              * this sheet's closure.
  2890.              */
  2891.             function setPicking(on) {
  2892.                 window.TNT_PICK = window.TNT_PICK || {};
  2893.                 window.TNT_PICK.active = !!on;
  2894.                 window.TNT_PICK.pick   = addStop;
  2895.             }
  2896.             function addStop(loc) {
  2897.                 if (!loc || !loc.lat || !loc.lng) return;
  2898.                 if (state.draw.length >= (TAXI.customMaxPts || 12)) {
  2899.                     state.error = L('cust.tooMany', { n: TAXI.customMaxPts || 12 });
  2900.                     render();
  2901.                     return;
  2902.                 }
  2903.                 // The same place twice in a row is not a second stop.
  2904.                 var last = state.draw[state.draw.length - 1];
  2905.                 if (last && last.id === loc.id) return;
  2906.                 state.draw.push({ id: loc.id, name: locName(loc), lat: loc.lat, lng: loc.lng });
  2907.                 state.error  = null;
  2908.                 state.picker = '';
  2909.                 paintDraw();
  2910.                 render();
  2911.             }
  2912.             function renderCustom() {
  2913.                 var min = TAXI.customMinPts || 2;
  2914.                 var n   = state.draw.length;
  2915.                 var q   = (state.picker || '').trim().toLowerCase();
  2916.                 el.title.textContent = L('cust.title');
  2917.                 el.sub.textContent   = n
  2918.                     ? n + ' ' + L(n === 1 ? 'cust.stop' : 'cust.stops')
  2919.                     : L('cust.pickToStart');
  2920.                 showDrawBadge(n < min ? L('cust.badgeNeed', { n: min - n }) : L('cust.badgeOk'));
  2921.                 var html = errorHtml() +
  2922.                     '<div class="draw-hint">' + esc(n ? L('cust.hintShort') : L('cust.hint')) + '</div>';
  2923.                 // The tour so far, in visiting order.
  2924.                 if (n) {
  2925.                     html += '<ul class="draw-list" style="--cust:' + esc(TAXI.customColor) + '">';
  2926.                     state.draw.forEach(function (p, i) {
  2927.                         html +=
  2928.                             '<li class="draw-item">' +
  2929.                                 '<span class="draw-dot">' + (i + 1) + '</span>' +
  2930.                                 '<span class="draw-name">' + esc(p.name) + '</span>' +
  2931.                                 '<button type="button" class="draw-del" data-drop="' + i + '" aria-label="' + esc(L('cust.remove')) + '">✕</button>' +
  2932.                             '</li>';
  2933.                     });
  2934.                     html += '</ul>';
  2935.                     html +=
  2936.                         '<div class="draw-acts">' +
  2937.                             '<button type="button" id="dr-undo">↩ ' + esc(L('cust.undo')) + '</button>' +
  2938.                             '<button type="button" id="dr-clear">' + esc(L('cust.clear')) + '</button>' +
  2939.                         '</div>';
  2940.                 }
  2941.                 // Places to add, filtered by the search box.
  2942.                 html +=
  2943.                     '<div class="pick-search">' +
  2944.                         '<input type="text" id="cu-search" class="sched-input" autocomplete="off"' +
  2945.                             ' placeholder="' + esc(L('cust.searchPh')) + '" value="' + esc(state.picker || '') + '">' +
  2946.                     '</div>';
  2947.                 var matches = pickableStops(q);
  2948.                 if (!matches.length) {
  2949.                     html += '<div class="pick-empty">' + esc(L('cust.noMatches')) + '</div>';
  2950.                 } else {
  2951.                     html += '<ul class="pick-list">';
  2952.                     matches.forEach(function (loc) {
  2953.                         html +=
  2954.                             '<li><button type="button" class="pick-item" data-add="' + loc.id + '">' +
  2955.                                 '<span class="pick-name">' + esc(locName(loc)) + '</span>' +
  2956.                                 (loc.category ? '<span class="pick-cat">' + esc(loc.category) + '</span>' : '') +
  2957.                                 '<span class="pick-plus">+</span>' +
  2958.                             '</button></li>';
  2959.                     });
  2960.                     html += '</ul>';
  2961.                 }
  2962.                 el.body.innerHTML = html;
  2963.                 el.body.querySelectorAll('[data-drop]').forEach(function (b) {
  2964.                     b.addEventListener('click', function () {
  2965.                         state.draw.splice(parseInt(b.dataset.drop, 10), 1);
  2966.                         paintDraw();
  2967.                         render();
  2968.                     });
  2969.                 });
  2970.                 el.body.querySelectorAll('[data-add]').forEach(function (b) {
  2971.                     b.addEventListener('click', function () {
  2972.                         addStop(locById(parseInt(b.dataset.add, 10)));
  2973.                     });
  2974.                 });
  2975.                 var undo = document.getElementById('dr-undo');
  2976.                 if (undo) undo.addEventListener('click', function () {
  2977.                     state.draw.pop();
  2978.                     paintDraw();
  2979.                     render();
  2980.                 });
  2981.                 var clear = document.getElementById('dr-clear');
  2982.                 if (clear) clear.addEventListener('click', function () {
  2983.                     state.draw = [];
  2984.                     paintDraw();
  2985.                     render();
  2986.                 });
  2987.                 // Filter as they type. Re-rendering the whole sheet would steal
  2988.                 // focus mid-word, so only the list below the box is redrawn.
  2989.                 var search = document.getElementById('cu-search');
  2990.                 if (search) {
  2991.                     search.addEventListener('input', function () {
  2992.                         state.picker = search.value;
  2993.                         refreshPickList();
  2994.                     });
  2995.                 }
  2996.                 setFoot(L('cust.continue'), {
  2997.                     disabled: n < min,
  2998.                     onClick:  goToCustomForm
  2999.                 });
  3000.             }
  3001.             function locById(id) {
  3002.                 if (typeof LOCS === 'undefined') return null;
  3003.                 for (var i = 0; i < LOCS.length; i++) {
  3004.                     if (LOCS[i].id === id) return LOCS[i];
  3005.                 }
  3006.                 return null;
  3007.             }
  3008.             /**
  3009.              * Our mappable places, minus the ones already in the tour, matched
  3010.              * against the search box in whichever language they are shown in.
  3011.              */
  3012.             function pickableStops(q) {
  3013.                 if (typeof LOCS === 'undefined') return [];
  3014.                 var chosen = {};
  3015.                 state.draw.forEach(function (p) { chosen[p.id] = true; });
  3016.                 return LOCS.filter(function (loc) {
  3017.                     if (!loc.lat || !loc.lng || chosen[loc.id]) return false;
  3018.                     if (!q) return true;
  3019.                     return (locName(loc) + ' ' + (loc.address || '') + ' ' + (loc.category || ''))
  3020.                         .toLowerCase().indexOf(q) !== -1;
  3021.                 }).sort(function (a, b) {
  3022.                     return locName(a).localeCompare(locName(b));
  3023.                 });
  3024.             }
  3025.             function refreshPickList() {
  3026.                 var list = el.body.querySelector('.pick-list');
  3027.                 var none = el.body.querySelector('.pick-empty');
  3028.                 var q    = (state.picker || '').trim().toLowerCase();
  3029.                 var matches = pickableStops(q);
  3030.                 if (!matches.length) {
  3031.                     if (list) list.remove();
  3032.                     if (!none) {
  3033.                         el.body.insertAdjacentHTML('beforeend',
  3034.                             '<div class="pick-empty">' + esc(L('cust.noMatches')) + '</div>');
  3035.                     }
  3036.                     return;
  3037.                 }
  3038.                 if (none) none.remove();
  3039.                 var html = matches.map(function (loc) {
  3040.                     return '<li><button type="button" class="pick-item" data-add="' + loc.id + '">' +
  3041.                         '<span class="pick-name">' + esc(locName(loc)) + '</span>' +
  3042.                         (loc.category ? '<span class="pick-cat">' + esc(loc.category) + '</span>' : '') +
  3043.                         '<span class="pick-plus">+</span>' +
  3044.                     '</button></li>';
  3045.                 }).join('');
  3046.                 if (list) {
  3047.                     list.innerHTML = html;
  3048.                 } else {
  3049.                     el.body.insertAdjacentHTML('beforeend', '<ul class="pick-list">' + html + '</ul>');
  3050.                     list = el.body.querySelector('.pick-list');
  3051.                 }
  3052.                 list.querySelectorAll('[data-add]').forEach(function (b) {
  3053.                     b.addEventListener('click', function () {
  3054.                         addStop(locById(parseInt(b.dataset.add, 10)));
  3055.                     });
  3056.                 });
  3057.             }
  3058.             function goToCustomForm() {
  3059.                 state.step  = STEP_CUSTOM_FORM;
  3060.                 state.error = null;
  3061.                 hideDrawBadge();
  3062.                 setPicking(false);   // pins go back to opening their guide
  3063.                 render();
  3064.                 snapTo(1);
  3065.             }
  3066.             function renderCustomForm() {
  3067.                 var c       = state.custom || {};
  3068.                 var today   = ymd(new Date());
  3069.                 var defDate = c.date || ymd(new Date(Date.now() + 864e5));
  3070.                 var defTime = c.time || '10:00';
  3071.                 el.title.textContent = L('cust.formTitle');
  3072.                 el.sub.textContent   = state.draw.length + ' ' + L('cust.stops');
  3073.                 el.body.innerHTML = errorHtml() +
  3074.                     '<div class="sched-field"><label>' + esc(L('cust.name')) + '</label>' +
  3075.                         '<input type="text" id="cu-title" class="sched-input" maxlength="120" placeholder="' + esc(L('cust.namePh')) + '" value="' + esc(c.title || '') + '"></div>' +
  3076.                     '<div class="sched-row">' +
  3077.                         '<div class="sched-field"><label>' + esc(L('sched.date')) + '</label>' +
  3078.                             '<input type="date" id="cu-date" class="sched-input" value="' + esc(defDate) + '" min="' + esc(today) + '"></div>' +
  3079.                         '<div class="sched-field"><label>' + esc(L('sched.time')) + '</label>' +
  3080.                             '<input type="time" id="cu-time" class="sched-input" value="' + esc(defTime) + '"></div>' +
  3081.                     '</div>' +
  3082.                     customPaxHtml() +
  3083.                     '<div class="sched-field" style="margin-top:10px"><label>' + esc(L('sched.pickup')) + '</label>' +
  3084.                         '<input type="text" id="cu-pickup" class="sched-input" placeholder="' + esc(L('sched.pickupPh')) + '" value="' + esc(c.pickup || '') + '"></div>' +
  3085.                     '<div class="sched-field"><label>' + esc(L('sched.note')) + '</label>' +
  3086.                         '<textarea id="cu-note" class="sched-input" placeholder="' + esc(L('cust.notePh')) + '">' + esc(c.note || '') + '</textarea></div>' +
  3087.                     '<div class="sched-note">' + esc(L('cust.priceHint')) + '</div>';
  3088.                 wirePax(el.body);
  3089.                 setFoot(state.busy ? L('sched.submitting') : L('sched.submit'), {
  3090.                     disabled: state.busy,
  3091.                     onClick:  placeCustomReservation
  3092.                 });
  3093.             }
  3094.             /**
  3095.              * Party size for a custom tour: the count, and nothing else.
  3096.              *
  3097.              * No price is shown anywhere in this flow. A tour we have never run
  3098.              * before cannot be quoted from a table — the back office works it
  3099.              * out from the stops and the day, and the passenger gets the figure
  3100.              * by email when the request is approved. Showing a table price here
  3101.              * would be a promise we might not keep.
  3102.              */
  3103.             function customPaxHtml() {
  3104.                 return '' +
  3105.                     '<div class="pax">' +
  3106.                         '<div class="pax-lbl">' + esc(L('pax.title')) +
  3107.                             '<small>' + esc(L('pax.hint', { n: maxPax() })) + '</small>' +
  3108.                         '</div>' +
  3109.                         '<div class="pax-ctl">' +
  3110.                             '<button type="button" class="pax-btn" data-pax-step="-1"' +
  3111.                                 (state.passengers <= 1 ? ' disabled' : '') +
  3112.                                 ' aria-label="' + esc(L('pax.fewer')) + '">−</button>' +
  3113.                             '<span class="pax-n">' + state.passengers + '</span>' +
  3114.                             '<button type="button" class="pax-btn" data-pax-step="1"' +
  3115.                                 (state.passengers >= maxPax() ? ' disabled' : '') +
  3116.                                 ' aria-label="' + esc(L('pax.more')) + '">+</button>' +
  3117.                         '</div>' +
  3118.                     '</div>';
  3119.             }
  3120.             function placeCustomReservation() {
  3121.                 if (state.busy) return;
  3122.                 if (!TAXI.isLoggedIn) { window.location.href = TAXI.loginUrl; return; }
  3123.                 captureForms();
  3124.                 var c = state.custom || {};
  3125.                 if (!c.date || !c.time) { state.error = L('sched.needDate'); render(); return; }
  3126.                 state.busy  = true;
  3127.                 state.error = null;
  3128.                 render();
  3129.                 api(TAXI.customUrl, {
  3130.                     method: 'POST',
  3131.                     body: {
  3132.                         // Ids only: the server reads each stop's name and
  3133.                         // coordinates off the Location row itself.
  3134.                         locationIds:   state.draw.map(function (p) { return p.id; }),
  3135.                         title:         c.title,
  3136.                         scheduledAt:   c.date + 'T' + c.time,
  3137.                         passengers:    state.passengers,
  3138.                         pickupAddress: c.pickup,
  3139.                         note:          c.note
  3140.                     }
  3141.                 }).then(function (data) {
  3142.                     state.busy        = false;
  3143.                     state.reservation = data.reservation;
  3144.                     state.custom      = null;
  3145.                     state.draw        = [];
  3146.                     state.step        = STEP_SCHEDULE_DONE;
  3147.                     clearDraw();
  3148.                     // Paint the snapped road back, so the passenger sees the
  3149.                     // actual streets their tour will follow.
  3150.                     paintReservation(data.reservation);
  3151.                     render();
  3152.                     snapTo(1);
  3153.                 }).catch(function (e) {
  3154.                     state.busy  = false;
  3155.                     state.error = e.message;
  3156.                     render();
  3157.                 });
  3158.             }
  3159.             // ── Viewing a scheduled tour (from /map?res=REF) ─────────────
  3160.             /**
  3161.              * Paint a reservation's line. A custom one carries its own snapped
  3162.              * geometry; one of our lines is already in state.routes.
  3163.              */
  3164.             function paintReservation(rv) {
  3165.                 if (!rv) return;
  3166.                 if (rv.kind === 'custom' && rv.custom) {
  3167.                     var pts = rv.custom.points || [];
  3168.                     if (!pts.length) return;
  3169.                     paintRoute({
  3170.                         color:    TAXI.customColor,
  3171.                         geometry: rv.custom.geometry,
  3172.                         points:   pts.map(function (p, i) {
  3173.                             return { id: null, lat: p.lat, lng: p.lng, name: p.label || L('cust.stopN', { n: i + 1 }) };
  3174.                         })
  3175.                     });
  3176.                     return;
  3177.                 }
  3178.                 if (rv.route) {
  3179.                     var full = state.routes.filter(function (r) { return r.id === rv.route.id; })[0];
  3180.                     if (full) paintRoute(full);
  3181.                 }
  3182.             }
  3183.             function renderReservation() {
  3184.                 var rv  = state.reservation || {};
  3185.                 var pax = rv.passengers || 1;
  3186.                 var when = rv.scheduledAt ? new Date(rv.scheduledAt) : null;
  3187.                 el.title.textContent = rv.tourName || L('cust.title');
  3188.                 el.sub.textContent   = rv.reference || '';
  3189.                 var statusTxt = L('res.' + rv.status);
  3190.                 if (statusTxt === 'res.' + rv.status) statusTxt = rv.statusLabel;
  3191.                 var html = errorHtml() +
  3192.                     '<div class="ord-status">' +
  3193.                         '<span class="ord-pulse"></span>' +
  3194.                         '<span class="ord-txt">' + esc(statusTxt) +
  3195.                             '<span class="ord-ref">' + esc(rv.reference || '') + '</span>' +
  3196.                         '</span>' +
  3197.                     '</div>' +
  3198.                     '<div style="margin-top:8px">' +
  3199.                         '<div class="sum-row"><span class="sum-k">' + esc(L('sched.when')) + '</span><span class="sum-v">' +
  3200.                             esc(when ? when.toLocaleDateString() + ' · ' + when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '') +
  3201.                         '</span></div>' +
  3202.                         '<div class="sum-row"><span class="sum-k">' + esc(L('pax.title')) + '</span><span class="sum-v">' + pax + ' ' + esc(paxWord(pax)) + '</span></div>' +
  3203.                         (rv.pickupAddress
  3204.                             ? '<div class="sum-row"><span class="sum-k">' + esc(L('sched.pickup')) + '</span><span class="sum-v">' + esc(rv.pickupAddress) + '</span></div>'
  3205.                             : '') +
  3206.                         // Until it is approved a custom tour has no agreed
  3207.                         // price, so quoting one here would be misleading.
  3208.                         (rv.kind === 'custom' && rv.status !== 'approved'
  3209.                             ? '<div class="sched-note" style="margin-top:10px">' + esc(L('cust.priceLater')) + '</div>'
  3210.                             : '<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>') +
  3211.                     '</div>';
  3212.                 el.body.innerHTML = html;
  3213.                 setFoot(L('sheet.bookAnother'), { onClick: resetToRoutes });
  3214.             }
  3215.             function renderTuks() {
  3216.                 el.title.textContent = L('sheet.chooseTuk');
  3217.                 if (state.busy) {
  3218.                     el.sub.textContent = '';
  3219.                     el.body.innerHTML  = '<div class="sheet-msg"><div class="spin"></div>' + esc(L('sheet.finding')) + '</div>';
  3220.                     setFoot(null);
  3221.                     return;
  3222.                 }
  3223.                 el.sub.textContent = state.tuks.length
  3224.                     ? L('sheet.availableNow', { n: state.tuks.length })
  3225.                     : '';
  3226.                 if (!state.tuks.length) {
  3227.                     el.body.innerHTML = errorHtml() +
  3228.                         '<div class="sheet-msg">' +
  3229.                             L('sheet.noTuksOnline') +
  3230.                         '</div>';
  3231.                     setFoot(L('sheet.retry'), { ghost: true, onClick: goToTuks });
  3232.                     return;
  3233.                 }
  3234.                 var html = errorHtml();
  3235.                 state.tuks.forEach(function (t) {
  3236.                     var d = t.driver;
  3237.                     html +=
  3238.                         '<button type="button" class="tuk-card' + (state.tuk && state.tuk.id === t.id ? ' active' : '') + '"' +
  3239.                             ' style="--tuk-color:' + esc(t.color) + '" data-tuk="' + t.id + '">' +
  3240.                             '<div class="tuk-avatar">' + esc(initials(d ? d.name : t.code)) + '</div>' +
  3241.                             '<div class="tuk-main">' +
  3242.                                 '<div class="tuk-name">' + esc(d ? d.name : t.code) + '</div>' +
  3243.                                 '<div class="tuk-meta">' +
  3244.                                     (d ? '<span class="tuk-star">★</span><span>' + d.rating.toFixed(1) + '</span><span class="dot-sep"></span>' : '') +
  3245.                                     '<span>' + esc(t.code) + '</span>' +
  3246.                                     '<span class="dot-sep"></span>' +
  3247.                                     '<span>' + t.seats + ' ' + esc(L('sheet.seats')) + '</span>' +
  3248.                                 '</div>' +
  3249.                             '</div>' +
  3250.                             '<div class="tuk-eta">' +
  3251.                                 (t.eta !== null
  3252.                                     ? '<div class="tuk-eta-val">' + t.eta + ' ' + esc(L('sheet.min')) + '</div>' +
  3253.                                       '<div class="tuk-eta-lbl">' + esc(metres(t.distance)) + '</div>'
  3254.                                     : '<div class="tuk-eta-lbl">' + esc(L('sheet.nearby')) + '</div>') +
  3255.                             '</div>' +
  3256.                         '</button>';
  3257.                 });
  3258.                 el.body.innerHTML = html;
  3259.                 el.body.querySelectorAll('.tuk-card').forEach(function (btn) {
  3260.                     btn.addEventListener('click', function () {
  3261.                         var id = parseInt(btn.dataset.tuk, 10);
  3262.                         var t  = state.tuks.filter(function (x) { return x.id === id; })[0];
  3263.                         if (t) selectTuk(t);
  3264.                     });
  3265.                 });
  3266.                 setFoot(L('sheet.continuePay'), {
  3267.                     disabled: !state.tuk,
  3268.                     onClick:  goToPay
  3269.                 });
  3270.             }
  3271.             function renderPay() {
  3272.                 el.title.textContent = L('sheet.payment');
  3273.                 el.sub.textContent   = state.tuk && state.tuk.driver ? state.tuk.driver.name + ' · ' + state.tuk.code : '';
  3274.                 var html = errorHtml();
  3275.                 // Signed out? Take the three details the order needs and let the
  3276.                 // server make the account. Nothing here blocks the booking: the
  3277.                 // visitor types name, email and phone and orders in the same tap.
  3278.                 if (!TAXI.isLoggedIn) {
  3279.                     var g = state.guest || {};
  3280.                     html +=
  3281.                         '<div class="guest-box">' +
  3282.                             '<div class="guest-lbl">' + esc(L('guest.title')) +
  3283.                                 '<small>' + esc(L('guest.hint')) + '</small>' +
  3284.                             '</div>' +
  3285.                             '<div class="sched-field"><label for="gu-name">' + esc(L('auth.fullName')) + '</label>' +
  3286.                                 '<input type="text" id="gu-name" class="sched-input" autocomplete="name"' +
  3287.                                 ' placeholder="' + esc(L('guest.namePh')) + '" value="' + esc(g.name || '') + '"></div>' +
  3288.                             '<div class="sched-field"><label for="gu-email">' + esc(L('auth.email')) + '</label>' +
  3289.                                 '<input type="email" id="gu-email" class="sched-input" autocomplete="email"' +
  3290.                                 ' placeholder="you@example.com" value="' + esc(g.email || '') + '"></div>' +
  3291.                             '<div class="sched-field"><label for="gu-phone">' + esc(L('auth.phone')) + '</label>' +
  3292.                                 '<input type="tel" id="gu-phone" class="sched-input" autocomplete="tel"' +
  3293.                                 ' placeholder="+374 XX XXXXXX" value="' + esc(g.phone || '') + '"></div>' +
  3294.                             // Only shown once the server says so, so an ordinary
  3295.                             // typo never nags anyone about signing in.
  3296.                             (state.accountExists
  3297.                                 ? '<button type="button" class="guest-signin" id="gu-signin">' +
  3298.                                       esc(L('guest.signinInstead')) +
  3299.                                   '</button>'
  3300.                                 : '<div class="guest-alt">' + esc(L('guest.haveAccount')) +
  3301.                                       ' <a href="#" id="gu-signin">' + esc(L('nav.signin')) + '</a></div>') +
  3302.                         '</div>';
  3303.                 }
  3304.                 // Cash
  3305.                 html +=
  3306.                     '<button type="button" class="pay-opt' + (state.pay === 'cash' ? ' active' : '') + '" data-pay="cash">' +
  3307.                         '<span class="pay-ico">💵</span>' +
  3308.                         '<span class="pay-main">' +
  3309.                             '<span class="pay-lbl">' + esc(L('pay.cash')) + '</span>' +
  3310.                             '<span class="pay-sub">' + esc(L('pay.cashSub')) + '</span>' +
  3311.                         '</span>' +
  3312.                         '<span class="pay-tick"></span>' +
  3313.                     '</button>';
  3314.                 // Saved cards
  3315.                 state.cards.forEach(function (c) {
  3316.                     var on = state.pay === 'card' && state.card && state.card.id === c.id;
  3317.                     html +=
  3318.                         '<button type="button" class="pay-opt' + (on ? ' active' : '') + (c.expired ? ' expired' : '') + '"' +
  3319.                             ' data-pay="card" data-card="' + c.id + '"' + (c.expired ? ' disabled' : '') + '>' +
  3320.                             '<span class="pay-ico">💳</span>' +
  3321.                             '<span class="pay-main">' +
  3322.                                 '<span class="pay-lbl">' + esc(c.label + ' ' + c.masked) + '</span>' +
  3323.                                 '<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>' +
  3324.                             '</span>' +
  3325.                             '<span class="pay-tick"></span>' +
  3326.                         '</button>';
  3327.                 });
  3328.                 // Pay by card with a card that is not on the account yet. This is
  3329.                 // the only card option a guest has; a signed-in passenger gets it
  3330.                 // under their saved ones as "use another card".
  3331.                 var newCardOn = state.pay === 'card' && !state.card;
  3332.                 var nc        = state.newCard || {};
  3333.                 html +=
  3334.                     '<button type="button" class="pay-opt' + (newCardOn ? ' active' : '') + '" data-pay="card" data-card="new">' +
  3335.                         '<span class="pay-ico">💳</span>' +
  3336.                         '<span class="pay-main">' +
  3337.                             '<span class="pay-lbl">' + esc(state.cards.length ? L('pay.anotherCard') : L('pay.card')) + '</span>' +
  3338.                             '<span class="pay-sub">' + esc(L('pay.cardSub')) + '</span>' +
  3339.                         '</span>' +
  3340.                         '<span class="pay-tick"></span>' +
  3341.                     '</button>';
  3342.                 if (newCardOn) {
  3343.                     html +=
  3344.                         '<div class="card-box">' +
  3345.                             '<div class="sched-field"><label for="cd-number">' + esc(L('acc.cardNumber')) + '</label>' +
  3346.                                 '<input type="text" id="cd-number" class="sched-input" inputmode="numeric" autocomplete="cc-number"' +
  3347.                                 ' placeholder="4242 4242 4242 4242" value="' + esc(nc.number || '') + '"></div>' +
  3348.                             '<div class="sched-row">' +
  3349.                                 '<div class="sched-field"><label for="cd-exp">' + esc(L('pay.expiry')) + '</label>' +
  3350.                                     '<input type="text" id="cd-exp" class="sched-input" inputmode="numeric" autocomplete="cc-exp"' +
  3351.                                     ' placeholder="MM/YY" maxlength="5" value="' + esc(nc.exp || '') + '"></div>' +
  3352.                                 '<div class="sched-field"><label for="cd-holder">' + esc(L('acc.holder')) + '</label>' +
  3353.                                     '<input type="text" id="cd-holder" class="sched-input" autocomplete="cc-name"' +
  3354.                                     ' placeholder="AS ON CARD" value="' + esc(nc.holder || '') + '"></div>' +
  3355.                             '</div>' +
  3356.                             // Says what the app actually does today: no gateway is
  3357.                             // wired up, so the fare is settled with the driver and
  3358.                             // only the brand and last four digits are kept.
  3359.                             '<div class="sched-note">' + esc(L('pay.cardNote')) + '</div>' +
  3360.                         '</div>';
  3361.                 }
  3362.                 // Summary
  3363.                 html +=
  3364.                     '<div style="margin-top:14px">' +
  3365.                         '<div class="sum-row"><span class="sum-k">' + esc(L('sum.route')) + '</span><span class="sum-v">' + esc(tr(state.route)) + '</span></div>' +
  3366.                         '<div class="sum-row"><span class="sum-k">' + esc(L('sum.duration')) + '</span><span class="sum-v">' + esc(mins(state.route.durationMinutes)) + '</span></div>' +
  3367.                         '<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>' +
  3368.                         '<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>' +
  3369.                         '<div class="sum-row"><span class="sum-k">' + esc(L('sheet.total')) + '</span><span class="sum-v big">' + esc(amd(currentPrice())) + '</span></div>' +
  3370.                     '</div>';
  3371.                 el.body.innerHTML = html;
  3372.                 el.body.querySelectorAll('.pay-opt').forEach(function (btn) {
  3373.                     if (btn.disabled) return;
  3374.                     btn.addEventListener('click', function () {
  3375.                         // Switching option re-renders, which throws the inputs
  3376.                         // away — keep what has been typed first.
  3377.                         captureForms();
  3378.                         state.pay = btn.dataset.pay;
  3379.                         if (state.pay === 'card') {
  3380.                             var id = parseInt(btn.dataset.card, 10);
  3381.                             state.card = state.cards.filter(function (c) { return c.id === id; })[0] || null;
  3382.                         } else {
  3383.                             state.card = null;
  3384.                         }
  3385.                         state.error = null;
  3386.                         render();
  3387.                     });
  3388.                 });
  3389.                 var signin = document.getElementById('gu-signin');
  3390.                 if (signin) {
  3391.                     signin.addEventListener('click', function (e) {
  3392.                         e.preventDefault();
  3393.                         captureForms();
  3394.                         goToLogin();
  3395.                     });
  3396.                 }
  3397.                 setFoot(state.busy ? L('sheet.ordering') : L('sheet.orderTuk'), {
  3398.                     price:    amd(currentPrice()),
  3399.                     disabled: state.busy,
  3400.                     onClick:  placeOrder
  3401.                 });
  3402.             }
  3403.             /**
  3404.              * Off to the sign-in page, leaving a note to ourselves about the tour
  3405.              * being ordered so coming back does not mean starting over. Used by
  3406.              * the guest form's "I already have an account" way out.
  3407.              */
  3408.             /**
  3409.              * The other end of goToLogin(): put the visitor back on the tour they
  3410.              * were ordering. Returns true when it took over the boot sequence.
  3411.              *
  3412.              * It stops at the tuk list rather than the payment step on purpose —
  3413.              * whichever tuk was free before the detour may not be free now, so
  3414.              * the list is re-fetched and they pick again.
  3415.              */
  3416.             function resumePendingOrder() {
  3417.                 var raw = null;
  3418.                 try {
  3419.                     raw = sessionStorage.getItem('tnt.pendingOrder');
  3420.                     sessionStorage.removeItem('tnt.pendingOrder');
  3421.                 } catch (e) {
  3422.                     return false;
  3423.                 }
  3424.                 if (!raw) return false;
  3425.                 var saved;
  3426.                 try { saved = JSON.parse(raw); } catch (e) { return false; }
  3427.                 var route = state.routes.filter(function (r) { return r.id === saved.routeId; })[0];
  3428.                 if (!route) return false;
  3429.                 // Set before selectRoute, which does not touch the party size.
  3430.                 state.passengers = Math.max(1, Math.min(saved.passengers || 1, maxPax()));
  3431.                 selectRoute(route);
  3432.                 goToTuks();
  3433.                 return true;
  3434.             }
  3435.             function goToLogin() {
  3436.                 try {
  3437.                     sessionStorage.setItem('tnt.pendingOrder', JSON.stringify({
  3438.                         routeId:    state.route ? state.route.id : null,
  3439.                         passengers: state.passengers
  3440.                     }));
  3441.                 } catch (e) {
  3442.                     // Private mode, quota, a browser that says no — the sign-in
  3443.                     // still has to happen, they just land on the tour list.
  3444.                 }
  3445.                 window.location.href = TAXI.loginUrl;
  3446.             }
  3447.             function renderActive() {
  3448.                 var b = state.booking;
  3449.                 el.title.textContent = L('sheet.yourTuk');
  3450.                 el.sub.textContent   = tr(b.route);
  3451.                 var d = b.driver;
  3452.                 var t = b.tuk;
  3453.                 // Translate the status client-side from booking.status rather than
  3454.                 // trusting the server's English statusLabel.
  3455.                 var statusTxt = L('status.' + b.status);
  3456.                 if (statusTxt === 'status.' + b.status) statusTxt = b.statusLabel;
  3457.                 var html =
  3458.                     errorHtml() +
  3459.                     '<div class="ord-status">' +
  3460.                         '<span class="ord-pulse"></span>' +
  3461.                         '<span class="ord-txt">' + esc(statusTxt) +
  3462.                             '<span class="ord-ref">' + esc(b.reference) + '</span>' +
  3463.                         '</span>' +
  3464.                     '</div>';
  3465.                 if (d) {
  3466.                     html +=
  3467.                         '<div class="tuk-card" style="--tuk-color:' + esc(t ? t.color : '#E8A020') + '; cursor:default">' +
  3468.                             '<div class="tuk-avatar">' + esc(initials(d.name)) + '</div>' +
  3469.                             '<div class="tuk-main">' +
  3470.                                 '<div class="tuk-name">' + esc(d.name) + '</div>' +
  3471.                                 '<div class="tuk-meta">' +
  3472.                                     '<span class="tuk-star">★</span><span>' + d.rating.toFixed(1) + '</span>' +
  3473.                                     '<span class="dot-sep"></span>' +
  3474.                                     '<span>' + esc(t ? t.code : '') + '</span>' +
  3475.                                     (t && t.plate ? '<span class="dot-sep"></span><span>' + esc(t.plate) + '</span>' : '') +
  3476.                                 '</div>' +
  3477.                             '</div>' +
  3478.                         '</div>';
  3479.                     // Contact is in-system only — chat and the in-app voice
  3480.                     // call. The driver's phone number is never shown and the
  3481.                     // old tel: fallback is gone. `d.phone` is still the gate:
  3482.                     // the server releases it over exactly the window in which
  3483.                     // the comm channel is open (accepted → in_progress).
  3484.                     if (d.phone && window.TNT_COMM) {
  3485.                         html += TNT_COMM.actionsHtml();
  3486.                     }
  3487.                 }
  3488.                 html +=
  3489.                     '<div style="margin-top:6px">' +
  3490.                         '<div class="sum-row"><span class="sum-k">' + esc(L('sheet.payment')) + '</span><span class="sum-v">' +
  3491.                             (b.paymentMethod === 'cash' ? esc(L('pay.cash')) : esc(b.card ? b.card.label + ' ' + b.card.masked : L('pay.card'))) +
  3492.                         '</span></div>' +
  3493.                         '<div class="sum-row"><span class="sum-k">' + esc(L('sheet.total')) + '</span><span class="sum-v big">' + esc(amd(b.priceAmd)) + '</span></div>' +
  3494.                     '</div>';
  3495.                 // After a completed tour, invite a rating and a tip (each once).
  3496.                 if (b.status === 'completed') {
  3497.                     html += ratingHtml(b, d);
  3498.                     html += tipHtml(b, d);
  3499.                 }
  3500.                 el.body.innerHTML = html;
  3501.                 wireTip(el.body);
  3502.                 if (window.TNT_COMM) TNT_COMM.wireActions(el.body);
  3503.                 // Wire the interactive stars, if the widget is showing.
  3504.                 var stars = el.body.querySelectorAll('.rate-star');
  3505.                 stars.forEach(function (s) {
  3506.                     s.addEventListener('click', function () {
  3507.                         state.ratingPending = parseInt(s.dataset.v, 10);
  3508.                         paintStars();
  3509.                     });
  3510.                     s.addEventListener('mouseenter', function () { paintStars(parseInt(s.dataset.v, 10)); });
  3511.                 });
  3512.                 if (stars.length) {
  3513.                     var wrap = el.body.querySelector('.rate-stars');
  3514.                     if (wrap) wrap.addEventListener('mouseleave', function () { paintStars(); });
  3515.                 }
  3516.                 var skip = el.body.querySelector('.rate-skip');
  3517.                 if (skip) skip.addEventListener('click', resetToRoutes);
  3518.                 refreshActiveFoot();
  3519.             }
  3520.             /**
  3521.              * The live order's footer button, which changes with the order's
  3522.              * state: cancel while it can still be called off, then rate, then
  3523.              * tip, then "book another". Split out of renderActive so the tip
  3524.              * input can refresh the button without re-rendering (and stealing
  3525.              * focus from) the field being typed into.
  3526.              */
  3527.             function refreshActiveFoot() {
  3528.                 var b = state.booking;
  3529.                 if (!b) { setFoot(null); return; }
  3530.                 if (b.cancellable) {
  3531.                     setFoot(state.busy ? L('sheet.cancelling') : L('sheet.cancelOrder'), {
  3532.                         ghost:    true,
  3533.                         disabled: state.busy,
  3534.                         onClick:  cancelOrder
  3535.                     });
  3536.                     return;
  3537.                 }
  3538.                 if (b.status === 'completed' && b.rateable) {
  3539.                     setFoot(state.busy ? L('rate.sending') : L('rate.submit'), {
  3540.                         disabled: state.busy || state.ratingPending < 1,
  3541.                         onClick:  submitRating
  3542.                     });
  3543.                     return;
  3544.                 }
  3545.                 // Rated (or rating skipped) and not yet tipped: offer the tip.
  3546.                 if (b.status === 'completed' && b.tippable && !state.tipDone) {
  3547.                     setFoot(state.busy ? L('tip.sending') : L('tip.submit'), {
  3548.                         price:    state.tipPending > 0 ? amd(state.tipPending) : null,
  3549.                         disabled: state.busy || state.tipPending < (TAXI.tipMin || 1),
  3550.                         onClick:  submitTip
  3551.                     });
  3552.                     return;
  3553.                 }
  3554.                 if (b.status === 'completed' || b.status === 'cancelled') {
  3555.                     setFoot(L('sheet.bookAnother'), { onClick: resetToRoutes });
  3556.                     return;
  3557.                 }
  3558.                 setFoot(null);
  3559.             }
  3560.             /**
  3561.              * The post-tour rating block: interactive stars while the tour is
  3562.              * unrated, a read-back once a score has been given.
  3563.              */
  3564.             function ratingHtml(b, d) {
  3565.                 var name = d ? d.name.split(' ')[0] : null;
  3566.                 if (b.rating) {
  3567.                     var filled = '';
  3568.                     for (var i = 1; i <= 5; i++) {
  3569.                         filled += '<span class="rate-star' + (i <= b.rating ? ' on' : '') + '">★</span>';
  3570.                     }
  3571.                     var thanks = name ? L('rate.thanks', { name: name }) : L('rate.thanksGeneric');
  3572.                     return '<div class="rate-box done">' +
  3573.                         '<div class="rate-q">' + esc(thanks) + '</div>' +
  3574.                         '<div class="rate-stars readonly">' + filled + '</div>' +
  3575.                     '</div>';
  3576.                 }
  3577.                 // Render the pending choice into the markup rather than relying
  3578.                 // on paintStars() to light it afterwards: anything that
  3579.                 // re-renders the sheet — submitting, a poll landing, tapping a
  3580.                 // tip amount — rebuilds this block, and stars painted only by
  3581.                 // the event handler would silently go dark each time.
  3582.                 var stars = '';
  3583.                 for (var j = 1; j <= 5; j++) {
  3584.                     stars += '<span class="rate-star' + (j <= state.ratingPending ? ' on' : '') +
  3585.                         '" data-v="' + j + '" role="button" tabindex="0">★</span>';
  3586.                 }
  3587.                 var q = name ? L('rate.q', { name: name }) : L('rate.qGeneric');
  3588.                 return '<div class="rate-box">' +
  3589.                     '<div class="rate-q">' + esc(q) + '</div>' +
  3590.                     '<div class="rate-stars">' + stars + '</div>' +
  3591.                     '<button type="button" class="rate-skip">' + esc(L('rate.skip')) + '</button>' +
  3592.                 '</div>';
  3593.             }
  3594.             /**
  3595.              * The post-tour tip block: preset amounts plus a free entry while
  3596.              * the tour is untipped, a thank-you read-back once one is given.
  3597.              *
  3598.              * The tip is a separate, optional gesture from the fare — it is
  3599.              * never rolled into the total the passenger was quoted, and the
  3600.              * amount is re-checked server-side before it is recorded.
  3601.              */
  3602.             function tipHtml(b, d) {
  3603.                 var name = d ? d.name.split(' ')[0] : null;
  3604.                 if (b.tipAmd) {
  3605.                     return '<div class="tip-box done">' +
  3606.                         '<div class="tip-q">' + esc(name ? L('tip.gave', { name: name }) : L('tip.gaveGeneric')) + '</div>' +
  3607.                         '<div class="tip-amount">' + esc(amd(b.tipAmd)) + '</div>' +
  3608.                     '</div>';
  3609.                 }
  3610.                 // Dismissed for this order — do not nag.
  3611.                 if (state.tipDone) return '';
  3612.                 var presets = b.tipPresets || TAXI.tipPresets || [];
  3613.                 var opts    = '';
  3614.                 presets.forEach(function (v) {
  3615.                     opts += '<button type="button" class="tip-opt' + (state.tipPending === v ? ' on' : '') +
  3616.                         '" data-tip="' + v + '">' + esc(amd(v)) + '</button>';
  3617.                 });
  3618.                 return '<div class="tip-box">' +
  3619.                     '<div class="tip-q">' + esc(name ? L('tip.q', { name: name }) : L('tip.qGeneric')) + '</div>' +
  3620.                     '<div class="tip-opts">' + opts + '</div>' +
  3621.                     '<div class="tip-custom">' +
  3622.                         '<input type="number" id="tip-other" inputmode="numeric" min="' + (TAXI.tipMin || 0) +
  3623.                             '" max="' + (TAXI.tipMax || 0) + '" placeholder="' + esc(L('tip.other')) + '"' +
  3624.                             (state.tipPending && presets.indexOf(state.tipPending) === -1 ? ' value="' + state.tipPending + '"' : '') + '>' +
  3625.                     '</div>' +
  3626.                     '<button type="button" class="tip-skip">' + esc(L('tip.skip')) + '</button>' +
  3627.                 '</div>';
  3628.             }
  3629.             function wireTip(root) {
  3630.                 root.querySelectorAll('[data-tip]').forEach(function (b) {
  3631.                     b.addEventListener('click', function () {
  3632.                         state.tipPending = parseInt(b.dataset.tip, 10);
  3633.                         var other = document.getElementById('tip-other');
  3634.                         if (other) other.value = '';
  3635.                         render();
  3636.                     });
  3637.                 });
  3638.                 var other = root.querySelector('#tip-other');
  3639.                 if (other) {
  3640.                     // Typed amounts must not re-render on every keystroke (the
  3641.                     // input would lose focus), so only the footer is refreshed.
  3642.                     other.addEventListener('input', function () {
  3643.                         state.tipPending = parseInt(other.value, 10) || 0;
  3644.                         root.querySelectorAll('[data-tip]').forEach(function (b) { b.classList.remove('on'); });
  3645.                         refreshActiveFoot();
  3646.                     });
  3647.                 }
  3648.                 var skip = root.querySelector('.tip-skip');
  3649.                 if (skip) {
  3650.                     skip.addEventListener('click', function () {
  3651.                         state.tipDone    = true;
  3652.                         state.tipPending = 0;
  3653.                         render();
  3654.                     });
  3655.                 }
  3656.             }
  3657.             function submitTip() {
  3658.                 var min = TAXI.tipMin || 0;
  3659.                 var max = TAXI.tipMax || 0;
  3660.                 if (state.busy || !state.booking) return;
  3661.                 if (state.tipPending < min || state.tipPending > max) {
  3662.                     state.error = L('tip.range', { min: amd(min), max: amd(max) });
  3663.                     render();
  3664.                     return;
  3665.                 }
  3666.                 state.busy  = true;
  3667.                 state.error = null;
  3668.                 render();
  3669.                 api(TAXI.tipUrlTpl.replace('__REF__', encodeURIComponent(state.booking.reference)), {
  3670.                     method: 'POST',
  3671.                     // Card tips would need the card picker; cash keeps the
  3672.                     // gesture one tap, matching how the fare is settled today.
  3673.                     body:   { amountAmd: state.tipPending, method: 'cash' }
  3674.                 }).then(function (data) {
  3675.                     state.busy       = false;
  3676.                     state.booking    = data.booking;
  3677.                     state.tipPending = 0;
  3678.                     render();
  3679.                 }).catch(function (e) {
  3680.                     state.busy  = false;
  3681.                     state.error = e.message;
  3682.                     render();
  3683.                 });
  3684.             }
  3685.             // Repaint the stars to reflect a hover value or the pending choice.
  3686.             function paintStars(hover) {
  3687.                 var v = hover || state.ratingPending;
  3688.                 el.body.querySelectorAll('.rate-stars:not(.readonly) .rate-star').forEach(function (s) {
  3689.                     s.classList.toggle('on', parseInt(s.dataset.v, 10) <= v);
  3690.                 });
  3691.             }
  3692.             function submitRating() {
  3693.                 if (state.busy || !state.booking || state.ratingPending < 1) return;
  3694.                 state.busy  = true;
  3695.                 state.error = null;
  3696.                 render();
  3697.                 api(TAXI.rateUrlTpl.replace('__REF__', encodeURIComponent(state.booking.reference)), {
  3698.                     method: 'POST',
  3699.                     body:   { rating: state.ratingPending }
  3700.                 }).then(function (data) {
  3701.                     state.busy    = false;
  3702.                     state.booking = data.booking;
  3703.                     state.ratingPending = 0;
  3704.                     render();
  3705.                 }).catch(function (e) {
  3706.                     state.busy  = false;
  3707.                     state.error = e.message;
  3708.                     render();
  3709.                 });
  3710.             }
  3711.             // ── Transitions ──────────────────────────────────────────────
  3712.             function selectRoute(route) {
  3713.                 state.route = route;
  3714.                 state.tuk   = null;
  3715.                 state.error = null;
  3716.                 state.step  = STEP_ROUTE;
  3717.                 clearTuks();
  3718.                 clearAmbient();         // hide the ambient fleet during the booking
  3719.                 setPoiVisible(false);   // let the road be the only thing on the map
  3720.                 paintRoute(route);
  3721.                 render();
  3722.                 snapTo(1);
  3723.             }
  3724.             function selectTuk(tuk) {
  3725.                 state.tuk   = tuk;
  3726.                 state.error = null;
  3727.                 // Re-colour the markers so the map shows the selection too.
  3728.                 paintTuks(state.tuks);
  3729.                 if (state.step === STEP_TUKS) render();
  3730.             }
  3731.             function goToTuks() {
  3732.                 state.step  = STEP_TUKS;
  3733.                 state.busy  = true;
  3734.                 state.error = null;
  3735.                 render();
  3736.                 // Ask for the tuks nearest the passenger; without GPS, fall back
  3737.                 // to the route's first stop, which is where the tour begins.
  3738.                 locate().then(function (pos) {
  3739.                     var origin = pos || firstStop();
  3740.                     var url    = TAXI.tuksUrl;
  3741.                     if (origin) {
  3742.                         url += '?lat=' + encodeURIComponent(origin[0]) + '&lng=' + encodeURIComponent(origin[1]);
  3743.                     }
  3744.                     return api(url);
  3745.                 }).then(function (data) {
  3746.                     state.tuks = data.tuks || [];
  3747.                     state.busy = false;
  3748.                     // Pre-select the closest, the way a taxi app does.
  3749.                     if (state.tuks.length && !state.tuk) state.tuk = state.tuks[0];
  3750.                     paintTuks(state.tuks);
  3751.                     render();
  3752.                 }).catch(function (e) {
  3753.                     state.busy  = false;
  3754.                     state.tuks  = [];
  3755.                     state.error = e.message;
  3756.                     render();
  3757.                 });
  3758.             }
  3759.             function goToPay() {
  3760.                 if (!state.tuk) return;
  3761.                 state.step  = STEP_PAY;
  3762.                 state.error = null;
  3763.                 render();
  3764.                 // A signed-out visitor has no saved cards to fetch — they type
  3765.                 // theirs into the step itself.
  3766.                 if (!TAXI.isLoggedIn || state.cards.length) return;
  3767.                 // Load saved cards so they can be picked alongside cash.
  3768.                 api(TAXI.cardsUrl).then(function (data) {
  3769.                     state.cards = data.cards || [];
  3770.                     var def = state.cards.filter(function (c) { return c.default && !c.expired; })[0];
  3771.                     if (def) { state.pay = 'card'; state.card = def; }
  3772.                     if (state.step === STEP_PAY) render();
  3773.                 }).catch(function () {
  3774.                     // Cards are optional — cash still works, so stay quiet.
  3775.                 });
  3776.             }
  3777.             function placeOrder() {
  3778.                 if (state.busy) return;
  3779.                 // Whatever is typed into the payment step has to survive both the
  3780.                 // busy re-render below and any error that sends us back here.
  3781.                 captureForms();
  3782.                 var body = {
  3783.                     routeId:       state.route.id,
  3784.                     tukId:         state.tuk.id,
  3785.                     paymentMethod: state.pay,
  3786.                     cardId:        state.card ? state.card.id : null,
  3787.                     passengers:    state.passengers
  3788.                 };
  3789.                 // Guest checkout: the server registers and signs them in as part
  3790.                 // of placing the order. Checked here first so an empty field costs
  3791.                 // a message rather than a round-trip.
  3792.                 if (!TAXI.isLoggedIn) {
  3793.                     var g = state.guest || {};
  3794.                     if (!String(g.name || '').trim() ||
  3795.                         !String(g.email || '').trim() ||
  3796.                         !String(g.phone || '').trim()) {
  3797.                         state.error = L('guest.needAll');
  3798.                         render();
  3799.                         return;
  3800.                     }
  3801.                     if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(g.email).trim())) {
  3802.                         state.error = L('guest.badEmail');
  3803.                         render();
  3804.                         return;
  3805.                     }
  3806.                     body.guest = { name: g.name, email: g.email, phone: g.phone };
  3807.                 }
  3808.                 // A card that is not saved on the account yet — the only kind a
  3809.                 // guest can have. The number never touches our storage: the
  3810.                 // server keeps the brand and last four digits and forgets it.
  3811.                 if (state.pay === 'card' && !state.card) {
  3812.                     var card = parseCardFields(state.newCard || {});
  3813.                     if (!card) {
  3814.                         state.error = L('pay.badCard');
  3815.                         render();
  3816.                         return;
  3817.                     }
  3818.                     body.card = card;
  3819.                 }
  3820.                 state.busy  = true;
  3821.                 state.error = null;
  3822.                 render();
  3823.                 var origin = state.userPos || firstStop();
  3824.                 body.pickupLat = origin ? origin[0] : null;
  3825.                 body.pickupLng = origin ? origin[1] : null;
  3826.                 api(TAXI.createUrl, {
  3827.                     method: 'POST',
  3828.                     body: body
  3829.                 }).then(function (data) {
  3830.                     state.busy    = false;
  3831.                     state.booking = data.booking;
  3832.                     state.step    = STEP_ACTIVE;
  3833.                     // Registered and signed in on the way through. From here the
  3834.                     // sheet behaves exactly as it does for anyone else.
  3835.                     if (data.account && data.account.created) {
  3836.                         onGuestRegistered(data.account);
  3837.                     }
  3838.                     state.guest   = null;
  3839.                     state.newCard = null;
  3840.                     clearTuks();
  3841.                     render();
  3842.                     snapTo(1);
  3843.                     startTracking();
  3844.                 }).catch(function (e) {
  3845.                     state.busy  = false;
  3846.                     state.error = e.message;
  3847.                     // The email is already an account: nothing to fix in the form,
  3848.                     // so offer the way in instead.
  3849.                     if (/already uses this email/i.test(e.message)) {
  3850.                         state.accountExists = true;
  3851.                         render();
  3852.                         return;
  3853.                     }
  3854.                     // "Tuk was just taken" — send them back to pick another.
  3855.                     if (/just taken|not found/i.test(e.message)) {
  3856.                         state.tuk = null;
  3857.                         goToTuks();
  3858.                         return;
  3859.                     }
  3860.                     render();
  3861.                 });
  3862.             }
  3863.             /**
  3864.              * Turn the checkout card fields into what /api/bookings expects, or
  3865.              * null when they are not filled in well enough to bother sending.
  3866.              * Deliberately forgiving about spacing and about "MM/YY" vs "MM/YYYY";
  3867.              * the real validation (Luhn, expiry) belongs to the server.
  3868.              */
  3869.             function parseCardFields(nc) {
  3870.                 var number = String(nc.number || '').replace(/\D/g, '');
  3871.                 var exp    = String(nc.exp || '').trim();
  3872.                 var m      = exp.match(/^(\d{1,2})\s*[\/\-.]?\s*(\d{2}|\d{4})$/);
  3873.                 if (number.length < 12 || !m) return null;
  3874.                 return {
  3875.                     number:     number,
  3876.                     expMonth:   parseInt(m[1], 10),
  3877.                     expYear:    parseInt(m[2], 10),
  3878.                     holderName: String(nc.holder || '').trim()
  3879.                 };
  3880.             }
  3881.             /**
  3882.              * The account the server just made for a guest. Everything the sheet
  3883.              * gates on `isLoggedIn` — polling, the chat channel, the account link
  3884.              * in the header — is switched on here rather than after a reload, so
  3885.              * the order they just placed keeps working in this same page.
  3886.              */
  3887.             var commStarted = TAXI.isLoggedIn;
  3888.             function onGuestRegistered(account) {
  3889.                 // Signing in gave the session a new CSRF token; the one this
  3890.                 // page was rendered with is dead. Swap it in before anything
  3891.                 // else, or the next POST from this page is a 403.
  3892.                 if (account.csrf) TAXI.csrf = account.csrf;
  3893.                 if (account.signedIn === false) return;
  3894.                 TAXI.isLoggedIn     = true;
  3895.                 state.accountExists = false;
  3896.                 // Chat and calls with the driver, which boot skipped for a
  3897.                 // logged-out visitor. Push (Mercure) needs a cookie only issued
  3898.                 // on a page render, so this session polls instead — which is
  3899.                 // exactly what the module falls back to.
  3900.                 if (window.TNT_COMM && !commStarted) {
  3901.                     commStarted = true;
  3902.                     TNT_COMM.init({
  3903.                         base:   TAXI.commBase,
  3904.                         iceUrl: TAXI.commIceUrl,
  3905.                         csrf:   TAXI.csrf,
  3906.                         role:   'passenger',
  3907.                         mount:  document.querySelector('.map-app')
  3908.                     });
  3909.                 }
  3910.                 // Swap "Sign in / Register" in the header for the account they
  3911.                 // now have, so the page stops contradicting itself.
  3912.                 var auth = document.querySelector('.map-auth');
  3913.                 if (auth) {
  3914.                     auth.innerHTML =
  3915.                         '<a href="' + esc(TAXI.accountUrl) + '" class="auth-acct" title="' + esc(L('nav.myAccount')) + '">' +
  3916.                             '<span class="auth-avatar">' + esc(initials(state.guest && state.guest.name)) + '</span>' +
  3917.                             '<span class="auth-name">' + esc(String((state.guest && state.guest.name) || '').split(' ')[0]) + '</span>' +
  3918.                         '</a>' +
  3919.                         '<a href="' + esc(TAXI.logoutUrl) + '" class="auth-out" title="' + esc(L('nav.signout')) + '">⎋</a>';
  3920.                 }
  3921.             }
  3922.             function cancelOrder() {
  3923.                 if (state.busy || !state.booking) return;
  3924.                 state.busy = true;
  3925.                 render();
  3926.                 api(TAXI.cancelUrlTpl.replace('__REF__', encodeURIComponent(state.booking.reference)), {
  3927.                     method: 'POST',
  3928.                     body:   { reason: 'Cancelled by passenger' }
  3929.                 }).then(function (data) {
  3930.                     state.busy    = false;
  3931.                     state.booking = data.booking;
  3932.                     stopPolling();
  3933.                     stopLive();
  3934.                     render();
  3935.                 }).catch(function (e) {
  3936.                     state.busy  = false;
  3937.                     state.error = e.message;
  3938.                     render();
  3939.                 });
  3940.             }
  3941.             function resetToRoutes() {
  3942.                 stopPolling();
  3943.                 stopLive();
  3944.                 state.step    = STEP_ROUTES;
  3945.                 state.route   = null;
  3946.                 state.tuk     = null;
  3947.                 state.tuks    = [];
  3948.                 state.booking = null;
  3949.                 state.error   = null;
  3950.                 // Start the next booking from a clean slate rather than
  3951.                 // inheriting the last party size, tip or half-drawn line.
  3952.                 state.passengers = 1;
  3953.                 state.tipPending = 0;
  3954.                 state.tipDone    = false;
  3955.                 state.draw       = [];
  3956.                 state.picker     = '';
  3957.                 state.custom     = null;
  3958.                 state.sched      = null;
  3959.                 state.reservation = null;
  3960.                 setPicking(false);
  3961.                 clearRoute();
  3962.                 clearTuks();
  3963.                 clearDraw();
  3964.                 setPoiVisible(true);   // back to browsing every location
  3965.                 refreshAmbient();      // and the live fleet comes back
  3966.                 render();
  3967.                 snapTo(1);
  3968.             }
  3969.             function goBack() {
  3970.                 state.error = null;
  3971.                 if (state.step === STEP_ROUTE)     { resetToRoutes(); return; }
  3972.                 if (state.step === STEP_TUKS)      { state.step = STEP_ROUTE; clearTuks(); render(); return; }
  3973.                 if (state.step === STEP_PAY)       { state.step = STEP_TUKS; paintTuks(state.tuks); render(); return; }
  3974.                 if (state.step === STEP_SCHEDULE)  { state.step = STEP_ROUTE; render(); return; }
  3975.                 if (state.step === STEP_CUSTOM)    { resetToRoutes(); return; }
  3976.                 // Back out of the custom form to the stop picker, keeping the
  3977.                 // stops already chosen.
  3978.                 if (state.step === STEP_CUSTOM_FORM) {
  3979.                     captureForms();
  3980.                     state.step = STEP_CUSTOM;
  3981.                     setPicking(true);
  3982.                     setPoiVisible(true);
  3983.                     paintDraw();
  3984.                     render();
  3985.                     snapTo(1);
  3986.                     return;
  3987.                 }
  3988.             }
  3989.             function firstStop() {
  3990.                 if (!state.route || !state.route.points.length) return null;
  3991.                 var p = state.route.points[0];
  3992.                 return [p.lat, p.lng];
  3993.             }
  3994.             /**
  3995.              * One-shot geolocation. Never rejects: a passenger who declines GPS
  3996.              * still gets to order, just without proximity sorting.
  3997.              */
  3998.             function locate() {
  3999.                 if (state.userPos) return Promise.resolve(state.userPos);
  4000.                 if (!navigator.geolocation) return Promise.resolve(null);
  4001.                 return new Promise(function (resolve) {
  4002.                     var done = false;
  4003.                     var finish = function (val) {
  4004.                         if (done) return;
  4005.                         done = true;
  4006.                         resolve(val);
  4007.                     };
  4008.                     navigator.geolocation.getCurrentPosition(
  4009.                         function (p) {
  4010.                             state.userPos = [p.coords.latitude, p.coords.longitude];
  4011.                             finish(state.userPos);
  4012.                         },
  4013.                         function () { finish(null); },
  4014.                         { enableHighAccuracy: true, timeout: 6000, maximumAge: 30000 }
  4015.                     );
  4016.                     // Belt and braces: some browsers never fire either callback.
  4017.                     setTimeout(function () { finish(null); }, 6500);
  4018.                 });
  4019.             }
  4020.             // ── Live order polling ───────────────────────────────────────
  4021.             // Re-read the authoritative booking and reflect it on screen. Used by
  4022.             // both the poll loop and a Mercure status push.
  4023.             function refetchBooking() {
  4024.                 if (!state.booking) return Promise.resolve();
  4025.                 return api(TAXI.showUrlTpl.replace('__REF__', encodeURIComponent(state.booking.reference)))
  4026.                     .then(function (data) {
  4027.                         state.booking = data.booking;
  4028.                         // Follow the tuk in on the map.
  4029.                         if (data.booking.tuk && data.booking.tuk.lat) {
  4030.                             paintTuks([data.booking.tuk]);
  4031.                         }
  4032.                         if (state.step === STEP_ACTIVE) render();
  4033.                         if (data.booking.status === 'completed' || data.booking.status === 'cancelled') {
  4034.                             stopPolling();
  4035.                             stopLive();
  4036.                         }
  4037.                     })
  4038.                     .catch(function () {
  4039.                         // A blip should not kill the order view; the next tick retries.
  4040.                     });
  4041.             }
  4042.             function startPolling() {
  4043.                 stopPolling();
  4044.                 pollTimer = setInterval(function () {
  4045.                     if (!state.booking) { stopPolling(); return; }
  4046.                     refetchBooking();
  4047.                 }, POLL_MS);
  4048.             }
  4049.             function stopPolling() {
  4050.                 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
  4051.             }
  4052.             // ── Live updates over Mercure, with polling as the floor ──────
  4053.             //
  4054.             // We start polling AND try to open an EventSource. If the hub answers,
  4055.             // onopen stops the poll loop and pushes take over — position updates
  4056.             // move the tuk directly; any other event re-reads the booking. If the
  4057.             // hub never answers (none configured, or down), the EventSource errors
  4058.             // and polling simply carries on. Nothing to configure, no dead ends.
  4059.             function startLive() {
  4060.                 if (liveOn || es) return;
  4061.                 if (!window.EventSource || !MERCURE || !MERCURE.url || !MERCURE.topic) return;
  4062.                 try {
  4063.                     es = new EventSource(
  4064.                         MERCURE.url + '?topic=' + encodeURIComponent(MERCURE.topic),
  4065.                         { withCredentials: true }
  4066.                     );
  4067.                 } catch (e) { es = null; return; }
  4068.                 es.onopen = function () {
  4069.                     liveOn = true;
  4070.                     stopPolling();
  4071.                     if (window.TNT_COMM) TNT_COMM.setLive(true);
  4072.                 };
  4073.                 es.onmessage = function (ev) {
  4074.                     var msg;
  4075.                     try { msg = JSON.parse(ev.data); } catch (e) { msg = {}; }
  4076.                     // Chat and call events ride the same stream; hand them to the
  4077.                     // comm module rather than re-reading the booking for them.
  4078.                     if (msg.event === 'chat' || msg.event === 'chat_read' || msg.event === 'call') {
  4079.                         if (window.TNT_COMM) TNT_COMM.handlePush(msg);
  4080.                         return;
  4081.                     }
  4082.                     if (msg.event === 'position' && state.booking && state.booking.tuk) {
  4083.                         state.booking.tuk.lat     = msg.lat;
  4084.                         state.booking.tuk.lng     = msg.lng;
  4085.                         state.booking.tuk.heading = msg.heading;
  4086.                         paintTuks([state.booking.tuk]);
  4087.                     } else {
  4088.                         refetchBooking();
  4089.                     }
  4090.                 };
  4091.                 es.onerror = function () {
  4092.                     // Never reached the hub, or the stream dropped. Fall back to
  4093.                     // polling; EventSource keeps retrying and onopen will stop it.
  4094.                     liveOn = false;
  4095.                     if (window.TNT_COMM) TNT_COMM.setLive(false);
  4096.                     if (!pollTimer && state.booking) startPolling();
  4097.                 };
  4098.             }
  4099.             function stopLive() {
  4100.                 if (es) { es.close(); es = null; }
  4101.                 liveOn = false;
  4102.             }
  4103.             // Convenience: begin following the current order by whatever transport
  4104.             // is available.
  4105.             function startTracking() {
  4106.                 startPolling();
  4107.                 startLive();
  4108.             }
  4109.             // Don't poll a tab nobody is looking at.
  4110.             document.addEventListener('visibilitychange', function () {
  4111.                 if (document.hidden) {
  4112.                     stopPolling();
  4113.                 } else if (state.booking && state.step === STEP_ACTIVE &&
  4114.                            state.booking.status !== 'completed' && state.booking.status !== 'cancelled') {
  4115.                     // Resume tracking; if the push stream is live, polling stays off.
  4116.                     if (liveOn) { refetchBooking(); } else { startPolling(); }
  4117.                 }
  4118.             });
  4119.             // ── Drag to resize ───────────────────────────────────────────
  4120.             function snapTo(i) {
  4121.                 if (window.innerWidth >= 900) return;   // desktop panel: fixed height
  4122.                 var h = Math.round(SNAP[i] * window.innerHeight);
  4123.                 el.sheet.style.setProperty('--sheet-h', h + 'px');
  4124.                 setTimeout(syncMapMargin, 280);
  4125.             }
  4126.             (function initDrag() {
  4127.                 var startY = 0, startH = 0, active = false;
  4128.                 el.grab.addEventListener('pointerdown', function (e) {
  4129.                     if (window.innerWidth >= 900) return;
  4130.                     active = true;
  4131.                     startY = e.clientY;
  4132.                     startH = el.sheet.getBoundingClientRect().height;
  4133.                     el.sheet.classList.add('dragging');
  4134.                     el.grab.setPointerCapture(e.pointerId);
  4135.                 });
  4136.                 el.grab.addEventListener('pointermove', function (e) {
  4137.                     if (!active) return;
  4138.                     // Dragging up (smaller clientY) makes the sheet taller.
  4139.                     var h = startH + (startY - e.clientY);
  4140.                     h = Math.max(120, Math.min(h, window.innerHeight * 0.88));
  4141.                     el.sheet.style.setProperty('--sheet-h', h + 'px');
  4142.                 });
  4143.                 function end(e) {
  4144.                     if (!active) return;
  4145.                     active = false;
  4146.                     el.sheet.classList.remove('dragging');
  4147.                     if (e.pointerId !== undefined && el.grab.hasPointerCapture(e.pointerId)) {
  4148.                         el.grab.releasePointerCapture(e.pointerId);
  4149.                     }
  4150.                     // Snap to whichever stop is closest to where they let go.
  4151.                     var h    = el.sheet.getBoundingClientRect().height / window.innerHeight;
  4152.                     var best = 0;
  4153.                     SNAP.forEach(function (s, i) {
  4154.                         if (Math.abs(s - h) < Math.abs(SNAP[best] - h)) best = i;
  4155.                     });
  4156.                     snapTo(best);
  4157.                 }
  4158.                 el.grab.addEventListener('pointerup', end);
  4159.                 el.grab.addEventListener('pointercancel', end);
  4160.             }());
  4161.             el.back.addEventListener('click', goBack);
  4162.             window.addEventListener('resize', function () {
  4163.                 el.sheet.style.removeProperty('--sheet-h');
  4164.                 syncMapMargin();
  4165.             });
  4166.             // Re-render the booking sheet when the language changes. The chooser
  4167.             // IIFE's listener (registered earlier) has already updated the shared
  4168.             // LANG global by the time this fires, so tr()/L() pick up the new value.
  4169.             document.addEventListener('tnt:langchange', function () {
  4170.                 if (!el.sheet.classList.contains('hidden')) render();
  4171.             });
  4172.             // ── Boot ─────────────────────────────────────────────────────
  4173.             // Chat + calls. Mounted inside .map-app on purpose: that element
  4174.             // opens a stacking context (z-index 9999), so anything meant to sit
  4175.             // over the map must live inside it or the map's event pane eats
  4176.             // every click. The call overlay is position:fixed on <body> and
  4177.             // deliberately escapes this — it must cover the modal too.
  4178.             if (window.TNT_COMM && TAXI.isLoggedIn) {
  4179.                 TNT_COMM.init({
  4180.                     base:   TAXI.commBase,
  4181.                     iceUrl: TAXI.commIceUrl,
  4182.                     csrf:   TAXI.csrf,
  4183.                     role:   'passenger',
  4184.                     mount:  document.querySelector('.map-app')
  4185.                 });
  4186.             }
  4187.             ymaps.ready(function () {
  4188.                 api(TAXI.routesUrl).then(function (data) {
  4189.                     state.routes      = data.routes || [];
  4190.                     state.customTable = data.customPriceTable || [];
  4191.                     el.sheet.classList.remove('hidden');
  4192.                     render();
  4193.                     snapTo(1);
  4194.                     syncMapMargin();
  4195.                     if (!TAXI.isLoggedIn) return;
  4196.                     // Back from signing in half-way through a guest checkout:
  4197.                     // pick the tour back up instead of dropping them on the list.
  4198.                     if (resumePendingOrder()) return;
  4199.                     // Arriving from the account page's "View on map" link:
  4200.                     // show that scheduled tour instead of the tour list.
  4201.                     if (TAXI.showReservation) {
  4202.                         return api(TAXI.resShowTpl.replace('__REF__', encodeURIComponent(TAXI.showReservation)))
  4203.                             .then(function (res) {
  4204.                                 if (!res.reservation) return;
  4205.                                 state.reservation = res.reservation;
  4206.                                 state.step        = STEP_RESERVATION;
  4207.                                 setPoiVisible(false);
  4208.                                 paintReservation(res.reservation);
  4209.                                 render();
  4210.                                 snapTo(1);
  4211.                             })
  4212.                             .catch(function () {
  4213.                                 // A stale or foreign reference just falls back
  4214.                                 // to the normal tour list.
  4215.                             });
  4216.                     }
  4217.                     // Restore a live order across a page reload.
  4218.                     return api(TAXI.activeUrl).then(function (res) {
  4219.                         if (!res.booking) return;
  4220.                         state.booking = res.booking;
  4221.                         state.step    = STEP_ACTIVE;
  4222.                         var r = state.routes.filter(function (x) { return x.id === res.booking.route.id; })[0];
  4223.                         if (r) { state.route = r; setPoiVisible(false); paintRoute(r); }
  4224.                         if (res.booking.tuk && res.booking.tuk.lat) paintTuks([res.booking.tuk]);
  4225.                         render();
  4226.                         startTracking();
  4227.                     });
  4228.                 }).then(function () {
  4229.                     // Start the live fleet loop last: refreshAmbient only paints on
  4230.                     // the tour-list step, so a restored active order is left alone.
  4231.                     startAmbient();
  4232.                 }).catch(function (e) {
  4233.                     // This catch covers the whole boot chain, so a programming
  4234.                     // error anywhere in it would otherwise be shown to the
  4235.                     // visitor as if the tour list had failed to load. Keep the
  4236.                     // banner for real request failures, but always leave the
  4237.                     // original on the console.
  4238.                     if (window.console) { console.error('[tnt] boot failed', e); }
  4239.                     state.error = e.message;
  4240.                     el.sheet.classList.remove('hidden');
  4241.                     render();
  4242.                     startAmbient();
  4243.                 });
  4244.             });
  4245.         }());
  4246.     </script>
  4247. {% endblock %}