NovaDXB / static /index.html
nipunkavindaAI's picture
Update Maps
3fd9a6d
Raw
History Blame Contribute Delete
33.7 kB
<!doctype html>
<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: "&copy; 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,
"&quot;",
);
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>