Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>NovaDXB β Dubai AI Concierge</title> | |
| <!-- Leaflet (OpenStreetMap) β free, no API key required --> | |
| <link | |
| rel="stylesheet" | |
| href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css" | |
| /> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: Arial, sans-serif; | |
| background: #0a0f2c; | |
| color: #f0f4ff; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* ββ Main layout β split panel ββ */ | |
| #main-layout { | |
| flex: 1; | |
| display: flex; | |
| overflow: hidden; | |
| } | |
| #chat-panel { | |
| flex: 1.3; | |
| display: flex; | |
| flex-direction: column; | |
| min-width: 0; | |
| border-right: 1px solid #1e3a5f; | |
| } | |
| #side-panel { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| background: #0d1230; | |
| overflow-y: auto; | |
| padding: 20px; | |
| } | |
| @media (max-width: 900px) { | |
| #side-panel { | |
| display: none; | |
| } | |
| } | |
| /* ββ Header ββ */ | |
| header { | |
| background: #111827; | |
| padding: 12px 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| border-bottom: 2px solid #d4af37; | |
| flex-shrink: 0; | |
| } | |
| header h1 { | |
| font-size: 20px; | |
| color: #d4af37; | |
| letter-spacing: 3px; | |
| line-height: 1.2; | |
| } | |
| header span { | |
| font-size: 11px; | |
| color: #6b7280; | |
| letter-spacing: 0.5px; | |
| } | |
| /* ββ Status bar ββ */ | |
| #status-bar { | |
| background: #111827; | |
| padding: 6px 24px; | |
| font-size: 12px; | |
| color: #00d4ff; | |
| border-bottom: 1px solid #1e3a5f; | |
| } | |
| /* ββ Chat container ββ */ | |
| #chat-container { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 20px 24px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| /* ββ Messages ββ */ | |
| .message { | |
| max-width: 75%; | |
| padding: 12px 16px; | |
| border-radius: 12px; | |
| font-size: 14px; | |
| line-height: 1.6; | |
| } | |
| .message.user, | |
| .message.thinking { | |
| white-space: pre-wrap; | |
| } | |
| .message.user { | |
| background: #1e3a5f; | |
| align-self: flex-end; | |
| border: 1px solid #00d4ff; | |
| } | |
| .message.assistant { | |
| background: #111827; | |
| align-self: flex-start; | |
| border: 1px solid #d4af37; | |
| } | |
| .message.assistant .name { | |
| font-size: 11px; | |
| color: #d4af37; | |
| margin-bottom: 6px; | |
| font-weight: bold; | |
| letter-spacing: 1px; | |
| } | |
| /* ββ Side panel content ββ */ | |
| .side-panel-header { | |
| font-size: 11px; | |
| font-weight: bold; | |
| color: #d4af37; | |
| letter-spacing: 2px; | |
| text-transform: uppercase; | |
| margin-bottom: 16px; | |
| padding-bottom: 8px; | |
| border-bottom: 1px solid #1e3a5f; | |
| } | |
| .side-empty { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100%; | |
| color: #2a3a5a; | |
| text-align: center; | |
| padding: 40px 20px; | |
| gap: 12px; | |
| } | |
| .side-empty-icon { | |
| font-size: 36px; | |
| opacity: 0.4; | |
| } | |
| .side-empty-text { | |
| font-size: 13px; | |
| line-height: 1.6; | |
| } | |
| .day-card { | |
| background: #111827; | |
| border: 1px solid #1e3a5f; | |
| border-radius: 12px; | |
| padding: 14px 16px; | |
| margin-bottom: 12px; | |
| animation: cardReveal 0.4s ease both; | |
| } | |
| @keyframes cardReveal { | |
| from { | |
| opacity: 0; | |
| transform: translateY(12px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .day-card-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 10px; | |
| } | |
| .day-label { | |
| font-size: 11px; | |
| font-weight: bold; | |
| color: #00d4ff; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| } | |
| .day-cost { | |
| font-size: 12px; | |
| color: #d4af37; | |
| font-weight: bold; | |
| } | |
| .day-title { | |
| font-size: 14px; | |
| font-weight: bold; | |
| color: #f0f4ff; | |
| margin-bottom: 10px; | |
| line-height: 1.4; | |
| } | |
| .day-activities { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 5px; | |
| } | |
| .day-activities li { | |
| font-size: 12px; | |
| color: #8a99b8; | |
| padding-left: 14px; | |
| position: relative; | |
| line-height: 1.5; | |
| } | |
| .day-activities li::before { | |
| content: "β"; | |
| position: absolute; | |
| left: 0; | |
| color: #d4af37; | |
| font-size: 10px; | |
| } | |
| /* ββ Itinerary map (per day-card) ββ */ | |
| .day-map-toggle { | |
| margin-top: 10px; | |
| background: transparent; | |
| border: 1px solid #1e3a5f; | |
| color: #00d4ff; | |
| font-size: 11px; | |
| padding: 5px 10px; | |
| border-radius: 14px; | |
| cursor: pointer; | |
| font-family: inherit; | |
| transition: | |
| border-color 0.15s, | |
| color 0.15s; | |
| } | |
| .day-map-toggle:hover { | |
| border-color: #00d4ff; | |
| } | |
| .day-map { | |
| margin-top: 10px; | |
| height: 220px; | |
| border-radius: 10px; | |
| border: 1px solid #1e3a5f; | |
| overflow: hidden; | |
| display: none; | |
| } | |
| .day-map.visible { | |
| display: block; | |
| } | |
| /* Leaflet's default popup/control styling clashes with the dark | |
| theme otherwise β keep this minimal override scoped to our maps. */ | |
| .day-map .leaflet-popup-content-wrapper { | |
| background: #111827; | |
| color: #f0f4ff; | |
| border: 1px solid #d4af37; | |
| } | |
| .day-map .leaflet-popup-tip { | |
| background: #111827; | |
| } | |
| .total-cost-bar { | |
| background: linear-gradient(135deg, #1e3a5f, #0d1230); | |
| border: 1px solid #d4af37; | |
| border-radius: 10px; | |
| padding: 12px 16px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-top: 4px; | |
| } | |
| .total-label { | |
| font-size: 12px; | |
| color: #8a99b8; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .total-value { | |
| font-size: 16px; | |
| font-weight: bold; | |
| color: #d4af37; | |
| } | |
| /* ββ Markdown rendering inside assistant messages ββ */ | |
| .message.assistant h1, | |
| .message.assistant h2, | |
| .message.assistant h3 { | |
| color: #d4af37; | |
| margin: 14px 0 8px; | |
| line-height: 1.4; | |
| } | |
| .message.assistant h1 { | |
| font-size: 18px; | |
| } | |
| .message.assistant h2 { | |
| font-size: 16px; | |
| } | |
| .message.assistant h3 { | |
| font-size: 14px; | |
| } | |
| .message.assistant h1:first-child, | |
| .message.assistant h2:first-child, | |
| .message.assistant h3:first-child { | |
| margin-top: 0; | |
| } | |
| .message.assistant p { | |
| margin: 8px 0; | |
| line-height: 1.6; | |
| } | |
| .message.assistant p:first-child { | |
| margin-top: 0; | |
| } | |
| .message.assistant strong { | |
| color: #00d4ff; | |
| font-weight: bold; | |
| } | |
| .message.assistant em { | |
| color: #a0b4d0; | |
| font-style: italic; | |
| } | |
| .message.assistant ul, | |
| .message.assistant ol { | |
| margin: 8px 0 8px 20px; | |
| line-height: 1.7; | |
| } | |
| .message.assistant li { | |
| margin: 4px 0; | |
| } | |
| .message.assistant li::marker { | |
| color: #d4af37; | |
| } | |
| .message.assistant hr { | |
| border: none; | |
| border-top: 1px solid #1e3a5f; | |
| margin: 14px 0; | |
| } | |
| .message.assistant code { | |
| background: #0a0f2c; | |
| border: 1px solid #1e3a5f; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| color: #00d4ff; | |
| } | |
| .message.assistant a { | |
| color: #00d4ff; | |
| text-decoration: underline; | |
| } | |
| .message.thinking { | |
| background: #111827; | |
| align-self: flex-start; | |
| border: 1px solid #6b7280; | |
| color: #6b7280; | |
| font-style: italic; | |
| } | |
| /* ββ Input area ββ */ | |
| #input-area { | |
| background: #111827; | |
| border-top: 2px solid #1e3a5f; | |
| padding: 16px 24px; | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| } | |
| #user-input { | |
| flex: 1; | |
| background: #0a0f2c; | |
| border: 1px solid #1e3a5f; | |
| border-radius: 8px; | |
| padding: 12px 16px; | |
| color: #f0f4ff; | |
| font-size: 14px; | |
| outline: none; | |
| resize: none; | |
| height: 48px; | |
| } | |
| #user-input:focus { | |
| border-color: #00d4ff; | |
| } | |
| #user-input::placeholder { | |
| color: #4b5563; | |
| } | |
| #send-btn { | |
| background: #d4af37; | |
| color: #0a0f2c; | |
| border: none; | |
| border-radius: 8px; | |
| padding: 12px 24px; | |
| font-size: 14px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| height: 48px; | |
| } | |
| #send-btn:hover { | |
| background: #b8941f; | |
| } | |
| #send-btn:disabled { | |
| background: #4b5563; | |
| cursor: not-allowed; | |
| } | |
| /* ββ Suggestions ββ | |
| Default state: full row, wraps, used before the first message. | |
| .compact state: collapses into a single-line, horizontally | |
| scrollable strip once the conversation has started, so it | |
| never disappears but also never crowds the chat. */ | |
| #suggestions { | |
| padding: 12px 24px; | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| background: #0a0f2c; | |
| border-bottom: 1px solid #1e3a5f; | |
| flex-shrink: 0; | |
| transition: padding 0.2s ease; | |
| } | |
| #suggestions.compact { | |
| padding: 8px 24px; | |
| flex-wrap: nowrap; | |
| overflow-x: auto; | |
| scrollbar-width: thin; | |
| } | |
| #suggestions.compact::-webkit-scrollbar { | |
| height: 4px; | |
| } | |
| #suggestions.compact::-webkit-scrollbar-thumb { | |
| background: #1e3a5f; | |
| border-radius: 4px; | |
| } | |
| .suggestion { | |
| background: #111827; | |
| border: 1px solid #1e3a5f; | |
| color: #a0b4d0; | |
| padding: 6px 14px; | |
| border-radius: 20px; | |
| font-size: 12px; | |
| cursor: pointer; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| transition: | |
| border-color 0.15s, | |
| color 0.15s, | |
| padding 0.2s, | |
| font-size 0.2s; | |
| } | |
| #suggestions.compact .suggestion { | |
| padding: 5px 12px; | |
| font-size: 11px; | |
| color: #6b7280; | |
| } | |
| .suggestion:hover { | |
| border-color: #d4af37; | |
| color: #d4af37; | |
| } | |
| </style> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script> | |
| </head> | |
| <body> | |
| <!-- Header --> | |
| <header> | |
| <div style="display: flex; align-items: center; gap: 14px; flex: 1"> | |
| <!-- Inline SVG logo β skyline mark --> | |
| <svg | |
| width="36" | |
| height="36" | |
| viewBox="0 0 64 64" | |
| xmlns="http://www.w3.org/2000/svg" | |
| style="flex-shrink: 0" | |
| > | |
| <defs> | |
| <linearGradient id="hLogoGrad" x1="0%" y1="100%" x2="0%" y2="0%"> | |
| <stop offset="0%" stop-color="#00D4FF" /> | |
| <stop offset="100%" stop-color="#D4AF37" /> | |
| </linearGradient> | |
| </defs> | |
| <rect width="64" height="64" rx="12" fill="#0A0F2C" /> | |
| <g transform="translate(10,46)"> | |
| <rect x="0" y="-10" width="6" height="10" fill="url(#hLogoGrad)" /> | |
| <rect x="8" y="-18" width="6" height="18" fill="url(#hLogoGrad)" /> | |
| <polygon points="20,3 24,-32 28,3" fill="url(#hLogoGrad)" /> | |
| <rect x="31" y="-16" width="6" height="16" fill="url(#hLogoGrad)" /> | |
| <rect x="39" y="-8" width="6" height="8" fill="url(#hLogoGrad)" /> | |
| </g> | |
| </svg> | |
| <div> | |
| <h1>NOVADXB</h1> | |
| <span>Explore Dubai. Intelligently.</span> | |
| </div> | |
| </div> | |
| <div | |
| style="display: flex; align-items: center; gap: 10px; flex-shrink: 0" | |
| > | |
| <button | |
| id="new-chat-btn" | |
| onclick="startNewChat()" | |
| title="Clear this conversation and start fresh" | |
| style=" | |
| font-size: 12px; | |
| color: #a0b4d0; | |
| background: transparent; | |
| text-decoration: none; | |
| white-space: nowrap; | |
| padding: 6px 14px; | |
| border: 1px solid #1e3a5f; | |
| border-radius: 20px; | |
| cursor: pointer; | |
| transition: | |
| color 0.2s, | |
| border-color 0.2s; | |
| font-family: inherit; | |
| " | |
| onmouseover=" | |
| this.style.color = '#D4AF37'; | |
| this.style.borderColor = '#D4AF37'; | |
| " | |
| onmouseout=" | |
| this.style.color = '#A0B4D0'; | |
| this.style.borderColor = '#1E3A5F'; | |
| " | |
| > | |
| β» New Chat | |
| </button> | |
| <a | |
| href="/" | |
| style=" | |
| font-size: 12px; | |
| color: #6b7280; | |
| text-decoration: none; | |
| white-space: nowrap; | |
| padding: 6px 14px; | |
| border: 1px solid #1e3a5f; | |
| border-radius: 20px; | |
| transition: | |
| color 0.2s, | |
| border-color 0.2s; | |
| " | |
| onmouseover=" | |
| this.style.color = '#00D4FF'; | |
| this.style.borderColor = '#00D4FF'; | |
| " | |
| onmouseout=" | |
| this.style.color = '#6B7280'; | |
| this.style.borderColor = '#1E3A5F'; | |
| " | |
| > | |
| β Home | |
| </a> | |
| </div> | |
| </header> | |
| <!-- Main split layout --> | |
| <div id="main-layout"> | |
| <!-- Left β Chat panel --> | |
| <div id="chat-panel"> | |
| <!-- Status --> | |
| <div id="status-bar"> | |
| β‘ AI Concierge ready β ask me anything about Dubai | |
| </div> | |
| <!-- Suggestions --> | |
| <div id="suggestions"> | |
| <div class="suggestion" onclick="sendSuggestion(this)"> | |
| Plan 3 days in Dubai for a couple | |
| </div> | |
| <div class="suggestion" onclick="sendSuggestion(this)"> | |
| Best areas to stay on mid budget | |
| </div> | |
| <div class="suggestion" onclick="sendSuggestion(this)"> | |
| Top romantic restaurants in Dubai | |
| </div> | |
| <div class="suggestion" onclick="sendSuggestion(this)"> | |
| How much money do I need for 5 days? | |
| </div> | |
| <div class="suggestion" onclick="sendSuggestion(this)"> | |
| Best time to visit Dubai | |
| </div> | |
| </div> | |
| <!-- Chat --> | |
| <div id="chat-container"> | |
| <div class="message assistant"> | |
| <div class="name">β¦ NOVADXB</div> | |
| Welcome! I'm your personal Dubai AI Concierge. I can help you plan | |
| itineraries, find the best areas to stay, recommend restaurants, | |
| estimate budgets, and answer anything about Dubai. How can I help | |
| you today? | |
| </div> | |
| </div> | |
| <!-- Input --> | |
| <div id="input-area"> | |
| <textarea | |
| id="user-input" | |
| placeholder="Ask me anything about Dubai..." | |
| onkeydown="handleKey(event)" | |
| ></textarea> | |
| <button id="send-btn" onclick="sendMessage()">Send</button> | |
| </div> | |
| </div> | |
| <!-- Right β Side panel (live itinerary) --> | |
| <div id="side-panel"> | |
| <div class="side-panel-header">β¦ Your Dubai Itinerary</div> | |
| <div id="side-content"> | |
| <div class="side-empty" id="side-empty-state"> | |
| <div class="side-empty-icon">πΊοΈ</div> | |
| <div class="side-empty-text"> | |
| Ask NovaDXB to plan your trip and your itinerary will appear here | |
| β day by day. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Leaflet (OpenStreetMap) β free, no API key required --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script> | |
| <script> | |
| const chatContainer = document.getElementById("chat-container"); | |
| const userInput = document.getElementById("user-input"); | |
| const sendBtn = document.getElementById("send-btn"); | |
| const statusBar = document.getElementById("status-bar"); | |
| const suggestions = document.getElementById("suggestions"); | |
| const sideContent = document.getElementById("side-content"); | |
| // The exact markup the side panel starts with β reused on reset | |
| // so "New Chat" restores pixel-identical empty state. | |
| const SIDE_EMPTY_HTML = ` | |
| <div class="side-empty" id="side-empty-state"> | |
| <div class="side-empty-icon">πΊοΈ</div> | |
| <div class="side-empty-text"> | |
| Ask NovaDXB to plan your trip and your itinerary will appear here β day by day. | |
| </div> | |
| </div>`; | |
| // The exact welcome message the chat starts with β reused on reset. | |
| const WELCOME_HTML = ` | |
| <div class="message assistant"> | |
| <div class="name">β¦ NOVADXB</div> | |
| Welcome! I'm your personal Dubai AI Concierge. I can help you plan itineraries, find the best areas to stay, | |
| recommend restaurants, estimate budgets, and answer anything about Dubai. How can I help you today? | |
| </div>`; | |
| // Tracks whether the user has sent at least one message in the | |
| // current conversation. Drives the suggestions bar's compact mode. | |
| let conversationStarted = false; | |
| // ββ Dubai landmark/area coordinates β for the itinerary map ββ | |
| // Static lookup, no geocoding API call. Names chosen to overlap | |
| // with the same real-place vocabulary used elsewhere in the | |
| // project's evaluation reference data, so "is this a real, | |
| // recognizable Dubai place" stays consistent across the app. | |
| // NOT exhaustive β an activity that doesn't mention one of these | |
| // simply won't get a pin. Add more entries here over time as | |
| // gaps are noticed; this never requires a backend change. | |
| const DUBAI_PLACES = [ | |
| ["Burj Khalifa", 25.1972, 55.2744], | |
| ["Dubai Mall", 25.1972, 55.2796], | |
| ["Dubai Fountain", 25.1958, 55.2773], | |
| ["Burj Al Arab", 25.1412, 55.1853], | |
| ["Palm Jumeirah", 25.1124, 55.139], | |
| ["Atlantis", 25.1306, 55.1169], | |
| ["Dubai Marina", 25.0805, 55.1403], | |
| ["JBR", 25.078, 55.1336], | |
| ["Jumeirah Beach Residence", 25.078, 55.1336], | |
| ["Jumeirah", 25.2048, 55.2708], | |
| ["La Mer", 25.2289, 55.2655], | |
| ["Kite Beach", 25.1565, 55.1881], | |
| ["Dubai Creek", 25.2654, 55.3209], | |
| ["Gold Souk", 25.2685, 55.2972], | |
| ["Spice Souk", 25.2676, 55.2997], | |
| ["Al Fahidi", 25.2637, 55.2972], | |
| ["Museum of the Future", 25.2189, 55.2822], | |
| ["Dubai Frame", 25.235, 55.3], | |
| ["Miracle Garden", 25.061, 55.2438], | |
| ["Ski Dubai", 25.0816, 55.1719], | |
| ["Global Village", 25.0686, 55.3088], | |
| ["Dubai Opera", 25.1944, 55.2737], | |
| ["Wild Wadi", 25.1408, 55.1875], | |
| ["Aquaventure", 25.1294, 55.1175], | |
| ["Dubai Aquarium", 25.1972, 55.2789], | |
| ["Business Bay", 25.1857, 55.2649], | |
| ["Downtown Dubai", 25.195, 55.2744], | |
| ["Al Barsha", 25.1124, 55.199], | |
| ["Deira", 25.2697, 55.3094], | |
| ["Bur Dubai", 25.2582, 55.2925], | |
| ["Dubai Hills", 25.1003, 55.2403], | |
| ["Al Seef", 25.2611, 55.3008], | |
| ["City Walk", 25.2055, 55.2604], | |
| ["Dubai Design District", 25.188, 55.288], | |
| ["Expo City", 24.9676, 55.1395], | |
| ["Green Planet", 25.2056, 55.2611], | |
| ["Dubai Marina Walk", 25.0772, 55.1408], | |
| ["Al Mamzar", 25.3082, 55.3438], | |
| ]; | |
| // Find DUBAI_PLACES entries mentioned in a piece of text. | |
| // Case-insensitive substring match β deliberately simple so it's | |
| // easy to verify and extend; no fuzzy matching or geocoding call. | |
| function findPlacesInText(text) { | |
| const lowered = text.toLowerCase(); | |
| return DUBAI_PLACES.filter(([name]) => | |
| lowered.includes(name.toLowerCase()), | |
| ); | |
| } | |
| // Render a Leaflet map for one day's activities into the given | |
| // container element. Plots a pin per recognized place (in the | |
| // order activities were listed) and connects them with a route | |
| // line. Returns true if at least one place was plotted, false if | |
| // there was nothing to show (caller can hide the toggle button). | |
| function renderDayMap(container, activities) { | |
| const matched = []; | |
| activities.forEach((activity) => { | |
| const found = findPlacesInText(activity); | |
| found.forEach((place) => { | |
| // Avoid plotting the exact same place twice if it's | |
| // mentioned in more than one activity for the same day. | |
| if (!matched.some((m) => m[0] === place[0])) { | |
| matched.push(place); | |
| } | |
| }); | |
| }); | |
| if (matched.length === 0) { | |
| return false; | |
| } | |
| // Center map on the average of matched coordinates. | |
| const avgLat = | |
| matched.reduce((sum, m) => sum + m[1], 0) / matched.length; | |
| const avgLng = | |
| matched.reduce((sum, m) => sum + m[2], 0) / matched.length; | |
| const map = L.map(container, { | |
| scrollWheelZoom: false, | |
| attributionControl: true, | |
| }).setView([avgLat, avgLng], matched.length > 1 ? 12 : 13); | |
| L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { | |
| attribution: "© OpenStreetMap contributors", | |
| maxZoom: 18, | |
| }).addTo(map); | |
| // Add numbered markers for each stop | |
| const latLngs = matched.map(([name, lat, lng], idx) => { | |
| const markerIcon = L.divIcon({ | |
| className: "", | |
| html: `<div style=" | |
| background: linear-gradient(135deg,#D4AF37,#00D4FF); | |
| color:#0A0F2C; width:26px; height:26px; | |
| border-radius:50%; display:flex; | |
| align-items:center; justify-content:center; | |
| font-size:11px; font-weight:bold; | |
| border:2px solid #0A0F2C; | |
| box-shadow:0 2px 8px rgba(0,212,255,0.4); | |
| ">${idx + 1}</div>`, | |
| iconSize: [26, 26], | |
| iconAnchor: [13, 13], | |
| }); | |
| L.marker([lat, lng], { icon: markerIcon }) | |
| .addTo(map) | |
| .bindPopup( | |
| `<b style="color:#D4AF37">Stop ${idx + 1}</b><br>${name}`, | |
| ); | |
| return [lat, lng]; | |
| }); | |
| // Draw OSRM road routes between consecutive stops | |
| if (latLngs.length > 1) { | |
| for (let i = 1; i < latLngs.length; i++) { | |
| const from = latLngs[i - 1]; | |
| const to = latLngs[i]; | |
| const osrmUrl = `https://router.project-osrm.org/route/v1/driving/${from[1]},${from[0]};${to[1]},${to[0]}?overview=full&geometries=geojson`; | |
| fetch(osrmUrl) | |
| .then((r) => r.json()) | |
| .then((data) => { | |
| if (data.routes && data.routes[0]) { | |
| const routeCoords = data.routes[0].geometry.coordinates.map( | |
| (c) => [c[1], c[0]], | |
| ); | |
| const dist = (data.routes[0].distance / 1000).toFixed(1); | |
| const dur = Math.round(data.routes[0].duration / 60); | |
| const routeLine = L.polyline(routeCoords, { | |
| color: "#00D4FF", | |
| weight: 4, | |
| opacity: 0.85, | |
| dashArray: "6 4", | |
| }).addTo(map); | |
| // Distance label at midpoint of route | |
| const mid = routeCoords[Math.floor(routeCoords.length / 2)]; | |
| const distIcon = L.divIcon({ | |
| className: "", | |
| html: `<div style=" | |
| background: rgba(10,15,44,0.9); | |
| color: #00D4FF; | |
| border: 1px solid #00D4FF; | |
| border-radius: 12px; | |
| padding: 2px 8px; | |
| font-size: 10px; | |
| font-weight: bold; | |
| white-space: nowrap; | |
| box-shadow: 0 2px 6px rgba(0,212,255,0.3); | |
| ">π ${dist} km Β· ${dur} min</div>`, | |
| iconAnchor: [40, 10], | |
| }); | |
| L.marker(mid, { icon: distIcon, interactive: false }).addTo( | |
| map, | |
| ); | |
| } | |
| }) | |
| .catch(() => { | |
| // Fallback β straight dashed line if OSRM unavailable | |
| L.polyline([from, to], { | |
| color: "#D4AF37", | |
| weight: 2, | |
| opacity: 0.5, | |
| dashArray: "4 4", | |
| }).addTo(map); | |
| }); | |
| } | |
| map.fitBounds(latLngs, { padding: [24, 24] }); | |
| } | |
| return true; | |
| } | |
| // Toggle a single day-card's map open/closed. The map is only | |
| // actually constructed the first time it's opened (lazy init) β | |
| // Leaflet requires its container to be visible with real | |
| // dimensions when initialized, which isn't true while display:none. | |
| function toggleDayMap(dayIndex) { | |
| const mapContainer = document.getElementById(`day-map-${dayIndex}`); | |
| const button = document.getElementById(`day-map-toggle-${dayIndex}`); | |
| const isVisible = mapContainer.classList.contains("visible"); | |
| if (isVisible) { | |
| mapContainer.classList.remove("visible"); | |
| button.textContent = "πΊοΈ Show map"; | |
| return; | |
| } | |
| mapContainer.classList.add("visible"); | |
| button.textContent = "πΊοΈ Hide map"; | |
| if (!mapContainer.dataset.initialized) { | |
| // Defer to next frame so the container has its visible | |
| // dimensions before Leaflet measures it. | |
| requestAnimationFrame(() => { | |
| const activities = JSON.parse(mapContainer.dataset.activities); | |
| renderDayMap(mapContainer, activities); | |
| mapContainer.dataset.initialized = "true"; | |
| }); | |
| } | |
| } | |
| function handleKey(e) { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| } | |
| function sendSuggestion(el) { | |
| userInput.value = el.textContent; | |
| sendMessage(); | |
| } | |
| function addMessage(text, role) { | |
| const div = document.createElement("div"); | |
| div.className = `message ${role}`; | |
| if (role === "assistant") { | |
| // Parse markdown (headers, bold, lists, etc.) into real HTML | |
| const rendered = marked.parse(text); | |
| div.innerHTML = `<div class="name">β¦ NOVADXB</div>${rendered}`; | |
| } else { | |
| div.textContent = text; | |
| } | |
| chatContainer.appendChild(div); | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| return div; | |
| } | |
| // ββ Side panel: render itinerary cards ββββββββββββββββββ | |
| function renderItinerary(itinerary) { | |
| if (!itinerary || !itinerary.has_itinerary || !itinerary.days) { | |
| return; // Leave panel as-is for non-itinerary responses | |
| } | |
| let html = ""; | |
| itinerary.days.forEach((day, idx) => { | |
| const delay = idx * 0.15; | |
| const hasMapData = day.activities.some( | |
| (a) => findPlacesInText(a).length > 0, | |
| ); | |
| // JSON-encode then HTML-escape so activity text with quotes | |
| // can't break out of the data-attribute it's stored in. | |
| const activitiesAttr = JSON.stringify(day.activities).replace( | |
| /"/g, | |
| """, | |
| ); | |
| html += ` | |
| <div class="day-card" style="animation-delay:${delay}s"> | |
| <div class="day-card-header"> | |
| <span class="day-label">Day ${day.day_number}</span> | |
| <span class="day-cost">${day.estimated_cost}</span> | |
| </div> | |
| <div class="day-title">${day.title}</div> | |
| <ul class="day-activities"> | |
| ${day.activities.map((a) => `<li>${a}</li>`).join("")} | |
| </ul> | |
| ${ | |
| hasMapData | |
| ? ` | |
| <button class="day-map-toggle" id="day-map-toggle-${idx}" | |
| onclick="toggleDayMap(${idx})">πΊοΈ Show map</button> | |
| <div class="day-map" id="day-map-${idx}" data-activities="${activitiesAttr}"></div> | |
| ` | |
| : "" | |
| } | |
| </div>`; | |
| }); | |
| if (itinerary.total_cost) { | |
| html += ` | |
| <div class="total-cost-bar"> | |
| <span class="total-label">Total estimate</span> | |
| <span class="total-value">${itinerary.total_cost}</span> | |
| </div>`; | |
| } | |
| sideContent.innerHTML = html; | |
| } | |
| // ββ Side panel: clear back to its empty placeholder ββββββ | |
| // Called on "New Chat" so a leftover itinerary from a previous, | |
| // unrelated topic never lingers next to a fresh conversation. | |
| function resetSidePanel() { | |
| sideContent.innerHTML = SIDE_EMPTY_HTML; | |
| } | |
| // ββ Start a brand new conversation βββββββββββββββββββββββ | |
| // Clears the chat log back to the welcome message, clears the | |
| // side panel, restores the suggestions row to its full (non-compact) | |
| // state, and resets the input box. This is the explicit "start over" | |
| // action returning users were missing. | |
| function startNewChat() { | |
| chatContainer.innerHTML = WELCOME_HTML; | |
| resetSidePanel(); | |
| suggestions.classList.remove("compact"); | |
| suggestions.style.display = "flex"; | |
| userInput.value = ""; | |
| statusBar.textContent = | |
| "β‘ AI Concierge ready β ask me anything about Dubai"; | |
| conversationStarted = false; | |
| userInput.focus(); | |
| } | |
| async function sendMessage() { | |
| const message = userInput.value.trim(); | |
| if (!message) return; | |
| // Collapse suggestions into a compact strip after the first | |
| // message β kept visible (never display:none) so returning | |
| // users always have quick prompts on hand. | |
| if (!conversationStarted) { | |
| conversationStarted = true; | |
| suggestions.classList.add("compact"); | |
| } | |
| // Add user message | |
| addMessage(message, "user"); | |
| userInput.value = ""; | |
| // Disable input | |
| sendBtn.disabled = true; | |
| statusBar.textContent = | |
| "π€ NovaDXB is planning your Dubai experience..."; | |
| // Add thinking indicator | |
| const thinking = addMessage("Thinking...", "thinking"); | |
| try { | |
| const res = await fetch("/chat", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ message }), | |
| }); | |
| const data = await res.json(); | |
| // Remove thinking | |
| thinking.remove(); | |
| if (data.status === "success") { | |
| addMessage(data.response, "assistant"); | |
| statusBar.textContent = | |
| "β‘ AI Concierge ready β ask me anything about Dubai"; | |
| // Render itinerary cards in side panel if itinerary data | |
| // returned. We deliberately do NOT auto-clear the panel | |
| // when a reply has no itinerary β follow-up questions | |
| // ("what about food?", "best time to go?") are normal | |
| // and shouldn't wipe out an itinerary still on screen. | |
| // Clearing is left to the explicit "New Chat" action. | |
| if (data.itinerary) { | |
| renderItinerary(data.itinerary); | |
| } | |
| } else { | |
| addMessage( | |
| "Sorry, something went wrong. Please try again.", | |
| "assistant", | |
| ); | |
| statusBar.textContent = "β οΈ Error β please try again"; | |
| } | |
| } catch (err) { | |
| thinking.remove(); | |
| addMessage("Connection error. Is the server running?", "assistant"); | |
| statusBar.textContent = "β οΈ Connection error"; | |
| } | |
| sendBtn.disabled = false; | |
| userInput.focus(); | |
| } | |
| </script> | |
| </body> | |
| </html> | |