1 2<!DOCTYPE html> 3<html lang="en"> 4 <head> 5 <meta charset="UTF-8" /> 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 <link rel="icon" href="/static/favicon.ico" /> 8
8<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
8 9
9<script 10 src="https://unpkg.com/[email protected]" 11 integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" 12 crossorigin="anonymous" 13 ></script>
13 14 <!-- Stripe.js --> 15
15<script src="https://js.stripe.com/v3/"></script>
15 16 <meta property="og:type" content="website" /> 17 <meta property="og:image" content="/static/og-image.png" /> 18 <meta name="twitter:card" content="summary_large_image" /> 19 <meta name="twitter:image" content="/static/og-image.png" /> 20
21<script>
vendor: 327 bytes, lines 21-25
21(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': 22new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], 23j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 24'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); 25})(window,document,'script','dataLayer','
25GTM-KTPMHMT9
vendor: 3 bytes, line 25
25');
25</script>
25 26 27 28<title>Chat - Nomados</title> 29<meta name="description" content="Chat interface"> 30<style> 31 .chat-tooltip { 32 position: relative; 33 } 34 35 .chat-tooltip::after { 36 content: attr(data-tooltip); 37 position: absolute; 38 bottom: -130%; 39 left: 0%; 40 transform: translateX(-20%); 41 background-color: #333; 42 color: white; 43 padding: 6px 10px; 44 border-radius: 4px; 45 font-size: 12px; 46 white-space: nowrap; 47 opacity: 0; 48 visibility: hidden; 49 transition: opacity 0.3s, visibility 0.3s; 50 z-index: 1000; 51 margin-bottom: 6px; 52 pointer-events: none; 53 } 54 55 .chat-tooltip::before { 56 content: ''; 57 position: absolute; 58 bottom: -130%; 59 left: 50%; 60 transform: translateX(-50%); 61 border: 4px solid transparent; 62 border-top-color: #333; 63 opacity: 0; 64 visibility: hidden; 65 transition: opacity 0.3s, visibility 0.3s; 66 z-index: 1000; 67 margin-bottom: 2px; 68 pointer-events: none; 69 } 70 71 @media (max-width: 768px) { 72 .chat-tooltip::after { 73 bottom: 100%; 74 } 75 .chat-tooltip::before { 76 bottom: 100%; 77 } 78 } 79 80 .chat-tooltip:hover::after, 81 .chat-tooltip:hover::before { 82 opacity: 1; 83 visibility: visible; 84 } 85 86 /* Mobile destination widget selection (check icon + dark border) */ 87 .destination-widget-compact .compact-icon-hover { display: none; } 88 .destination-widget-compact:hover .compact-icon-static { display: none; } 89 .destination-widget-compact:hover .compact-icon-hover { display: block; } 90 .destination-widget-compact.mobile-selected { 91 border-width: 2px !important; 92 border-color: #3B3B3C !important; 93 } 94 .destination-widget-compact .check-overlay { 95 display: none; 96 } 97 .destination-widget-compact.mobile-selected .check-overlay { 98 display: flex; 99 } 100 101 /* Mobile services tracker - active/done states */ 102 .tracker-icon-wrap.tracker-active { 103 background: #5DD2C5 !important; 104 } 105 .tracker-icon-wrap.tracker-done { 106 background: #FFAA9C !important; 107 } 108 109 /* Go Pro view open/close animation (slide from right on desktop, bottom-to-top on mobile) */ 110 #go-pro-view { 111 transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); 112 } 113 #go-pro-view.go-pro-view--closed { 114 transform: translateX(100%); 115 } 116 @media (max-width: 767px) { 117 #go-pro-view.go-pro-view--closed { 118 transform: translateY(100%); 119 } 120 } 121 122 .go-pro-close { 123 z-index: 2002; 124 background: #ffffff; 125 border: 1px solid #e5e7eb; 126 border-radius: 50%; 127 width: 40px; 128 height: 40px; 129 display: flex; 130 align-items: center; 131 justify-content: center; 132 cursor: pointer; 133 transition: all 0.2s ease; 134 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 135 } 136 137 .go-pro-close:hover { 138 background: #f3f4f6; 139 transform: scale(1.1); 140 } 141 142 .go-pro-close img { 143 width: 20px; 144 height: 20px; 145 object-fit: contain; 146 } 147 148 /* On mobile, keep close button at default position */ 149 @media (max-width: 1080px) { 150 .go-pro-close { 151 right: 16px; 152 } 153 } 154</style> 155 156 157 <!-- Google Maps API - Load globally for panel views --> 158 159
159<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyA6Tf1MrQwlFTTe1nTFFJAuhqGNQ05CzZk&libraries=places"></script>
159 160 161 162 <!-- Global CSS --> 163 <link rel="stylesheet" href="/static/css/global.css" /> 164 165 <style> 166 html, body { width: 100%; height: 100%; margin: 0; padding: 0; } 167 #page, .page-container { width: 100%; height: 100%; max-width: none; } 168 169 /* Sidebar styles are now handled by Tailwind CSS classes */ 170 /* Active button state - managed by JavaScript */ 171 .sidebar-btn-active { 172 font-weight: 600; 173 } 174 175 /* Nomados 2.0 Right Panel Styles - No Modal, Direct Panel */ 176 /* Match the right container width exactly: md:w-5/10 xl:w-3/5 */ 177 .nomados-panel { 178 position: fixed; 179 top: 0; 180 /* Position to match the right container exactly */ 181 /* Chat container: md:w-5/10 (50%) or xl:w-2/5 (40%) */ 182 /* Panel starts after chat, same width as right container: md:w-5/10 (50%) or xl:w-3/5 (60%) */ 183 /* On mobile: no sidebar, so left: 0 */ 184 left: 0; 185 right: 0; 186 width: 100%; 187 height: 100%; 188 z-index: 2000; 189 pointer-events: none; 190 } 191 192 /* Desktop: account for sidebar */ 193 @media (min-width: 768px) { 194 .nomados-panel { 195 left: 80px; 196 width: calc(100% - 80px); 197 } 198 } 199 200 .nomados-panel.open { 201 pointer-events: all; 202 } 203 204 .nomados-panel-content { 205 position: absolute; 206 top: 0; 207 right: 0; 208 width: 100%; 209 height: 100%; 210 background: #ffffff; 211 transform: translateX(100%); 212 transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); 213 display: flex; 214 flex-direction: column; 215 pointer-events: all; 216 overflow: hidden; 217 } 218 219 /* Mobile: bottom to top animation */ 220 @media (max-width: 767px) { 221 .nomados-panel-content { 222 transform: translateY(100%); 223 } 224 } 225 226 /* Panel Header with Back Button */ 227 .nomados-panel-header { 228 display: none; 229 align-items: center; 230 gap: 12px; 231 padding: 16px 24px; 232 233 background: #ffffff; 234 flex-shrink: 0; 235 z-index: 10; 236 max-width: 1080px; 237 margin-left: auto; 238 margin-right: auto; 239 width: 100%; 240 @media (max-width: 768px) { 241 padding: 16px; 242 } 243 } 244 245 .nomados-panel-header.visible { 246 display: flex; 247 } 248 249 .nomados-panel-back-btn { 250 display: flex; 251 align-items: center; 252 justify-content: center; 253 width: 40px; 254 height: 40px; 255 border-radius: 50%; 256 border: 1px solid #e5e7eb; 257 background: #ffffff; 258 cursor: pointer; 259 transition: all 0.2s ease; 260 flex-shrink: 0; 261 } 262 263 .nomados-panel-back-btn:hover { 264 background: #f3f4f6; 265 transform: translateX(-2px); 266 } 267 268 .nomados-panel-back-btn img { 269 width: 20px; 270 height: 20px; 271 object-fit: contain; 272 } 273 274 .nomados-panel-header-title { 275 font-size: 18px; 276 font-weight: 600; 277 color: #1f2937; 278 flex: 1; 279 } 280 281 /* Mobile-specific panel header classes */ 282 @media (max-width: 767px) { 283 .nomados-panel-header.mobile-journey-detail { 284 position: fixed; 285 background: transparent; 286 margin-top: 4px; 287 } 288 289 #nomados-panel-destination-detail > :first-child > div { 290 position: fixed; 291 background: transparent; 292 margin-top: 4px; 293 } 294 } 295 296 .nomados-panel.open .nomados-panel-content { 297 transform: translateX(0); 298 } 299 300 /* Mobile: slide up from bottom when open */ 301 @media (max-width: 767px) { 302 .nomados-panel.open .nomados-panel-content { 303 transform: translateY(0); 304 } 305 } 306 307 /* Match responsive widths exactly like the right container: md:w-5/10 xl:w-3/5 */ 308 /* The right container in chat.html is: md:w-5/10 xl:w-3/5 */ 309 /* Main container starts at 80px (sidebar), chat takes md:50% xl:40%, panel takes md:50% xl:60% */ 310 @media (min-width: 768px) { 311 .nomados-panel { 312 left: 50vw; 313 width: 50vw; 314 } 315 } 316 317 @media (min-width: 1280px) { 318 .nomados-panel { 319 left: 40vw; 320 width: 60vw; 321 } 322 } 323 324 @media (max-width: 767px) { 325 .nomados-panel { 326 left: 0; /* No sidebar on mobile */ 327 width: 100%; 328 } 329 } 330 331 /* Loader Styles - overlay so it's visible on top of panel content */ 332 .nomados-panel-body { 333 position: relative; 334 } 335 .nomados-panel-loader { 336 position: absolute; 337 top: 0; 338 left: 0; 339 right: 0; 340 bottom: 0; 341 z-index: 100; 342 display: flex; 343 flex-direction: column; 344 align-items: center; 345 justify-content: center; 346 min-height: 200px; 347 gap: 16px; 348 background: rgba(255, 255, 255, 0.96); 349 } 350 351 .nomados-panel-loader-spinner { 352 width: 48px; 353 height: 48px; 354 border: 4px solid #f3f4f6; 355 border-top-color: #3b82f6; 356 border-radius: 50%; 357 animation: nomados-spin 1s linear infinite; 358 } 359 360 .nomados-panel-loader-text { 361 color: #6b7280; 362 font-size: 14px; 363 font-weight: 500; 364 } 365 366 @keyframes nomados-spin { 367 to { 368 transform: rotate(360deg); 369 } 370 } 371 372 .nomados-panel-close { 373 position: absolute; 374 top: 16px; 375 right: max(16px, calc((100% - 1080px) / 2 + 16px)); 376 z-index: 2002; 377 background: #ffffff; 378 border: 1px solid #e5e7eb; 379 border-radius: 50%; 380 width: 40px; 381 height: 40px; 382 display: flex; 383 align-items: center; 384 justify-content: center; 385 cursor: pointer; 386 transition: all 0.2s ease; 387 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 388 } 389 390 .nomados-panel-close:hover { 391 background: #f3f4f6; 392 transform: scale(1.1); 393 } 394 395 .nomados-panel-close img {
396 width: 20px; 397 height: 20px; 398 object-fit: contain; 399 } 400 401 /* On mobile, keep close button at default position */ 402 @media (max-width: 1080px) { 403 .nomados-panel-close { 404 right: 16px; 405 } 406 } 407 408 .nomados-panel-body { 409 flex: 1; 410 overflow-y: auto; 411 overflow-x: hidden; 412 padding: 0; 413 -webkit-overflow-scrolling: touch; 414 position: relative; 415 } 416 417 /* Mobile Responsive */ 418 @media (max-width: 768px) { 419 .nomados-sidebar { 420 width: 70px; 421 } 422 423 .nomados-panel-content { 424 max-width: 100%; 425 } 426 427 .nomados-panel-body { 428 padding: 16px; 429 } 430 431 /* Mobile-specific panel body classes for journey-detail and destination-detail */ 432 .nomados-panel-body.mobile-journey-detail-body { 433 padding: 0; 434 } 435 436 .nomados-panel-body.mobile-destination-detail-body { 437 padding: 0; 438 } 439 } 440 441 /* Smooth transitions for panel content */ 442 .nomados-panel-view { 443 animation: panelContentFadeIn 0.3s ease; 444 } 445 446 @keyframes panelContentFadeIn { 447 from { 448 opacity: 0; 449 transform: translateY(10px); 450 } 451 to { 452 opacity: 1; 453 transform: translateY(0); 454 } 455 } 456 457 /* Ensure panel views take full height and are properly isolated */ 458 .nomados-panel-view { 459 min-height: 100%; 460 width: 100%; 461 max-width: 1080px; 462 margin-left: auto; 463 margin-right: auto; 464 } 465 466 /* Ensure only one view is visible at a time */ 467 .nomados-panel-view:not([style*="display: block"]) { 468 display: none !important; 469 } 470 471 /* Isolate view content */ 472 .nomados-panel-view > * { 473 width: 100%; 474 } 475 </style> 476 477 478 479 </head> 480 481 <body id="page" class="h-screen overflow-hidden"> 482 <!-- Toast notification container --> 483 <div id="toast-container" class="fixed top-4 right-4 z-[99999] flex flex-col gap-2 pointer-events-none max-w-sm w-full"></div> 484 485 <!-- Cancel subscription confirmation modal --> 486 <div 487 id="cancelSubModal" 488 class="fixed inset-0 z-[99997] hidden items-center justify-center bg-black/50 opacity-0 transition-opacity duration-200 ease-out" 489 style="backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);" 490 aria-modal="true" role="dialog" aria-labelledby="cancelSubModalTitle" 491 > 492 <div class="relative bg-white rounded-2xl shadow-xl border border-gray-100 w-full max-w-sm mx-4 p-6 md:p-8 transform transition-all duration-200" onclick="event.stopPropagation()"> 493 <!-- Icon --> 494 <div class="flex items-center justify-center w-12 h-12 rounded-full bg-red-50 mx-auto mb-4"> 495 <svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 496 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/> 497 </svg> 498 </div> 499 <!-- Title --> 500 <h3 id="cancelSubModalTitle" class="text-lg font-bold text-[#3B3B3C] text-center mb-2">Cancel your Pro subscription?</h3> 501 <!-- Body --> 502 <p class="text-sm text-gray-500 text-center mb-6"> 503 Your Pro access will continue until the end of your current billing period. You won't be charged again after that. 504 </p> 505 <!-- Actions --> 506 <div class="flex flex-col gap-3"> 507 <button 508 type="button" 509 onclick="closeCancelSubModal()" 510 class="w-full py-3 rounded-xl bg-[#3B3B3C] text-white font-semibold text-sm hover:bg-[#2d2d2e] transition-colors" 511 > 512 Keep my Pro 513 </button> 514 <button 515 type="button" 516 id="cancelSubConfirmBtn" 517 onclick="confirmCancelSubscription()" 518 class="w-full py-3 rounded-xl border border-red-300 text-red-600 font-semibold text-sm hover:bg-red-50 transition-colors" 519 >
520 Yes, cancel subscription 521 </button> 522 </div> 523 </div> 524 </div> 525
525<script> 526 window.showNotification = function(message, type) { 527 type = type || 'error'; 528 var container = document.getElementById('toast-container'); 529 if (!container) return; 530 var colorMap = { error: 'bg-red-600', success: 'bg-[#14B8A6]', info: 'bg-[#3B3B3C]' }; 531 var bg = colorMap[type] || colorMap.info; 532 var toast = document.createElement('div'); 533 toast.className = 'pointer-events-auto flex items-start gap-3 px-4 py-3 rounded-xl shadow-lg text-white text-sm opacity-0 translate-y-1 transition-all duration-200 ' + bg; 534 var safe = (message || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'<br>'); 535 toast.innerHTML = '<span class="flex-1">' + safe + '</span><button onclick="this.parentElement.remove()" class="shrink-0 text-white/70 hover:text-white text-lg leading-none font-bold">×</button>'; 536 container.appendChild(toast); 537 requestAnimationFrame(function() { toast.classList.remove('opacity-0','translate-y-1'); }); 538 setTimeout(function() { 539 toast.classList.add('opacity-0','translate-y-1'); 540 setTimeout(function() { toast.remove(); }, 200); 541 }, 5000); 542 }; 543 </script>
543 544 <div class="page-container text-gray-800 h-full"> 545 <!-- Main Content Container --> 546 <div class="w-full h-full flex relative"> 547 <!-- Nomados 2.0 Sidebar - Desktop Only --> 548 549 550 551 552<style> 553 .dropdown-menu { 554 border: 1px solid #BBB; 555 background: rgba(255, 255, 255, 0.95); 556 box-shadow: 0 6px 9px 2px rgba(0, 0, 0, 0.09); 557 } 558 559 .dropdown-button { 560 box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.09) inset; 561 } 562 563 .dropdown-item { 564 padding: 8px 16px; 565 cursor: pointer; 566 transition: background-color 0.2s; 567 font-size: 14px; 568 } 569 570 .dropdown-item:hover { 571 background-color: #f5f5f5; 572 } 573 574 .dropdown-item:first-child { 575 border-radius: 16px 16px 0 0; 576 } 577 578 .dropdown-item:last-child { 579 border-radius: 0 0 16px 16px; 580 } 581</style> 582 583<!-- Nomados 2.0 Mobile Header --> 584<header class="fixed z-[2500] md:hidden bg-transparent top-0 left-0 w-full"> 585 <div class="w-full px-2 py-1 mobile-header-container bg-white border-0"> 586 <div class="flex items-center gap-5"> 587 <button type="button" class="mobile-menu-button flex items-center" onclick="openMobileSidebar()"> 588 <img src="/static/icons/menu.svg" alt="Menu" draggable="false" /> 589 </button> 590 591 <!-- Logo - shown on chat page and journey list page --> 592 593 <button id="mobile-header-logo" type="button" class="flex items-center" onclick="handleLogoClick()"> 594 <img src="/static/icons/nomados_logo.svg" alt="Nomados" draggable="false" class="h-4" /> 595 </button> 596 597 <!-- Dynamic view title - shown when a panel view is open (Market, Profile, Memberships) --> 598 <div id="mobile-header-view-title" class="text-lg font-semibold text-[#3b3b3c] hidden"></div> 599 600 <!-- Launch view tab dropdown - shown next to logo when panel is on journey view --> 601 <div id="mobile-header-journey-dropdown" class="relative hidden" style="min-width: 140px;"> 602 <button 603 type="button" 604 class="dropdown-button flex items-center justify-between w-full px-4 py-1.5 bg-white rounded-full border border-[#D9D9D9] text-sm text-dark" 605 onclick="togglePanelJourneyDropdown()" 606 > 607 <span id="panel-selected-tab">Journeys</span> 608 <img id="panel-dropdown-arrow" src="/static/icons/arrow_drop_down.svg" alt="" draggable="false" class="w-[10px] h-[5px] ml-2 transition-transform duration-200" /> 609 </button> 610 <div id="panel-journey-dropdown" class="dropdown-menu absolute w-full translate-y-2 left-0 rounded-2xl hidden z-10"> 611 <div class="dropdown-item" onclick="selectPanelJourneyTab('journeys')">Journeys</div> 612 <div class="dropdown-item" onclick="selectPanelJourneyTab('favorites')">Favorites</div> 613 <div class="dropdown-item" onclick="selectPanelJourneyTab('addons')">Featured Add-Ons</div> 614 </div> 615 </div> 616 617 618 <!-- Destination List Page - Results Count and Table View --> 619 620 <!-- Market Page - Heading Only --> 621 622 </div> 623 </div> 624</header> 625 626 627 628 629 630 631<style> 632 /* Prevent sidebar flash during page transitions */ 633 #mobileSidebar { 634 visibility: hidden !important; 635 } 636 637 /* Only show sidebar when explicitly opened or after proper initialization */ 638 #mobileSidebar.sidebar-ready { 639 visibility: visible !important; 640 } 641</style> 642 643 644 645<!-- Nomados 2.0 Sidebar - Hidden on mobile, visible on desktop --> 646<div id="nomados-sidebar" class="hidden md:flex h-screen w-16 z-[1000] flex-col py-6 bg-[#F7F7F7]"> 647 <nav class="flex flex-col gap-0 flex-1 w-full h-full justify-between items-center"> 648 <div class="w-full"> 649 <!-- Active Miles Button (Top) --> 650 <button 651 id="sidebar-logo-btn" 652 class="w-full flex flex-col items-center justify-center gap-2 cursor-pointer mb-12 hover:scale-110 transition-transform duration-200 disabled:cursor-not-allowed disabled:hover:scale-100" 653 data-panel-view="logo" 654 aria-label="Logo" 655 onclick="handleLogoClick()" 656 > 657 <img 658 src="/static/icons/logo.svg" 659 alt="Logo" 660 draggable="false" 661 class="" 662 /> 663 </button> 664 665 <div class="flex flex-col gap-4 w-full"> 666 <!-- Miles Button --> 667 <button 668 id="sidebar-miles-btn" 669 class="sidebar-btn flex flex-col items-center justify-center gap-2 cursor-pointer transition-all duration-200 w-full min-h-[80px] hover:bg-black/5 active" 670 data-panel-view="miles" 671 onclick="closeNomadosPanel()" 672 aria-label="Miles" 673 > 674 <div class="flex items-center justify-center"> 675 <img 676 src="/static/icons/miles_filled.svg" 677 alt="Miles" 678 draggable="false" 679 class="w-full h-full object-contain miles-icon-filled" 680 /> 681 <img 682 src="/static/icons/miles_blank.svg" 683 alt="Miles" 684 draggable="false" 685 class="w-full h-full object-contain miles-icon-blank hidden" 686 /> 687 </div>
688 <span class="sidebar-label text-xs font-bold text-center leading-tight text-[#3B3B3C] miles-text">Miles</span> 689 </button> 690 691 <!-- Launch Button --> 692 <button 693 id="sidebar-launch-btn" 694 class="sidebar-btn flex flex-col items-center justify-center gap-2 cursor-pointer transition-all duration-200 w-full min-h-[80px] hover:bg-black/5" 695 data-panel-view="journey" 696 onclick="openNomadosPanel('journey')" 697 aria-label="Launch - Journey List" 698 > 699 <div class="flex items-center justify-center"> 700 <img 701 src="/static/icons/launch_filled.svg" 702 alt="Launch" 703 draggable="false" 704 class="w-full h-full object-contain launch-icon-filled hidden" 705 /> 706 <img 707 src="/static/icons/launch_blank.svg" 708 alt="Launch" 709 draggable="false" 710 class="w-full h-full object-contain launch-icon-blank" 711 /> 712 </div> 713 <span class="sidebar-label text-xs font-normal text-center leading-tight text-[#3B3B3C] launch-text">Launch</span> 714 </button> 715 716 <!-- Market Button --> 717 <button 718 id="sidebar-market-btn" 719 class="sidebar-btn flex flex-col items-center justify-center gap-2 cursor-pointer transition-all duration-200 w-full min-h-[80px] hover:bg-black/5" 720 data-panel-view="market" 721 onclick="openNomadosPanel('market')" 722 aria-label="Market" 723 > 724 <div class="flex items-center justify-center"> 725 <img 726 src="/static/icons/market_filled.svg" 727 alt="Market" 728 draggable="false" 729 class="w-full h-full object-contain market-icon-filled hidden" 730 /> 731 <img 732 src="/static/icons/market_blank.svg" 733 alt="Market" 734 draggable="false" 735 class="w-full h-full object-contain market-icon-blank" 736 /> 737 </div> 738 <span class="sidebar-label text-xs font-normal text-center leading-tight text-[#3B3B3C] market-text">Market</span> 739 </button> 740 </div> 741 </div> 742 743 <!-- Profile Button + Popup (desktop only when authenticated) --> 744 <div class="relative w-full flex flex-col items-center"> 745 <button 746 id="sidebar-profile-btn" 747 class="sidebar-btn flex flex-col items-center justify-center gap-2 cursor-pointer transition-all duration-200 w-full min-h-[80px] hover:bg-black/5" 748 data-panel-view="profile" 749 onclick="window.location.href = '/chat/login/';" 750 aria-label="Profile" 751 aria-expanded="false" 752 aria-haspopup="true" 753 > 754 <div class="flex items-center justify-center"> 755 <img 756 src="/static/icons/profile_filled.svg" 757 alt="Profile" 758 draggable="false" 759 class="w-full h-full object-contain profile-icon-filled hidden" 760 /> 761 <img 762 src="/static/icons/login_dark.svg" 763 alt="Profile" 764 draggable="false" 765 class="w-full h-full object-contain profile-icon-blank" 766 /> 767 </div> 768 <span class="sidebar-label text-xs font-normal text-center leading-tight text-[#3B3B3C] profile-text !font-bold">Log In</span> 769 </button> 770 771 772 </div> 773 </nav> 774</div> 775 776<!-- Mobile Sidebar Overlay --> 777<div id="sidebarOverlay" class="fixed inset-0 z-[2999] bg-black/30 hidden lg:hidden transition-opacity duration-500"></div> 778 779<!-- Mobile Sidebar --> 780<div id="mobileSidebar" class="fixed top-0 left-0 h-full w-80 bg-[#3b3b3c] text-white z-[3000] transform -translate-x-[calc(100%+30px)] transition-transform duration-500 ease-in-out lg:hidden flex flex-col"> 781 782 <!-- Header with Logo and Close Button --> 783 <div class="flex items-center justify-center p-4 relative mt-8"> 784 <button id="mobile-sidebar-logo-btn" class="cursor-pointer hover:scale-110 transition-transform duration-200 disabled:cursor-not-allowed disabled:hover:scale-100" onclick="handleLogoClick()"> 785 <img 786 src="/static/logo-white.svg" 787 alt="Nomados" 788 draggable="false" 789 class="sidebar-logo" 790 /> 791 </button> 792 <button id="closeSidebar" class="absolute right-4 top-1/2 -translate-y-1/2"> 793 <img 794 src="/static/icons/close-white.svg" 795 alt="Close" 796 draggable="false" 797 /> 798 </button> 799 </div> 800 801 <!-- Scrollable Content Area --> 802 <div class="flex flex-col justify-between w-full h-full overflow-y-auto scrollbar-hide"> 803 <!-- Navigation Menu --> 804 <nav class="py-4"> 805 <button 806 class="w-full flex items-center gap-3 px-6 py-3 hover:bg-[#5d5d5d] transition-colors" 807 onclick="handleMilesClick()" 808 > 809 <div class="w-6"> 810 <img 811 src="/static/icons/miles_light.svg" 812 alt="Miles" 813 draggable="false" 814 /> 815 </div>
816 <span class="text-base font-medium">Miles</span> 817 </button> 818 819 <buttton 820 class="w-full flex items-center gap-3 px-6 py-3 hover:bg-[#5d5d5d] transition-colors" 821 data-panel-view="journey" 822 onclick="handleLaunchClick()" 823 aria-label="Launch - Journey List" 824 > 825 <div class="w-6"> 826 <img 827 src="/static/icons/launch.svg" 828 alt="Launch" 829 draggable="false" 830 /> 831 </div> 832 <span class="text-base font-medium">Launch</span> 833 </buttton> 834 835 836 837 <button 838 class="w-full flex items-center gap-3 px-6 py-3 hover:bg-[#5d5d5d] transition-colors" 839 data-panel-view="profile" 840 onclick="window.location.href = '/chat/login/';" 841 aria-label="Profile" 842 > 843 <div class="w-6"> 844 <img 845 src="/static/icons/profile_empty.svg" 846 alt="My Profile" 847 draggable="false" 848 /> 849 </div> 850 <span class="text-base font-medium">Profile</span> 851 </button> 852 853 854 <a 855 href="/chat/login/" 856 class="w-full flex items-center gap-3 px-6 py-3 hover:bg-[#5d5d5d] transition-colors" 857 > 858 <div class="w-6"> 859 <img 860 src="/static/icons/login_white.svg" 861 alt="Log In" 862 draggable="false" 863 /> 864 </div> 865 <span class="text-base font-medium">Log In</span> 866 </a> 867 868 </nav> 869 870 871 872 <!-- Footer Links Section --> 873 <div class="px-6 pb-6"> 874 <!-- Divider --> 875 <div class="border-t border-[#979797] my-4"></div> 876 <div class="flex w-full gap-6"> 877 <!-- About Column --> 878 <div class="w-1/2 flex flex-col gap-6"> 879 <div> 880 <h3 class="text-sm font-bold mb-3">About</h3> 881 <div class="flex flex-col gap-2"> 882 <a href="/#" class="text-sm text-white hover:text-gray-300"> 883 Company 884 </a> 885 <a href="/#" class="text-sm text-white hover:text-gray-300"> 886 Community 887 </a> 888 </div> 889 </div> 890 891 <!-- Resources Column --> 892 <div class="w-1/2"> 893 <h3 class="text-sm font-bold mb-3">Resources</h3> 894 <div class="flex flex-col gap-2"> 895 <a href="/#" class="text-sm text-white hover:text-gray-300"> 896 Articles 897 </a> 898 <a href="/feedback/" class="text-sm text-white hover:text-gray-300"> 899 Support 900 </a> 901 </div> 902 </div> 903 </div> 904 905 <!-- FAQs Column --> 906 <div> 907 <h3 class="text-sm font-bold mb-3">FAQs</h3> 908 <div class="flex flex-col gap-2"> 909 <a href="/#" class="text-sm text-white hover:text-gray-300"> 910 Booking 911 </a> 912 <a href="/#" class="text-sm text-white hover:text-gray-300"> 913 HelloLounge 914 </a> 915 <a href="/#" class="text-sm text-white hover:text-gray-300"> 916 Resort Residences 917 </a> 918 <a href="/#" class="text-sm text-white hover:text-gray-300"> 919 Membership 920 </a> 921 </div> 922 </div> 923 </div> 924 925 <!-- Terms and Privacy --> 926 <div class="mt-6 flex items-center justify-center gap-3 text-xs text-white font-light"> 927 <a href="https://nomados.ai/terms-and-conditions/" target="_blank" class="hover:text-gray-300 transition-colors cursor-pointer"> 928 Terms and Conditions 929 </a> 930 <span>|</span> 931 <a href="https://nomados.ai/privacy-policy/" target="_blank" class="hover:text-gray-300 transition-colors cursor-pointer"> 932 Privacy Policy 933 </a> 934 </div> 935 936 <!-- Social Media Icons --> 937 <div class="mt-4 flex items-center justify-center gap-4"> 938 <a href="https://facebook.com/nomadosai" target="_blank" rel="noopener noreferrer" class="w-10 h-10 bg-white rounded-lg flex items-center justify-center hover:bg-gray-200 transition-colors"> 939 <img 940 src="/static/icons/facebook.svg" 941 alt="Facebook" 942 draggable="false" 943 class="w-5 h-5" 944 /> 945 </a> 946 <a href="https://twitter.com/nomadosai" target="_blank" rel="noopener noreferrer" class="w-10 h-10 bg-white rounded-lg flex items-center justify-center hover:bg-gray-200 transition-colors">
947 <svg class="w-4 h-4 text-black" viewBox="0 0 24 24" fill="currentColor"> 948 <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/> 949 </svg> 950 </a> 951 <a href="https://instagram.com/nomadosai" target="_blank" rel="noopener noreferrer" class="w-10 h-10 bg-white rounded-lg flex items-center justify-center hover:bg-gray-200 transition-colors"> 952 <svg class="w-5 h-5 text-black" viewBox="0 0 24 24" fill="currentColor"> 953 <path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/> 954 </svg> 955 </a> 956 </div> 957 958 <!-- Copyright --> 959 <div class="mt-4 text-center text-xs text-gray-300 font-light"> 960 © 2026 NomadosAI. All Rights Reserved 961 </div> 962 </div> 963 </div> 964 965 <!-- User Profile Section at Bottom --> 966 <div class="px-6 py-4"> 967 <!-- Divider --> 968 <div class="border-t border-[#979797] my-4"></div> 969 <div class="flex items-center gap-3"> 970 <div class="w-12 h-12 rounded-full bg-[#bbb] flex items-center justify-center overflow-hidden"> 971 972 <div class="rounded-full avatar p-0.5 hover:-translate-y-0.5 ease-in-out duration-200 transition-all"> 973 <img 974 src="/static/icons/profile-default.svg" 975 alt="Wanderer" 976 draggable="false" 977 class="w-10 h-10 rounded-full" 978 /> 979 </div> 980 981 </div> 982 <div class="flex-1"> 983 984 <p class="text-sm font-semibold">Wanderer</p> 985 <p id="mobile-sidebar-membership-text" class="text-xs text-[#bbb]">No membership data</p> 986 987 </div> 988 </div> 989 </div> 990</div> 991 992 993<!-- Sidebar JavaScript -->
994<script> 995 function initSidebar() { 996 const sidebar = document.getElementById('mobileSidebar'); 997 const overlay = document.getElementById('sidebarOverlay'); 998 const menuButton = document.querySelector('.mobile-menu-button'); 999 const closeButton = document.getElementById('closeSidebar'); 1000 1001 // Ensure sidebar is closed on page load/initialization 1002 function ensureSidebarClosed() { 1003 if (sidebar) { 1004 sidebar.classList.add('-translate-x-[calc(100%+30px)]'); 1005 // Make visible after ensuring transform is applied (prevents flash) 1006 // Use double requestAnimationFrame to ensure CSS is fully applied 1007 requestAnimationFrame(() => { 1008 requestAnimationFrame(() => { 1009 sidebar.classList.add('sidebar-ready'); 1010 }); 1011 }); 1012 } 1013 if (overlay) { 1014 overlay.classList.add('hidden'); 1015 } 1016 document.body.style.overflow = ''; 1017 } 1018 1019 window.openMobileSidebar = function() { 1020 if (sidebar) { 1021 sidebar.classList.add('sidebar-ready'); 1022 sidebar.classList.remove('-translate-x-[calc(100%+30px)]'); 1023 } 1024 if (overlay) { 1025 overlay.classList.remove('hidden'); 1026 } 1027 document.body.style.overflow = 'hidden'; 1028 } 1029 1030 window.closeSidebar = function() { 1031 if (sidebar) { 1032 sidebar.classList.add('-translate-x-[calc(100%+30px)]'); 1033 } 1034 if (overlay) { 1035 overlay.classList.add('hidden'); 1036 } 1037 document.body.style.overflow = ''; 1038 } 1039 1040 // Ensure sidebar is closed on initialization 1041 ensureSidebarClosed(); 1042 1043 // Open sidebar when menu button is clicked 1044 if (menuButton) { 1045 menuButton.addEventListener('click', function(e) { 1046 e.preventDefault(); 1047 e.stopPropagation(); 1048 openMobileSidebar(); 1049 }); 1050 } else { 1051 console.warn('Menu button not found!'); 1052 } 1053 1054 // Close sidebar when close button is clicked 1055 if (closeButton) { 1056 closeButton.addEventListener('click', closeSidebar); 1057 } 1058 1059 // Close sidebar when overlay is clicked 1060 if (overlay) { 1061 overlay.addEventListener('click', closeSidebar); 1062 } 1063 1064 // Close sidebar when clicking on navigation links 1065 if (sidebar) { 1066 const navLinks = sidebar.querySelectorAll('nav a'); 1067 navLinks.forEach(link => { 1068 link.addEventListener('click', closeSidebar); 1069 }); 1070 } 1071 } 1072 1073 function handleMilesClick() { 1074 // Close Go Pro view if open 1075 if (typeof closeGoProView === 'function') { 1076 var goProEl = document.getElementById('go-pro-view'); 1077 if (goProEl && !goProEl.classList.contains('hidden')) closeGoProView(); 1078 } 1079 closeNomadosPanel(); 1080 closeSidebar(); 1081 } 1082 1083 function handleLaunchClick() { 1084 openNomadosPanel('journey'); 1085 closeSidebar(); 1086 } 1087 1088 function handleMarketClick() { 1089 openNomadosPanel('market'); 1090 closeSidebar(); 1091 } 1092 1093 function handleProfileClick() { 1094 openNomadosPanel('profile'); 1095 closeSidebar(); 1096 } 1097 1098 function handleUpgradeClick() { 1099 closeSidebar(); 1100 if (typeof openGoProView === 'function') openGoProView(); 1101 } 1102 1103 // Desktop profile popup: toggle and close on outside click 1104 window.toggleProfilePopup = function(event) { 1105 if (event) event.stopPropagation(); 1106 const popup = document.getElementById('profile-popup'); 1107 const btn = document.getElementById('sidebar-profile-btn'); 1108 if (!popup || !btn) return; 1109 const isHidden = popup.classList.contains('hidden'); 1110 popup.classList.toggle('hidden', !isHidden); 1111 popup.classList.toggle('flex', isHidden); 1112 btn.setAttribute('aria-expanded', isHidden ? 'true' : 'false'); 1113 if (isHidden) { 1114 document.addEventListener('click', closeProfilePopupOnClickOutside); 1115 } else { 1116 document.removeEventListener('click', closeProfilePopupOnClickOutside); 1117 } 1118 }; 1119 1120 function closeProfilePopupOnClickOutside(e) { 1121 const popup = document.getElementById('profile-popup'); 1122 const btn = document.getElementById('sidebar-profile-btn'); 1123 if (!popup || !btn) return; 1124 if (popup.contains(e.target) || btn.contains(e.target)) return; 1125 popup.classList.add('hidden'); 1126 popup.classList.remove('flex'); 1127 if (btn) btn.setAttribute('aria-expanded', 'false'); 1128 document.removeEventListener('click', closeProfilePopupOnClickOutside); 1129 } 1130
1131 window.closeProfilePopupAndOpenPanel = function(view) { 1132 const popup = document.getElementById('profile-popup'); 1133 const btn = document.getElementById('sidebar-profile-btn'); 1134 if (popup) { 1135 popup.classList.add('hidden'); 1136 popup.classList.remove('flex'); 1137 } 1138 if (btn) btn.setAttribute('aria-expanded', 'false'); 1139 document.removeEventListener('click', closeProfilePopupOnClickOutside); 1140 if (typeof openNomadosPanel === 'function') openNomadosPanel(view); 1141 }; 1142 1143 window.closeProfilePopupAndOpenGoProView = function() { 1144 var popup = document.getElementById('profile-popup'); 1145 var btn = document.getElementById('sidebar-profile-btn'); 1146 if (popup) { 1147 popup.classList.add('hidden'); 1148 popup.classList.remove('flex'); 1149 } 1150 if (btn) btn.setAttribute('aria-expanded', 'false'); 1151 document.removeEventListener('click', closeProfilePopupOnClickOutside); 1152 if (typeof openGoProView === 'function') openGoProView(); 1153 }; 1154 1155 1156 function handleLogoClick() { 1157 // Refresh chat - works for both desktop and mobile 1158 if (typeof window.refreshChat === 'function') { 1159 window.refreshChat(); 1160 } else if (typeof refreshChat === 'function') { 1161 refreshChat(); 1162 } 1163 1164 // Close any open panels 1165 if (typeof closeNomadosPanel === 'function') { 1166 closeNomadosPanel(); 1167 } 1168 1169 // Close mobile sidebar if open 1170 if (typeof closeSidebar === 'function') { 1171 closeSidebar(); 1172 } 1173 } 1174 1175 // Initialize when DOM is ready 1176 if (document.readyState === 'loading') { 1177 document.addEventListener('DOMContentLoaded', initSidebar); 1178 } else { 1179 initSidebar(); 1180 } 1181 1182 1183</script>
1183 1184 1185 1186<div class="relative w-full h-full flex flex-1 min-w-0" id="chat-main-area-wrapper"> 1187<div class="w-full h-full flex main-container"> 1188 1189 <!-- Chat Container - 2/5 width (desktop) / Fixed overlay (mobile) --> 1190 <div id="chat-container" class="md:w-[calc(50%-30px)] xl:w-[calc(40%-30px)] flex-shrink-0 bg-white mobile-chat-overlay expanded"> 1191 <div class="h-full max-w-[960px] mx-auto"> 1192 <!-- HTMX Chat Widget --> 1193 <div id="chat-widget" class="chat-widget p-4 pt-11 md:pt-4 relative flex flex-col h-full"> 1194 <!-- Mobile Services Tracker Bar (shown during essentials wizard) --> 1195 <div id="mobile-services-tracker" class="md:hidden flex-shrink-0" style="display: none;"> 1196 <div class="flex items-center justify-around gap-6 py-2 pb-3 border-b border-[#EEE]"> 1197 <div class="flex flex-col items-center gap-1" data-tracker-step="visa"> 1198 <div class="w-9 h-9 rounded-full bg-[#BBBBBB] flex items-center justify-center overflow-hidden tracker-icon-wrap"> 1199 <img id="tracker-icon-visa" src="/static/icons/visa.svg" alt="Visa" class="w-7 h-7 object-contain" draggable="false" /> 1200 </div> 1201 <span class="text-[10px] font-medium text-[#3B3B3C]">Visa</span> 1202 </div> 1203 <div class="flex flex-col items-center gap-1" data-tracker-step="health-insurance"> 1204 <div class="w-9 h-9 rounded-full bg-[#BBBBBB] flex items-center justify-center overflow-hidden tracker-icon-wrap"> 1205 <img id="tracker-icon-health" src="/static/icons/healthcare.svg" alt="Insurance" class="w-7 h-7 object-contain" draggable="false" /> 1206 </div> 1207 <span class="text-[10px] font-medium text-[#3B3B3C]">Insurance</span> 1208 </div> 1209 <div class="flex flex-col items-center gap-1" data-tracker-step="internet"> 1210 <div class="w-9 h-9 rounded-full bg-[#BBBBBB] flex items-center justify-center overflow-hidden tracker-icon-wrap"> 1211 <img id="tracker-icon-internet" src="/static/icons/internet_speed.svg" alt="eSIM" class="w-7 h-7 object-contain" draggable="false" /> 1212 </div> 1213 <span class="text-[10px] font-medium text-[#3B3B3C]">eSIM</span> 1214 </div> 1215 <div class="flex flex-col items-center gap-1" data-tracker-step="airport-lounge"> 1216 <div class="w-9 h-9 rounded-full bg-[#BBBBBB] flex items-center justify-center overflow-hidden tracker-icon-wrap"> 1217 <img id="tracker-icon-lounge" src="/static/icons/lounge.svg" alt="Lounge" class="w-7 h-7 object-contain" draggable="false" /> 1218 </div> 1219 <span class="text-[10px] font-medium text-[#3B3B3C]">Lounge</span> 1220 </div> 1221 </div> 1222 </div> 1223 <!-- Sticky GIF at top --> 1224 <div class="hidden md:flex flex-shrink-0 mb-0 lg:mb-2"> 1225 <img 1226 src="/static/chat/idle.gif" 1227 alt="Anthony" 1228 class="w-[60px] h-[90px] lg:w-[80px] lg:h-[120px]" 1229 draggable="false" 1230 id="chatGif" 1231 /> 1232 </div> 1233 1234 <!-- Messages Container --> 1235 <div class="chat-messages flex-1 px-1 pb-6 md:pb-14 mb-6" id="chatMessages"> 1236 <div class="message bot"> 1237 <div class="message-content"> 1238 Hey! 1239 ð <b>Miles </b>here. 4 quick questions - I'll supply your next journey and the vibes. 1240 </div> 1241 1242 <button 1243 type="button" 1244 id="startFilterWorkflow" 1245 class="w-full flex justify-between items-center mt-4 text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" 1246 onclick="startDestinationFilter()" 1247 > 1248 <span>Guided Journey</span> 1249 <img src="/static/icons/chevron_right.gif" alt="Arrow Right" class="w-5 h-5" /> 1250 </button> 1251 <!-- Sample Questions --> 1252 <div class="sample-questions" id="sampleQuestions" style="display: none;"> 1253 <div class="sample-questions-title flex items-center gap-1"> 1254 <img src="/static/icons/psychology.svg" alt="Question Mark" class="" />
1255 <span class="leading-none">Related questions:</span> 1256 </div> 1257 <div class="sample-questions-list" id="sampleQuestionsList"> 1258 1259 <div class="sample-question" onclick="submitQuestion('Which places have a thriving art and music scene for creatives?')"> 1260 Which places have a thriving art and music scene for creatives? 1261 </div> 1262 1263 <div class="sample-question" onclick="submitQuestion('Can you recommend cities that offer both modern amenities and historic charm?')"> 1264 Can you recommend cities that offer both modern amenities and historic charm? 1265 </div> 1266 1267 <div class="sample-question" onclick="submitQuestion('Are there any cities with relaxed visa policies for digital nomads?')"> 1268 Are there any cities with relaxed visa policies for digital nomads? 1269 </div> 1270 1271 </div> 1272 </div> 1273 </div> 1274 </div> 1275 1276 <!-- Typing Indicator â absolute so it doesn't steal flex space from chatMessages --> 1277 <div class="typing-indicator" id="typingIndicator"> 1278 <div class="flex items-center gap-2"> 1279 <div class="typing-dots"> 1280 <div class="typing-dot"></div> 1281 <div class="typing-dot"></div> 1282 <div class="typing-dot"></div> 1283 </div> 1284 </div> 1285 </div> 1286 1287 <div class="w-full flex flex-col-reverse md:flex-col"> 1288 <!-- Chat Input Form with HTMX --> 1289 <div class="chat-input mb-14 md:mb-0 bg-white p-2 md:p-3 2xl:p-5 mt-2 md:mt-auto transition-all duration-400 lg:duration-0 ease-in-out lg:opacity-100 bottom-0" id="chat-input"> 1290 <form 1291 hx-post="/chat/chat/api/" 1292 hx-target="#chatMessages" 1293 hx-swap="beforeend" 1294 hx-indicator="#typingIndicator" 1295 hx-trigger="submit" 1296 class="chat-input-form" 1297 id="chatForm" 1298 > 1299 <input type="hidden" name="csrfmiddlewaretoken" value="wW5XRNroFwTKrAvYlp7o0ZIlcpfIHaiR8jbqrSOG95se38pTkp2zbUYkj50vDt3a"> 1300 <button 1301 type="button" 1302 id="filtersButton" 1303 class="p-1.5 w-9 h-9 border border-[#3B3B3C] rounded-full transition-all duration-200 hover:-translate-y-0.5" 1304 onclick="openFiltersModal()" 1305 aria-label="Open filters" 1306 > 1307 <img src="/static/icons/filter_dark.svg" alt="Filters" class="" /> 1308 </button> 1309 <input 1310 type="text" 1311 name="message" 1312 class="chat-input-field p-2 md:p-3 2xl:p-5 pe-0 2xl:pe-5" 1313 id="messageInput" 1314 placeholder="What is on your mind?..." 1315 autocomplete="off" 1316 required 1317 /> 1318 <button 1319 type="submit" 1320 class="send-button w-9 h-9 p-1.5 bg-[#3B3B3C] rounded-full transition-all duration-200 hover:-translate-y-0.5" 1321 id="sendButton" 1322 > 1323 <img 1324 src="/static/icons/add_journey.svg" 1325 alt="Add journey" 1326 class="" 1327 /> 1328 </button> 1329 </form> 1330 </div> 1331 1332 <!-- Add-on Buttons --> 1333 <div class="w-full mt-1.5 md:mt-6 pb-0 md:pb-6 flex gap-3 justify-center items-center relative"> 1334 <button class="bg-gray-300 text-gray-500 cursor-not-allowed chat-tooltip relative text-xs md:text-sm px-3 py-1.5 rounded-full hover:-translate-y-0.5 transition-all duration-200" onclick="" 1335 data-tooltip="Only available to registered users." 1336 > 1337 eVisa 1338 </button> 1339 <button class="bg-gray-300 text-gray-500 cursor-not-allowed chat-tooltip text-xs md:text-sm px-3 py-1.5 rounded-full hover:-translate-y-0.5 transition-all duration-200" onclick="" 1340 data-tooltip="Only available to registered users." 1341 > 1342 Airport Lounge 1343 </button> 1344 <button class="bg-gray-300 text-gray-500 cursor-not-allowed chat-tooltip text-xs md:text-sm px-3 py-1.5 rounded-full hover:-translate-y-0.5 transition-all duration-200" onclick="" 1345 data-tooltip="Only available to registered users." 1346 > 1347 Insurance 1348 </button> 1349 <button class="bg-gray-300 text-gray-500 cursor-not-allowed chat-tooltip text-xs md:text-sm px-3 py-1.5 rounded-full hover:-translate-y-0.5 transition-all duration-200" onclick="" 1350 data-tooltip="Only available to registered users." 1351 > 1352 eSIM 1353 </button> 1354 1355 <div class="flex md:hidden flex-shrink-0 mb-0 lg:mb-2"> 1356 <img 1357 src="/static/chat/mobile_idle.gif" 1358 alt="Anthony" 1359 class="w-[54px] h-[54px] border border-[#D9D9D9] rounded-full" 1360 draggable="false" 1361 id="mobileChatGif" 1362 /> 1363 </div> 1364 </div> 1365 </div> 1366 </div> 1367 </div> 1368</div> 1369 <!-- View Toggle Buttons --> 1370 <div class="w-fit absolute hidden md:flex top-4 right-4 z-10 gap-2 toggle-buttons rounded-full bg-[#f7f7f7] p-1.5" id="viewButtonsContainer"> 1371 <button 1372 id="chatViewButton" 1373 class="view-button hidden" 1374 data-view="chat" 1375 aria-label="Chat view" 1376 > 1377 Chat 1378 </button> 1379 <button 1380 id="mapViewButton" 1381 class="view-button active" 1382 data-view="map" 1383 aria-label="Map view" 1384 > 1385 Map 1386 </button> 1387 <button 1388 id="listViewButton" 1389 class="view-button" 1390 data-view="list" 1391 aria-label="List view" 1392 > 1393 List 1394 </button> 1395 <button 1396 id="tableViewButton" 1397 class="view-button" 1398 data-view="table" 1399 aria-label="Table view" 1400 > 1401 Table 1402 </button> 1403 </div> 1404
1405 <!-- Mobile View Toggle Buttons (shown in chat view when cities exist) --> 1406 <div class="w-fit absolute md:hidden bottom-[205px] md:bottom-auto top-auto md:top-4 right-4 z-10 gap-2 toggle-buttons rounded-full bg-white hidden" id="mobileViewButtonsContainer" style="display: none;"> 1407 <button 1408 id="mobileMapViewButton" 1409 class="view-button flex items-center gap-1 !w-16 !bg-[#3B3B3C] !text-white" 1410 data-view="map" 1411 aria-label="Map view" 1412 onclick="switchView('map')" 1413 > 1414 <img 1415 src="/static/icons/map.svg" 1416 alt="Map" 1417 class="" 1418 /> 1419 Map 1420 </button> 1421 </div> 1422 1423 <!-- Mobile Back to Chat Button (shown only in map view) --> 1424 <button 1425 id="mobileBackToChatButton" 1426 class="md:hidden fixed bottom-10 left-1/2 -translate-x-1/2 z-[3000] bg-white border border-[#3B3B3C] rounded-full items-center justify-center shadow-lg hover:-translate-y-0.5 transition-all duration-200 hidden ps-1.5 pe-3 py-1.5 gap-2" 1427 style="display: none;" 1428 onclick="goBackToChatFromMap()" 1429 aria-label="Back to chat" 1430 > 1431 <img 1432 src="/static/icons/arrow_left.svg" 1433 alt="Back to chat" 1434 class="" 1435 /> 1436 <span class="text-sm">Back to chat</span> 1437 </button> 1438 1439 1440<!-- Right Side Container - 3/5 width (desktop) / Full screen (mobile) --> 1441<div class="md:w-[calc(50%+34px)] xl:w-[calc(60%+34px)] flex-shrink-0 bg-white relative h-full mobile-content-layer"> 1442 <!-- Chat Toggle Button (shown when chat is collapsed on mobile) --> 1443 <button id="content-chat-toggle-btn" class="mobile-chat-toggle" onclick="toggleMobileChat()" aria-label="Open chat" style="display: none;"> 1444 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1445 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path> 1446 </svg> 1447 </button> 1448 <!-- Map Container --> 1449 <div id="map-container" class="h-screen md:h-full map-container"> 1450 1451 <div id="map"></div> 1452 1453 </div> 1454 1455 <!-- List Container --> 1456 <div id="list-container" class="h-full overflow-y-auto pb-24 mt-16 bg-white max-w-[1080px] mx-auto" style="display: none;"> 1457 <div class="p-6" id="citiesListContainer"> 1458 <div class="text-center text-gray-500 py-12"> 1459 <p class="text-lg font-semibold mb-2">No destinations yet</p> 1460 <p class="text-sm">Start chatting to see recommended destinations</p> 1461 </div> 1462 </div> 1463 </div> 1464 1465 <!-- Table Container --> 1466 <div id="table-container" class="h-full overflow-y-auto pb-24 mt-16 bg-white max-w-[1080px] mx-auto" style="display: none;"> 1467 <div class="p-6" id="destinationsTableContainer"> 1468 <div class="text-center text-gray-500 py-12"> 1469 <p class="text-lg font-semibold mb-2">No destinations yet</p> 1470 <p class="text-sm">Start chatting to see recommended destinations</p> 1471 </div> 1472 </div> 1473 </div> 1474</div> 1475 1476 <!-- Go Pro full-screen view (same layout as panel but fills main area) --> 1477 <div id="go-pro-view" class="absolute inset-0 bg-white z-[100] hidden flex-col overflow-y-auto go-pro-view--closed" aria-hidden="true"> 1478 <div class="hidden md:flex justify-end items-center gap-3 px-4 md:px-6 py-4"> 1479 <button type="button" onclick="closeGoProView()" class="go-pro-close" aria-label="Back to chat"> 1480 <img 1481 src="/static/icons/close-dark.svg" 1482 alt="Close" 1483 draggable="false" 1484 /> 1485 </button> 1486 </div> 1487 <div class="flex-1 flex flex-col items-center pt-4 md:py-12 px-4"> 1488 1489<h1 class="text-2xl md:text-4xl font-bold text-[#3B3B3C] text-center mb-2 mt-12 md:mt-0">Go Pro</h1> 1490<p class="text-sm md:text-base text-gray-500 text-center mb-8 md:mb-10">Everything you need to live and work anywhere, seamlessly.</p> 1491 1492<div class="relative border border-[#3B3B3C] rounded-[36px] w-96 p-4 mb-16 md:mb-0"> 1493 1494 <!-- Processing loader overlay --> 1495 <div id="go-pro-processing-loader" class="hidden absolute inset-0 z-10 bg-white/80 rounded-[36px] flex flex-col items-center justify-center"> 1496 <svg class="animate-spin h-6 w-6 text-[#3B3B3C] mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> 1497 <span id="go-pro-loader-text" class="text-sm text-gray-600">Processing...</span> 1498 </div> 1499 1500 <!-- âââ Pro Status Header (shown when user is Pro) âââ --> 1501 <div id="go-pro-already-pro" class="w-full max-w-md rounded-full border py-3 px-5 mb-5 text-center 1502 hidden 1503 border-[#3B3B3C]"> 1504 1505 <p id="go-pro-already-pro-title" class="text-lg font-bold leading-none pb-1 text-[#3B3B3C]"> 1506 You're a <span class="text-[#FF735D]">Pro</span> member 1507 </p> 1508 <p id="go-pro-already-pro-renewal" class="text-xs text-[#777777] leading-none"> 1509 Active subscription 1510 </p> 1511 </div> 1512 1513 <!-- âââ Price Display (shown for all states) âââ --> 1514 1515 <!-- Price for Pro users (always $9/month, no toggle) --> 1516 <div id="go-pro-price-pro" class="text-center mb-5 hidden"> 1517 <span class="text-5xl font-bold text-[#3B3B3C]">$9</span> 1518 <span class="text-gray-500 text-base ml-1">/month</span> 1519 </div> 1520 1521 <!-- CTA block: shown when not Pro (hidden in real time when status_update says pro) --> 1522 <div id="go-pro-cta-block" class="w-full max-w-md "> 1523 <div class="flex rounded-full p-1.5 mb-5 bg-[#F3F3F3]"> 1524 <button 1525 type="button" 1526 id="goProToggleMonthly" 1527 onclick="setGoProBilling('monthly')" 1528 class="flex-1 py-3 px-3 text-base font-medium rounded-full transition-colors bg-white text-[#3B3B3C]" 1529 > 1530 Monthly 1531 </button> 1532 <button 1533 type="button" 1534 id="goProToggleAnnual" 1535 onclick="setGoProBilling('annual')" 1536 class="flex-1 py-3 px-3 text-base font-medium rounded-full transition-colors text-[#777777] flex items-center justify-center gap-2" 1537 > 1538 Annual
1539 <span class="bg-[#14B8A6] text-white text-xs font-semibold px-2 py-0.5 rounded-full">Save 45%</span> 1540 </button> 1541 </div> 1542 1543 <div id="goProPriceMonthly" class="go-pro-price-block text-center mb-7"> 1544 <span class="text-5xl font-bold text-[#3B3B3C]">$9</span> 1545 <span class="text-gray-500 text-base ml-1">/month</span> 1546 </div> 1547 <div id="goProPriceAnnual" class="go-pro-price-block hidden text-center mb-1"> 1548 <span class="text-5xl font-bold text-[#3B3B3C]">$59</span> 1549 <span class="text-gray-500 text-base ml-1">/year</span> 1550 <p class="text-sm text-gray-500 mt-1">Just $4.92/month, billed annually</p> 1551 </div> 1552 1553 <button 1554 type="button" 1555 id="goProCtaBtn" 1556 onclick="submitGoProCheckout()" 1557 class="w-full py-3.5 rounded-full bg-[#3B3B3C] text-white font-semibold text-base hover:bg-[#2d2d2e] transition-colors" 1558 > 1559 Get Nomados Pro 1560 </button> 1561 <p class="text-xs text-black text-center mt-3">Continue to our secure portal</p> 1562 </div> 1563 1564 <!-- âââ Manage Membership Button (shown for Pro users) âââ --> 1565 <div id="go-pro-manage-block" class="w-full max-w-md hidden"> 1566 1567 <a id="go-pro-manage-btn" href="https://nomados.samcart.com/customer_hub/login" target="_blank" rel="noopener noreferrer" 1568 class="block w-full py-3.5 rounded-full bg-[#3B3B3C] text-white font-semibold text-base text-center hover:bg-[#2d2d2e] transition-colors"> 1569 Manage Membership 1570 </a> 1571 1572 <p id="go-pro-manage-subtext" class="text-xs text-black text-center mt-3"> 1573 Continue to our secure portal 1574 </p> 1575 </div> 1576 1577 <!-- âââ Features List âââ --> 1578 <div id="go-pro-features-wrap" class="w-full max-w-md border-t border-gray-200 p-4 pt-6 mt-6"> 1579 <h2 class="text-xs font-semibold text-[#777777] uppercase tracking-wider mb-4">Everything included</h2> 1580 <ul class="space-y-3"> 1581 <li class="flex items-center gap-3"> 1582 <img src="/static/icons/pro-flight.svg" /> 1583 <div> 1584 <p class="font-medium text-[#3B3B3C]">Airport Lounges</p> 1585 <p class="text-sm text-gray-500">Unlimited access worldwide</p> 1586 </div> 1587 </li> 1588 <li class="flex items-center gap-3"> 1589 <img src="/static/icons/pro-esim.svg" /> 1590 <div> 1591 <p class="font-medium text-[#3B3B3C]">eSIM Data</p> 1592 <p class="text-sm text-gray-500">20% off via Airalo</p> 1593 </div> 1594 </li> 1595 <li class="flex items-center gap-3"> 1596 <img src="/static/icons/pro-evisa.svg" /> 1597 <div> 1598 <p class="font-medium text-[#3B3B3C]">eVisa Services</p> 1599 <p class="text-sm text-gray-500">20% off via SimpleVisa</p> 1600 </div> 1601 </li> 1602 <li class="flex items-center gap-3"> 1603 <img src="/static/icons/pro-insurance.svg" /> 1604 <div> 1605 <p class="font-medium text-[#3B3B3C]">Travel Insurance</p> 1606 <p class="text-sm text-gray-500">SafetyWing coverage available</p> 1607 </div> 1608 </li> 1609 <li class="flex items-center gap-3"> 1610 <img src="/static/icons/pro-miles.svg" /> 1611 <div> 1612 <p class="font-medium text-[#3B3B3C]">AI Travel Assistant</p> 1613 <p class="text-sm text-gray-500">Unlimited conversations with Miles</p> 1614 </div> 1615 </li> 1616 <li class="flex items-center gap-3"> 1617 <img src="/static/icons/pro-journey.svg" /> 1618 <div> 1619 <p class="font-medium text-[#3B3B3C]">Journey Planning</p> 1620 <p class="text-sm text-gray-500">Unlimited saved itineraries</p> 1621 </div> 1622 </li> 1623 </ul> 1624 </div> 1625</div> 1626 1627<p class="text-xs text-gray-400 text-center mt-6">Cancel anytime · Secure payment · Instant access</p> 1628 1629 </div> 1630 </div> 1631</div> 1632 1633<!-- Destination Detail is now handled by Nomados Panel System --> 1634 1635<!-- Filters Modal - Moved outside main-container to avoid stacking context issues --> 1636<div id="filtersModal" class="fixed inset-0 hidden items-center justify-center opacity-0 scale-95 transition-all duration-200 ease-out" style="z-index: 2147483647; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); background: rgba(0,0,0,0.5); display: none; pointer-events: auto;"> 1637 <div class="bg-white rounded-3xl shadow-2xl border border-gray-200 p-6 w-[92%] max-w-3xl mx-auto transform transition-all duration-200 max-h-[90vh] overflow-y-auto"> 1638 <div class="flex items-center justify-between mb-4"> 1639 <div> 1640 <h3 class="text-xl font-semibold color-black-100">Filters</h3> 1641 <p class="text-sm text-gray-600 mt-1">Refine destinations in the list view</p> 1642 </div> 1643 <button onclick="closeFiltersModal()" class="text-gray-400 hover:text-gray-600 transition-colors" type="button" aria-label="Close filters"> 1644 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1645 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 1646 </svg> 1647 </button> 1648 </div> 1649 1650 <div class="mb-4 flex justify-between items-center"> 1651 <h4 class="text-lg font-bold color-black-100 leading-none">Select Your Priorities</h4> 1652 <button type="button" onclick="resetAllFilters()" class="text-xs color-gray-100 hover:text-blue-600 cursor-pointer transition-colors">Reset</button> 1653 </div> 1654 1655 <form id="modalFilterForm" hx-get="/destinations/filter/" hx-target="#citiesListContainer" hx-swap="innerHTML" onsubmit="return false;"> 1656 <input type="hidden" name="continent" value="" /> 1657 <input type="hidden" name="climate" value="" /> 1658 <input type="hidden" name="budget" value="" /> 1659 <input type="hidden" name="internet" value="" /> 1660 <input type="hidden" name="safety" value="" /> 1661 <input type="hidden" name="nomads" value="" /> 1662 <input type="hidden" name="english" value="" /> 1663 <input type="hidden" name="visa" value="" /> 1664 <input type="hidden" name="political" value="" /> 1665 <input type="hidden" name="healthcare" value="" /> 1666 1667 <div class="space-y-6"> 1668 <div class="filter-group"> 1669 <label class="filter-label flex items-center gap-2 text-sm font-semibold text-black uppercase">Continent</label> 1670 <div class="filter-options flex flex-wrap gap-2"> 1671 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="continent" data-value="europe" onclick="toggleFilter(this)">Europe</button> 1672 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="continent" data-value="asia" onclick="toggleFilter(this)">Asia</button> 1673 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="continent" data-value="north-america" onclick="toggleFilter(this)">North America</button> 1674 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="continent" data-value="south-america" onclick="toggleFilter(this)">South America</button> 1675 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="continent" data-value="africa" onclick="toggleFilter(this)">Africa</button> 1676 </div> 1677 </div> 1678 1679 <div class="filter-group"> 1680 <label class="filter-label flex items-center gap-2 text-sm font-semibold text-black uppercase">Budget (USD)</label> 1681 <div class="filter-options flex flex-wrap gap-2"> 1682 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="budget" data-value="0-1000" onclick="toggleFilter(this)"><$1k</button> 1683 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="budget" data-value="1000-2000" onclick="toggleFilter(this)">$1k-$2k</button> 1684 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="budget" data-value="2000-3000" onclick="toggleFilter(this)">$2k-$3k</button> 1685 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="budget" data-value="3000+" onclick="toggleFilter(this)">>$3k</button> 1686 </div> 1687 </div> 1688 1689 <div class="filter-group"> 1690 <label class="filter-label flex items-center gap-2 text-sm font-semibold text-black uppercase">Climate</label> 1691 <div class="filter-options flex flex-wrap gap-2"> 1692 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="TROPICAL" onclick="toggleFilter(this)">Tropical</button> 1693 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="TEMPERATE" onclick="toggleFilter(this)">Temperate</button> 1694 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="MEDITERRANEAN" onclick="toggleFilter(this)">Mediterranean</button> 1695 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="SUBTROPICAL" onclick="toggleFilter(this)">Subtropical</button> 1696 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="ARID" onclick="toggleFilter(this)">Arid</button> 1697 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="COLD" onclick="toggleFilter(this)">Cold</button> 1698 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="CONTINENTAL" onclick="toggleFilter(this)">Continental</button> 1699 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="DESERT" onclick="toggleFilter(this)">Desert</button> 1700 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="MOUNTAIN" onclick="toggleFilter(this)">Mountain</button> 1701 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="climate" data-value="WARM" onclick="toggleFilter(this)">Warm</button> 1702 </div> 1703 </div> 1704 1705 <div class="filter-group"> 1706 <label class="filter-label flex items-center gap-2 text-sm font-semibold text-black uppercase">Internet Speed</label> 1707 <div class="filter-options flex flex-wrap gap-2"> 1708 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="internet" data-value="ULTRA_FAST" onclick="toggleFilter(this)">Ultra Fast</button> 1709 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="internet" data-value="FAST" onclick="toggleFilter(this)">Fast</button> 1710 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="internet" data-value="BASIC" onclick="toggleFilter(this)">Basic</button> 1711 </div> 1712 </div> 1713 1714 <div class="filter-group"> 1715 <label class="filter-label flex items-center gap-2 text-sm font-semibold text-black uppercase">Safety</label> 1716 <div class="filter-options flex flex-wrap gap-2"> 1717 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="safety" data-value="HIGH" onclick="toggleFilter(this)">High</button> 1718 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="safety" data-value="MOD" onclick="toggleFilter(this)">Moderate</button> 1719 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="safety" data-value="LOW" onclick="toggleFilter(this)">Low</button> 1720 </div> 1721 </div> 1722 1723 <div class="filter-group"> 1724 <label class="filter-label flex items-center gap-2 text-sm font-semibold text-black uppercase">Nomad Community</label> 1725 <div class="filter-options flex flex-wrap gap-2"> 1726 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="nomads" data-value="HIGH" onclick="toggleFilter(this)">Large</button> 1727 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="nomads" data-value="MOD" onclick="toggleFilter(this)">Growing</button> 1728 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="nomads" data-value="LOW" onclick="toggleFilter(this)">Small</button> 1729 </div> 1730 </div> 1731 1732 <div class="filter-group"> 1733 <label class="filter-label flex items-center gap-2 text-sm font-semibold text-black uppercase">English Speaking</label> 1734 <div class="filter-options flex flex-wrap gap-2"> 1735 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="english" data-value="HIGH" onclick="toggleFilter(this)">High</button> 1736 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="english" data-value="MOD" onclick="toggleFilter(this)">Moderate</button> 1737 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="english" data-value="LOW" onclick="toggleFilter(this)">Low</button> 1738 </div> 1739 </div> 1740 1741 <div class="filter-group"> 1742 <label class="filter-label flex items-center gap-2 text-sm font-semibold text-black uppercase">Healthcare Quality</label> 1743 <div class="filter-options flex flex-wrap gap-2"> 1744 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="healthcare" data-value="HIGH" onclick="toggleFilter(this)">High</button> 1745 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="healthcare" data-value="MOD" onclick="toggleFilter(this)">Moderate</button> 1746 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="healthcare" data-value="LOW" onclick="toggleFilter(this)">Low</button> 1747 </div> 1748 </div> 1749 1750 <div class="filter-group"> 1751 <label class="filter-label flex items-center gap-2 text-sm font-semibold text-black uppercase">Visa Requirements</label> 1752 <div class="filter-options flex flex-wrap gap-2"> 1753 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="visa" data-value="required" onclick="toggleFilter(this)">Required</button> 1754 <button type="button" class="filter-option px-3 py-1.5 border border-gray-300 rounded-full text-xs" data-filter="visa" data-value="not_required" onclick="toggleFilter(this)">Not Required</button> 1755 </div> 1756 </div> 1757 </div> 1758 1759 <div class="mt-6 flex justify-end gap-2"> 1760 <button 1761 type="button" 1762 class="flex bg-white items-center hover:-translate-y-0.5 color-black-100 py-1.5 px-4 border border-solid border-[#bbb] rounded-full font-medium transition duration-200 gap-1" 1763 onclick="closeFiltersModal()" 1764 > 1765 Close 1766 </button> 1767 <button 1768 type="button" 1769 class="flex bg-dark-100 text-white items-center hover:-translate-y-0.5 py-1.5 px-4 roun
1769ded-full font-medium transition duration-200 gap-1" 1770 onclick="applyCurrentFiltersFromModal()" 1771 > 1772 Apply 1773 </button> 1774 </div> 1775 </form> 1776 </div> 1777</div> 1778 1779 1780<!-- Chat Widget Styles --> 1781<style> 1782 .main-container { width: 100%; max-width: 100%; } 1783 .chat-widget { 1784 font-family: inherit; 1785 display: flex; 1786 flex-direction: column; 1787 position: relative; 1788 overflow: visible; 1789 } 1790 1791 .chat-blur-overlay { 1792 position: absolute; 1793 inset: 0; 1794 z-index: 10; 1795 display: flex; 1796 align-items: center; 1797 justify-content: center; 1798 border-radius: 16px; 1799 background: rgba(255, 255, 255, 0.5); 1800 backdrop-filter: blur(6px); 1801 pointer-events: all; 1802 } 1803 1804 .chat-blur-overlay .chat-blur-content { 1805 text-align: center; 1806 padding: 2rem 1rem; 1807 max-width: 90%; 1808 } 1809 1810 .chat-messages { 1811 flex: 1; 1812 min-height: 120px; 1813 overflow-x: hidden; 1814 overflow-y: auto; 1815 transition: max-height 0.2s; 1816 scroll-behavior: smooth; 1817 } 1818 1819 /* Mobile responsive layout */ 1820 @media (max-width: 768px) { 1821 .main-container { 1822 flex-direction: column !important; 1823 position: relative !important; 1824 } 1825 1826 /* Content layer (map/list/table) - full screen on mobile */ 1827 .mobile-content-layer { 1828 width: 100% !important; 1829 height: 100vh !important; 1830 position: absolute !important; 1831 top: 0 !important; 1832 left: 0 !important; 1833 z-index: 1 !important; 1834 } 1835 1836 /* Go Pro view - fixed overlay on mobile, below mobile header (z-2500) */ 1837 #go-pro-view { 1838 position: fixed !important; 1839 top: 0 !important; 1840 left: 0 !important; 1841 right: 0 !important; 1842 bottom: 0 !important; 1843 z-index: 2400 !important; 1844 } 1845 1846 /* Hide Go Pro close button on mobile - navigation via mobile header hamburger */ 1847 #go-pro-view .go-pro-close { 1848 display: none !important; 1849 } 1850 1851 /* Chat overlay - fixed at bottom, top layer */ 1852 .mobile-chat-overlay { 1853 position: fixed !important; 1854 bottom: 0 !important; 1855 left: 0 !important; 1856 right: 0 !important; 1857 width: 100% !important; 1858 height: 100vh !important; 1859 z-index: 150 !important; 1860 border: none !important; 1861 transform: translateY(0); 1862 transition: transform 0.3s ease-in-out; 1863 pointer-events: auto !important; 1864 overflow-y: auto !important; 1865 -webkit-overflow-scrolling: touch !important; 1866 } 1867 1868 .mobile-chat-overlay.expanded { 1869 transform: translateY(0) !important; 1870 pointer-events: auto !important; 1871 visibility: visible !important; 1872 opacity: 1 !important; 1873 } 1874 1875 .mobile-chat-overlay.collapsed { 1876 transform: translateY(100%) !important; 1877 pointer-events: none !important; 1878 } 1879 1880 /* Chat toggle button - base styles */ 1881 .mobile-chat-toggle { 1882 display: flex !important; 1883 background: white !important; 1884 border: 1px solid #e5e7eb !important; 1885 border-radius: 50% !important; 1886 align-items: center !important; 1887 justify-content: center !important; 1888 cursor: pointer !important; 1889 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; 1890 transition: all 0.3s ease !important; 1891 } 1892 1893 .mobile-chat-toggle:hover { 1894 transform: scale(1.1); 1895 box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important; 1896 } 1897 1898 /* Toggle button in content layer when chat is collapsed */ 1899 #content-chat-toggle-btn { 1900 position: fixed !important; 1901 bottom: 20px !important; 1902 right: 20px !important; 1903 width: 56px !important; 1904 height: 56px !important; 1905 z-index: 210 !important; 1906 display: none !important; 1907 pointer-events: auto !important; 1908 } 1909 1910 .mobile-content-layer.has-collapsed-chat #content-chat-toggle-btn { 1911 display: flex !important; 1912 } 1913 1914 /* Show chat button only on mobile */ 1915 #chatViewButton { 1916 display: inline-flex !important; 1917 } 1918 1919 /* Ensure filter modal appears above chat container on mobile */ 1920 #filtersModal { 1921 z-index: 2147483647 !important; 1922 } 1923 1924 /* Adjust toggle buttons for mobile - ensure they're always visible and above chat */ 1925 .toggle-buttons { 1926 position: fixed !important; 1927 left: 50% !important; 1928 transform: translateX(-50%) !important; 1929 gap: 8px !important; 1930 z-index: 210 !important; 1931 flex-wrap: nowrap !important; 1932 pointer-events: auto !important; 1933 } 1934 1935 .toggle-buttons button { 1936 padding: 6px 12px !important; 1937 font-size: 12px !important; 1938 align-items: center !important; 1939 pointer-events: auto !important; 1940 } 1941 } 1942 1943 /* View Button Styles */ 1944 .view-button { 1945 width: 55px; 1946 padding: 5px 12px; 1947 font-size: 14px; 1948 font-weight: 500; 1949 border-radius: 50px; 1950 transition: all 0.2s ease; 1951 justify-content: center;
1952 align-items: center; 1953 cursor: pointer; 1954 border: none; 1955 background: #f7f7f7; 1956 color: #3B3B3C; 1957 } 1958 1959 .view-button:hover { 1960 background: #D9D9D9; 1961 transform: translateY(-1px); 1962 box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15); 1963 } 1964 1965 .view-button.active { 1966 background: #3B3B3C; 1967 color: white; 1968 box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3); 1969 } 1970 1971 .view-button:disabled { 1972 opacity: 0.5; 1973 cursor: not-allowed; 1974 } 1975 1976 @media (max-width: 768px) { 1977 .view-button { 1978 padding: 6px 12px; 1979 font-size: 12px; 1980 } 1981 } 1982 1983 /* Hide toggle button on desktop */ 1984 @media (min-width: 769px) { 1985 .mobile-chat-toggle { 1986 display: none !important; 1987 } 1988 /* Hide chat button on desktop */ 1989 #chatViewButton { 1990 display: none !important; 1991 } 1992 /* Ensure filter modal appears above chat container on desktop */ 1993 #filtersModal { 1994 z-index: 2147483647 !important; 1995 } 1996 } 1997 1998 /* Ensure filter modal always appears above chat container (all screen sizes) */ 1999 #filtersModal { 2000 z-index: 2147483647 !important; 2001 position: fixed !important; 2002 } 2003 2004 .message { 2005 display: flex; 2006 flex-direction: column; 2007 animation: messageSlideIn 0.5s ease-in-out; 2008 } 2009 2010 .message.user { 2011 align-items: flex-end; 2012 } 2013 2014 .message.bot { 2015 align-items: flex-start; 2016 } 2017 2018 .message-content { 2019 max-width: 100%; 2020 padding-top: 12px; 2021 border-radius: 20px; 2022 font-size: 16px; 2023 line-height: 1.25; 2024 position: relative; 2025 backdrop-filter: blur(10px); 2026 transition: all 0.3s ease; 2027 word-wrap: break-word; 2028 overflow-wrap: break-word; 2029 margin-top: 16px; 2030 } 2031 2032 .message-content:hover { 2033 transform: translateY(-1px); 2034 } 2035 2036 .message.user .message-content { 2037 background: #f3f3f3; 2038 border: 1px solid #eee; 2039 color: black; 2040 border-bottom-right-radius: 0px; 2041 position: relative; 2042 padding: 12px 16px; 2043 } 2044 2045 .message.user .message-content::before { 2046 content: ""; 2047 position: absolute; 2048 top: 0; 2049 left: 0; 2050 right: 0; 2051 bottom: 0; 2052 background: linear-gradient( 2053 135deg, 2054 rgba(255, 255, 255, 0.1) 0%, 2055 rgba(255, 255, 255, 0.05) 100% 2056 ); 2057 border-radius: 20px; 2058 border-bottom-right-radius: 0px; 2059 pointer-events: none; 2060 } 2061 2062 .message.bot .message-content { 2063 background: rgba(255, 255, 255, 0.95); 2064 color: #1f2937; 2065 border-bottom-left-radius: 8px; 2066 } 2067 2068 .message-time { 2069 font-size: 11px; 2070 color: #6b7280; 2071 margin-top: 4px; 2072 font-weight: 500; 2073 opacity: 0.8; 2074 } 2075 2076 .chat-input { 2077 border-radius: 16px; 2078 position: sticky; 2079 z-index: 2; 2080 border: 1px solid #d9d9d9; 2081 box-shadow: 0 6px 9px 2px rgba(0, 0, 0, 0.09); 2082 2083 @media (max-width: 768px) { 2084 box-shadow: none; 2085 } 2086 } 2087 2088 2089 2090 .chat-input-form { 2091 display: flex; 2092 gap: 10px; 2093 align-items: center; 2094 } 2095 2096 .chat-input-field { 2097 flex: 1; 2098 border-radius: 16px; 2099 font-size: 16px; 2100 outline: none; 2101 background: #fff; 2102 } 2103 2104 .chat-input-field:disabled { 2105 color: #9ca3af; 2106 cursor: not-allowed; 2107 } 2108 2109 .send-button { 2110 cursor: pointer; 2111 display: flex; 2112 align-items: center; 2113 justify-content: center; 2114 } 2115 2116 .send-button:active { 2117 transform: translateY(0); 2118 } 2119 2120 .send-button:disabled { 2121 background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%); 2122 cursor: not-allowed; 2123 transform: none; 2124 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 2125 opacity: 0.6; 2126 } 2127 2128 .typing-indicator { 2129 display: none; 2130 padding: 12px 16px; 2131 background: rgba(255, 255, 255, 0.95); 2132 border: 1px solid rgba(229, 231, 235, 0.8); 2133 border-radius: 20px; 2134 border-bottom-left-radius: 8px; 2135 box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); 2136 backdrop-filter: blur(10px); 2137 width: fit-content; 2138 flex-shrink: 0; 2139 margin: 0 0 4px 4px; 2140 } 2141 2142 .typing-indicator.show { 2143 display: block; 2144 animation: typingSlideIn 0.3s ease-out; 2145 } 2146 2147 .typing-dots { 2148 display: flex; 2149 gap: 4px; 2150 align-items: center; 2151 } 2152 2153 .typing-dot { 2154 width: 6px; 2155 height: 6px; 2156 border-radius: 50%; 2157 background: linear-gradient(135deg, #3b3b3c 0%, #969696 100%); 2158 animation: typing 1.4s infinite ease-in-out; 2159 } 2160 2161 .typing-dot:nth-child(1) { 2162 animation-delay: -0.32s; 2163 } 2164 .typing-dot:nth-child(2) { 2165 animation-delay: -0.16s; 2166 } 2167 2168 @keyframes typing { 2169 0%, 2170 80%, 2171 100% { 2172 transform: scale(0.8); 2173 opacity: 0.5; 2174 } 2175 40% { 2176 transform: scale(1); 2177 opacity: 1; 2178 } 2179 } 2180 2181 @keyframes messageSlideIn { 2182 from { 2183 opacity: 0; 2184 transform: translateY(10px); 2185 } 2186 to { 2187 opacity: 1; 2188 transform: translateY(0); 2189 } 2190 } 2191 2192 @keyframes typingSlideIn { 2193 from { 2194 opacity: 0; 2195 transform: translateY(5px); 2196 } 2197 to { 2198 opacity: 1; 2199 transform: translateY(0); 2200 } 2201 } 2202 2203 /* Minimal styles to ensure filter buttons are clearly interactive */ 2204 .filter-option { cursor: pointer; user-select: none; } 2205 .filter-active { background-color: #3b3b3c !important; color: #fff !important; border-color: #3b3b3c !important; } 2206 2207 #citiesListContainer { 2208 grid-auto-rows: max-content; 2209 } 2210 2211 .avatar { 2212 box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px, rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px, rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px; 2213 } 2214 2215 .sample-questions { 2216 margin: 16px 0; 2217 padding: 0; 2218 background: transparent; 2219 border: none; 2220 animation: messageSlideIn 0.5s ease-in-out; 2221 } 2222 2223 .sample-questions-title { 2224 font-size: 15px; 2225 font-weight: 500; 2226 color: #9ca3af; 2227 margin-bottom: 8px; 2228 text-align: left; 2229 } 2230 2231 .sample-questions-list { 2232 display: flex; 2233 flex-direction: column; 2234 gap: 6px; 2235 } 2236 2237 .sample-question { 2238 padding: 8px 12px; 2239 background: transparent; 2240 border: 1px solid #e5e7eb; 2241 border-radius: 6px; 2242 font-size: 15px; 2243 color: #374151; 2244 cursor: pointer; 2245 transition: all 0.15s ease; 2246 text-align: left; 2247 } 2248 2249 .sample-question:hover { 2250 background: transparent; 2251 border-color: #9ca3af; 2252 color: #374151; 2253 } 2254 2255 .sample-question:active { 2256 border-color: #6b7280; 2257 color: #1f2937; 2258 } 2259 2260 /* Compact destination widget styling */ 2261 .destination-widget-compact { 2262 word-wrap: break-word; 2263 overflow-wrap: break-word; 2264 } 2265 2266 .destination-widget-compact .flex { 2267 flex-wrap: nowrap; 2268 } 2269 2270 .destination-widget-compact .min-w-0 { 2271 min-width: 0; 2272 } 2273 2274 /* Google Maps specific styles */ 2275 #map { 2276 width: 100% !important; 2277 height: 100% !important; 2278 min-height: 100% !important; 2279 background-color: #f0f0f0; 2280 position: absolute !important; 2281 top: 0 !important; 2282 left: 0 !important; 2283 } 2284 2285 /* Ensure the map container has proper dimensions */ 2286 .map-container { 2287 position: relative; 2288 width: 100%; 2289 height: 100%; 2290 overflow: hidden; 2291 } 2292 2293 /* On mobile, when in map view, make map container fixed and full-screen */ 2294 @media (max-width: 768px) { 2295 .map-container.map-view-active { 2296 position: fixed !important; 2297 top: 0 !important; 2298 left: 0 !important; 2299 width: 100vw !important; 2300 height: 100vh !important; 2301 z-index: 100 !important; 2302 } 2303 } 2304 2305 /* Ensure full height layout */ 2306 .main-container { 2307 height: 100vh; 2308 max-height: 100vh; 2309 overflow: hidden; 2310 } 2311 2312 /* Ensure chat widget takes full height */ 2313 .chat-widget { 2314 height: 100%; 2315 max-height: 100%; 2316 } 2317 2318 /* List view styles */ 2319 #list-container { 2320 height: 100%; 2321 max-height: 100%; 2322 overflow-y: auto; 2323 overflow-x: hidden; 2324 position: relative; 2325 -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ 2326 } 2327 2328 #citiesListContainer { 2329 max-width: 1200px; 2330 margin: 0 auto; 2331 min-height: min-content; 2332 padding-bottom: 2rem; /* Extra padding at bottom for better scrolling */ 2333 } 2334 2335 /* Make list view items scroll smoothly */ 2336 #list-container .destination-widget-compact, 2337 #list-container .city-card { 2338 transition: transform 0.2s ease, box-shadow 0.2s ease; 2339 } 2340 2341 #list-container .destination-widget-compact:hover, 2342 #list-container .city-card:hover { 2343 transform: translateY(-2px); 2344 box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); 2345 } 2346 2347 /* Destination Detail Panel Styles */ 2348 .destination-detail-panel { 2349 position: fixed; 2350 top: 0; 2351 left: 0; 2352 right: 0; 2353 bottom: 0; 2354 width: 100%; 2355 height: 100%; 2356 background: rgba(0, 0, 0, 0.5); 2357 z-index: 20000 !important; 2358 opacity: 0; 2359 pointer-events: none; 2360 transition: opacity 0.3s ease; 2361 } 2362 2363 .destination-detail-panel.open { 2364 opacity: 1; 2365 pointer-events: all; 2366 } 2367 2368 .destination-detail-content { 2369 position: absolute; 2370 top: 0; 2371 right: 0; 2372 width: 60%; 2373 height: 100%; 2374 background: white; 2375 box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); 2376 transform: translateX(100%); 2377 transition: transform 0.3s ease; 2378 z-index: 20001 !important; 2379 } 2380 2381 .destination-detail-panel.open .destination-detail-content { 2382 transform: translateX(0); 2383 } 2384 2385 .destination-detail-close { 2386 position: absolute; 2387 top: 16px; 2388 right: 16px; 2389 z-index: 10001; 2390 background: white; 2391 border: 1px solid #e5e7eb; 2392 border-radius: 50%; 2393 width: 40px; 2394 height: 40px; 2395 display: flex; 2396 align-items: center; 2397 justify-content: center; 2398 cursor: pointer; 2399 transition: all 0.2s ease; 2400 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 2401 } 2402 2403 .destination-detail-close:hover { 2404 background: #f3f4f6; 2405 transform: scale(1.1); 2406 } 2407 2408 /* Mobile responsive for destination detail panel */ 2409 @media (max-width: 1024px) { 2410 .destination-detail-content { 2411 width: 100%; 2412 } 2413 } 2414</style> 2415 2416<!-- HTMX Event Handlers -->
2417<script> 2418 document.body.addEventListener('htmx:beforeRequest', function(evt) { 2419 const target = evt.detail.target; 2420 const form = evt.detail.elt; 2421 if (form && form.id === 'chatForm') { 2422 document.getElementById('typingIndicator').classList.add('show'); 2423 changeChatGif("thinking"); 2424 2425 const input = document.getElementById('messageInput'); 2426 const sendButton = document.getElementById('sendButton'); 2427 input.disabled = true; 2428 sendButton.disabled = true; 2429 2430 const logoButton = document.getElementById('sidebar-logo-btn'); 2431 const mobileLogoButton = document.getElementById('mobile-sidebar-logo-btn'); 2432 if (logoButton) { 2433 logoButton.disabled = true; 2434 } 2435 if (mobileLogoButton) { 2436 mobileLogoButton.disabled = true; 2437 } 2438 } 2439 }); 2440 2441 document.body.addEventListener('htmx:afterRequest', function(evt) { 2442 const form = evt.detail.elt; 2443 if (form && form.id === 'chatForm') { 2444 document.getElementById('typingIndicator').classList.remove('show'); 2445 2446 const input = document.getElementById('messageInput'); 2447 const sendButton = document.getElementById('sendButton'); 2448 input.disabled = false; 2449 sendButton.disabled = false; 2450 input.value = ''; 2451 input.focus(); 2452 2453 const logoButton = document.getElementById('sidebar-logo-btn'); 2454 const mobileLogoButton = document.getElementById('mobile-sidebar-logo-btn'); 2455 if (logoButton) { 2456 logoButton.disabled = false; 2457 } 2458 if (mobileLogoButton) { 2459 mobileLogoButton.disabled = false; 2460 } 2461 2462 const chatMessages = document.getElementById('chatMessages'); 2463 chatMessages.scrollTop = chatMessages.scrollHeight; 2464 2465 setTimeout(() => { 2466 chatMessages.scrollTop = chatMessages.scrollHeight; 2467 }, 300); 2468 } 2469 }); 2470 2471 document.body.addEventListener('htmx:responseError', function(evt) { 2472 const form = evt.detail.elt; 2473 if (form && form.id === 'chatForm') { 2474 document.getElementById('typingIndicator').classList.remove('show'); 2475 2476 const input = document.getElementById('messageInput'); 2477 const sendButton = document.getElementById('sendButton'); 2478 input.disabled = false; 2479 sendButton.disabled = false; 2480 2481 const logoButton = document.getElementById('sidebar-logo-btn'); 2482 const mobileLogoButton = document.getElementById('mobile-sidebar-logo-btn'); 2483 if (logoButton) { 2484 logoButton.disabled = false; 2485 } 2486 if (mobileLogoButton) { 2487 mobileLogoButton.disabled = false; 2488 } 2489 2490 addMessage("Sorry, I encountered an error. Please try again.", false); 2491 changeChatGif("concerned"); 2492 setTimeout(() => { 2493 changeChatGif("idle"); 2494 }, 5000); 2495 } 2496 }); 2497 2498 2499 document.body.addEventListener('htmx:afterSwap', function(evt) { 2500 if (evt.detail.target.id === 'chatMessages') { 2501 const messages = document.querySelectorAll('#chatMessages .message'); 2502 const lastMessage = messages[messages.length - 1]; 2503 2504 if (lastMessage && lastMessage.classList.contains('bot')) { 2505 const chatStyle = lastMessage.getAttribute('data-chat-style'); 2506 if (chatStyle) { 2507 changeChatGif(chatStyle); 2508 setTimeout(() => { 2509 changeChatGif("idle"); 2510 }, 5000); 2511 } else { 2512 changeChatGif("talking"); 2513 setTimeout(() => { 2514 changeChatGif("idle"); 2515 }, 3000); 2516 } 2517 2518 // Show sample questions after bot response (unless filter wizard was used) 2519 if (!filterWorkflowActive && !filterWizardUsed) { 2520 var sqEl = document.getElementById('sampleQuestions'); 2521 if (sqEl) sqEl.style.display = ''; 2522 } 2523 } 2524 2525 try { 2526 const xhr = evt.detail.xhr; 2527 if (xhr && xhr.responseText) { 2528 const temp = document.createElement('div'); 2529 temp.innerHTML = xhr.responseText; 2530 const from = temp.querySelector('#destinationsTableContainer'); 2531 const to = document.getElementById('destinationsTableContainer'); 2532 if (from && to) { 2533 to.innerHTML = from.innerHTML; 2534 } 2535 const fromList = temp.querySelector('#citiesListContainer'); 2536 const toList = document.getElementById('citiesListContainer'); 2537 if (fromList && toList) { 2538 toList.innerHTML = fromList.innerHTML; 2539 } 2540 2541 try { 2542 console.log("[chat] Map update: parsing response for city pins..."); 2543 const pinScope = temp.querySelector('[data-city-pins]') || temp; 2544 const pinEls = pinScope ? pinScope.querySelectorAll('[data-city-pin]') : []; 2545 console.log(`[chat] Found ${pinEls.length} city pin(s) in the response.`); 2546
2547 if (typeof clearMapMarkers === 'function') { 2548 console.log("[chat] Clearing map markers..."); 2549 clearMapMarkers(); 2550 } else { 2551 console.warn("[chat] clearMapMarkers() not found."); 2552 } 2553 2554 pinEls.forEach(el => { 2555 const data = { 2556 lat: parseFloat(el.getAttribute('data-lat')), 2557 lng: parseFloat(el.getAttribute('data-lng')), 2558 title: el.getAttribute('data-title'), 2559 comment: el.getAttribute('data-comment'), 2560 cityName: el.getAttribute('data-city-name'), 2561 countryName: el.getAttribute('data-country-name'), 2562 slug: el.getAttribute('data-city-slug'), 2563 coverPhoto: el.getAttribute('data-cover-photo') 2564 }; 2565 if (!isNaN(data.lat) && !isNaN(data.lng)) { 2566 try { 2567 console.log(`[chat] Adding city pin for ${data.cityName} (${data.lat}, ${data.lng})`); 2568 addCityPin(data); 2569 } catch (err) { 2570 console.error(`[chat] Failed to add city pin for ${data.cityName}:`, err); 2571 } 2572 } else { 2573 console.warn(`[chat] Invalid coordinates for city pin:`, data); 2574 } 2575 }); 2576 2577 // When pins are returned, switch to map view on desktop only 2578 // On mobile, user switches to map manually via the Map button 2579 if (pinEls.length > 0 && typeof switchView === 'function' && window.innerWidth > 768) { 2580 switchView('map'); 2581 } 2582 2583 // Fit map to show all pins when cities are listed (both mobile and desktop) 2584 if (pinEls.length > 0) { 2585 const fitMapToAllPins = () => { 2586 if (typeof google !== 'undefined' && google.maps && map && mapInitialized && typeof fitMapToMarkers === 'function') { 2587 try { 2588 console.log(`[chat] Fitting map to show all ${markers.length} pin(s)`); 2589 fitMapToMarkers(); 2590 google.maps.event.trigger(map, 'resize'); 2591 return true; 2592 } catch (err) { 2593 console.error("[chat] Error fitting map to all pins:", err); 2594 return false; 2595 } 2596 } 2597 return false; 2598 }; 2599 2600 // Try immediately after a short delay to ensure all markers are added 2601 setTimeout(() => { 2602 if (!fitMapToAllPins()) { 2603 // If map not ready, retry with increasing delays (especially important for mobile) 2604 let retryCount = 0; 2605 const maxRetries = 15; 2606 const retryInterval = setInterval(() => { 2607 if (fitMapToAllPins() || retryCount >= maxRetries) { 2608 clearInterval(retryInterval); 2609 if (retryCount >= maxRetries) { 2610 console.warn("[chat] Failed to fit map to all pins after max retries"); 2611 } 2612 } 2613 retryCount++; 2614 }, 200); 2615 } 2616 }, 300); // Delay to ensure all markers are added 2617 } else if (window.currentView === 'map' && pinEls.length === 0) { 2618 setTimeout(() => { 2619 if (typeof google !== 'undefined' && map) { 2620 try { 2621 console.log("[chat] Triggering google.maps.event.resize"); 2622 google.maps.event.trigger(map, 'resize'); 2623 } catch (err) { 2624 console.error("[chat] Error on google.maps.event.resize:", err); 2625 } 2626 } 2627 }, 50); 2628 } 2629 2630 const hiddenWidgetContainer = temp.querySelector('.hidden-full-widgets'); 2631 if (hiddenWidgetContainer) { 2632 const fullWidgets = hiddenWidgetContainer.querySelectorAll('[data-city-widget]'); 2633 allCities = [];
2634 fullWidgets.forEach(widget => { 2635 const slug = widget.getAttribute('data-city-slug'); 2636 if (slug) allCities.push({ slug, html: widget.outerHTML }); 2637 }); 2638 } else { 2639 if (pinEls.length > 0) { 2640 allCities = Array.from(pinEls).map(pin => ({ 2641 slug: pin.getAttribute('data-city-slug') || '', 2642 html: pin.outerHTML 2643 })).filter(city => city.slug); 2644 } 2645 } 2646 2647 // Update mobile view buttons visibility after cities are added 2648 updateMobileViewButtonsVisibility(); 2649 } catch (err) { 2650 console.error("[chat] Error during map update from response HTML:", err); 2651 } 2652 } 2653 } catch (err) { 2654 console.error("[chat] Error while handling response swap:", err); 2655 } 2656 2657 const chatMessages = document.getElementById('chatMessages'); 2658 chatMessages.scrollTop = chatMessages.scrollHeight; 2659 setTimeout(() => { chatMessages.scrollTop = chatMessages.scrollHeight; }, 200); 2660 } 2661 }); 2662 2663 document.body.addEventListener('switchView', function(e) { 2664 try { 2665 const detail = e && e.detail ? e.detail : null; 2666 const view = (detail && (detail.view || detail)) || 'list'; 2667 switchView(view); 2668 } catch (_) {} 2669 }); 2670 2671 document.getElementById('chatForm').addEventListener('submit', function(e) { 2672 const input = document.getElementById('messageInput'); 2673 const message = input.value.trim(); 2674 2675 if (message) { 2676 addMessage(message, true); 2677 // Permanently remove sample questions once user starts chatting 2678 var sqEl = document.getElementById('sampleQuestions'); 2679 if (sqEl) sqEl.remove(); 2680 } 2681 }); 2682 2683 function addMessage(text, isUser) { 2684 const chatMessages = document.getElementById('chatMessages'); 2685 const messageDiv = document.createElement('div'); 2686 messageDiv.className = `message ${isUser ? 'user' : 'bot'}`; 2687 2688 const now = new Date(); 2689 const timeString = now.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); 2690 2691 messageDiv.innerHTML = ` 2692 <div class="message-content">${text}</div> 2693 <div class="message-time">${timeString}</div> 2694 `; 2695 2696 chatMessages.appendChild(messageDiv); 2697 chatMessages.scrollTop = chatMessages.scrollHeight; 2698 } 2699 2700 function changeChatGif(style) { 2701 const desktopGif = document.getElementById('chatGif'); 2702 const mobileGif = document.getElementById('mobileChatGif'); 2703 const desktopGifPath = `/static/chat/${style}.gif`; 2704 const mobileGifPath = `/static/chat/mobile_${style}.gif`; 2705 desktopGif.src = desktopGifPath; 2706 mobileGif.src = mobileGifPath; 2707 } 2708 2709 window.submitQuestion = function(questionText) { 2710 const input = document.getElementById('messageInput'); 2711 const form = document.getElementById('chatForm'); 2712 2713 var sqEl = document.getElementById('sampleQuestions'); 2714 if (sqEl) sqEl.remove(); 2715 2716 input.value = questionText; 2717 htmx.trigger(form, 'submit'); 2718 }; 2719 2720 function storeInitialChatMessage() { 2721 const chatMessages = document.getElementById('chatMessages'); 2722 if (chatMessages) { 2723 const initialMessage = chatMessages.querySelector('.message.bot'); 2724 if (initialMessage && !chatMessages.getAttribute('data-initial-message')) { 2725 chatMessages.setAttribute('data-initial-message', initialMessage.outerHTML); 2726 } 2727 } 2728 } 2729 2730 window.refreshChat = function() { 2731 const chatMessages = document.getElementById('chatMessages'); 2732 const chatGif = document.getElementById('chatGif'); 2733 const mobileChatGif = document.getElementById('mobileChatGif'); 2734 2735 if (!chatMessages) return; 2736 2737 storeInitialChatMessage(); 2738 2739 const initialMessageHTML = chatMessages.getAttribute('data-initial-message'); 2740 if (initialMessageHTML) { 2741 chatMessages.innerHTML = initialMessageHTML; 2742 } 2743 2744 if (chatGif | mobileChatGif) { 2745 chatGif.src = `/static/chat/idle.gif`; 2746 mobileChatGif.src = `/static/chat/mobile_idle.gif`; 2747 } 2748 2749 const sampleQuestions = document.getElementById('sampleQuestions'); 2750 if (sampleQuestions) { 2751 sampleQuestions.style.display = 'none'; 2752 } 2753 2754 // Reset wizard flags so sample questions can show again 2755 window.filterWorkflowActive = false;
2756 window.filterWizardUsed = false; 2757 2758 if (typeof resetAllFilters === 'function') { 2759 resetAllFilters(); 2760 } 2761 2762 const messageInput = document.getElementById('messageInput'); 2763 if (messageInput) { 2764 messageInput.value = ''; 2765 } 2766 2767 // Clear cities list container 2768 const citiesListContainer = document.getElementById('citiesListContainer'); 2769 if (citiesListContainer) { 2770 citiesListContainer.innerHTML = ` 2771 <div class="text-center text-gray-500 py-12"> 2772 <p class="text-lg font-semibold mb-2">No destinations yet</p> 2773 <p class="text-sm">Start chatting to see recommended destinations</p> 2774 </div> 2775 `; 2776 } 2777 2778 // Clear destinations table container 2779 const destinationsTableContainer = document.getElementById('destinationsTableContainer'); 2780 if (destinationsTableContainer) { 2781 destinationsTableContainer.innerHTML = ` 2782 <div class="text-center text-gray-500 py-12"> 2783 <p class="text-lg font-semibold mb-2">No destinations yet</p> 2784 <p class="text-sm">Start chatting to see recommended destinations</p> 2785 </div> 2786 `; 2787 } 2788 2789 // Clear allCities array 2790 if (typeof allCities !== 'undefined') { 2791 allCities = []; 2792 } 2793 2794 // Clear map markers
2795 if (typeof clearMapMarkers === 'function') { 2796 clearMapMarkers(); 2797 } 2798 2799 // Clear city pins data 2800 const cityPins = document.getElementById('cityPins'); 2801 if (cityPins) { 2802 cityPins.innerHTML = ''; 2803 } 2804 2805 // Update mobile view buttons visibility after refresh (will hide since cities are cleared) 2806 updateMobileViewButtonsVisibility(); 2807 2808 chatMessages.scrollTop = 0; 2809 }; 2810 2811 if (document.readyState === 'loading') { 2812 document.addEventListener('DOMContentLoaded', storeInitialChatMessage); 2813 } else { 2814 storeInitialChatMessage(); 2815 } 2816 2817 // Google Maps initialization 2818 let map; 2819 let markers = []; 2820 let mapInitialized = false; 2821 2822 function initMap() { 2823 try { 2824 // Check if Google Maps API is loaded 2825 if (typeof google === 'undefined' || !google.maps || !google.maps.Map) { 2826 // Retry after a short delay 2827 setTimeout(initMap, 100); 2828 return; 2829 } 2830 2831 // Check if the map element exists 2832 const mapElement = document.getElementById("map"); 2833 if (!mapElement) { 2834 return; 2835 } 2836 2837 // Default center focused on Europe and Asia 2838 const defaultCenter = { lat: 50.0, lng: 50.0 }; // Center between Europe and Asia 2839 const isMobile = window.innerWidth <= 768; 2840 2841 map = new google.maps.Map(mapElement, { 2842 zoom: isMobile ? 2 : 3, // Higher initial zoom for mobile devices 2843 minZoom: isMobile ? 2 : 3, // Prevent zooming out too far to avoid gray areas 2844 center: defaultCenter, 2845 mapTypeId: google.maps.MapTypeId.ROADMAP, 2846 restriction: { 2847 latLngBounds: { 2848 north: 85, 2849 south: -85, 2850 east: 180, 2851 west: -180 2852 }, 2853 strictBounds: false 2854 }, 2855 styles: [ 2856 { 2857 featureType: "poi", 2858 elementType: "labels", 2859 stylers: [{ visibility: "off" }] 2860 } 2861 ], 2862 fullscreenControl: false, 2863 streetViewControl: false, 2864 mapTypeControl: false, 2865 zoomControl: true, 2866 zoomControlOptions: { 2867 position: google.maps.ControlPosition.RIGHT_CENTER 2868 }, 2869 cameraControl: false, 2870 gestureHandling: 'greedy' 2871 }); 2872 2873 mapInitialized = true; 2874 2875 // Trigger multiple resize events to ensure the map fills the container 2876 setTimeout(() => { 2877 if (map) { 2878 google.maps.event.trigger(map, 'resize'); 2879 } 2880 }, 100); 2881 2882 setTimeout(() => { 2883 if (map) { 2884 google.maps.event.trigger(map, 'resize'); 2885 } 2886 }, 500); 2887 2888 setTimeout(() => { 2889 if (map) { 2890 google.maps.event.trigger(map, 'resize'); 2891 } 2892 }, 1000); 2893 2894 } catch (error) { 2895 // Retry after a short delay 2896 setTimeout(initMap, 500); 2897 } 2898 } 2899 2900 // Function to add a city pin to the map 2901 window.addCityPin = function(cityData) { 2902 if (!map || !mapInitialized) { 2903 // Retry after a short delay 2904 setTimeout(() => addCityPin(cityData), 500); 2905 return; 2906 } 2907 2908 2909 try { 2910 const position = { lat: cityData.lat, lng: cityData.lng }; 2911 2912 // Create custom marker icon 2913 const markerIcon = { 2914 url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(` 2915 <svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> 2916 <circle cx="16" cy="16" r="12" fill="#3B82F6" stroke="#1E40AF" stroke-width="2"/> 2917 <circle cx="16" cy="16" r="6" fill="white"/> 2918 <text x="16" y="20" text-anchor="middle" font-family="Arial" font-size="10" fill="#1E40AF">ð</text> 2919 </svg> 2920 `), 2921 scaledSize: new google.maps.Size(32, 32), 2922 anchor: new google.maps.Point(16, 16) 2923 }; 2924 2925 // Create marker 2926 const marker = new google.maps.Marker({ 2927 position: position, 2928 map: map, 2929 title: cityData.title, 2930 icon: markerIcon, 2931 animation: google.maps.Animation.DROP 2932 }); 2933 2934 2935 // Create info window content 2936 const infoWindowContent = ` 2937 <div style="max-width: 250px;"> 2938 <img src="${cityData.coverPhoto}" alt="${cityData.cityName}" class="w-full h-48 object-cover ps-2"> 2939 <div class="ps-4 p-2"> 2940 <h3 style="margin: 0 0 8px 0; color: #1E40AF; font-size: 16px;">${cityData.cityName}</h3> 2941 <p style="margin: 0 0 8px 0; color: #6B7280; font-size: 14px;">${cityData.countryName}</p> 2942 <p style="margin: 0 8px 8px 0; color: #374151; font-size: 13px; line-height: 1.4;">${cityData.comment}</p> 2943 ${cityData.slug ? `<a href="#" id="infowin-open-${cityData.slug}" style="color:#2563EB; font-size:13px; text-decoration:none;">View details â¶</a>` : ''} 2944 </div> 2945 </div> 2946 `; 2947 2948 const infoWindow = new google.maps.InfoWindow({ 2949 content: infoWindowContent 2950 }); 2951 2952 // Add click listener to marker 2953 marker.addListener('click', function() { 2954 // Close other info windows
2955 markers.forEach(m => { 2956 if (m.infoWindow) { 2957 m.infoWindow.close(); 2958 } 2959 }); 2960 2961 infoWindow.open(map, marker); 2962 }); 2963 2964 // Bind a click handler to the InfoWindow content once it's rendered 2965 google.maps.event.addListener(infoWindow, 'domready', function() { 2966 try { 2967 if (cityData.slug) { 2968 const anchor = document.getElementById(`infowin-open-${cityData.slug}`); 2969 if (anchor) { 2970 anchor.addEventListener('click', function(e) { 2971 e.preventDefault(); 2972 try { openDestinationDetail(cityData.slug); } catch (_) {} 2973 }); 2974 } 2975 } 2976 } catch (_) {} 2977 }); 2978 2979 marker.infoWindow = infoWindow; 2980 markers.push(marker); 2981 2982 } catch (error) { 2983 } 2984 }; 2985 2986 // Function to fit map to show all markers 2987 function fitMapToMarkers() { 2988 if (markers.length === 0) return; 2989 2990 const bounds = new google.maps.LatLngBounds(); 2991 markers.forEach(marker => { 2992 bounds.extend(marker.getPosition()); 2993 }); 2994 2995 // Cap zoom so fitBounds never over-zooms on a single / clustered markers 2996 map.setOptions({ maxZoom: 6 }); 2997 map.fitBounds(bounds); 2998 google.maps.event.addListenerOnce(map, 'idle', function() { 2999 map.setOptions({ maxZoom: undefined }); 3000 }); 3001 }; 3002 3003 // Function to clear all markers 3004 window.clearMapMarkers = function() { 3005 markers.forEach(marker => { 3006 marker.setMap(null); 3007 }); 3008 markers = []; 3009 }; 3010 3011 // Function to process city pins in a message 3012 function processCityPinsInMessage(messageElement) { 3013 if (!messageElement) { 3014 return; 3015 } 3016 3017 // Look for divs with city pin data 3018 let cityPinContainers = []; 3019 try { 3020 // Preferred: pins provided inside the latest message 3021 cityPinContainers = Array.from(messageElement.querySelectorAll('[data-city-pins]')); 3022 // Fallback: a global container with id="cityPins" if present 3023 const globalPins = document.getElementById('cityPins'); 3024 if (globalPins) { 3025 cityPinContainers.push(globalPins); 3026 } 3027 } catch (_) { 3028 cityPinContainers = []; 3029 } 3030 3031 cityPinContainers.forEach(container => { 3032 const cityPins = container.querySelectorAll('[data-city-pin]'); 3033 3034 cityPins.forEach(pinElement => { 3035 try { 3036 const cityData = { 3037 lat: parseFloat(pinElement.getAttribute('data-lat')), 3038 lng: parseFloat(pinElement.getAttribute('data-lng')), 3039 title: pinElement.getAttribute('data-title'), 3040 comment: pinElement.getAttribute('data-comment'), 3041 cityName: pinElement.getAttribute('data-city-name'), 3042 countryName: pinElement.getAttribute('data-country-name'), 3043 slug: pinElement.getAttribute('data-city-slug'), 3044 coverPhoto: pinElement.getAttribute('data-cover-photo') 3045 }; 3046 3047 if (cityData.lat && cityData.lng && !isNaN(cityData.lat) && !isNaN(cityData.lng)) { 3048 addCityPin(cityData); 3049 } 3050 } catch (error) { 3051 // Silent error handling 3052 } 3053 }); 3054 }); 3055 } 3056 3057 // Handle Google Maps API loading errors 3058 window.gm_authFailure = function() { 3059 // Silent error handling 3060 }; 3061 3062 // Fallback initialization in case the callback doesn't work 3063 document.addEventListener('DOMContentLoaded', function() { 3064 if (typeof google !== 'undefined' && google.maps && !mapInitialized) { 3065 initMap(); 3066 } 3067 }); 3068 3069 // Additional fallback - check periodically if map is not initialized 3070 let initRetryCount = 0; 3071 const maxRetries = 20; // 10 seconds max 3072 3073 function checkMapInitialization() { 3074 if (mapInitialized) { 3075 return; 3076 } 3077 3078 if (initRetryCount >= maxRetries) { 3079 return; 3080 } 3081 3082 initRetryCount++; 3083 3084 if (typeof google !== 'undefined' && google.maps && !mapInitialized) { 3085 initMap(); 3086 } else { 3087 setTimeout(checkMapInitialization, 500); 3088 } 3089 } 3090 3091 // Start checking after a short delay 3092 setTimeout(checkMapInitialization, 1000); 3093 3094 // Function to scroll chat to bottom 3095 function scrollChatToBottom() { 3096 const chatMessages = document.getElementById('chatMessages'); 3097 if (chatMessages) { 3098 // Use setTimeout to ensure DOM is updated 3099 setTimeout(() => { 3100 chatMessages.scrollTop = chatMessages.scrollHeight; 3101 }, 100); 3102 setTimeout(() => { 3103 chatMessages.scrollTop = chatMessages.scrollHeight; 3104 }, 300); 3105 } 3106 } 3107 3108 // Function to update mobile view buttons visibility based on screen size, cities, and current view 3109 function updateMobileViewButtonsVisibility() { 3110 const mobileViewButtons = document.getElementById('mobileViewButtonsContainer'); 3111 if (!mobileViewButtons) return; 3112 3113 const isMobile = window.innerWidth <= 768; 3114 const hasCities = typeof allCities !== 'undefined' && allCities.length > 0; 3115 const isMapView = window.currentView === 'map'; 3116 3117 // ALWAYS hide on desktop (screen width > 768px) 3118 if (!isMobile) { 3119 mobileViewButtons.style.display = 'none'; 3120 mobileViewButtons.classList.add('hidden'); 3121 return; 3122 } 3123 3124 // On mobile: show Map button when: 3125 // - Cities exist 3126 // - NOT already in map view (so user can switch to it) 3127 // The button is to switch TO map view, so show it whenever we're not in map view and have cities 3128 if (isMobile && hasCities && !isMapView) { 3129 mobileViewButtons.style.display = 'flex'; 3130 mobileViewButtons.classList.remove('hidden'); 3131 } else { 3132 mobileViewButtons.style.display = 'none'; 3133 mobileViewButtons.classList.add('hidden'); 3134 } 3135 } 3136 3137 // Handle window resize to ensure map fills container and scroll chat on mobile 3138 let lastMobileState = window.innerWidth <= 768; 3139 let generalResizeTimeout; 3140 window.addEventListener('resize', function() { 3141 // Check if we switched to mobile view 3142 const isMobileNow = window.innerWidth <= 768; 3143 if (isMobileNow && !lastMobileState) { 3144 // Just switched to mobile - scroll chat to bottom 3145 scrollChatToBottom(); 3146 } 3147 lastMobileState = isMobileNow; 3148 3149 // Debounce and update mobile view buttons visibility on resize 3150 clearTimeout(generalResizeTimeout); 3151 generalResizeTimeout = setTimeout(() => { 3152 updateMobileViewButtonsVisibility(); 3153 }, 100); 3154 3155 // Handle map resize 3156 if (map && mapInitialized) { 3157 setTimeout(() => { 3158 google.maps.event.trigger(map, 'resize'); 3159 }, 100); 3160 } 3161 }); 3162 3163 // Scroll chat to bottom on initial load if in mobile view 3164 if (window.innerWidth <= 768) { 3165 setTimeout(scrollChatToBottom, 500); 3166 } 3167 3168 // Function to restore the map view 3169 window.showMap = function() { 3170 const mapContainer = document.getElementById('map-container'); 3171 if (!mapContainer) return; 3172 3173 mapContainer.innerHTML = ` 3174 <div class="h-screen md:h-full map-container"> 3175 <div id="map"></div> 3176 </div> 3177 `; 3178 3179 // Reinitialize the map 3180 setTimeout(() => { 3181 if (typeof google !== 'undefined' && google.maps) { 3182 initMap(); 3183 } 3184 }, 100); 3185 }; 3186 3187 // Function to update map location (can be called from chat responses) 3188 window.updateMapLocation = function(lat, lng, title = "New Location") { 3189 if (!map || !mapInitialized) { 3190 return; 3191 } 3192 3193 const newCenter = { lat: parseFloat(lat), lng: parseFloat(lng) }; 3194 map.setCenter(newCenter); 3195 if (window.innerWidth <= 768) { 3196 map.setZoom(2.5); 3197 } else { 3198 map.setZoom(3.5); 3199 } 3200 3201 // Clear existing markers
3202 clearMapMarkers(); 3203 3204 // Add new marker 3205 new google.maps.Marker({ 3206 position: newCenter, 3207 map: map, 3208 title: title 3209 }); 3210 }; 3211 3212 // ============================================ 3213 // View Button Management - Refactored 3214 // ============================================ 3215 3216 // Current active view state 3217 window.currentView = 'map'; 3218 3219 // Get all view buttons 3220 function getViewButtons() { 3221 return { 3222 chat: document.getElementById('chatViewButton'), 3223 map: document.getElementById('mapViewButton'), 3224 list: document.getElementById('listViewButton'), 3225 table: document.getElementById('tableViewButton') 3226 }; 3227 } 3228 3229 function getMobileViewButtons() { 3230 return { 3231 chat: document.getElementById('mobileChatViewButton'), 3232 map: document.getElementById('mobileMapViewButton'), 3233 }; 3234 } 3235 3236 // Get view containers 3237 function getViewContainers() { 3238 return { 3239 map: document.getElementById('map-container'), 3240 list: document.getElementById('list-container'), 3241 table: document.getElementById('table-container') 3242 }; 3243 } 3244 3245 // Set active button - ensures only one button is active at a time 3246 function setActiveButton(view) { 3247 const buttons = getViewButtons(); 3248 3249 // Remove active class from all buttons 3250 Object.values(buttons).forEach(button => { 3251 if (button) { 3252 button.classList.remove('active'); 3253 button.disabled = false; 3254 } 3255 }); 3256 3257 // Add active class to the selected button (if view is not null) 3258 if (view && buttons[view]) { 3259 buttons[view].classList.add('active'); 3260 } 3261 } 3262 3263 // Show the selected view container 3264 function showView(view) { 3265 const containers = getViewContainers(); 3266 3267 // Hide all containers 3268 Object.values(containers).forEach(container => { 3269 if (container) { 3270 container.style.display = 'none'; 3271 } 3272 }); 3273 3274 // Show the selected container 3275 if (containers[view]) { 3276 containers[view].style.display = 'block'; 3277 } 3278 3279 // Special handling for map view 3280 if (view === 'map') { 3281 const mapContainer = document.getElementById('map-container'); 3282 const isMobile = window.innerWidth <= 768; 3283 3284 // On mobile, ensure map container is properly positioned 3285 if (isMobile && mapContainer) { 3286 mapContainer.classList.add('map-view-active'); 3287 } 3288 3289 setTimeout(() => { 3290 if (!mapInitialized) { 3291 if (typeof showMap === 'function') { showMap(); } 3292 else if (typeof initMap === 'function') { initMap(); } 3293 } 3294 if (typeof google !== 'undefined' && map) { 3295 try { 3296 google.maps.event.trigger(map, 'resize'); 3297 } catch (_) {} 3298 } 3299 }, 100); 3300 } else { 3301 const mapContainer = document.getElementById('map-container'); 3302 if (mapContainer) { 3303 mapContainer.classList.remove('map-view-active'); 3304 } 3305 } 3306 } 3307 3308 // Main function to switch views 3309 window.switchView = function(view) { 3310 // Validate view 3311 const validViews = ['chat', 'map', 'list', 'table']; 3312 if (!validViews.includes(view)) { 3313 console.warn('Invalid view:', view); 3314 return; 3315 } 3316 3317 const isMobile = window.innerWidth <= 768; 3318 const mobileHeader = document.querySelector('header.md\\:hidden'); 3319 const mobileFooter = document.getElementById('nomados-mobile-footer'); 3320 const mobileViewButtons = document.getElementById('mobileViewButtonsContainer'); 3321 const mobileBackToChatButton = document.getElementById('mobileBackToChatButton'); 3322 3323 // On mobile, if switching to a non-chat view and chat is expanded, collapse it 3324 if (view !== 'chat' && isMobile) { 3325 const chatContainer = document.getElementById('chat-container'); 3326 if (chatContainer && mobileChatState === 'expanded') { 3327 const contentLayer = document.querySelector('.mobile-content-layer'); 3328 const contentToggleBtn = document.getElementById('content-chat-toggle-btn'); 3329 3330 // Collapse chat 3331 chatContainer.classList.remove('expanded'); 3332 chatContainer.classList.add('collapsed'); 3333 mobileChatState = 'collapsed'; 3334 3335 // Show toggle button in content layer 3336 if (contentLayer) { 3337 contentLayer.classList.add('has-collapsed-chat'); 3338 } 3339 if (contentToggleBtn) { 3340 contentToggleBtn.style.display = 'flex'; 3341 } 3342 } 3343 } 3344 3345 // On mobile, handle header and footer visibility based on view 3346 if (isMobile) { 3347 const mapContainer = document.getElementById('map-container'); 3348 3349 if (view === 'map') { 3350 // Hide header and footer in map view 3351 if (mobileHeader) { 3352 mobileHeader.style.display = 'none'; 3353 } 3354 if (mobileFooter) { 3355 mobileFooter.style.display = 'none'; 3356 } 3357 // Make map container fixed and full-screen on mobile 3358 if (mapContainer) { 3359 mapContainer.classList.add('map-view-active'); 3360 } 3361 // Hide mobile view buttons, show back to chat button 3362 if (mobileViewButtons) { 3363 mobileViewButtons.style.display = 'none'; 3364 mobileViewButtons.classList.add('hidden'); 3365 } 3366 if (mobileBackToChatButton) { 3367 mobileBackToChatButton.style.display = 'flex'; 3368 } 3369 } else { 3370 // Show header and footer in other views 3371 if (mobileHeader) { 3372 mobileHeader.style.display = 'block'; 3373 } 3374 if (mobileFooter) { 3375 mobileFooter.style.display = 'block'; 3376 } 3377 // Remove fixed positioning from map container 3378 if (mapContainer) { 3379 mapContainer.classList.remove('map-view-active'); 3380 } 3381 // Update mobile view buttons visibility (will check screen size, cities, and view) 3382 updateMobileViewButtonsVisibility(); 3383 if (mobileBackToChatButton) { 3384 mobileBackToChatButton.style.display = 'none'; 3385 } 3386 } 3387 } 3388 3389 // Update current view state 3390 window.currentView = view; 3391 3392 // Update previous view before chat when switching to non-chat views 3393 // This ensures we can restore the correct view when collapsing chat 3394 if (view !== 'chat' && typeof previousViewBeforeChat !== 'undefined') { 3395 previousViewBeforeChat = view; 3396 } 3397 3398 // Update button states 3399 setActiveButton(view); 3400 3401 // Show the corresponding view (skip for chat as it's handled separately) 3402 if (view !== 'chat') { 3403 showView(view); 3404 } 3405 }; 3406 3407 // Initialize view buttons with event listeners 3408 function initializeViewButtons() { 3409 const buttons = getViewButtons(); 3410 3411 // Add click handlers to all buttons
3412 Object.entries(buttons).forEach(([view, button]) => { 3413 if (button) { 3414 button.addEventListener('click', function(e) { 3415 e.preventDefault(); 3416 3417 if (view === 'chat') { 3418 // Save the current view before switching to chat (if not already chat) 3419 // This is needed so we can restore it when collapsing chat 3420 if (window.currentView && window.currentView !== 'chat') { 3421 previousViewBeforeChat = window.currentView; 3422 } 3423 3424 // First, switch view to deactivate other buttons 3425 switchView('chat'); 3426 // Then toggle mobile chat (expand/collapse) 3427 if (typeof toggleMobileChat === 'function') { 3428 toggleMobileChat(); 3429 } 3430 } else { 3431 // Switch to the selected view (this will deactivate chat button) 3432 switchView(view); 3433 } 3434 }); 3435 3436 // Ensure button is enabled 3437 button.disabled = false; 3438 } 3439 }); 3440 3441 // Set initial active state (map is default) 3442 setActiveButton('map'); 3443 showView('map'); 3444 } 3445 // Fallback client-side right panel rendering 3446 let allCities = []; 3447 3448 function cleanWidgetHTML(html) { 3449 const temp = document.createElement('div'); 3450 temp.innerHTML = html; 3451 temp.querySelectorAll('script').forEach(s => s.remove()); 3452 return temp.innerHTML; 3453 } 3454 3455 function extractCitiesFromMessage(messageElement) { 3456 if (!messageElement) return 0; 3457 const hiddenWidgetContainer = messageElement.querySelector('.hidden-full-widgets'); 3458 allCities = []; 3459 if (hiddenWidgetContainer) { 3460 const fullWidgets = hiddenWidgetContainer.querySelectorAll('[data-city-widget]'); 3461 fullWidgets.forEach(widget => { 3462 const slug = widget.getAttribute('data-city-slug'); 3463 if (slug) allCities.push({ slug, html: widget.outerHTML }); 3464 }); 3465 return fullWidgets.length; 3466 } 3467 return 0; 3468 } 3469 3470 function updateListView() { 3471 const listContainer = document.getElementById('citiesListContainer'); 3472 if (!listContainer) return; 3473 if (!allCities.length) { 3474 listContainer.innerHTML = ` 3475 <div class="text-center text-gray-500 py-12"> 3476 <p class="text-lg font-semibold mb-2">No destinations yet</p> 3477 <p class="text-sm">Start chatting to see recommended destinations</p> 3478 </div> 3479 `; 3480 return; 3481 } 3482 listContainer.innerHTML = allCities.map(c => cleanWidgetHTML(c.html)).join(''); 3483 } 3484 3485 // Initialize with map view 3486 window.currentView = 'map'; 3487 3488 // Mobile chat toggle state (for chat.html page only) 3489 let mobileChatState = 'expanded'; // 'expanded' or 'collapsed' 3490 3491 // Track the previous view before switching to chat (used when collapsing chat) 3492 let previousViewBeforeChat = 'map'; // Default to 'map' 3493 3494 // Function to go back to chat from map view (closes any open panels f
3494irst) 3495 window.goBackToChatFromMap = function () { 3496 const isMobile = window.innerWidth <= 768; 3497 if (!isMobile) return; // Only works on mobile 3498 3499 function performBackToChat() { 3500 // Ensure panel is fully closed 3501 const panelElement = document.getElementById("nomados-panel"); 3502 if (panelElement) { 3503 panelElement.classList.remove("open"); 3504 if (typeof NomadosPanel !== "undefined") { 3505 NomadosPanel.isOpen = false; 3506 } 3507 } 3508 3509 // Hide map, list, and table containers 3510 const mapContainer = document.getElementById("map-container"); 3511 const listContainer = document.getElementById("list-container"); 3512 const tableContainer = document.getElementById("table-container"); 3513 if (mapContainer) { 3514 mapContainer.style.display = "none"; 3515 mapContainer.classList.remove("map-view-active"); 3516 } 3517 if (listContainer) listContainer.style.display = "none"; 3518 if (tableContainer) tableContainer.style.display = "none"; 3519 3520 // Expand chat container to ensure it's visible 3521 const chatContainer = document.getElementById("chat-container"); 3522 if (chatContainer) { 3523 chatContainer.classList.remove("collapsed"); 3524 chatContainer.classList.add("expanded"); 3525 mobileChatState = "expanded"; 3526 // Force visibility and ensure it's on top 3527 chatContainer.style.display = "block"; 3528 chatContainer.style.visibility = "visible"; 3529 chatContainer.style.opacity = "1"; 3530 chatContainer.style.zIndex = "150"; 3531 chatContainer.style.transform = "translateY(0)"; 3532 chatContainer.style.pointerEvents = "auto"; 3533 } 3534 3535 // Switch view to chat (this handles header/footer/buttons) 3536 switchView("chat"); 3537 3538 // Hide content layer toggle button 3539 const contentLayer = document.querySelector(".mobile-content-layer"); 3540 const contentToggleBtn = document.getElementById("content-chat-toggle-btn"); 3541 if (contentLayer) { 3542 contentLayer.classList.remove("has-collapsed-chat"); 3543 } 3544 if (contentToggleBtn) { 3545 contentToggleBtn.style.display = "none"; 3546 } 3547 3548 // Update mobile view buttons visibility when going back to chat 3549 setTimeout(() => { 3550 updateMobileViewButtonsVisibility(); 3551 }, 100); 3552 } 3553 3554 // Close panel if open 3555 if ( 3556 typeof NomadosPanel !== "undefined" && 3557 NomadosPanel.isOpen && 3558 typeof closeNomadosPanel === "function" 3559 ) { 3560 closeNomadosPanel(); 3561 // Wait for panel to fully close (animation + cleanup) 3562 setTimeout(() => { 3563 performBackToChat(); 3564 }, 400); 3565 } else { 3566 // No panel open, switch to chat immediately 3567 performBackToChat(); 3568 } 3569 }; 3570 3571 // Toggle mobile chat - compatible with both base.html MobileModalState and chat.html local implementation 3572 // This function detects which system is available and uses the appropriate implementation 3573 window.toggleMobileChat = function() { 3574 // Check if we're on the chat.html page (has chat-container) 3575 const chatContainer = document.getElementById('chat-container'); 3576 const mobileModalContainer = document.getElementById('mobile-modal-container'); 3577 3578 // If chat-container exists, use chat.html's local implementation 3579 if (chatContainer) { 3580 const contentLayer = document.querySelector('.mobile-content-layer'); 3581 const contentToggleBtn = document.getElementById('content-chat-toggle-btn'); 3582 const chatViewButton = document.getElementById('chatViewButton'); 3583 3584 if (mobileChatState === 'expanded') { 3585 // Collapse chat (hide completely) 3586 chatContainer.classList.remove('expanded'); 3587 chatContainer.classList.add('collapsed'); 3588 mobileChatState = 'collapsed'; 3589 chatContainer.style.transform = 'translateY(100%)'; 3590 chatContainer.style.pointerEvents = 'none'; 3591 3592 // Update button states - use previous view before chat, not currentView (which is 'chat') 3593 const viewToShow = previousViewBeforeChat || window.currentView || 'map'; 3594 window.currentView = viewToShow;
3595 if (typeof setActiveButton === 'function') { 3596 setActiveButton(viewToShow); 3597 } 3598 if (typeof showView === 'function') { 3599 showView(viewToShow); 3600 } 3601 3602 // Show toggle button in content layer 3603 if (contentLayer) { 3604 contentLayer.classList.add('has-collapsed-chat'); 3605 } 3606 if (contentToggleBtn) { 3607 contentToggleBtn.style.display = 'flex'; 3608 } 3609 } else { 3610 // Expand chat (full screen) 3611 chatContainer.classList.remove('collapsed'); 3612 chatContainer.classList.add('expanded'); 3613 mobileChatState = 'expanded'; 3614 chatContainer.style.transform = 'translateY(0)'; 3615 chatContainer.style.pointerEvents = 'auto'; 3616 chatContainer.style.visibility = 'visible'; 3617 chatContainer.style.opacity = '1'; 3618 chatContainer.style.zIndex = '150'; 3619 3620 // Set chat button as active when chat is expanded (this will deactivate other buttons) 3621 if (typeof setActiveButton === 'function') {
3622 setActiveButton('chat'); 3623 } 3624 3625 // Hide toggle button in content layer 3626 if (contentLayer) { 3627 contentLayer.classList.remove('has-collapsed-chat'); 3628 } 3629 if (contentToggleBtn) { 3630 contentToggleBtn.style.display = 'none'; 3631 } 3632 3633 // Update mobile view buttons visibility when chat is expanded 3634 setTimeout(() => { 3635 updateMobileViewButtonsVisibility(); 3636 }, 100); 3637 } 3638 return; // Exit early, handled by chat.html implementation 3639 } 3640 3641 // Otherwise, check if MobileModalState exists (from base.html) 3642 if (window.MobileModalState && mobileModalContainer) { 3643 // Use base.html's MobileModalState implementation 3644 console.log("toggleMobileChat: Using MobileModalState"); 3645 window.MobileModalState.switchContent('chat'); 3646 window.MobileModalState.cycle(); 3647 return; // Exit early, handled by base.html implementation 3648 } 3649 3650 // Fallback: if neither system is available, log a warning 3651 console.warn('toggleMobileChat: No compatible mobile chat system found'); 3652 }; 3653 3654 // Initialize view buttons and mobile chat state on load 3655 document.addEventListener('DOMContentLoaded', function() { 3656 // Only initialize if we're on the chat.html page (has chat-container) 3657 const chatContainer = document.getElementById('chat-container'); 3658 if (!chatContainer) { 3659 // Not on chat.html page, don't initialize chat.html-specific features 3660 return; 3661 } 3662 3663 // Immediately ensure mobile view buttons are hidden on desktop, shown correctly on mobile 3664 updateMobileViewButtonsVisibility(); 3665 3666 // Initialize view buttons 3667 if (typeof initializeViewButtons === 'function') { 3668 initializeViewButtons(); 3669 } 3670 3671 // Handle mobile-specific initialization 3672 if (window.innerWidth <= 768) { 3673 const contentLayer = document.querySelector('.mobile-content-layer'); 3674 const contentToggleBtn = document.getElementById('content-chat-toggle-btn'); 3675 const mobileViewButtons = document.getElementById('mobileViewButtonsContainer'); 3676 const mobileBackToChatButton = document.getElementById('mobileBackToChatButton'); 3677 3678 chatContainer.classList.add('expanded'); 3679 mobileChatState = 'expanded'; 3680 window.currentView = 'chat'; 3681 // Ensure initial styles are set 3682 chatContainer.style.transform = 'translateY(0)'; 3683 chatContainer.style.pointerEvents = 'auto'; 3684 chatContainer.style.visibility = 'visible'; 3685 chatContainer.style.opacity = '1'; 3686 chatContainer.style.zIndex = '150'; 3687 3688 // Set chat button as active on mobile when chat is expanded (this will deactivate other buttons) 3689 if (typeof setActiveButton === 'function') { 3690 setActiveButton('chat'); 3691 } 3692 3693 // Ensure toggle button in content layer is hidden initially 3694 if (contentLayer) { 3695 contentLayer.classList.remove('has-collapsed-chat'); 3696 } 3697 if (contentToggleBtn) { 3698 contentToggleBtn.style.display = 'none'; 3699 } 3700 3701 // Update mobile view buttons visibility (will hide initially since no cities yet) 3702 updateMobileViewButtonsVisibility(); 3703 3704 // Hide back to chat button initially (only shown in map view) 3705 if (mobileBackToChatButton) { 3706 mobileBackToChatButton.style.display = 'none'; 3707 } 3708 } 3709 }); 3710 3711 // Handle window resize to maintain state (only for chat.html page) 3712 // Use a debounced resize handler to avoid too many calls 3713 let resizeTimeout; 3714 window.addEventListener('resize', function() { 3715 const chatContainer = document.getElementById('chat-container'); 3716 3717 // Only handle resize if we're on the chat.html page 3718 if (!chatContainer) return; 3719 3720 // Debounce resize events 3721 clearTimeout(resizeTimeout); 3722 resizeTimeout = setTimeout(() => { 3723 const contentLayer = document.querySelector('.mobile-content-layer'); 3724 const contentToggleBtn = document.getElementById('content-chat-toggle-btn'); 3725 3726 if (window.innerWidth <= 768) { 3727 // Mobile view 3728 if (!chatContainer.classList.contains('expanded') && !chatContainer.classList.contains('collapsed')) { 3729 chatContainer.classList.add('expanded'); 3730 mobileChatState = 'expanded'; 3731 } 3732 3733 // If we have cities and are in map view, switch to chat view to show the map button 3734 const hasCities = typeof allCities !== 'undefined' && allCities.length > 0; 3735 if (hasCities && window.currentView === 'map' && mobileChatState === 'expanded') { 3736 // Don't actually switch view, but ensure chat is the active view for button visibility 3737 window.currentView = 'chat';
3738 if (typeof setActiveButton === 'function') { 3739 setActiveButton('chat'); 3740 } 3741 } 3742 3743 // Update button states based on mobile chat state 3744 if (mobileChatState === 'collapsed') { 3745 if (contentLayer) contentLayer.classList.add('has-collapsed-chat'); 3746 if (contentToggleBtn) contentToggleBtn.style.display = 'flex'; 3747 // Show current view button as active 3748 const currentView = window.currentView || 'map'; 3749 if (typeof setActiveButton === 'function') { 3750 setActiveButton(currentView); 3751 } 3752 } else { 3753 if (contentLayer) contentLayer.classList.remove('has-collapsed-chat'); 3754 if (contentToggleBtn) contentToggleBtn.style.display = 'none'; 3755 // Show chat button as active (this will deactivate other buttons) 3756 if (typeof setActiveButton === 'function') { 3757 setActiveButton('chat'); 3758 } 3759 // Ensure currentView is set to chat when chat is expanded 3760 if (hasCities) { 3761 window.currentView = 'chat'; 3762 } 3763 } 3764 } else { 3765 // Desktop view - remove mobile classes 3766 chatContainer.classList.remove('expanded', 'collapsed'); 3767 if (contentLayer) contentLayer.classList.remove('has-collapsed-chat'); 3768 if (contentToggleBtn) contentToggleBtn.style.display = 'none'; 3769 // Show current view button as active 3770 const currentView = window.currentView || 'map'; 3771 if (typeof setActiveButton === 'function') {
3772 setActiveButton(currentView); 3773 } 3774 } 3775 3776 // Update mobile view buttons visibility on resize (with a small delay to ensure state is updated) 3777 setTimeout(() => { 3778 updateMobileViewButtonsVisibility(); 3779 }, 50); 3780 }, 150); 3781 }); 3782 3783 // Destination Detail Panel Functions 3784 window.openDestinationDetail = function(citySlug) { 3785 const panel = document.getElementById('destination-detail-panel'); 3786 const content = document.getElementById('destination-detail-content'); 3787 3788 if (!panel || !content) { 3789 console.error('Panel or content element not found'); 3790 return; 3791 } 3792 3793 console.log('Opening destination detail for:', citySlug); 3794 3795 // Show loading state 3796 content.innerHTML = '<div class="text-center py-12"><p class="text-gray-500">Loading destination details...</p></div>'; 3797 panel.classList.add('open'); 3798 3799 // Fetch destination details 3800 fetch(`/chat/destinations/${citySlug}/`, { 3801 headers: { 3802 'X-Requested-With': 'XMLHttpRequest', 3803 } 3804 }) 3805 .then(response => { 3806 if (!response.ok) { 3807 throw new Error(`HTTP error! status: ${response.status}`); 3808 } 3809 return response.text(); 3810 }) 3811 .then(html => { 3812 console.log('Received HTML, length:', html.length); 3813 3814 // Extract just the main content (without navigation, etc.) 3815 const temp = document.createElement('div'); 3816 temp.innerHTML = html; 3817 3818 // Find the main content area - look for destination-content ID 3819 let mainContent = temp.querySelector('#destination-content'); 3820 3821 // Debug: log what we found 3822 if (mainContent) { 3823 console.log('Found #destination-content'); 3824 } else { 3825 console.log('Searching in temp:', temp); 3826 console.log('All IDs in temp:', Array.from(temp.querySelectorAll('[id]')).map(el => el.id)); 3827 3828 // Try to find it in a different way 3829 const body = temp.querySelector('body'); 3830 if (body) { 3831 mainContent = body.querySelector('#destination-content'); 3832 } 3833 } 3834 3835 // If still not found, try other selectors 3836 if (!mainContent) { 3837 mainContent = temp.querySelector('[class*="destination-content"]'); 3838 } 3839 3840 // If still not found, look for the main content wrapper 3841 if (!mainContent) { 3842 const wrappers = temp.querySelectorAll('.w-full'); 3843 mainContent = wrappers[0]; 3844 } 3845 3846 // If still not found, just use the body content 3847 if (!mainContent) { 3848 mainContent = temp.querySelector('body') || temp; 3849 } 3850 3851 console.log('Found content element:', mainContent); 3852 3853 // Clean and inject content 3854 if (mainContent && mainContent.innerHTML) { 3855 content.innerHTML = mainContent.innerHTML; 3856 console.log('Content injected, length:', content.innerHTML.length); 3857 } else { 3858 console.warn('No content found in response'); 3859 content.innerHTML = '<div class="text-center py-12"><p class="text-red-500">No content available</p></div>'; 3860 } 3861 }) 3862 .catch(error => { 3863 console.error('Error loading destination details:', error); 3864 content.innerHTML = '<div class="text-center py-12"><p class="text-red-500">Error loading destination details: ' + error.message + '</p></div>'; 3865 }); 3866 }; 3867 3868 // ------------------------------- 3869 // Filters Modal Logic (reused) 3870 // ------------------------------- 3871 const selectedFilters = { 3872 continent: [], 3873 climate: [], 3874 budget: [], 3875 internet: [], 3876 safety: [], 3877 nomads: [], 3878 english: [], 3879 visa: [], 3880 political: [], 3881 healthcare: [] 3882 }; 3883 3884 window.openFiltersModal = function() { 3885 const modal = document.getElementById('filtersModal'); 3886 if (!modal) return; 3887 modal.classList.remove('hidden'); 3888 modal.style.display = 'flex'; 3889 setTimeout(() => { 3890 modal.classList.remove('opacity-0', 'scale-95'); 3891 modal.classList.add('opacity-100', 'scale-100'); 3892 }, 10); 3893 }; 3894 3895 window.closeFiltersModal = function() { 3896 const modal = document.getElementById('filtersModal'); 3897 if (!modal) return; 3898 modal.classList.add('opacity-0', 'scale-95'); 3899 modal.classList.remove('opacity-100', 'scale-100'); 3900 setTimeout(() => { 3901 modal.classList.add('hidden'); 3902 modal.style.display = 'none'; 3903 }, 200); 3904 }; 3905 3906 function disableAllFilters() { 3907 const filterButtons = document.querySelectorAll('#filtersModal .filter-option'); 3908 filterButtons.forEach(b => { b.disabled = true; b.style.opacity = '0.5'; b.style.cursor = 'not-allowed'; }); 3909 } 3910 3911 // Build a simple table view from currently extracted cities (uses data attributes in widgets) 3912 function renderTableView() { 3913 console.log('renderTableView'); 3914 const container = document.getElementById('destinationsTableContainer'); 3915 console.log('destinationsTableContainer', container); 3916 if (!container) return; 3917 3918 // If no cities, show empty state 3919 if (!allCities || allCities.length === 0) { 3920 container.innerHTML = ` 3921 <div class="text-center text-gray-500 py-12"> 3922 <p class="text-lg font-semibold mb-2">No destinations yet</p> 3923 <p class="text-sm">Start chatting to see recommended destinations</p> 3924 </div> 3925 `; 3926 return; 3927 } 3928 3929 // Create a temporary wrapper to parse the city widgets 3930 const temp = document.createElement('div'); 3931 temp.innerHTML = allCities.map(c => cleanWidgetHTML(c.html)).join(''); 3932 3933 const rows = Array.from(temp.querySelectorAll('[data-city-widget]')).map(widget => { 3934 const name = widget.getAttribute('data-city-name') || ''; 3935 const country = widget.getAttribute('data-country-name') || ''; 3936 const budget = widget.getAttribute('data-budget') || ''; 3937 const climate = widget.getAttribute('data-climate') || ''; 3938 const internet = widget.getAttribute('data-internet') || ''; 3939 const safety = widget.getAttribute('data-safety') || ''; 3940 const nomads = widget.getAttribute('data-nomads') || ''; 3941 const slug = widget.getAttribute('data-city-slug') || ''; 3942 const coverImg = widget.querySelector('div[style*="background-image:"]'); 3943 const photo = coverImg ? (coverImg.getAttribute('style') || '').replace(/.*url\(['"]?(.*?)['"]?\).*/, '$1') : ''; 3944 return { name, country, budget, climate, internet, safety, nomads, slug, photo }; 3945 }); 3946 3947 const tableHead = ` 3948 <thead class="bg-gray-50"> 3949 <tr> 3950 <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">City</th> 3951 <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th> 3952 <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Budget</th> 3953 <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Climate</th> 3954 <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Internet</th> 3955 <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Safety</th> 3956 <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Nomads</th> 3957 <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> 3958 </tr> 3959 </thead> 3960 `; 3961 3962 const rowHtml = rows.map(r => ` 3963 <tr class="hover:bg-gray-50 transition-colors duration-150"> 3964 <td class="px-4 py-3"> 3965 <div class="flex items-center"> 3966 ${r.photo ? `<img class=\"h-8 w-8 rounded-md object-cover mr-2\" src=\"${r.ph
3966oto}\" alt=\"${r.name}\">` : ''} 3967 <span class="text-sm font-medium text-gray-900">${r.name}</span> 3968 </div> 3969 </td> 3970 <td class="px-4 py-3 text-sm text-gray-700">${r.country}</td> 3971 <td class="px-4 py-3 text-sm text-gray-700">${r.budget ? `~$${Number(r.budget).toFixed(0)}/mo` : ''}</td> 3972 <td class="px-4 py-3 text-sm text-gray-700">${r.climate || ''}</td> 3973 <td class="px-4 py-3 text-sm text-gray-700">${r.internet || ''}</td> 3974 <td class="px-4 py-3 text-sm text-gray-700">${r.safety || ''}</td> 3975 <td class="px-4 py-3 text-sm text-gray-700">${r.nomads || ''}</td> 3976 <td class="px-4 py-3 text-sm font-medium"> 3977 ${r.slug ? `<button type=\"button\" class=\"text-blue-600 hover:text-blue-800\" onclick=\"openDestinationDetail('${r.slug}')\">View</button>` : ''} 3978 </td> 3979 </tr> 3980 `).join(''); 3981 3982 const tableHtml = ` 3983 <div class="table-responsive overflow-x-auto overflow-y-auto" style="height: 100%; max-height: 100%; overflow-y: auto; overflow-x: auto;"> 3984 <table class="w-full bg-white rounded-lg shadow-sm border border-gray-200 min-w-max"> 3985 ${tableHead} 3986 <tbody class="bg-white divide-y divide-gray-200"> 3987 ${rowHtml} 3988 </tbody> 3989 </table> 3990 </div> 3991 `; 3992 3993 container.innerHTML = tableHtml; 3994 } 3995 3996 function enableAllFilters() { 3997 const filterButtons = document.querySelectorAll('#filtersModal .filter-option'); 3998 filterButtons.forEach(b => { b.disabled = false; b.style.opacity = '1'; b.style.cursor = 'pointer'; }); 3999 } 4000 4001 function updateFormInputs() { 4002 const form = document.getElementById('modalFilterForm'); 4003 if (!form) return; 4004 Object.keys(selectedFilters).forEach(type => { 4005 const input = form.querySelector(`input[name="${type}"]`); 4006 if (input) input.value = selectedFilters[type].join(','); 4007 }); 4008 } 4009 4010 window.toggleFilter = function(button) { 4011 try { 4012 const filterType = button.dataset.filter; 4013 const filterValue = button.dataset.value; 4014 if (!filterType || !filterValue) return; 4015 4016 disableAllFilters(); 4017 button.classList.toggle('filter-active'); 4018 4019 if (button.classList.contains('filter-active')) { 4020 if (!selectedFilters[filterType].includes(filterValue)) { 4021 selectedFilters[filterType].push(filterValue); 4022 } 4023 } else { 4024 const idx = selectedFilters[filterType].indexOf(filterValue); 4025 if (idx > -1) selectedFilters[filterType].splice(idx, 1); 4026 } 4027 4028 updateFormInputs(); 4029 4030 // Optimistic apply without closing; user can press Apply for fetch 4031 enableAllFilters(); 4032 } catch (e) { 4033 enableAllFilters(); 4034 } 4035 }; 4036 4037 window.resetAllFilters = function() { 4038 Object.keys(selectedFilters).forEach(k => { selectedFilters[k] = []; }); 4039 document.querySelectorAll('#filtersModal .filter-option').forEach(btn => btn.classList.remove('filter-active')); 4040 updateFormInputs(); 4041 }; 4042 4043 window.applyCurrentFiltersFromModal = function() { 4044 try { 4045 disableAllFilters(); 4046 const form = document.getElementById('modalFilterForm'); 4047 if (!form) { enableAllFilters(); return; } 4048 4049 // Ensure inputs are in sync 4050 updateFormInputs(); 4051 4052 // Build a natural-language question from selected filters 4053 const question = buildFiltersQuestion(); 4054 4055 // Inject into chat input and submit via HTMX 4056 const chatInput = document.getElementById('messageInput'); 4057 const chatForm = document.getElementById('chatForm'); 4058 if (chatInput && chatForm) { 4059 chatInput.value = question; 4060 // On mobile, ensure chat is expanded so the input/submission works 4061 if (window.innerWidth <= 768 && typeof toggleMobileChat === 'function') { 4062 const chatContainer = document.getElementById('chat-container'); 4063 if (chatContainer && mobileChatState === 'collapsed') { 4064 toggleMobileChat(); 4065 } 4066 } 4067 // Close filters and submit 4068 closeFiltersModal(); 4069 htmx.trigger(chatForm, 'submit'); 4070 } 4071 4072 enableAllFilters(); 4073 } catch (e) { 4074 enableAllFilters(); 4075 } 4076 }; 4077 4078 function buildFiltersQuestion() { 4079 const parts = []; 4080 4081 const labelMap = { 4082 continent: { 4083 key: 'continents', 4084 value: {
4085 'europe': 'Europe', 'asia': 'Asia', 'north-america': 'North America', 'south-america': 'South America', 'africa': 'Africa' 4086 } 4087 }, 4088 budget: { key: 'monthly budget', value: { '0-1000': '< $1k', '1000-2000': '$1kâ$2k', '2000-3000': '$2kâ$3k', '3000+': '> $3k' } }, 4089 climate: { key: 'climate', value: { 'TROPICAL': 'Tropical', 'TEMPERATE': 'Temperate', 'MEDITERRANEAN': 'Mediterranean', 'SUBTROPICAL': 'Subtropical', 'ARID': 'Arid', 'COLD': 'Cold', 'CONTINENTAL': 'Continental', 'DESERT': 'Desert', 'MOUNTAIN': 'Mountain', 'WARM': 'Warm' } }, 4090 internet: { key: 'internet', value: { 'ULTRA_FAST': 'Ultra Fast', 'FAST': 'Fast', 'BASIC': 'Basic' } }, 4091 safety: { key: 'safety', value: { 'HIGH': 'High', 'MOD': 'Moderate', 'LOW': 'Low' } }, 4092 nomads: { key: 'nomad community', value: { 'HIGH': 'Large', 'MOD': 'Growing', 'LOW': 'Small' } }, 4093 english: { key: 'english speaking', value: { 'HIGH': 'High', 'MOD': 'Moderate', 'LOW': 'Low' } }, 4094 healthcare: { key: 'healthcare', value: { 'HIGH': 'High', 'MOD': 'Moderate', 'LOW': 'Low' } }, 4095 visa: { key: 'visa', value: { 'required': 'Required', 'not_required': 'Not Required' } } 4096 }; 4097 4098 Object.keys(selectedFilters).forEach(type => { 4099 const values = selectedFilters[type]; 4100 if (!values || values.length === 0 || !labelMap[type]) return; 4101 const human = values.map(v => (labelMap[type].value && labelMap[type].value[v]) ? labelMap[type].value[v] : v).join(', '); 4102 parts.push(`${labelMap[type].key}: ${human}`); 4103 }); 4104 4105 if (parts.length === 0) { 4106 return 'Recommend digital nomad destinations that are great for me.'; 4107 } 4108 4109 return `Recommend digital nomad destinations matching these preferences: ${parts.join('; ')}.`; 4110 } 4111 4112 // ============================================= 4113 // Text Truncation for Destination Widgets 4114 // ============================================= 4115 4116 function truncateTextByScreenWidth() { 4117 const descriptionElements = document.querySelectorAll('.description-text'); 4118 const screenWidth = window.innerWidth; 4119 4120 descriptionElements.forEach(element => { 4121 const fullText = element.getAttribute('data-full-text'); 4122 if (!fullText) return; 4123 4124 let charLimit; 4125 if (screenWidth >= 1920) { 4126 charLimit = 115; 4127 } else if (screenWidth >= 1440) { 4128 charLimit = 80; 4129 } else { 4130 charLimit = 40; 4131 } 4132 4133 const chars = fullText.split(''); 4134 if (chars.length > charLimit) { 4135 const truncatedChars = chars.slice(0, charLimit); 4136 element.textContent = truncatedChars.join('') + '...'; 4137 } else { 4138 element.textContent = fullText; 4139 } 4140 }); 4141 } 4142 4143 // Make it globally available 4144 window.truncateTextByScreenWidth = truncateTextByScreenWidth; 4145 4146 // Also store in DestinationWidget namespace for compatibility 4147 if (!window.DestinationWidget) { 4148 window.DestinationWidget = {}; 4149 } 4150 window.DestinationWidget.truncateTextByScreenWidth = truncateTextByScreenWidth; 4151 4152 // Run truncation on page load 4153 document.addEventListener('DOMContentLoaded', function() { 4154 setTimeout(truncateTextByScreenWidth, 100); 4155 }); 4156 4157 // Run truncation on window resize (debounced) 4158 let truncateResizeTimeout; 4159 window.addEventListener('resize', function() { 4160 clearTimeout(truncateResizeTimeout); 4161 truncateResizeTimeout = setTimeout(truncateTextByScreenWidth, 150); 4162 }); 4163 4164 // ============================================= 4165 // Destination Filter Workflow Functions 4166 // ============================================= 4167 4168 window.filterWorkflowActive = false; 4169 window.filterWizardUsed = false; 4170 4171 // Start the destination filter workflow 4172 window.startDestinationFilter = function() { 4173 if (filterWorkflowActive) return; 4174 filterWorkflowActive = true; 4175 filterWizardUsed = true; 4176 4177 const chatMessages = document.getElementById('chatMessages'); 4178 const typingIndicator = document.getElementById('typingIndicator'); 4179 const startButton = document.getElementById('startFilterWorkflow'); 4180 4181 4182 // Disable the start button and swap icon to static SVG 4183 if (startButton) { 4184 startButton.disabled = true; 4185 startButton.style.opacity = '0.5'; 4186 startButton.style.cursor = 'not-allowed'; 4187 var btnIcon = startButton.querySelector('img'); 4188 if (btnIcon) btnIcon.src = '/static/icons/chevron_right_dark.svg'; 4189 } 4190 4191 // Hide sample questions when filter wizard starts 4192 var sampleQEl = document.getElementById('sampleQuestions'); 4193 if (sampleQEl) { 4194 sampleQEl.style.display = 'none'; 4195 } 4196 4197 // Show typing indicator (use class like HTMX does) 4198 if (typingIndicator) { 4199 typingIndicator.classList.add('show'); 4200 } 4201 // Change chat gif to thinking 4202 if (typeof changeChatGif === 'function') { 4203 changeChatGif('thinking'); 4204 } 4205 4206 // Get CSRF token 4207 const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; 4208 4209 // Call the filter start endpoint 4210 fetch('/chat/filter/start/', { 4211 method: 'POST', 4212 headers: { 4213 'X-CSRFToken': csrfToken, 4214 'Content-Type': 'application/x-www-form-urlencoded', 4215 }, 4216 body: '' 4217 }) 4218 .then(response => response.text()) 4219 .then(html => { 4220 // Hide typing indicator 4221 if (typingIndicator) { 4222 typingIndicator.classList.remove('show'); 4223 } 4224 // Reset chat gif 4225 if (typeof changeChatGif === 'function') { 4226 changeChatGif('idle'); 4227 } 4228 4229 // Append the filter step to chat 4230 if (chatMessages) { 4231 chatMessages.insertAdjacentHTML('beforeend', html); 4232 chatMessages.scrollTop = chatMessages.scrollHeight; 4233 setTimeout(function() { chatMessages.scrollTop = chatMessages.scrollHeight; }, 100); 4234 } 4235 }) 4236 .catch(error => { 4237 console.error('Error starting filter workflow:', error); 4238 filterWorkflowActive = false;
4239 4240 // Hide typing indicator 4241 if (typingIndicator) { 4242 typingIndicator.classList.remove('show'); 4243 } 4244 // Reset chat gif 4245 if (typeof changeChatGif === 'function') { 4246 changeChatGif('idle'); 4247 } 4248 4249 // Re-enable the start button 4250 if (startButton) { 4251 startButton.disabled = false; 4252 startButton.style.opacity = '1'; 4253 startButton.style.cursor = 'pointer'; 4254 } 4255 }); 4256 }; 4257 4258 // Toggle a filter option on/off (multi-select) 4259 window.toggleFilterOption = function(button) { 4260 const step = button.dataset.filterStep; 4261 4262 const optionsContainer = document.getElementById(`filterOptions-${step}`); 4263 const isSelected = button.classList.contains('selected'); 4264 const maxSelect = optionsContainer ? parseInt(optionsContainer.dataset.maxSelect || '0', 10) : 0; 4265 4266 // Enforce max selection limit â if at max and trying to select a new one, block it 4267 if (!isSelected && maxSelect > 0) { 4268 const currentCount = optionsContainer.querySelectorAll('.selected').length; 4269 if (currentCount >= maxSelect) return; 4270 } 4271 4272 button.classList.toggle('selected', !isSelected); 4273 4274 // Enable / disable the Continue button based on whether anything is selected 4275 const nextBtn = document.getElementById(`filterNext-${step}`); 4276 if (nextBtn && optionsContainer) { 4277 const hasSelection = optionsContainer.querySelectorAll('.selected').length > 0; 4278 const continueBtn = nextBtn.querySelector('button'); 4279 if (continueBtn) { 4280 continueBtn.disabled = !hasSelection; 4281 } 4282 } 4283 4284 // Scroll chat to bottom smoothly 4285 const chatMessages = document.getElementById('chatMessages'); 4286 if (chatMessages) { 4287 setTimeout(function() { chatMessages.scrollTo({ top: chatMessages.scrollHeight, behavior: 'smooth' }); }, 50); 4288 } 4289 }; 4290 4291 // Submit all selected options for a filter step 4292 window.submitFilterStep = function(step, filterType) { 4293 const optionsContainer = document.getElementById(`filterOptions-${step}`); 4294 if (!optionsContainer) return; 4295 4296 const selectedButtons = Array.from(optionsContainer.querySelectorAll('.selected')); 4297 if (selectedButtons.length === 0) return; 4298 4299 const values = selectedButtons.map(b => b.dataset.filterValue); 4300 const labels = selectedButtons.map(b => b.dataset.filterLabel); 4301 4302 const chatMessages = document.getElementById('chatMessages'); 4303 const typingIndicator = document.getElementById('typingIndicator'); 4304 4305 // Lock all option buttons and disable Continue 4306 const allButtons = optionsContainer.querySelectorAll('button'); 4307 allButtons.forEach(btn => { btn.disabled = true; }); 4308 const nextBtn = document.getElementById(`filterNext-${step}`); 4309 if (nextBtn) { 4310 var continueBtn = nextBtn.querySelector('button'); 4311 if (continueBtn) continueBtn.disabled = true; 4312 } 4313 4314 if (typingIndicator) typingIndicator.classList.add('show'); 4315 if (typeof changeChatGif === 'function') changeChatGif('thinking'); 4316 if (chatMessages) chatMessages.scrollTo({ top: chatMessages.scrollHeight, behavior: 'smooth' }); 4317 4318 const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; 4319 const formData = new URLSearchParams(); 4320 formData.append('step', step); 4321 formData.append('filter_type', filterType); 4322 formData.append('filter_values', JSON.stringify(values)); 4323 formData.append('filter_labels', JSON.stringify(labels)); 4324 4325 fetch('/chat/filter/step/', { 4326 method: 'POST', 4327 headers: { 4328 'X-CSRFToken': csrfToken, 4329 'Content-Type': 'application/x-www-form-urlencoded', 4330 }, 4331 body: formData.toString() 4332 }) 4333 .then(response => response.text()) 4334 .then(html => { 4335 if (typingIndicator) typingIndicator.classList.remove('show'); 4336 if (typeof changeChatGif === 'function') changeChatGif('idle'); 4337 4338 const isFinalStep = html.includes('data-city-pins') || html.includes('data-chat-style'); 4339 4340 let cleanHtml = html; 4341 if (isFinalStep) { 4342 cleanHtml = processFilterResponse(html); 4343 filterWorkflowActive = false; 4344 if (typeof updateMobileViewButtonsVisibility === 'function') { 4345 updateMobileViewButtonsVisibility(); 4346 } 4347 } 4348 4349 if (chatMessages) { 4350 chatMessages.insertAdjacentHTML('beforeend', cleanHtml); 4351 chatMessages.scrollTo({ top: chatMessages.scrollHeight, behavior: 'smooth' }); 4352 setTimeout(function() { chatMessages.scrollTo({ top: chatMessages.scrollHeight, behavior: 'smooth' }); }, 100); 4353 setTimeout(function() { chatMessages.scrollTo({ top: chatMessages.scrollHeight, behavior: 'smooth' }); }, 300); 4354 setTimeout(truncateTextByScreenWidth, 50); 4355 setTimeout(truncateTextByScreenWidth, 150); 4356 setTimeout(truncateTextByScreenWidth, 300); 4357 } 4358 }) 4359 .catch(error => { 4360 console.error('Error processing filter step:', error); 4361 if (typingIndicator) typingIndicator.classList.remove('show'); 4362 if (typeof changeChatGif === 'function') changeChatGif('idle'); 4363 // Re-enable buttons on error 4364 allButtons.forEach(btn => { btn.disabled = false; }); 4365 if (nextBtn) { 4366 var cBtn = nextBtn.querySelector('button'); 4367 if (cBtn) cBtn.disabled = false;
4368 } 4369 }); 4370 }; 4371 4372 // Process and clean HTML response - extract OOB elements and return clean chat HTML 4373 function processFilterResponse(html) { 4374 const temp = document.createElement('div'); 4375 temp.innerHTML = html; 4376 4377 // Process OOB updates for destinations table - move to right panel 4378 const tableContainer = temp.querySelector('#destinationsTableContainer'); 4379 if (tableContainer) { 4380 const existingTable = document.getElementById('destinationsTableContainer'); 4381 if (existingTable) { 4382 existingTable.innerHTML = tableContainer.innerHTML; 4383 } 4384 // Remove from temp so it doesn't get inserted into chat 4385 tableContainer.remove(); 4386 } 4387 4388 // Process OOB updates for cities list - move to right panel 4389 const listContainer = temp.querySelector('#citiesListContainer'); 4390 if (listContainer) { 4391 const existingList = document.getElementById('citiesListContainer'); 4392 if (existingList) { 4393 existingList.innerHTML = listContainer.innerHTML; 4394 4395 // Trigger text truncation for destination widgets in the list 4396 setTimeout(truncateTextByScreenWidth, 50); 4397 setTimeout(truncateTextByScreenWidth, 150); 4398 setTimeout(truncateTextByScreenWidth, 300); 4399 } 4400 // Remove from temp so it doesn't get inserted into chat 4401 listContainer.remove(); 4402 } 4403 4404 // Process city pins for map 4405 const cityPinsWrapper = temp.querySelector('#cityPins'); 4406 if (cityPinsWrapper) { 4407 const cityPinsContainer = cityPinsWrapper.querySelector('[data-city-pins]'); 4408 if (cityPinsContainer) { 4409 const pins = cityPinsContainer.querySelectorAll('[data-city-pin]'); 4410 if (pins.length > 0) { 4411 const cities = Array.from(pins).map(pin => ({ 4412 lat: parseFloat(pin.dataset.lat), 4413 lng: parseFloat(pin.dataset.lng), 4414 title: pin.dataset.title, 4415 comment: pin.dataset.comment, 4416 cityName: pin.dataset.cityName, 4417 countryName: pin.dataset.countryName, 4418 citySlug: pin.dataset.citySlug, 4419 coverPhoto: pin.dataset.coverPhoto 4420 })); 4421 4422 // Store cities for view switching 4423 if (typeof allCities === 'undefined') { 4424 window.allCities = []; 4425 } 4426 4427 // Add new cities to the list 4428 cities.forEach(city => { 4429 if (!allCities.some(c => c.citySlug === city.citySlug)) { 4430 allCities.push({ 4431 lat: city.lat, 4432 lng: city.lng, 4433 title: city.title, 4434 citySlug: city.citySlug, 4435 cityName: city.cityName, 4436 countryName: city.countryName, 4437 coverPhoto: city.coverPhoto, 4438 comment: city.comment 4439 }); 4440 } 4441 }); 4442 4443 // Add pins to map and switch to map view
4444 if (typeof clearMapMarkers === 'function') { 4445 clearMapMarkers(); 4446 } 4447 cities.forEach(city => { 4448 if (typeof addCityPin === 'function') { 4449 addCityPin(city); 4450 } 4451 }); 4452 // Switch to map view on desktop only; mobile stays in chat 4453 if (typeof switchView === 'function' && window.innerWidth > 768) { 4454 switchView('map'); 4455 } 4456 if (typeof fitMapToMarkers === 'function') { 4457 setTimeout(fitMapToMarkers, 300); 4458 } 4459 } 4460 } 4461 // Remove from temp so it doesn't get inserted into chat 4462 cityPinsWrapper.remove(); 4463 } 4464 4465 // Return the cleaned HTML (only the chat message part) 4466 return temp.innerHTML; 4467 } 4468 4469 // handleJourneyClick â defined here so it's always available on mobile 4470 // (destination_detail.html re-defines it on desktop after panel opens â that's fine) 4471 if (!window.handleJourneyClick) { 4472 window.handleJourneyClick = function(citySlug, cityName, countryName) { 4473 if (typeof showPlanMyTripTimeframeStep === 'function') { 4474 showPlanMyTripTimeframeStep(citySlug, cityName, countryName || ''); 4475 } else if (typeof NomadosPanel !== 'undefined') { 4476 NomadosPanel.open('journey-create', { 4477 isSubView: true, 4478 citySlug: citySlug, 4479 cityName: cityName, 4480 previousTitle: 'Destination Detail' 4481 }); 4482 } 4483 }; 4484 } 4485 4486 // Mobile compact destination selection: single-select toggle (check icon + dark border) 4487 window.selectMobileDestination = function(el) { 4488 var wasSelected = el.classList.contains('mobile-selected'); 4489 4490 // Deselect all other compact widgets (single-select) 4491 document.querySelectorAll('.destination-widget-compact.mobile-selected').forEach(function(w) { 4492 if (w !== el) w.classList.remove('mobile-selected'); 4493 }); 4494 4495 // Toggle current card 4496 el.classList.toggle('mobile-selected', !wasSelected); 4497 4498 // Show Plan my Trip prompt when selecting a destination 4499 if (!wasSelected) { 4500 var slug = el.getAttribute('data-city-slug'); 4501 var name = el.getAttribute('data-city-name'); 4502 var country = el.getAttribute('data-country-name'); 4503 if (typeof window.appendPlanMyTripPrompt === 'function') { 4504 window.appendPlanMyTripPrompt(slug, name, country); 4505 } 4506 } 4507 }; 4508 4509 // Go Pro full-screen view (panel-like, fills main area) with slide animation 4510 window.openGoProView = function() { 4511 var el = document.getElementById('go-pro-view'); 4512 if (!el) return; 4513 // Close NomadosPanel if it's open 4514 if (typeof NomadosPanel !== 'undefined' && NomadosPanel.isOpen) NomadosPanel.close(); 4515 el.classList.remove('hidden'); 4516 el.classList.add('flex', 'go-pro-view--closed'); 4517 el.setAttribute('aria-hidden', 'false'); 4518 // On mobile, show "Memberships" heading in the mobile header 4519 if (window.innerWidth < 768) { 4520 var headerLogo = document.getElementById('mobile-header-logo'); 4521 var headerViewTitle = document.getElementById('mobile-header-view-title'); 4522 if (headerLogo) headerLogo.classList.add('hidden'); 4523 if (headerViewTitle) { headerViewTitle.textContent = 'Memberships'; headerViewTitle.classList.remove('hidden'); } 4524 } 4525 requestAnimationFrame(function() { 4526 requestAnimationFrame(function() { 4527 el.classList.remove('go-pro-view--closed'); 4528 }); 4529 }); 4530 }; 4531 window.closeGoProView = function() { 4532 var el = document.getElementById('go-pro-view'); 4533 if (!el) return; 4534 el.classList.add('go-pro-view--closed'); 4535 // On mobile, restore logo and hide view title in the mobile header 4536 if (window.innerWidth < 768) { 4537 var headerLogo = document.getElementById('mobile-header-logo'); 4538 var headerViewTitle = document.getElementById('mobile-header-view-title'); 4539 if (headerLogo) headerLogo.classList.remove('hidden'); 4540 if (headerViewTitle) { headerViewTitle.classList.add('hidden'); headerViewTitle.textContent = ''; } 4541 } 4542 var onEnd = function() { 4543 el.removeEventListener('transitionend', onEnd); 4544 el.classList.add('hidden'); 4545 el.classList.remove('flex', 'go-pro-view--closed'); 4546 el.setAttribute('aria-hidden', 'true'); 4547 }; 4548 el.addEventListener('transitionend', onEnd); 4549 }; 4550 4551 // Go Pro billing toggle (panel + modal): keep data-selected-plan in sync for checkout URL 4552 (function() { 4553 var goProBilling = 'monthly'; 4554 window.setGoProBilling = function(plan) { 4555 goProBilling = plan; 4556 var ctaBlock = document.getElementById('go-pro-cta-block'); 4557 if (ctaBlock) ctaBlock.setAttribute('data-selected-plan', plan); 4558 var modal = document.getElementById('goProModal'); 4559 if (modal) modal.setAttribute('data-selected-plan', plan); 4560 if (typeof setGoProModalPlan === 'function') setGoProModalPlan(plan); 4561 var monthlyBlock = document.getElementById('goProPriceMonthly'); 4562 var annualBlock = document.getElementById('goProPriceAnnual'); 4563 var btnMonthly = document.getElementById('goProToggleMonthly'); 4564 var btnAnnual = document.getElementById('goProToggleAnnual'); 4565 if (!monthlyBlock || !annualBlock || !btnMonthly || !btnAnnual) return; 4566 if (plan === 'monthly') { 4567 monthlyBlock.classList.remove('hidden'); 4568 annualBlock.classList.add('hidden'); 4569 btnMonthly.classList.remove('text-[#777777]'); 4570 btnMonthly.classList.add('bg-white', 'text-[#3B3B3C]'); 4571 btnAnnual.classList.remove('bg-white', 'text-[#3B3B3C]'); 4572 btnAnnual.classList.add('text-[#777777]'); 4573 } else { 4574 monthlyBlock.classList.add('hidden'); 4575 annualBlock.classList.remove('hidden'); 4576 btnAnnual.classList.remove('text-[#777777]'); 4577 btnAnnual.classList.add('bg-white', 'text-[#3B3B3C]'); 4578 btnMonthly.classList.remove('bg-white', 'text-[#3B3B3C]'); 4579 btnMonthly.classList.add('text-[#777777]'); 4580 } 4581 }; 4582 })(); 4583</script>
4583 4584 4585 4586 4587 4588<!-- Nomados 2.0 Mobile Footer --> 4589<div id="nomados-mobile-footer" class="block md:hidden fixed bottom-0 left-0 h-auto md:h-16 w-full z-[2500] bg-white"> 4590 <nav class="w-full"> 4591 <div class="flex items-center justify-center gap-4 w-full h-full"> 4592 <!-- Miles Button --> 4593 <button 4594 id="sidebar-miles-btn" 4595 class="sidebar-btn flex flex-col items-center justify-center gap-1 md:gap-2 cursor-pointer transition-all duration-200 w-full min-h-auto md:min-h-[80px] hover:bg-black/5 active py-3 md:py-0" 4596 data-panel-view="miles" 4597 onclick="closeNomadosPanel()" 4598 aria-label="Miles" 4599 > 4600 <div class="flex items-center justify-center"> 4601 <img 4602 src="/static/icons/miles_filled.svg" 4603 alt="Miles" 4604 draggable="false" 4605 class="w-full h-full object-contain miles-icon-filled" 4606 /> 4607 <img 4608 src="/static/icons/miles_blank.svg" 4609 alt="Miles" 4610 draggable="false" 4611 class="w-full h-full object-contain miles-icon-blank hidden" 4612 /> 4613 </div> 4614 <span class="sidebar-label text-xs font-bold text-center leading-tight text-[#3B3B3C] miles-text">Miles</span> 4615 </button> 4616 4617 <!-- Launch Button --> 4618 <button 4619 id="sidebar-launch-btn" 4620 class="sidebar-btn flex flex-col items-center justify-center gap-1 md:gap-2 cursor-pointer transition-all duration-200 w-full min-h-auto md:min-h-[80px] hover:bg-black/5 py-3 md:py-0py-3 md:py-0" 4621 data-panel-view="journey" 4622 onclick="openNomadosPanel('journey')" 4623 aria-label="Launch - Journey List" 4624 > 4625 <div class="flex items-center justify-center"> 4626 <img 4627 src="/static/icons/launch_filled.svg" 4628 alt="Launch" 4629 draggable="false" 4630 class="w-full h-full object-contain launch-icon-filled hidden" 4631 /> 4632 <img 4633 src="/static/icons/launch_blank.svg" 4634 alt="Launch" 4635 draggable="false" 4636 class="w-full h-full object-contain launch-icon-blank" 4637 /> 4638 </div> 4639 <span class="sidebar-label text-xs font-normal text-center leading-tight text-[#3B3B3C] launch-text">Launch</span> 4640 </button> 4641 4642 <!-- Market Button --> 4643 <button 4644 id="sidebar-market-btn" 4645 class="sidebar-btn flex flex-col items-center justify-center gap-1 md:gap-2 cursor-pointer transition-all duration-200 w-full min-h-auto md:min-h-[80px] hover:bg-black/5 py-3 md:py-0" 4646 data-panel-view="market" 4647 onclick="openNomadosPanel('market')" 4648 aria-label="Market" 4649 > 4650 <div class="flex items-center justify-center"> 4651 <img 4652 src="/static/icons/market_filled.svg" 4653 alt="Market" 4654 draggable="false" 4655 class="w-full h-full object-contain market-icon-filled hidden" 4656 /> 4657 <img 4658 src="/static/icons/market_blank.svg" 4659 alt="Market" 4660 draggable="false" 4661 class="w-full h-full object-contain market-icon-blank" 4662 /> 4663 </div> 4664 <span class="sidebar-label text-xs font-normal text-center leading-tight text-[#3B3B3C] market-text">Market</span> 4665 </button> 4666 </div> 4667 </nav> 4668</div> 4669 4670 4671 </div> 4672 </div> 4673 <!-- Nomados 2.0 Right Panel - No Modal, Direct Panel --> 4674 <div id="nomados-panel" class="nomados-panel"> 4675 <div class="nomados-panel-content"> 4676 <!-- Panel Header with Back Button --> 4677 <div id="nomados-panel-header" class="nomados-panel-header mt-2 md:mt-0"> 4678 <button 4679 class="nomados-panel-back-btn" 4680 onclick="nomadosPanelGoBack()" 4681 aria-label="Go back" 4682 > 4683 <img 4684 src="/static/icons/arrow-left_no_circle.svg" 4685 alt="Back" 4686 draggable="false" 4687 /> 4688 </button> 4689 <h2 id="nomados-panel-header-title" class="nomados-panel-header-title"></h2> 4690 </div> 4691 4692 <!-- Close Button (only when header is hidden) --> 4693 <button 4694 id="nomados-panel-close-btn" 4695 class="nomados-panel-close" 4696 onclick="closeNomadosPanel()" 4697 aria-label="Close panel" 4698 > 4699 <img 4700 src="/static/icons/close-dark.svg" 4701 alt="Close" 4702 draggable="false" 4703 /> 4704 </button> 4705 4706 <!-- Panel Content Container --> 4707 <div id="nomados-panel-body" class="nomados-panel-body"> 4708 <!-- Main Views --> 4709 <!-- Journey List View --> 4710 <div id="nomados-panel-journey" class="nomados-panel-view" style="display: none;"> 4711 4712 4713 4714 4715<style> 4716 .overlay-badge { 4717 text-align: center; 4718 text-shadow: 0 0 1px rgba(0, 0, 0, 0.55); 4719 font-style: normal; 4720 font-weight: 500; 4721 padding: 5px 10px; 4722 border-radius: 99px; 4723 background: #C4C4C438; 4724 backdrop-filter: blur(4px); 4725 -webkit-backdrop-filter: blur(4px); 4726 color: #fff; 4727 } 4728 .city-name { 4729 color: #fff; 4730 text-shadow: 0 0 4px #000; 4731 font-style: normal; 4732 font-weight: 800; 4733 opacity: 0.87; 4734 text-align: center; 4735 line-height: 100%; 4736 } 4737 .panel-tab-pane { display: none; } 4738 .panel-tab-pane.active { display: block; } 4739 @media (min-width: 768px) { 4740 #panel-content-journeys { display: block !important; } 4741 } 4742</style> 4743 4744<div class="journey-list-view h-full overflow-y-auto pt-4 md:pt-10"> 4745 <div class="p-0 md:p-6 pb-20 md:pb-6"> 4746 <div class="mb-6 hidden md:block"> 4747 <h2 class="text-2xl font-bold color-black-100">Ready for your next journey?</h2> 4748 <h3 class="text-lg text-dark pt-2 pb-4">Work anywhere. Live everywhere.</h3> 4749 </div> 4750
4751 <!-- Journeys Tab Content (default, always visible on desktop) --> 4752 <div id="panel-content-journeys" class="panel-tab-pane active"> 4753 <div class="w-full grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> 4754 <!-- New Journey Button --> 4755 4756 <!-- Not authenticated message --> 4757 <div class="col-span-full text-center py-12"> 4758 <p class="text-gray-500">Please log in to view your journeys</p> 4759 </div> 4760 4761 </div> 4762 </div> 4763 4764 <!-- Favorites Tab Content (mobile only) --> 4765 <div id="panel-content-favorites" class="panel-tab-pane"> 4766 4767 <div class="text-center py-12"> 4768 <div class="text-gray-400 mb-4"> 4769 <svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 4770 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path> 4771 </svg> 4772 </div> 4773 <h3 class="text-lg font-medium text-gray-900 mb-2">No favorites yet</h3> 4774 <p class="text-gray-500">Start exploring destinations and add them to your favorites!</p> 4775 </div> 4776 4777 </div> 4778 4779 <!-- Featured Add-Ons Tab Content (mobile only) --> 4780 <div id="panel-content-addons" class="panel-tab-pane"> 4781 <div class="space-y-4"> 4782 <!-- Visa Card --> 4783 <div 4784 class="bg-white rounded-2xl p-4 border border-[#D9D9D9] add-ons-card relative cursor-pointer hover:-translate-y-1 transition-all duration-300 ease-in-out" 4785 onclick="if(typeof openMarketVisa === 'function') openMarketVisa()" 4786 > 4787 <div class="bg-purple-200 rounded-xl p-4 mb-2 flex justify-center relative"> 4788 <img src="/static/icons/market_visa.jpg" alt="Visa" draggable="false" class="max-h-52" /> 4789 </div> 4790 <div> 4791 <div class="flex items-start justify-between mb-0"> 4792 <h3 class="text-lg font-semibold color-black-100 mb-1">Visa</h3> 4793 <span class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full">Required</span> 4794 </div> 4795 <div class="flex items-center gap-2 text-sm color-gray-100"> 4796 <span>90 Day Duration</span> 4797 <span>|</span> 4798 <span>Work Visa</span> 4799 </div> 4800 <p class="text-sm text-gray-600 leading-tight py-2"> 4801 Handle your paperwork with ease. Get the right quickly and stress-free, so you can focus on your next adventure. 4802 </p> 4803 </div> 4804 <div class="flex items-center justify-end pt-0 pb-0"> 4805 <div class="bg-dark-100 text-white ps-4 pe-2 py-1 rounded-full flex items-center"> 4806 <span class="text-sm font-medium">Add</span> 4807 <img src="/static/icons/arrow_right.svg" alt="Arrow right" draggable="false" /> 4808 </div> 4809 </div> 4810 </div> 4811 4812 <!-- Health Insurance Card --> 4813 <div 4814 class="bg-white rounded-2xl p-4 border border-[#D9D9D9] add-ons-card relative cursor-pointer hover:-translate-y-1 transition-all duration-300 ease-in-out" 4815 onclick="if(typeof openMarketHealthcare === 'function') openMarketHealthcare()" 4816 > 4817 <div class="bg-blue-100 rounded-xl p-4 mb-2 flex justify-center relative"> 4818 <img src="/static/icons/market_health.jpg" alt="Health" draggable="false" class="max-h-52" /> 4819 </div> 4820 <div> 4821 <div class="flex items-start justify-between mb-0"> 4822 <h3 class="text-lg font-semibold color-black-100 mb-1">Health Insurance</h3> 4823 <span class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full">Essential</span> 4824 </div> 4825 <div class="flex items-center gap-2 text-sm color-gray-100"> 4826 <span>Annual Coverage</span> 4827 </div> 4828 <p class="text-sm text-gray-600 leading-tight py-2"> 4829 Comprehensive global coverage in 175+ countries, with 24/7 support and the freedom to see any doctor. 4830 </p> 4831 </div> 4832 <div class="flex items-center justify-end pt-0 pb-0"> 4833 <div class="bg-dark-100 text-white ps-4 pe-2 py-1 rounded-full flex items-center">
4834 <span class="text-sm font-medium">Add</span> 4835 <img src="/static/icons/arrow_right.svg" alt="Arrow right" draggable="false" /> 4836 </div> 4837 </div> 4838 </div> 4839 4840 <!-- Airport Lounge Card --> 4841 <div 4842 class="bg-white rounded-2xl p-4 border border-[#D9D9D9] add-ons-card relative cursor-pointer hover:-translate-y-1 transition-all duration-300 ease-in-out" 4843 onclick="if(typeof openMarketLounge === 'function') openMarketLounge()" 4844 > 4845 <div class="bg-green-100 rounded-xl p-4 mb-2 flex justify-center relative"> 4846 <img src="/static/icons/market_airport_lounge.jpg" alt="Lounge" draggable="false" class="max-h-52" /> 4847 </div> 4848 <div> 4849 <div class="flex items-start justify-between mb-0"> 4850 <h3 class="text-lg font-semibold color-black-100 mb-1">Airport Lounge</h3> 4851 <span class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full">Premium</span> 4852 </div> 4853 <div class="flex items-center gap-2 text-sm color-gray-100"> 4854 <span>One Adult</span> 4855 <span>|</span> 4856 <span>Participating Lounges</span> 4857 </div> 4858 <p class="text-sm text-gray-600 leading-tight py-2"> 4859 Begin your trip in comfort. Enjoy airport lounge access worldwide at one of our participating locations. 4860 </p> 4861 </div> 4862 <div class="flex items-center justify-end pt-0 pb-0"> 4863 <div class="bg-dark-100 text-white ps-4 pe-2 py-1 rounded-full flex items-center"> 4864 <span class="text-sm font-medium">Add</span> 4865 <img src="/static/icons/arrow_right.svg" alt="Arrow right" draggable="false" /> 4866 </div> 4867 </div> 4868 </div> 4869 4870 <!-- Data Connectivity (eSIM) Card --> 4871 <div 4872 class="bg-white rounded-2xl p-4 border border-[#D9D9D9] add-ons-card relative cursor-pointer hover:-translate-y-1 transition-all duration-300 ease-in-out" 4873 onclick="if(typeof openMarketESim === 'function') openMarketESim()" 4874 > 4875 <div class="bg-purple-200 rounded-xl p-4 mb-2 flex justify-center relative"> 4876 <img src="/static/icons/market_esim.jpg" alt="eSIM" draggable="false" class="max-h-52" /> 4877 </div> 4878 <div> 4879 <div class="flex items-start justify-between mb-0"> 4880 <h3 class="text-lg font-semibold color-black-100 mb-1">Data Connectivity</h3> 4881 <span class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full">Flexible</span> 4882 </div> 4883 <div class="flex items-center gap-2 text-sm color-gray-100"> 4884 <span>Global</span> 4885 <span>|</span> 4886 <span>eSIM</span> 4887 </div> 4888 <p class="text-sm text-gray-600 leading-tight py-2"> 4889 Stay connected anywhere without breaking the bank from expensive roaming charges. 4890 </p> 4891 </div> 4892 <div class="flex items-center justify-end pt-0 pb-0"> 4893 <div class="bg-dark-100 text-white ps-4 pe-2 py-1 rounded-full flex items-center"> 4894 <span class="text-sm font-medium">Add</span> 4895 <img src="/static/icons/arrow_right.svg" alt="Arrow right" draggable="false" /> 4896 </div> 4897 </div> 4898 </div> 4899 </div> 4900 </div> 4901 4902 </div> 4903</div> 4904
4905<script> 4906 function togglePanelJourneyDropdown() { 4907 var dropdown = document.getElementById('panel-journey-dropdown'); 4908 var arrow = document.getElementById('panel-dropdown-arrow'); 4909 if (dropdown) { 4910 dropdown.classList.toggle('hidden'); 4911 if (arrow) { 4912 arrow.style.transform = dropdown.classList.contains('hidden') ? '' : 'rotate(180deg)'; 4913 } 4914 } 4915 } 4916 4917 function selectPanelJourneyTab(tab) { 4918 // Hide all tab panes 4919 document.querySelectorAll('.panel-tab-pane').forEach(function(el) { 4920 el.classList.remove('active'); 4921 }); 4922 // Show selected tab pane 4923 var pane = document.getElementById('panel-content-' + tab); 4924 if (pane) pane.classList.add('active'); 4925 4926 // Update dropdown label 4927 var label = document.getElementById('panel-selected-tab'); 4928 var labels = { journeys: 'Journeys', favorites: 'Favorites', addons: 'Featured Add-Ons' }; 4929 if (label) label.textContent = labels[tab] || tab; 4930 4931 // Close dropdown 4932 var dropdown = document.getElementById('panel-journey-dropdown'); 4933 var arrow = document.getElementById('panel-dropdown-arrow'); 4934 if (dropdown) dropdown.classList.add('hidden'); 4935 if (arrow) arrow.style.transform = ''; 4936 } 4937 4938 // Close panel dropdown when clicking outside 4939 document.addEventListener('click', function(e) { 4940 var dropdown = document.getElementById('panel-journey-dropdown'); 4941 if (!dropdown || dropdown.classList.contains('hidden')) return; 4942 var container = document.getElementById('mobile-header-journey-dropdown'); 4943 if (container && !container.contains(e.target)) { 4944 dropdown.classList.add('hidden'); 4945 var arrow = document.getElementById('panel-dropdown-arrow'); 4946 if (arrow) arrow.style.transform = ''; 4947 } 4948 }); 4949</script>
4949 4950 4951 </div> 4952 <!-- Market View --> 4953 <div id="nomados-panel-market" class="nomados-panel-view" style="display: none;"> 4954 4955 4956 4957<style> 4958 .tooltip { 4959 position: relative; 4960 } 4961 4962 .tooltip::after { 4963 content: attr(data-tooltip); 4964 position: absolute; 4965 bottom: 100%; 4966 left: 0%; 4967 transform: translateX(-60%); 4968 background-color: #333; 4969 color: white; 4970 padding: 6px 10px; 4971 border-radius: 4px; 4972 font-size: 12px; 4973 white-space: nowrap; 4974 opacity: 0; 4975 visibility: hidden; 4976 transition: opacity 0.3s, visibility 0.3s; 4977 z-index: 1000; 4978 margin-bottom: 6px; 4979 pointer-events: none; 4980 } 4981 4982 .tooltip::before { 4983 content: ''; 4984 position: absolute; 4985 bottom: 100%; 4986 left: 50%; 4987 transform: translateX(-50%); 4988 border: 4px solid transparent; 4989 border-top-color: #333; 4990 opacity: 0; 4991 visibility: hidden; 4992 transition: opacity 0.3s, visibility 0.3s; 4993 z-index: 1000; 4994 margin-bottom: 2px; 4995 pointer-events: none; 4996 } 4997 4998 .tooltip:hover::after, 4999 .tooltip:hover::before { 5000 opacity: 1; 5001 visibility: visible; 5002 } 5003</style> 5004 5005<div class="market-view h-full overflow-y-auto pt-4 md:pt-10"> 5006 <div class="p-0 md:p-6 pb-20 md:pb-6"> 5007 <div class="hidden md:block mb-4"> 5008 <h2 class="text-2xl font-bold color-black-100">Featured Add-Ons</h2> 5009 <h3 class="text-lg text-dark pt-2 pb-2">Your essentials, one tap away.</h3> 5010 </div> 5011 5012 <div class="w-full grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4 md:gap-0"> 5013 <!-- Visa Card --> 5014 <div 5015 class="bg-white rounded-2xl m-0 md:m-2 p-4 border border-[#D9D9D9] add-ons-card relative hover:-translate-y-1 transition-all duration-300 ease-in-out cursor-not-allowed" 5016 5017 > 5018 <div class="bg-purple-200 rounded-xl p-4 mb-2 xl:mb-4 flex justify-center relative"> 5019 <img src="/static/icons/market_visa.jpg" alt="Visa" draggable="false" class="max-h-52" /> 5020 </div> 5021 <div> 5022 <div class="flex items-start justify-between mb-0 xl:mb-2"> 5023 <h3 class="text-lg font-semibold color-black-100 mb-1">Visa</h3> 5024 <span class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full">Required</span> 5025 </div> 5026 <div class="flex items-center gap-2 text-sm color-gray-100"> 5027 <span>90 Day Duration</span> 5028 <span>|</span> 5029 <span>Work Visa</span> 5030 </div> 5031 <p class="text-sm xl:text-base text-gray-600 leading-tight xl:leading-relaxed py-2 xl:py-4"> 5032 Handle your paperwork with ease. Get the right quickly and stress-free, so you can focus on your next adventure. 5033 </p> 5034 </div> 5035 <div class="flex items-center justify-end pt-0 xl:pt-2 pb-0 lg:pb-2 xl:pb-6"> 5036 <div class="bg-gray-300 text-gray-500 tooltip ps-4 pe-2 py-1 rounded-full flex items-center" 5037 data-tooltip="Only available to registered users."> 5038 <span class="text-sm font-medium">Add</span> 5039 <img src="/static/icons/arrow_right.svg" alt="Arrow right" draggable="false" /> 5040 </div> 5041 </div> 5042 </div> 5043 5044 <!-- Health Insurance Card --> 5045 <div 5046 class="bg-white rounded-2xl m-0 md:m-2 p-4 border border-[#D9D9D9] add-ons-card relative hover:-translate-y-1 transition-all duration-300 ease-in-out cursor-not-allowed" 5047 5048 > 5049 <div class="bg-blue-100 rounded-xl p-4 mb-2 xl:mb-4 flex justify-center relative"> 5050 <img src="/static/icons/market_health.jpg" alt="Health" draggable="false" class="max-h-52" /> 5051 </div> 5052 <div> 5053 <div class="flex items-start justify-between mb-0 xl:mb-2"> 5054 <h3 class="text-lg font-semibold color-black-100 mb-1">Health Insurance</h3> 5055 <span class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full">Essential</span> 5056 </div> 5057 <div class="flex items-center gap-2 text-sm color-gray-100"> 5058 <span>Annual Coverage</span> 5059 </div> 5060 <p class="text-sm xl:text-base text-gray-600 leading-tight xl:leading-relaxed py-2 xl:py-4"> 5061 Comprehensive global coverage in 175+ countries, with 24/7 support and the freedom to see any doctor. 5062 </p> 5063 </div> 5064 <div class="flex items-center justify-end pt-0 xl:pt-2 pb-0 lg:pb-2 xl:pb-6"> 5065 <div class="bg-gray-300 text-gray-500 tooltip ps-4 pe-2 py-1 rounded-full flex items-center" 5066 data-tooltip="Only available to registered users.">
5067 <span class="text-sm font-medium">Add</span> 5068 <img src="/static/icons/arrow_right.svg" alt="Arrow right" draggable="false" /> 5069 </div> 5070 </div> 5071 </div> 5072 5073 <!-- Airport Lounge Card --> 5074 <div 5075 class="bg-white rounded-2xl m-0 md:m-2 p-4 border border-[#D9D9D9] add-ons-card relative hover:-translate-y-1 transition-all duration-300 ease-in-out cursor-not-allowed" 5076 5077 > 5078 <div class="bg-green-100 rounded-xl p-4 mb-2 xl:mb-4 flex justify-center relative"> 5079 <img src="/static/icons/market_airport_lounge.jpg" alt="Lounge" draggable="false" class="max-h-52" /> 5080 </div> 5081 <div> 5082 <div class="flex items-start justify-between mb-0 xl:mb-2"> 5083 <h3 class="text-lg font-semibold color-black-100 mb-1">Airport Lounge</h3> 5084 <span class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full">Premium</span> 5085 </div> 5086 <div class="flex items-center gap-2 text-sm color-gray-100"> 5087 <span>One Adult</span> 5088 <span>|</span> 5089 <span>Participating Lounges</span> 5090 </div> 5091 <p class="text-sm xl:text-base text-gray-600 leading-tight xl:leading-relaxed py-2 xl:py-4"> 5092 Begin your trip in comfort. Enjoy airport lounge access worldwide at one of our participating locations. 5093 </p> 5094 </div> 5095 <div class="flex items-center justify-end pt-0 xl:pt-2 pb-0 lg:pb-2 xl:pb-6"> 5096 <div class="bg-gray-300 text-gray-500 tooltip ps-4 pe-2 py-1 rounded-full flex items-center" 5097 data-tooltip="Only available to registered users."> 5098 <span class="text-sm font-medium">Add</span> 5099 <img src="/static/icons/arrow_right.svg" alt="Arrow right" draggable="false" /> 5100 </div> 5101 </div> 5102 </div> 5103 5104 <!-- eSIM Card --> 5105 <div 5106 class="bg-white rounded-2xl m-0 md:m-2 p-4 border border-[#D9D9D9] add-ons-card relative hover:-translate-y-1 transition-all duration-300 ease-in-out" 5107 5108 > 5109 <!-- eSIM - Top Section - Illustration --> 5110 <div class="bg-purple-200 rounded-xl p-4 mb-2 xl:mb-4 flex justify-center relative"> 5111 <img 5112 src="/static/icons/market_esim.jpg" 5113 alt="eSIM" 5114 class="max-h-52" 5115 draggable="false" 5116 /> 5117 </div> 5118 5119 <!-- eSIM - Middle Section - Details --> 5120 <div> 5121 <div class="flex items-start justify-between mb-0 xl:mb-2"> 5122 <h3 class="text-lg font-semibold color-black-100 mb-1"> 5123 Data Connectivity 5124 </h3> 5125 <span 5126 class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full" 5127 >Flexible</span 5128 > 5129 </div> 5130 5131 <div class="flex items-center gap-2 text-sm color-gray-100"> 5132 <span>Global</span> 5133 <span>|</span> 5134 <span>eSIM</span> 5135 </div> 5136 5137 <p class="text-sm xl:text-base text-gray-600 leading-tight xl:leading-relaxed py-2 xl:py-4"> 5138 Stay connected anywhere without breaking the bank from expensive roaming charges. 5139 </p> 5140 </div> 5141 5142 <!-- eSIM - Bottom Section - Pricing and Action --> 5143 <div class="flex items-center justify-end pt-0 xl:pt-2 pb-0 lg:pb-2 xl:pb-6"> 5144 <div class="bg-gray-300 text-gray-500 tooltip ps-4 pe-2 py-1 rounded-full flex items-center" 5145 data-tooltip="Only available to registered users."> 5146 <span class="text-sm font-medium">Add</span> 5147 <img src="/static/icons/arrow_right.svg" alt="Arrow right" draggable="false" /> 5148 </div> 5149 </div> 5150 </div> 5151 5152 <!-- Stay Card --> 5153 <div 5154 class="bg-white rounded-2xl m-0 md:m-2 p-4 border border-[#D9D9D9] add-ons-card relative hover:-translate-y-1 transition-all duration-300 ease-in-out" 5155 > 5156 <!-- Stay - Top Section - Illustration --> 5157 <div class="bg-blue-100 rounded-xl p-4 mb-2 xl:mb-4 flex justify-center relative"> 5158 <img 5159 src="/static/icons/market_stay.jpg" 5160 alt="Stay" 5161 class="max-h-52" 5162 draggable="false" 5163 /> 5164 <h2 class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-3xl font-bold color-dark-100 text-center">Coming Soon</h2> 5165 </div> 5166 5167 <!-- Stay - Middle Section - Details --> 5168 <div> 5169 <div class="flex items-start justify-between mb-0 xl:mb-2"> 5170 <h3 class="text-lg font-semibold color-black-100 mb-1"> 5171 Stay 5172 </h3>
5173 <span 5174 class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full" 5175 >Recommended</span 5176 > 5177 </div> 5178 5179 <div class="flex items-center gap-2 text-sm color-gray-100"> 5180 <span>2 Nights</span> 5181 <span>|</span> 5182 <span>Non-Cancellable</span> 5183 </div> 5184 5185 <p class="text-sm xl:text-base text-gray-600 leading-tight xl:leading-relaxed py-2 xl:py-4"> 5186 Find a place to rest. Convenient short-term stays to fit your travel plans. 5187 </p> 5188 </div> 5189 5190 <!-- Stay - Bottom Section - Pricing and Action --> 5191 <div class="flex items-center justify-end pt-0 xl:pt-2 pb-0 lg:pb-2 xl:pb-6"> 5192 <div 5193 class="bg-dark-100 text-white ps-4 pe-2 py-1 rounded-full flex items-center" 5194 > 5195 <span class="text-sm font-medium">Add</span> 5196 <img 5197 src="/static/icons/arrow_right.svg" 5198 alt="Arrow right" 5199 class="" 5200 draggable="false" 5201 /> 5202 </div> 5203 </div> 5204 5205 <div 5206 class="absolute inset-0 bg-white/40 rounded-2xl flex items-center justify-center z-10" 5207 > 5208 </div> 5209 5210 5211 <!-- 5212 <div 5213 class="absolute inset-0 bg-transparent backdrop-blur-sm rounded-2xl flex items-center justify-center z-10" 5214 > 5215 </div> 5216 --> 5217 </div> 5218 5219 <!-- Flight Card --> 5220 <div 5221 class="bg-white rounded-2xl m-0 md:m-2 p-4 border border-[#D9D9D9] add-ons-card relative hover:-translate-y-1 transition-all duration-300 ease-in-out" 5222 > 5223 <!-- Flight - Top Section - Illustration --> 5224 <div class="bg-green-100 rounded-xl p-4 mb-2 xl:mb-4 flex justify-center relative"> 5225 <img 5226 src="/static/icons/market_flight.jpg" 5227 alt="Flight" 5228 class="max-h-52" 5229 draggable="false" 5230 /> 5231 <h2 class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-3xl font-bold color-dark-100 text-center">Coming Soon</h2> 5232 </div> 5233 5234 <!-- Flight - Middle Section - Details --> 5235 <div> 5236 <div class="flex items-start justify-between mb-0 xl:mb-2"> 5237 <h3 class="text-lg font-semibold color-black-100 mb-1"> 5238 Flight 5239 </h3> 5240 <span 5241 class="text-xs bg-white-100 text-black px-2 py-1.5 rounded-full" 5242 >One-Way</span 5243 > 5244 </div> 5245 5246 <div class="flex items-center gap-2 text-sm color-gray-100"> 5247 <span>One-Adult</span> 5248 <span>|</span> 5249 <span>International</span> 5250 </div> 5251 5252 <p class="text-sm xl:text-base text-gray-600 leading-tight xl:leading-relaxed py-2 xl:py-4"> 5253 Book your sear with ease. Simple one-way tickets to get you moving on your journey. 5254 </p> 5255 </div> 5256 5257 <!-- Flight - Bottom Section - Pricing and Action --> 5258 <div class="flex items-center justify-end pt-0 xl:pt-2 pb-0 lg:pb-2 xl:pb-6"> 5259 <div 5260 class="bg-dark-100 text-white ps-4 pe-2 py-1 rounded-full flex items-center" 5261 > 5262 <span class="text-sm font-medium">Add</span> 5263 <img 5264 src="/static/icons/arrow_right.svg" 5265 alt="Arrow right" 5266 class="" 5267 draggable="false" 5268 /> 5269 </div> 5270 </div> 5271 5272 <div 5273 class="absolute inset-0 bg-white/40 rounded-2xl flex items-center justify-center z-10" 5274 > 5275 </div> 5276 5277 5278 <!-- 5279 <div 5280 class="absolute inset-0 bg-transparent backdrop-blur-sm rounded-2xl flex items-center justify-center z-10" 5281 > 5282 </div> 5283 --> 5284 </div> 5285 </div> 5286 </div> 5287</div> 5288 </div> 5289 <!-- Profile View --> 5290 <div id="nomados-panel-profile" class="nomados-panel-view" style="display: none;"> 5291 5292 5293 5294<style> 5295 .recent-journey:hover { 5296 box-shadow: 0 8px 25px #3B3B3C1A; 5297 } 5298 5299 .checkbox-input { 5300 width: 16px; 5301 height: 16px; 5302 border-radius: 4px; 5303 border: 1.5px solid #bbb; 5304 background: #fff; 5305 box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.12) inset; 5306 } 5307 5308 .profile-tab.active { 5309 color: #3B3B3C; 5310 border-bottom-color: #3B3B3C; 5311 background-color: #F4F7F9; 5312 border-radius: 8px 8px 0 0; 5313 } 5314 5315 .profile-tab { 5316 color: #5D5D5D; 5317 transition: all 0.2s ease; 5318 } 5319 5320 .profile-tab:hover { 5321 color: #3B3B3C; 5322 } 5323</style> 5324
5325<script> 5326 function copyReferralLink(event) { 5327 const referralLink = document.getElementById("referral-link"); 5328 if (!referralLink) return; 5329 referralLink.select(); 5330 referralLink.setSelectionRange(0, 99999); 5331 5332 try { 5333 document.execCommand("copy"); 5334 const button = event ? event.target.closest("button") : document.querySelector('button[onclick*="copyReferralLink"]'); 5335 if (button) { 5336 const originalText = button.innerHTML; 5337 button.innerHTML = `<span>Copied!</span>`; 5338 setTimeout(() => { 5339 button.innerHTML = originalText; 5340 }, 2000); 5341 } 5342 } catch (err) { 5343 console.error("Failed to copy: ", err); 5344 if (typeof showNotification === 'function') showNotification("Failed to copy referral link. Please copy manually."); 5345 else console.error("Failed to copy referral link."); 5346 } 5347 } 5348 5349 function switchProfileTab(tabName) { 5350 // Hide all tab contents 5351 const tabContents = document.querySelectorAll('.profile-tab-content'); 5352 tabContents.forEach(content => { 5353 content.style.display = 'none'; 5354 }); 5355 5356 // Remove active class from all tabs 5357 const tabs = document.querySelectorAll('.profile-tab');
5358 tabs.forEach(tab => { 5359 tab.classList.remove('active'); 5360 tab.classList.remove('border-[#3B3B3C]'); 5361 tab.classList.add('border-transparent'); 5362 }); 5363 5364 // Show selected tab content 5365 const selectedContent = document.getElementById(`${tabName}-tab-content`); 5366 if (selectedContent) { 5367 selectedContent.style.display = 'block'; 5368 } 5369 5370 // Add active class to selected tab 5371 const selectedTab = document.querySelector(`[data-tab="${tabName}"]`); 5372 if (selectedTab) { 5373 selectedTab.classList.add('active'); 5374 selectedTab.classList.remove('border-transparent'); 5375 selectedTab.classList.add('border-[#3B3B3C]'); 5376 } 5377 } 5378 5379 function saveProfile(e) { 5380 e.preventDefault(); 5381 var form = document.getElementById('profileForm'); 5382 var btn = document.getElementById('profileSaveBtn'); 5383 var alert = document.getElementById('profile-alert'); 5384 var formData = new FormData(form); 5385 5386 // Disable button while saving 5387 btn.disabled = true; 5388 btn.style.opacity = '0.5'; 5389 btn.style.cursor = 'not-allowed'; 5390 btn.querySelector('span').textContent = 'Saving...'; 5391 5392 // Hide any existing alert 5393 alert.className = 'hidden rounded-lg px-4 py-3 mb-3 text-sm font-medium'; 5394 alert.textContent = ''; 5395 5396 fetch('/chat/panel/profile/', { 5397 method: 'POST', 5398 headers: { 5399 'X-CSRFToken': formData.get('csrfmiddlewaretoken'), 5400 }, 5401 body: formData, 5402 }) 5403 .then(function(response) { return response.json().then(function(data) { return { ok: response.ok, data: data }; }); }) 5404 .then(function(result) { 5405 if (result.ok && result.data.success) { 5406 alert.className = 'rounded-lg px-4 py-3 mb-3 text-sm font-medium bg-green-50 border border-green-200 text-green-800'; 5407 alert.textContent = result.data.message || 'Profile updated successfully!'; 5408 } else { 5409 alert.className = 'rounded-lg px-4 py-3 mb-3 text-sm font-medium bg-red-50 border border-red-200 text-red-800'; 5410 alert.textContent = result.data.message || 'Failed to update profile. Please try again.'; 5411 } 5412 // Auto-hide success alert after 4s 5413 if (result.ok && result.data.success) { 5414 setTimeout(function() { 5415 alert.className = 'hidden rounded-lg px-4 py-3 mb-3 text-sm font-medium'; 5416 }, 4000); 5417 } 5418 }) 5419 .catch(function() { 5420 alert.className = 'rounded-lg px-4 py-3 mb-3 text-sm font-medium bg-red-50 border border-red-200 text-red-800'; 5421 alert.textContent = 'Something went wrong. Please try again.'; 5422 }) 5423 .finally(function() { 5424 btn.disabled = false; 5425 btn.style.opacity = '1'; 5426 btn.style.cursor = 'pointer'; 5427 btn.querySelector('span').textContent = 'Save Changes'; 5428 }); 5429 5430 return false; 5431 } 5432 5433 // Initialize: show profile tab by default 5434 document.addEventListener('DOMContentLoaded', function() { 5435 switchProfileTab('profile'); 5436 }); 5437 5438 // Real-time membership updates via WebSocket 5439 (function() { 5440 var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 5441 var wsUrl = protocol + '//' + window.location.host + '/ws/status/'; 5442 try { 5443 var ws = new WebSocket(wsUrl); 5444 ws.onmessage = function(event) { 5445 var data = JSON.parse(event.data); 5446 if (data.type !== 'status_update' || !data.payload) return; 5447 var p = data.payload; 5448 var isPro = (p.tier === 'pro') || (p.event === 'granted' || p.event === 'renewed'); 5449 var tierEl = document.getElementById('membership-tier-badge'); 5450 var planEl = document.getElementById('membership-plan-text'); 5451 var renewalEl = document.getElementById('membership-renewal-text'); 5452 if (tierEl) { 5453 tierEl.textContent = isPro ? 'Pro' : 'Registered'; 5454 tierEl.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ' + (isPro ? 'bg-[#14B8A6]/20 text-[#0D9488]' : 'bg-gray-100 text-gray-700'); 5455 } 5456 if (planEl) { 5457 if (p.plan_type === 'monthly') planEl.textContent = 'Monthly plan'; 5458 else planEl.textContent = isPro ? 'Pro' : 'Free account'; 5459 } 5460 if (renewalEl && p.current_period_end) { 5461 try { 5462 var d = new Date(p.current_period_end); 5463 renewalEl.textContent = 'Renews ' + d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); 5464 } catch (e) { 5465 renewalEl.textContent = 'Active subscription'; 5466 } 5467 } else if (renewalEl && p.event) { 5468 renewalEl.textContent = p.event === 'revoked' ? 'Upgrade to Pro for unlimited journeys and more.' : 'Active subscription'; 5469 } 5470 }; 5471 ws.onclose = function() { }; 5472 } catch (e) { } 5473 })(); 5474</script>
5474 5475<div class="profile-view h-full overflow-y-auto pt-4 md:pt-10"> 5476 <div class="p-0 md:p-6 pb-20 md:pb-6"> 5477 <!-- Header --> 5478 <div class="mb-6"> 5479 <h1 class="text-2xl font-bold color-black-100 mb-2">Nomad Profile</h1> 5480 <div class="flex items-center justify-between"> 5481 <div class="w-3/4 md:w-auto"> 5482 <p class="text-sm md:text-base text-black"> 5483 Manage your account settings and view your travel statistics 5484 </p> 5485 </div> 5486 <a 5487 href="/chat/panel/logout/" 5488 class="flex bg-white items-center hover:bg-gray-200 color-black-100 ps-2 py-1.5 pe-4 border border-solid border-[#E5E5E5] rounded-full font-medium transition duration-200 gap-1 w-[100px]" 5489 > 5490 <img src="/static/icons/logout.svg" alt="Logout" draggable="false" /> 5491 Logout 5492 </a> 5493 </div> 5494 </div> 5495 5496 <!-- Statistics Cards --> 5497 <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> 5498 <div class="bg-[#F4F7F9] flex flex-col gap-2 p-4 rounded-xl"> 5499 <div class="flex items-center justify-between"> 5500 <img src="/static/icons/total_journeys.svg" alt="Journey" /> 5501 <span class="text-xl font-medium">0</span> 5502 </div> 5503 <h3 class="text-xl font-medium text-black">Total Journeys</h3> 5504 <p class="text-sm color-gray-100">Planned adventures around the world</p> 5505 </div> 5506 5507 <div class="bg-[#F4F7F9] flex flex-col gap-2 p-4 rounded-xl"> 5508 <div class="flex items-center justify-between"> 5509 <img src="/static/icons/favorite.svg" alt="Favorite" /> 5510 <span class="text-xl font-medium"></span> 5511 </div> 5512 <h3 class="text-xl font-medium text-black">Favorite Cities</h3> 5513 <p class="text-sm color-gray-100">Cities you've marked as favorites</p> 5514 </div> 5515 5516 <div class="bg-[#F4F7F9] flex flex-col gap-2 p-4 rounded-xl"> 5517 <div class="flex items-center justify-between"> 5518 <img src="/static/icons/calendar.svg" alt="Member Since" /> 5519 <span class="text-xl font-medium"></span> 5520 </div> 5521 <h3 class="text-xl font-medium text-black">Member Since</h3> 5522 <p class="text-sm color-gray-100">When you joined Nomados</p> 5523 </div> 5524 </div> 5525 5526 <!-- Membership / Subscription status (updates in real time via WebSocket) --> 5527 <div id="membership-status-card" class="bg-white rounded-xl border border-[#D9D9D9] p-4 mb-8 relative"> 5528 <!-- Processing loader overlay --> 5529 <div id="membership-processing-loader" class="hidden absolute inset-0 z-10 bg-white/80 rounded-xl flex flex-col items-center justify-center"> 5530 <svg class="animate-spin h-6 w-6 text-[#3B3B3C] mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> 5531 <span id="membership-loader-text" class="text-sm text-gray-600">Processing...</span> 5532 </div> 5533 5534 <h2 class="text-xl font-bold text-black mb-3">Membership</h2> 5535 <div class="flex flex-wrap items-center gap-2 mb-2"> 5536 <span id="membership-tier-badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-700"> 5537 Registered 5538 </span> 5539 <span id="membership-plan-text" class="text-sm text-gray-600"> 5540 Free account 5541 </span> 5542 <!-- Status sub-badge (hidden for active) --> 5543 <span id="membership-status-badge" class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium 5544 hidden"> 5545 5546 </span> 5547 </div> 5548 5549 <!-- Status-specific messaging --> 5550 5551 <div id="membership-status-msg"> 5552 <p id="membership-renewal-text" class="text-sm text-gray-500"> 5553 5554 Upgrade to Pro for unlimited journeys and more. 5555 5556 </p> 5557 </div> 5558 5559 5560 <!-- ââ Actions: Canceled Pro users (scheduled cancel â still has Pro until period end) ââ --> 5561 5562 5563 <!-- ââ Actions: Active / Past Due Pro users ââ --> 5564 5565 </div> 5566 5567 <!-- Referral Link Section --> 5568 5569 5570 <!-- Tab Navigation --> 5571 <div class="mb-6 border-b border-[#D9D9D9]"> 5572 <div class="flex gap-4"> 5573 <button 5574 class="profile-tab px-3 md:px-4 py-2 text-base font-medium border-b-2 border-[#3B3B3C] hover:text-gray-700 transition-colors active" 5575 data-tab="profile" 5576 onclick="switchProfileTab('profile')" 5577 > 5578 Profile 5579 </button> 5580 <button 5581 class="profile-tab px-3 md:px-4 py-2 text-base font-medium border-b-2 border-transparent hover:text-gray-700 transition-colors" 5582 data-tab="addons" 5583 onclick="switchProfileTab('addons')" 5584 > 5585 Add-Ons 5586 </button> 5587 <button 5588 class="profile-tab px-3 md:px-4 py-2 text-base font-medium border-b-2 border-transparent hover:text-gray-700 transition-colors" 5589 data-tab="journeys" 5590 onclick="switchProfileTab('journeys')" 5591 > 5592 Journeys 5593 </button> 5594 <button 5595 class="profile-tab px-4 py-2 text-base font-medium border-b-2 border-transparent hover:text-gray-700 transition-colors" 5596 data-tab="favorites" 5597 onclick="switchProfileTab('favorites')" 5598 > 5599 Favorites 5600 </button> 5601 </div> 5602 </div> 5603 5604 <!-- Tab Content --> 5605 <!-- Profile Tab Content -->
5606 <div id="profile-tab-content" class="profile-tab-content"> 5607 <div class="bg-white rounded-xl border border-[#D9D9D9] px-4 py-5"> 5608 <h2 class="text-xl font-bold text-black mb-3">Profile Information</h2> 5609 <!-- Alert message --> 5610 <div id="profile-alert" class="hidden rounded-lg px-4 py-3 mb-3 text-sm font-medium"></div> 5611 <form id="profileForm" class="space-y-3" onsubmit="return saveProfile(event)"> 5612 <input type="hidden" name="csrfmiddlewaretoken" value="wW5XRNroFwTKrAvYlp7o0ZIlcpfIHaiR8jbqrSOG95se38pTkp2zbUYkj50vDt3a"> 5613 <div class="flex flex-col"> 5614 <label for="first_name" class="color-gray-100 text-sm font-semibold">First Name</label> 5615 <input type="text" id="first_name" name="first_name" value="" class="w-full px-4 py-3 border border-solid border-[#bbb] rounded-full pr-12" style="box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.09) inset" required /> 5616 </div> 5617 <div class="flex flex-col"> 5618 <label for="last_name" class="color-gray-100 text-sm font-semibold">Last Name</label> 5619 <input type="text" id="last_name" name="last_name" value="" class="w-full px-4 py-3 border border-solid border-[#bbb] rounded-full pr-12" style="box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.09) inset" required /> 5620 </div> 5621 <div class="flex flex-col"> 5622 <label for="email" class="color-gray-100 text-sm font-semibold">Email Address<sup style="color: #ff735d; font-size: 10px" class="font-semibold">â±</sup></label> 5623 <input type="email" id="email" name="email" value="" class="w-full px-4 py-3 border border-solid border-[#bbb] rounded-full pr-12" style="box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.09) inset" required /> 5624 </div> 5625 5626 <div class="flex justify-end pt-2"> 5627 <button type="submit" id="profileSaveBtn" class="flex bg-dark-100 text-white items-center ps-4 pe-2 py-1.5 rounded-full font-medium transition duration-200 gap-1"> 5628 <span>Save Changes</span> 5629 <img src="/static/icons/arrow_right.svg" alt="Arrow" draggable="false" /> 5630 </button> 5631 </div> 5632 </form> 5633 </div> 5634 </div> 5635 5636 <!-- Add-Ons Tab Content (empty as requested) --> 5637 <div id="addons-tab-content" class="profile-tab-content" style="display: none;"> 5638 <!-- Add-Ons content skipped as requested --> 5639 </div> 5640 5641 <!-- Journeys Tab Content --> 5642 <div id="journeys-tab-content" class="profile-tab-content" style="display: none;"> 5643 5644 <div class="bg-white rounded-xl border border-[#D9D9D9] p-8 text-center"> 5645 <p class="text-gray-500">No journeys yet. Start planning your next adventure!</p> 5646 </div> 5647 5648 </div> 5649 5650 <!-- Favorites Tab Content --> 5651 <div id="favorites-tab-content" class="profile-tab-content" style="display: none;"> 5652 5653 <div class="bg-white rounded-xl border border-[#D9D9D9] p-8 text-center"> 5654 <p class="text-gray-500">No favorite cities yet. Start exploring destinations!</p> 5655 </div> 5656 5657 </div> 5658 5659 <!-- Terms and Privacy --> 5660 <div class="mt-6 flex items-center justify-center gap-3 text-xs color-gray-200"> 5661 <a href="https://nomados.ai/terms-and-conditions/" target="_blank" class="hover:font-bold transition-all duration-200 cursor-pointer"> 5662 Terms of Service 5663 </a> 5664 <span>|</span> 5665 <a href="https://nomados.ai/privacy-policy/" target="_blank" class="hover:font-bold transition-all duration-200 cursor-pointer"> 5666 Privacy Policy 5667 </a> 5668 </div> 5669 </div> 5670</div> 5671 5672 </div> 5673 5674 <!-- Sub-Views (loaded dynamically) --> 5675 <!-- Journey Create View --> 5676 <div id="nomados-panel-journey-create" class="nomados-panel-view" style="display: none;"></div> 5677 <!-- Journey Detail View --> 5678 <div id="nomados-panel-journey-detail" class="nomados-panel-view" style="display: none;"></div> 5679 <!-- Journey Edit View --> 5680 <div id="nomados-panel-journey-edit" class="nomados-panel-view" style="display: none;"></div> 5681 <!-- Market Visa View --> 5682 <div id="nomados-panel-market-visa" class="nomados-panel-view" style="display: none;"></div> 5683 <!-- Market Healthcare View --> 5684 <div id="nomados-panel-market-healthcare" class="nomados-panel-view" style="display: none;"></div> 5685 <!-- Market Lounge View --> 5686 <div id="nomados-panel-market-lounge" class="nomados-panel-view" style="display: none;"></div> 5687 <!-- Market eSIM View --> 5688 <div id="nomados-panel-market-esim" class="nomados-panel-view" style="display: none;"></div> 5689 <!-- Destination Detail View --> 5690 <div id="nomados-panel-destination-detail" class="nomados-panel-view" style="display: none;"></div> 5691 <div id="nomados-panel-journey-preview" class="nomados-panel-view" style="display: none;"></div> 5692 </div> 5693 </div> 5694 </div> 5695 5696 5697 5698 5699<!-- Feedback Modal --> 5700<div 5701 id="feedbackModal" 5702 class="fixed inset-0 z-[99999] flex items-center justify-center bg-black/50 hidden opacity-0 scale-95 transition-all duration-200 ease-out" 5703 style="backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px)" 5704> 5705 <div 5706 class="bg-white rounded-3xl shadow-2xl border border-gray-200 p-8 max-w-2xl w-full mx-4 transform transition-all duration-200" 5707 > 5708 <div class="flex items-center justify-between mb-6"> 5709 <h3 class="text-xl font-semibold color-black-100">Send Us Feedback</h3> 5710 <button 5711 onclick="closeFeedbackModal()" 5712 class="text-gray-400 hover:text-gray-600 transition-colors" 5713 > 5714 <svg 5715 class="w-6 h-6" 5716 fill="none" 5717 stroke="currentColor"
5718 viewBox="0 0 24 24" 5719 > 5720 <path 5721 stroke-linecap="round" 5722 stroke-linejoin="round" 5723 stroke-width="2" 5724 d="M6 18L18 6M6 6l12 12" 5725 ></path> 5726 </svg> 5727 </button> 5728 </div> 5729 5730 <!-- Feedback Form --> 5731 <div id="feedbackFormContent"> 5732 <form 5733 id="feedbackModalForm" 5734 onsubmit="submitFeedbackForm(event)" 5735 class="space-y-4" 5736 > 5737 <input type="hidden" name="csrfmiddlewaretoken" value="wW5XRNroFwTKrAvYlp7o0ZIlcpfIHaiR8jbqrSOG95se38pTkp2zbUYkj50vDt3a"> 5738 5739 <div> 5740 <label 5741 for="feedbackMessage" 5742 class="block text-sm font-medium color-gray-100 mb-1" 5743 >Your Message<sup 5744 style="color: #FF735D; font-size: 10px;" 5745 class="font-semibold" 5746 >â±</sup 5747 ></label 5748 > 5749 <textarea 5750 id="feedbackMessage" 5751 name="message" 5752 rows="4" 5753 class="w-full px-4 py-3 border border-solid border-[#bbb] rounded-3xl transition-colors" 5754 style="box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.09) inset" 5755 placeholder="Tell us what you think, what we can improve, or any issues you've found..." 5756 required 5757 ></textarea> 5758 </div> 5759 5760 <div class="flex justify-end gap-4 pt-2"> 5761 <button 5762 type="button" 5763 onclick="closeFeedbackModal()" 5764 class="flex bg-white items-center hover:bg-gray-200 color-black-100 ps-2 py-1.5 pe-4 border border-solid border-[#bbb] hover:border-gray-200 rounded-full font-medium transition duration-200 gap-1" 5765 > 5766 <img 5767 src="/static/icons/cancel.svg" 5768 alt="Cancel" 5769 draggable="false" 5770 /> 5771 Cancel 5772 </button> 5773 <button 5774 type="submit" 5775 class="flex bg-white items-center hover:bg-gray-200 color-black-100 ps-2 py-1.5 pe-4 border border-solid border-[#bbb] hover:border-gray-200 rounded-full font-medium transition duration-200 gap-1" 5776 > 5777 <img 5778 src="/static/icons/create_journey.svg" 5779 alt="Create Journey" 5780 draggable="false" 5781 /> 5782 Send Feedback 5783 </button> 5784 </div> 5785 </form> 5786 </div> 5787 5788 <!-- Thank You Message --> 5789 <div id="feedbackThankYouContent" class="hidden text-center"> 5790 <div class="mb-6"> 5791 <div 5792 class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-4" 5793 > 5794 <svg 5795 class="h-8 w-8 text-green-600" 5796 fill="none" 5797 stroke="currentColor"
5798 viewBox="0 0 24 24" 5799 > 5800 <path 5801 stroke-linecap="round" 5802 stroke-linejoin="round" 5803 stroke-width="2" 5804 d="M5 13l4 4L19 7" 5805 ></path> 5806 </svg> 5807 </div> 5808 <h3 class="text-xl font-bold text-gray-900 mb-2">Thank You!</h3> 5809 <p class="text-gray-600"> 5810 Your feedback has been submitted successfully. We appreciate you 5811 taking the time to help us improve! 5812 </p> 5813 </div> 5814 5815 <button 5816 onclick="closeFeedbackModal()" 5817 class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-3 rounded-lg font-medium transition duration-200" 5818 > 5819 Close 5820 </button> 5821 </div> 5822 </div> 5823</div> 5824 5825<!-- Feedback Button (Floating) --> 5826<div class="fixed hidden lg:block bottom-16 right-6 z-40"> 5827 <button 5828 onclick="openFeedbackModal()" 5829 class="bg-dark-100 text-white p-3 rounded-full shadow-lg hover:bg-stone-700 focus:outline-none focus:ring-2 focus:ring-stone-500 focus:ring-offset-2 transition duration-200 group" 5830 title="Send Feedback" 5831 > 5832 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 5833 <path 5834 stroke-linecap="round" 5835 stroke-linejoin="round" 5836 stroke-width="2" 5837 d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" 5838 ></path> 5839 </svg> 5840 </button> 5841</div> 5842
5843<script> 5844 // Modal functions 5845 function openFeedbackModal() { 5846 const modal = document.getElementById("feedbackModal"); 5847 if (modal) { 5848 modal.classList.remove("hidden"); 5849 setTimeout(() => { 5850 modal.classList.remove("opacity-0", "scale-95"); 5851 modal.classList.add("opacity-100", "scale-100"); 5852 }, 10); 5853 document.getElementById("feedbackMessage").focus(); 5854 } 5855 } 5856 5857 function closeFeedbackModal() { 5858 const modal = document.getElementById("feedbackModal"); 5859 if (modal) { 5860 modal.classList.add("opacity-0", "scale-95"); 5861 modal.classList.remove("opacity-100", "scale-100"); 5862 setTimeout(() => { 5863 modal.classList.add("hidden"); 5864 }, 200); 5865 5866 // Reset to form view 5867 document.getElementById("feedbackFormContent").classList.remove("hidden"); 5868 document 5869 .getElementById("feedbackThankYouContent") 5870 .classList.add("hidden"); 5871 document.getElementById("feedbackModalForm").reset(); 5872 } 5873 } 5874 5875 function submitFeedbackForm(e) { 5876 e.preventDefault(); 5877 5878 const form = document.getElementById("feedbackModalForm"); 5879 const submitBtn = form.querySelector('button[type="submit"]'); 5880 const messageTextarea = document.getElementById("feedbackMessage"); 5881 5882 // Disable form 5883 submitBtn.disabled = true; 5884 submitBtn.textContent = "Sending..."; 5885 5886 fetch('/feedback/submit/', { 5887 method: "POST", 5888 headers: { 5889 "Content-Type": "application/json", 5890 "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]") 5891 .value, 5892 "X-Requested-With": "XMLHttpRequest", 5893 }, 5894 body: JSON.stringify({ 5895 message: messageTextarea.value, 5896 }), 5897 }) 5898 .then((response) => { 5899 // Check if response is JSON 5900 const contentType = response.headers.get("content-type"); 5901 if (contentType && contentType.includes("application/json")) { 5902 return response.json(); 5903 } else { 5904 throw new Error("Server returned non-JSON response"); 5905 } 5906 }) 5907 .then((data) => { 5908 if (data.success) { 5909 // Hide form and show thank you message 5910 document 5911 .getElementById("feedbackFormContent") 5912 .classList.add("hidden"); 5913 document 5914 .getElementById("feedbackThankYouContent") 5915 .classList.remove("hidden"); 5916 } else { 5917 throw new Error(data.error || "Failed to submit feedback"); 5918 } 5919 }) 5920 .catch((error) => { 5921 console.error("Error:", error); 5922 alert("An error occurred while submitting feedback. Please try again."); 5923 }) 5924 .finally(() => { 5925 // Re-enable form 5926 submitBtn.disabled = false; 5927 submitBtn.textContent = "Send Feedback"; 5928 }); 5929 } 5930 5931 // Close modal when clicking outside 5932 document.addEventListener("click", function (e) { 5933 if (e.target.id === "feedbackModal") { 5934 closeFeedbackModal(); 5935 } 5936 }); 5937 5938 // Close modal with Escape key 5939 document.addEventListener("keydown", function (e) { 5940 if (e.key === "Escape") { 5941 closeFeedbackModal(); 5942 } 5943 }); 5944</script>
5944 5945 5946 5947<!-- Go Pro subscription modal: SamCart checkout in iframe --> 5948<div 5949 id="goProModal" 5950 class="fixed inset-0 z-[99998] hidden items-center justify-center bg-black/50 opacity-0 transition-opacity duration-200 ease-out overflow-y-auto py-8" 5951 style="backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);" 5952 aria-hidden="true" 5953 role="dialog" 5954 aria-labelledby="goProModalTitle" 5955 data-samcart-monthly-url="https://nomados.samcart.com/products/nomados-pro-monthly-internal" 5956 data-samcart-annual-url="https://nomados.samcart.com/products/nomados-pro-annual-internal" 5957> 5958 <div 5959 id="goProModalCard" 5960 class="relative bg-white rounded-3xl shadow-xl border border-gray-100 w-full max-w-md mx-4 my-auto transform transition-all duration-200" 5961 onclick="event.stopPropagation()" 5962 > 5963 <button 5964 type="button" 5965 onclick="closeGoProModal()" 5966 class="absolute top-1 right-2 bg-white border border-[#e5e7eb] rounded-full hover:bg-[#f3f4f6] hover:scale-[1.1] z-10 shadow-sm transition-all duration-200 w-8 h-8 flex items-center justify-center" 5967 aria-label="Close" 5968 > 5969 <img 5970 src="/static/icons/close-dark.svg" 5971 alt="Close" 5972 draggable="false" 5973 /> 5974 </button> 5975 5976 <!-- Step 1: Pricing choice (optional; when opened from panel we go straight to iframe) --> 5977 <div id="goProStepPricing" class="relative pt-10 pb-8 px-6 md:px-8"> 5978 <h2 id="goProModalTitle" class="text-2xl md:text-3xl font-bold text-[#3B3B3C] text-center mb-2">Go Pro</h2> 5979 <p class="text-sm md:text-base text-gray-500 text-center mb-8">Everything you need to live and work anywhere, seamlessly.</p> 5980 <div class="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 md:p-6 mb-6" style="box-shadow: 0 4px 12px rgba(0,0,0,0.06);"> 5981 <div class="flex rounded-xl bg-gray-200 p-1 mb-5"> 5982 <button type="button" id="goProModalToggleMonthly" onclick="setGoProBilling('monthly'); setGoProModalPlan('monthly');" class="flex-1 py-2.5 px-3 text-sm font-medium rounded-lg transition-colors go-pro-modal-toggle-active bg-white text-[#3B3B3C] shadow-sm">Monthly</button> 5983 <button type="button" id="goProModalToggleAnnual" onclick="setGoProBilling('annual'); setGoProModalPlan('annual');" class="flex-1 py-2.5 px-3 text-sm font-medium rounded-lg transition-colors text-[#3B3B3C]">Annual <span class="bg-[#14B8A6] text-white text-xs font-semibold px-2 py-0.5 rounded-full">Save 45%</span></button> 5984 </div> 5985 <div id="goProModalPriceMonthly" class="go-pro-modal-price text-center mb-7"> 5986 <span class="text-4xl font-bold text-[#3B3B3C]">$9</span><span class="text-gray-500 text-base ml-1">/month</span> 5987 </div> 5988 <div id="goProModalPriceAnnual" class="go-pro-modal-price hidden text-center mb-1"> 5989 <span class="text-4xl font-bold text-[#3B3B3C]">$59</span><span class="text-gray-500 text-base ml-1">/year</span> 5990 <p class="text-sm text-gray-500 mt-1">Just $4.92/month, billed annually</p> 5991 </div> 5992 <button type="button" onclick="submitGoProCheckout()" class="w-full py-3.5 rounded-xl bg-[#3B3B3C] text-white font-semibold text-base hover:bg-[#2d2d2e] transition-colors">Get Nomados Pro</button> 5993 <p class="text-xs text-gray-400 text-center mt-3">Checkout opens below.</p> 5994 </div> 5995 </div> 5996 5997 <!-- Step 2: Checkout iframe (SamCart) --> 5998 <div id="goProStepIframe" class="relative hidden pt-10 pb-6 px-4"> 5999 <div class="rounded-xl border border-gray-200 overflow-hidden bg-gray-100" style="min-height: 520px;"> 6000 <iframe 6001 id="goProCheckoutIframe" 6002 title="Nomados Pro checkout" 6003 class="w-full border-0" 6004 style="height: 520px; min-height: 520px;" 6005 sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-top-navigation" 6006 ></iframe> 6007 </div> 6008 <p class="text-xs text-gray-400 text-center mt-3">Secure checkout by SamCart. Use the same email as your Nomados account.</p> 6009 </div> 6010 </div> 6011</div> 6012
6013<script> 6014(function() { 6015 var FALLBACK_MONTHLY = 'https://nomados.samcart.com/products/nomados\u002Dpro\u002Dmonthly\u002Dinternal'; 6016 var FALLBACK_ANNUAL = 'https://nomados.samcart.com/products/nomados\u002Dpro\u002Dannual\u002Dinternal'; 6017 6018 function getCheckoutUrlFromModal() { 6019 var modal = document.getElementById('goProModal'); 6020 if (!modal) return ''; 6021 var plan = (modal.getAttribute('data-selected-plan') || 'monthly').trim(); 6022 return plan === 'annual' 6023 ? (modal.getAttribute('data-samcart-annual-url') || '').trim() || FALLBACK_ANNUAL 6024 : (modal.getAttribute('data-samcart-monthly-url') || '').trim() || FALLBACK_MONTHLY; 6025 } 6026 6027 window.openGoProModal = function() { 6028 var modal = document.getElementById('goProModal'); 6029 if (!modal) return; 6030 document.getElementById('goProStepPricing').classList.remove('hidden'); 6031 document.getElementById('goProStepIframe').classList.add('hidden'); 6032 var card = document.getElementById('goProModalCard'); 6033 if (card) { card.classList.remove('max-w-4xl'); card.classList.add('max-w-md'); } 6034 modal.classList.remove('hidden'); 6035 modal.classList.add('flex'); 6036 requestAnimationFrame(function() { modal.classList.remove('opacity-0'); }); 6037 document.body.style.overflow = 'hidden'; 6038 }; 6039 6040 window.closeGoProModal = function() { 6041 var modal = document.getElementById('goProModal'); 6042 if (!modal) return; 6043 var iframe = document.getElementById('goProCheckoutIframe');
6044 if (iframe && iframe.src) iframe.src = 'about:blank'; 6045 modal.classList.add('opacity-0'); 6046 setTimeout(function() { 6047 modal.classList.add('hidden'); 6048 modal.classList.remove('flex'); 6049 document.body.style.overflow = ''; 6050 }, 200); 6051 // Hide processing loaders â no purchase was completed if user closed the modal 6052 var memberLoader = document.getElementById('membership-processing-loader'); 6053 var goProLoader = document.getElementById('go-pro-processing-loader'); 6054 if (memberLoader) { memberLoader.classList.add('hidden'); memberLoader.classList.remove('flex'); } 6055 if (goProLoader) { goProLoader.classList.add('hidden'); goProLoader.classList.remove('flex'); } 6056 }; 6057 6058 window.setGoProModalPlan = function(plan) { 6059 var modal = document.getElementById('goProModal'); 6060 if (modal) modal.setAttribute('data-selected-plan', plan); 6061 var monthlyBlock = document.getElementById('goProModalPriceMonthly'); 6062 var annualBlock = document.getElementById('goProModalPriceAnnual'); 6063 var btnMonthly = document.getElementById('goProModalToggleMonthly'); 6064 var btnAnnual = document.getElementById('goProModalToggleAnnual'); 6065 if (!monthlyBlock || !annualBlock || !btnMonthly || !btnAnnual) return; 6066 if (plan === 'monthly') { 6067 monthlyBlock.classList.remove('hidden'); 6068 annualBlock.classList.add('hidden'); 6069 btnMonthly.classList.add('go-pro-modal-toggle-active', 'bg-white', 'shadow-sm', 'text-[#3B3B3C]'); 6070 btnAnnual.classList.remove('go-pro-modal-toggle-active', 'bg-white', 'shadow-sm'); 6071 btnAnnual.classList.add('text-[#3B3B3C]'); 6072 } else { 6073 annualBlock.classList.remove('hidden'); 6074 monthlyBlock.classList.add('hidden'); 6075 btnAnnual.classList.add('go-pro-modal-toggle-active', 'bg-white', 'shadow-sm', 'text-[#3B3B3C]'); 6076 btnMonthly.classList.remove('go-pro-modal-toggle-active', 'bg-white', 'shadow-sm'); 6077 btnMonthly.classList.add('text-[#3B3B3C]'); 6078 } 6079 }; 6080 6081 window.submitGoProCheckout = function() { 6082 var modal = document.getElementById('goProModal'); 6083 if (!modal) return; 6084 var ctaBlock = document.getElementById('go-pro-cta-block'); 6085 if (ctaBlock) { 6086 var plan = (ctaBlock.getAttribute('data-selected-plan') || 'monthly').trim(); 6087 modal.setAttribute('data-selected-plan', plan); 6088 } else { 6089 modal.setAttribute('data-selected-plan', modal.getAttribute('data-selected-plan') || 'monthly'); 6090 } 6091 var url = getCheckoutUrlFromModal(); 6092 var stepPricing = document.getElementById('goProStepPricing'); 6093 var stepIframe = document.getElementById('goProStepIframe'); 6094 var iframe = document.getElementById('goProCheckoutIframe'); 6095 var linkNewTab = document.getElementById('goProOpenNewTab'); 6096 var card = document.getElementById('goProModalCard'); 6097 if (!stepPricing || !stepIframe || !iframe) return; 6098 var modalNotOpen = !modal.classList.contains('flex'); 6099 if (modalNotOpen) openGoProModal(); 6100 function showIframeStep() { 6101 stepPricing.classList.add('hidden'); 6102 stepIframe.classList.remove('hidden'); 6103 if (card) { card.classList.remove('max-w-md'); card.classList.add('max-w-4xl'); } 6104 iframe.src = url || 'about:blank'; 6105 if (linkNewTab) linkNewTab.href = url || '#'; 6106 // Show processing loader on membership card while checkout is happening 6107 var memberLoader = document.getElementById('membership-processing-loader'); 6108 var goProLoader = document.getElementById('go-pro-processing-loader'); 6109 if (memberLoader) { 6110 var txt = memberLoader.querySelector('#membership-loader-text'); 6111 if (txt) txt.textContent = 'Processing your subscription...'; 6112 memberLoader.classList.remove('hidden'); memberLoader.classList.add('flex'); 6113 } 6114 if (goProLoader) { 6115 var txt2 = goProLoader.querySelector('#go-pro-loader-text'); 6116 if (txt2) txt2.textContent = 'Processing your subscription...'; 6117 goProLoader.classList.remove('hidden'); goProLoader.classList.add('flex'); 6118 } 6119 } 6120 if (modalNotOpen) setTimeout(showIframeStep, 50); 6121 else showIframeStep(); 6122 }; 6123 6124 document.addEventListener('click', function(e) { 6125 var modal = document.getElementById('goProModal'); 6126 if (modal && modal.classList.contains('flex') && e.target === modal) closeGoProModal(); 6127 }); 6128})(); 6129</script>
6129 6130 6131 6132 <!-- Nomados 2.0 Panel Management JavaScript --> 6133
6133<script> 6134 // Journey limit tracking 6135 window._journeyCount = 0; 6136 window._maxJourneys = null; 6137 6138 /** 6139 * Nomados 2.0 Panel Management System 6140 * Global functions for managing the right panel and sidebar 6141 */ 6142 6143 // Panel state management with navigation stack 6144 const NomadosPanel = { 6145 currentView: null, 6146 isOpen: false, 6147 panelElement: null, 6148 panelBody: null, 6149 panelHeader: null, 6150 panelHeaderTitle: null, 6151 panelCloseBtn: null, 6152 navigationStack: [], // Stack to track navigation history 6153 previousView: null, // Track previous view for back navigation (e.g., destination-detail -> journey-create -> journey) 6154 previousViewOptions: null, // Store options for previous view (e.g., citySlug for destination-detail) 6155 6156 init: function() { 6157 // Wait for DOM to be fully loaded 6158 if (document.readyState === 'loading') { 6159 document.addEventListener('DOMContentLoaded', () => this.initialize()); 6160 } else { 6161 this.initialize(); 6162 } 6163 }, 6164 6165 initialize: function() { 6166 this.panelElement = document.getElementById('nomados-panel'); 6167 this.panelBody = document.getElementById('nomados-panel-body'); 6168 this.panelHeader = document.getElementById('nomados-panel-header'); 6169 this.panelHeaderTitle = document.getElementById('nomados-panel-header-title'); 6170 this.panelCloseBtn = document.getElementById('nomados-panel-close-btn'); 6171 6172 if (!this.panelElement) { 6173 console.error('Nomados panel element not found'); 6174 return; 6175 } 6176 6177 if (!this.panelBody) { 6178 console.error('Nomados panel body element not found'); 6179 return; 6180 } 6181 6182 // Initialize sidebar buttons - Miles should be active when panel is closed 6183 this.updateSidebarButtons(); 6184 6185 // Verify view elements exist 6186 const viewIds = ['nomados-panel-journey', 'nomados-panel-market', 'nomados-panel-profile']; 6187 viewIds.forEach(id => { 6188 const element = document.getElementById(id); 6189 if (!element) { 6190 console.warn('View element not found:', id); 6191 } 6192 }); 6193 6194 // Close panel on Escape key 6195 document.addEventListener('keydown', (e) => { 6196 if (e.key === 'Escape' && this.isOpen) { 6197 // Check if we're in a sub-view 6198 const subViews = ['journey-detail', 'journey-create', 'journey-edit', 'journey-preview', 'market-visa', 'market-healthcare', 'market-lounge', 'market-esim', 'destination-detail']; 6199 if (subViews.includes(this.currentView)) { 6200 this.goBack(); 6201 } else { 6202 this.close(); 6203 } 6204 } 6205 }); 6206 6207 // Initialize sidebar button states 6208 this.updateSidebarButtons(); 6209 }, 6210 6211 open: function(view, options = {}) { 6212 if (!this.panelElement || !this.panelBody) { 6213 console.error('Panel elements not initialized'); 6214 return; 6215 } 6216 6217 // Close Go Pro view if it's open 6218 var goProViewEl = document.getElementById('go-pro-view'); 6219 if (goProViewEl && !goProViewEl.classList.contains('hidden') && typeof closeGoProView === 'function') { 6220 closeGoProView(); 6221 } 6222 6223 // Track previous view for back navigation 6224 // Store destination-detail city slug when opening it 6225 if (view === 'destination-detail' && options && options.citySlug) { 6226 // Store city slug for potential back navigation 6227 this.previousViewOptions = { citySlug: options.citySlug }; 6228 } 6229 6230 // If opening journey-create from destination-detail, remember it and its options 6231 if (view === 'journey-create' && this.currentView === 'destination-detail') { 6232 this.previousView = 'destination-detail'; 6233 // Store the city slug from current options or from stored destination city slug 6234 if (options && options.citySlug) { 6235 this.previousViewOptions = { citySlug: options.citySlug }; 6236 } else if (this.previousViewOptions && this.previousViewOptions.citySlug) { 6237 // Keep the existing city slug from destination-detail 6238 } else { 6239 // Try to extract city slug from the URL used to load destination-detail 6240 // The URL pattern is /chat/panel/destination/<city_slug>/ 6241 const destView = document.getElementById('nomados-panel-destination-detail'); 6242 if (destView) { 6243 // Try to get from the fetch URL that loaded this view 6244 // We'll extract it from the current URL or stored in a data attribute 6245 const urlMatch = window.location.href.match(/\/panel\/destination\/([^\/]+)\//); 6246 if (urlMatch) { 6247 this.previousViewOptions = { citySlug: urlMatch[1] }; 6248 } 6249 } 6250 } 6251 } 6252 // If opening journey list after creating from destination-detail, keep previous view and options 6253 else if (view === 'journey' && this.previousView === 'destination-detail' && this.currentView === 'journey-create') { 6254 // Keep previousView and previousViewOptions as destination-detail 6255 } 6256 // Clear previous view when opening main views directly 6257 else { 6258 const mainViews = ['journey', 'market', 'profile']; 6259 if (mainViews.includes(view)) { 6260 this.previousView = null; 6261 this.previousViewOptions = null; 6262 this.navigationStack = []; 6263 } 6264 } 6265 6266 // Track previous view for back navigation 6267 // Store destination-detail city slug when opening it 6268 if (view === 'destination-detail' && options && options.citySlug) { 6269 // Store city slug for potential back navigation 6270 this.previousViewOptions = { citySlug: options.citySlug }; 6271 } 6272 6273 // If opening journey-create from destination-detail, remember it and its options 6274 if (view === 'journey-create' && this.currentView === 'destination-detail') { 6275 this.previousView = 'destination-detail'; 6276 // Store the city slug from current options or from stored destination city slug 6277 if (options && options.citySlug) { 6278 this.previousViewOptions = { citySlug: options.citySlug }; 6279 } else if (this.previousViewOptions && this.previousViewOptions.citySlug) { 6280 // Keep the existing city slug from destination-detail 6281 } else { 6282 // Try to extract city slug from the URL used to load destination-detail 6283 // The URL pattern is /experiments/panel/destination/<city_slug>/ 6284 const destView = document.getElementById('nomados-panel-destination-detail'); 6285 if (destView) { 6286 // Try to get from the fetch URL that loaded this view 6287 // We'll extract it from the current URL or stored in a data attribute 6288 const urlMatch = window.location.href.match(/\/panel\/destination\/([^\/]+)\//); 6289 if (urlMatch) { 6290 this.previousViewOptions = { citySlug: urlMatch[1] }; 6291 } 6292 } 6293 } 6294 } 6295 // If opening journey list after creating from destination-detail, keep previous view and options 6296 else if (view === 'journey' && this.previousView === 'destination-detail' && this.currentView === 'journey-create') { 6297 // Keep previousView and previousViewOptions as destination-detail 6298 } 6299 // Clear previous view when opening main views directly 6300 else { 6301 const mainViews = ['journey', 'market', 'profile']; 6302 if (mainViews.includes(view)) { 6303 this.previousView = null; 6304 this.previousViewOptions = null; 6305 this.navigationStack = []; 6306 } 6307 } 6308 6309 // Simplified navigation: don't track history, just set current view 6310 // Navigation stack is no longer used for back button (requirement #2) 6311 const mainViews = ['journey', 'market', 'profile']; 6312 if (mainViews.includes(view)) { 6313 this.navigationStack = []; 6314 } 6315 6316 // Track journey-preview â market navigation so back returns to preview 6317 const marketSubViews = ['market-visa', 'market-healthcare', 'market-lounge', 'market-esim']; 6318 if (this.currentView === 'journey-preview' && marketSubViews.includes(view)) { 6319 this._returnToPreview = true; 6320 } else if (!marketSubViews.includes(view)) { 6321 // Clear the flag when navigating to anything other than a market sub-view 6322 this._returnToPreview = false;
6323 } 6324 6325 // Track whether market sub-view was opened from the market view 6326 // If opened from chat or elsewhere, back should close the panel instead of going to market 6327 if (marketSubViews.includes(view)) { 6328 this._serviceOpenedFromMarket = (this.currentView === 'market'); 6329 } 6330 6331 this.currentView = view; 6332 this.isOpen = true; 6333 6334 // Update sidebar button states 6335 this.updateSidebarButtons(); 6336 6337 // Load content for the view 6338 this.loadViewContent(view, options); 6339 6340 // Update header visibility 6341 this.updateHeaderVisibility(view, options); 6342 6343 // Open panel with animation 6344 this.panelElement.classList.add('open'); 6345 }, 6346 6347 close: function() { 6348 if (!this.panelElement) { 6349 console.error('Panel element not found'); 6350 return; 6351 } 6352 6353 // On mobile, restore the chat header and reset panel padding 6354 if (window.innerWidth < 768) { 6355 var mh = document.querySelector('header.md\\:hidden'); 6356 if (mh) mh.style.display = ''; 6357 var panelContent = this.panelElement ? this.panelElement.querySelector('.nomados-panel-content') : null; 6358 if (panelContent) panelContent.style.paddingTop = ''; 6359 // Hide the journey tab dropdown, view title, and restore logo in header 6360 var headerDropdown = document.getElementById('mobile-header-journey-dropdown'); 6361 if (headerDropdown) headerDropdown.classList.add('hidden'); 6362 var headerViewTitle = document.getElementById('mobile-header-view-title'); 6363 if (headerViewTitle) { headerViewTitle.classList.add('hidden'); headerViewTitle.textContent = ''; } 6364 var headerLogo = document.getElementById('mobile-header-logo'); 6365 if (headerLogo) headerLogo.classList.remove('hidden'); 6366 } 6367 6368 // Cleanup view-specific state before closing 6369 if (this.currentView === 'market-esim') { 6370 this.cleanupESimView(); 6371 } else if (this.currentView === 'market-lounge') { 6372 if (typeof window.resetLoungeView === 'function') { 6373 try { window.resetLoungeView(); } catch (e) {} 6374 } 6375 } 6376 6377 this.isOpen = false; 6378 this._returnToPreview = false; 6379 // Don't clear navigation stack on close - keep it for reopening 6380 // this.navigationStack = []; 6381 this.panelElement.classList.remove('open'); 6382 6383 // Flush pending essentials step now that user closed the panel 6384 if (typeof window._flushPendingEssentialStep === 'function') { 6385 window._flushPendingEssentialStep(); 6386 } 6387 6388 // Hide current view after animation 6389 setTimeout(() => { 6390 if (!this.isOpen && this.currentView) { 6391 const viewId = this.getViewId(this.currentView); 6392 if (viewId) { 6393 const viewElement = document.getElementById(viewId); 6394 if (viewElement) { 6395 viewElement.style.display = 'none'; 6396 // Clear content for sub-views to force fresh load next time 6397 // Healthcare view is preserved â its React widget cannot be reloaded 6398 const subViews = ['journey-create', 'journey-detail', 'journey-preview', 'market-visa', 'market-lounge', 'market-esim', 'destination-detail']; 6399 if (subViews.includes(this.currentView)) { 6400 viewElement.innerHTML = ''; 6401 } 6402 } 6403 } 6404 } 6405 this.currentView = null; 6406 this.updateSidebarButtons(); 6407 this.hideHeader(); 6408 }, 300); 6409 }, 6410 6411 cleanupESimView: function() { 6412 // Reset eSIM view state 6413 if (typeof window.resetESimView === 'function') { 6414 try { 6415 window.resetESimView(); 6416 } catch (err) { 6417 console.error('Error resetting eSIM view:', err); 6418 } 6419 } 6420 }, 6421 6422 goBack: function() { 6423 // Cleanup view-specific state before going back 6424 if (this.currentView === 'market-esim') { 6425 this.cleanupESimView(); 6426 } else if (this.currentView === 'market-lounge') { 6427 if (typeof window.resetLoungeView === 'function') { 6428 try { window.resetLoungeView(); } catch (e) {} 6429 } 6430 } 6431 6432 // Clear current view content before going back 6433 // Healthcare view is preserved â its React widget cannot be reloaded 6434 if (this.currentView && this.currentView !== 'market-healthcare') { 6435 const viewId = this.getViewId(this.currentView); 6436 if (viewId) { 6437 const viewElement = document.getElementById(viewId); 6438 if (viewElement) { 6439 viewElement.innerHTML = ''; 6440 } 6441 } 6442 } 6443 6444 // On mobile, navigate back to parent view if on a sub-view, otherwise close 6445 if (window.innerWidth < 768) { 6446 const mobileViewToParent = { 6447 'journey-detail': 'journey', 6448 'journey-create': 'journey', 6449 'journey-edit': 'journey-detail', 6450 'journey-preview': null, // close panel (accessed via "Go to Journey" button) 6451 'market-visa': this._serviceOpenedFromMarket ? 'market' : null, 6452 'market-healthcare': this._serviceOpenedFromMarket ? 'market' : null, 6453 'market-lounge': this._serviceOpenedFromMarket ? 'market' : null, 6454 'market-esim': this._serviceOpenedFromMarket ? 'market' : null, 6455 'destination-detail': null, // close panel 6456 }; 6457 const parentView = mobileViewToParent[this.currentView]; 6458 if (parentView) { 6459 // Clear current view content 6460 const curViewId = this.getViewId(this.currentView); 6461 if (curViewId) { 6462 const curEl = document.getElementById(curViewId); 6463 if (curEl) curEl.innerHTML = ''; 6464 } 6465 this.currentView = parentView; 6466 this.loadViewContent(parentView, { skipStack: true }); 6467 this.updateHeaderVisibility(parentView); 6468 return; 6469 } 6470 this._returnToPreview = false;
6471 this.close(); 6472 return; 6473 } 6474 6475 // Special case: return to journey-preview from market sub-views (desktop only) 6476 const marketViews = ['market-visa', 'market-healthcare', 'market-lounge', 'market-esim']; 6477 if (this._returnToPreview && marketViews.includes(this.currentView)) { 6478 // Cleanup current market view content 6479 const curViewId = this.getViewId(this.currentView); 6480 if (curViewId) { 6481 const curEl = document.getElementById(curViewId); 6482 if (curEl) { 6483 if (this.currentView === 'market-esim') this.cleanupESimView(); 6484 if (this.currentView === 'market-lounge' && typeof window.resetLoungeView === 'function') { 6485 try { window.resetLoungeView(); } catch (e) {} 6486 } 6487 // Healthcare view is preserved â its React widget cannot be reloaded 6488 if (this.currentView !== 'market-healthcare') { 6489 curEl.innerHTML = ''; 6490 } 6491 } 6492 } 6493 this._returnToPreview = false; 6494 this.currentView = 'journey-preview'; 6495 this.navigationStack = []; 6496 // Show existing journey-preview content (preserved in DOM) 6497 var allViews = this.panelBody.querySelectorAll('.nomados-panel-view'); 6498 allViews.forEach(function(v) { if (v && v.id) v.style.display = 'none'; }); 6499 var previewEl = document.getElementById('nomados-panel-journey-preview'); 6500 if (previewEl && previewEl.innerHTML.trim()) { 6501 previewEl.style.display = 'block'; 6502 this.updateHeaderVisibility('journey-preview'); 6503 this.updateSidebarButtons(); 6504 if (this.panelBody) this.panelBody.scrollTop = 0; 6505 } else { 6506 // Fallback to journey list if preview content was lost 6507 this.loadViewContent('journey', { skipStack: true }); 6508 this.updateHeaderVisibility('journey'); 6509 } 6510 // Flush pending essentials step now that user left the service panel 6511 if (typeof window._flushPendingEssentialStep === 'function') { 6512 window._flushPendingEssentialStep(); 6513 } 6514 return; 6515 } 6516 6517 // Special case: if we're at journey list and came from destination-detail, go back there 6518 if (this.currentView === 'journey' && this.previousView === 'destination-detail') { 6519 const citySlug = this.previousViewOptions && this.previousViewOptions.citySlug; 6520 6521 // Go back to destination-detail 6522 const prevView = this.previousView; 6523 const prevOptions = this.previousViewOptions; 6524 this.previousView = null; // Clear previous view 6525 this.previousViewOptions = null; 6526 this.currentView = 'destination-detail'; 6527 6528 if (citySlug) { 6529 this.loadViewContent('destination-detail', { 6530 skipStack: true, 6531 citySlug: citySlug 6532 }); 6533 this.updateHeaderVisibility('destination-detail'); 6534 } else { 6535 // Fallback: just close the panel 6536 this.close(); 6537 } 6538 return; 6539 } 6540 6541 // Map sub-views to their main views 6542 // Market sub-views only go back to market if opened from market, otherwise close 6543 const viewToMainView = { 6544 'journey-detail': 'journey', 6545 'journey-preview': 'journey', 6546 'journey-create': 'journey', 6547 'journey-edit': 'journey-detail', // Edit goes back to detail view 6548 'market-visa': this._serviceOpenedFromMarket ? 'market' : null, 6549 'market-healthcare': this._serviceOpenedFromMarket ? 'market' : null, 6550 'market-lounge': this._serviceOpenedFromMarket ? 'market' : null, 6551 'market-esim': this._serviceOpenedFromMarket ? 'market' : null, 6552 // destination-detail should close panel (no back button, shows miles) 6553 }; 6554 6555 const mainView = viewToMainView[this.currentView]; 6556 6557 if (mainView) { 6558 // Cleanup view-specific state before switching 6559 if (this.currentView === 'market-esim') { 6560 this.cleanupESimView(); 6561 } else if (this.currentView === 'market-lounge' && typeof window.resetLoungeView === 'function') { 6562 try { window.resetLoungeView(); } catch (e) {} 6563 } 6564 6565 // Go to main view 6566 this.currentView = mainView; 6567 this.navigationStack = []; // Clear navigation stack 6568 this.loadViewContent(mainView, { skipStack: true }); 6569 this.updateHeaderVisibility(mainView); 6570 } else { 6571 // Already at main view (or destination-detail), close panel 6572 this.close(); 6573 } 6574 }, 6575 6576 getViewId: function(view) { 6577 const viewMap = { 6578 'destination-detail': 'nomados-panel-destination-detail', 6579 'journey': 'nomados-panel-journey', 6580 'journey-detail': 'nomados-panel-journey-detail', 6581 'journey-preview': 'nomados-panel-journey-preview', 6582 'journey-create': 'nomados-panel-journey-create', 6583 'journey-edit': 'nomados-panel-journey-edit', 6584 'market': 'nomados-panel-market', 6585 'market-visa': 'nomados-panel-market-visa', 6586 'market-healthcare': 'nomados-panel-market-healthcare', 6587 'market-lounge': 'nomados-panel-market-lounge', 6588 'market-esim': 'nomados-panel-market-esim', 6589 'profile': 'nomados-panel-profile', 6590 }; 6591 return viewMap[view] || null; 6592 }, 6593 6594 getViewTitle: function(view) { 6595 const titleMap = { 6596 'destination-detail': '', // Hide title for destination-detail 6597 'journey': 'My Journeys', 6598 'journey-detail': '', // Hide title for journey-detail 6599 'journey-preview': '', // Hide title for journey-preview 6600 'journey-create': 'New Journey', 6601 'journey-edit': 'Edit Journey', 6602 'market': 'Marketplace', 6603 'market-visa': 'Visa Services', 6604 'market-healthcare': 'Health Insurance', 6605 'market-lounge': 'Airport Lounge', 6606 'market-esim': 'Mobile Internet (eSIM)', 6607 'profile': 'Profile', 6608 }; 6609 return titleMap[view] || ''; 6610 }, 6611 6612 updateHeaderVisibility: function(view, options = {}) { 6613 if (!this.panelHeader || !this.panelHeaderTitle || !this.panelCloseBtn) return; 6614 6615 const mainViews = ['journey', 'market', 'profile', 'destination-detail']; 6616 const isMainView = mainViews.includes(view); 6617 const isMobile = screen.width < 768; 6618 6619 // Add mobile-specific classes to panel header 6620 if (isMobile) { 6621 this.panelHeader.classList.add('mobile-panel-header'); 6622 // Add view-specific mobile classes 6623 this.panelHeader.classList.remove('mobile-journey-detail', 'mobile-destination-detail'); 6624 if (view === 'journey-detail' || view === 'journey-preview') { 6625 this.panelHeader.classList.add('mobile-journey-detail'); 6626 } else if (view === 'destination-detail') { 6627 this.panelHeader.classList.add('mobile-destination-detail'); 6628 } 6629 } else { 6630 this.panelHeader.classList.remove('mobile-panel-header', 'mobile-journey-detail', 'mobile-destination-detail'); 6631 } 6632 6633 // Add mobile-specific classes to panel body 6634 if (this.panelBody) { 6635 if (isMobile) { 6636 // Remove all view-specific classes first 6637 this.panelBody.classList.remove('mobile-journey-detail-body', 'mobile-destination-detail-body'); 6638 // Add view-specific classes 6639 if (view === 'journey-detail' || view === 'journey-preview') { 6640 this.panelBody.classList.add('mobile-journey-detail-body'); 6641 } else if (view === 'destination-detail') { 6642 this.panelBody.classList.add('mobile-destination-detail-body'); 6643 } 6644 } else { 6645 this.panelBody.classList.remove('mobile-journey-detail-body', 'mobile-destination-detail-body'); 6646 } 6647 } 6648 6649 // Hide/show mobile header (the top navigation header) based on view 6650 if (isMobile) { 6651 const mobileHeader = document.querySelector('header.md\\:hidden'); 6652 const panelContent = this.panelElement ? this.panelElement.querySelector('.nomados-panel-content') : null; 6653 const headerDropdown = document.getElementById('mobile-header-journey-dropdown'); 6654 if (mobileHeader) { 6655 const hideHeaderViews = ['journey-detail', 'journey-preview', 'destination-detail']; 6656 if (hideHeaderViews.includes(view)) { 6657 mobileHeader.style.display = 'none'; 6658 if (panelContent) panelContent.style.paddingTop = ''; 6659 } else { 6660 mobileHeader.style.display = 'block';
6661 // Add top padding so panel content isn't hidden behind the mobile header 6662 if (panelContent) { 6663 panelContent.style.paddingTop = mobileHeader.offsetHeight + 'px'; 6664 } 6665 } 6666 } 6667 // Show/hide the journey tab dropdown, view title, and logo in the mobile header 6668 const headerLogo = document.getElementById('mobile-header-logo'); 6669 const headerViewTitle = document.getElementById('mobile-header-view-title'); 6670 const mobileHeaderTitles = { 'market': 'Market', 'profile': 'Profile' }; 6671 6672 // Reset all dynamic header elements first 6673 if (headerDropdown) headerDropdown.classList.add('hidden'); 6674 if (headerViewTitle) { headerViewTitle.classList.add('hidden'); headerViewTitle.textContent = ''; } 6675 if (headerLogo) headerLogo.classList.remove('hidden'); 6676 6677 if (view === 'journey') { 6678 // Show journey dropdown, hide logo 6679 if (headerDropdown) headerDropdown.classList.remove('hidden'); 6680 if (headerLogo) headerLogo.classList.add('hidden'); 6681 // Re-measure header height after dropdown is shown 6682 if (mobileHeader && panelContent) { 6683 panelContent.style.paddingTop = mobileHeader.offsetHeight + 'px'; 6684 } 6685 } else if (mobileHeaderTitles[view]) { 6686 // Show view-specific heading, hide logo 6687 if (headerViewTitle) { 6688 headerViewTitle.textContent = mobileHeaderTitles[view]; 6689 headerViewTitle.classList.remove('hidden'); 6690 } 6691 if (headerLogo) headerLogo.classList.add('hidden'); 6692 } 6693 } 6694 6695 if (isMainView) { 6696 // Hide header, show close button 6697 this.panelHeader.classList.remove('visible'); 6698 } else { 6699 // Show header with title 6700 this.panelHeader.classList.add('visible'); 6701 const title = options.title || this.getViewTitle(view); 6702 if (this.panelHeaderTitle) { 6703 this.panelHeaderTitle.textContent = title; 6704 } 6705 } 6706 6707 // Always show close button (requirement #1) 6708 if (this.panelCloseBtn) { 6709 if (isMobile) { 6710 this.panelCloseBtn.style.display = 'none'; 6711 } else { 6712 this.panelCloseBtn.style.display = 'flex'; 6713 } 6714 } 6715 }, 6716 6717 hideHeader: function() { 6718 if (this.panelHeader) { 6719 this.panelHeader.classList.remove('visible'); 6720 } 6721 if (this.panelCloseBtn) { 6722 this.panelCloseBtn.style.display = 'flex'; 6723 } 6724 6725 // Show mobile header again when panel is closed 6726 const isMobile = screen.width < 768; 6727 if (isMobile) { 6728 const mobileHeader = document.querySelector('header.md\\:hidden'); 6729 if (mobileHeader) { 6730 mobileHeader.style.display = 'block'; 6731 } 6732 } 6733 6734 // Remove mobile classes from panel body 6735 if (this.panelBody) { 6736 this.panelBody.classList.remove('mobile-journey-detail-body', 'mobile-destination-detail-body'); 6737 } 6738 }, 6739 6740 loadViewContent: function(view, options = {}) { 6741 if (!this.panelBody) { 6742 console.error('Panel body not found'); 6743 return; 6744 } 6745 6746 const viewId = this.getViewId(view); 6747 if (!viewId) { 6748 console.error('Invalid view:', view); 6749 return; 6750 } 6751 6752 // Check if this is a sub-view that needs dynamic loading 6753 const subViews = ['journey-create', 'journey-detail', 'journey-edit', 'journey-preview', 'market-visa', 'market-healthcare', 'market-lounge', 'market-esim', 'destination-detail']; 6754 const isSubView = subViews.includes(view); 6755 6756 if (isSubView) { 6757 // Load sub-view content dynamically 6758 this.loadSubViewContent(view, viewId, options); 6759 return; 6760 } 6761 6762 // Special handling for journey view: reload from server if forceRefresh is true 6763 if (view === 'journey' && options.forceRefresh) { 6764 this.reloadJourneyView(viewId); 6765 return; 6766 } 6767 6768 // Show loader 6769 this.showLoader(); 6770 6771 // Hide all views first 6772 const allViews = this.panelBody.querySelectorAll('.nomados-panel-view'); 6773 allViews.forEach(v => { 6774 if (v && v.id) { 6775 v.style.display = 'none'; 6776 } 6777 }); 6778 6779 // Find the view element 6780 const findViewElement = () => { 6781 let viewElement = document.getElementById(viewId); 6782 6783 if (!viewElement) { 6784 console.warn('View element not found immediately, retrying...', viewId); 6785 setTimeout(() => { 6786 viewElement = document.getElementById(viewId); 6787 if (viewElement) { 6788 showView(viewElement); 6789 } else { 6790 console.error('View element not found after retry:', viewId); 6791 this.hideLoader(); 6792 } 6793 }, 50); 6794 return; 6795 } 6796 6797 showView(viewElement); 6798 }; 6799 6800 const showView = (viewElement) => { 6801 if (!viewElement) { 6802 console.error('View element is null'); 6803 this.hideLoader(); 6804 return; 6805 } 6806 6807 // Hide all views 6808 const allViews = this.panelBody.querySelectorAll('.nomados-panel-view'); 6809 allViews.forEach(v => { 6810 if (v && v.id && v.id !== viewId) { 6811 v.style.display = 'none'; 6812 } 6813 }); 6814 6815 // Show the selected view 6816 setTimeout(() => { 6817 viewElement.style.display = 'block'; 6818 this.hideLoader(); 6819 6820 // Scroll to top 6821 if (this.panelBody) { 6822 this.panelBody.scrollTop = 0; 6823 } 6824 }, 100); 6825 }; 6826 6827 // Try to find element immediately 6828 findViewElement(); 6829 }, 6830 6831 reloadJourneyView: function(viewId) { 6832 // Show loader 6833 this.showLoader(); 6834 6835 // Hide all views 6836 const allViews = this.panelBody.querySelectorAll('.nomados-panel-view'); 6837 allViews.forEach(v => { 6838 if (v && v.id) { 6839 v.style.display = 'none'; 6840 } 6841 }); 6842 6843 // Get the view element 6844 const viewElement = document.getElementById(viewId); 6845 if (!viewElement) { 6846 console.error('Journey view element not found:', viewId); 6847 this.hideLoader(); 6848 return; 6849 } 6850 6851 // Fetch fresh content from server 6852 const baseUrl = window.location.origin; 6853 const url = baseUrl + '/chat/panel/journey/'; 6854 6855 fetch(url, { 6856 method: 'GET', 6857 headers: { 6858 'X-Requested-With': 'XMLHttpRequest', 6859 }, 6860 }) 6861 .then(response => { 6862 if (!response.ok) { 6863 throw new Error(`HTTP error! status: ${response.status}`); 6864 } 6865 return response.text(); 6866 }) 6867 .then(html => { 6868 // Parse the HTML response 6869 try { 6870 const parser = new DOMParser(); 6871 const doc = parser.parseFromString(html, 'text/html'); 6872 6873 // Extract body content 6874 const bodyContent = doc.body || doc.querySelector('body'); 6875 if (bodyContent && bodyContent.innerHTML) { 6876 viewElement.innerHTML = bodyContent.innerHTML; 6877 } else { 6878 // Fallback: try to extract content between body tags 6879 const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i); 6880 if (bodyMatch && bodyMatch[1]) { 6881 viewElement.innerHTML = bodyMatch[1]; 6882 } else { 6883 viewElement.innerHTML = html; 6884 } 6885 } 6886 } catch (error) { 6887 console.error('Error parsing HTML:', error); 6888 // Fallback: try to extract content between body tags 6889 const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i); 6890 if (bodyMatch && bodyMatch[1]) { 6891 viewElement.innerHTML = bodyMatch[1]; 6892 } else { 6893 viewElement.innerHTML = html; 6894 } 6895 } 6896 6897 // Show the view 6898 viewElement.style.display = 'block'; 6899 this.hideLoader(); 6900 6901 // Scroll to top 6902 if (this.panelBody) { 6903 this.panelBody.scrollTop = 0; 6904 } 6905 6906 // Update current view 6907 this.currentView = 'journey'; 6908 this.updateHeaderVisibility('journey'); 6909 }) 6910 .catch(error => { 6911 console.error('Error reloading journey view:', error); 6912 this.hideLoader(); 6913 // Show the existing view as fallback 6914 viewElement.style.display = 'block'; 6915 }); 6916 }, 6917 6918 reloadJourneyView: function(viewId) { 6919 // Show loader 6920 this.showLoader(); 6921 6922 // Hide all views 6923 const allViews = this.panelBody.querySelectorAll('.nomados-panel-view'); 6924 allViews.forEach(v => { 6925 if (v && v.id) { 6926 v.style.display = 'none'; 6927 } 6928 }); 6929 6930 // Get the view element 6931 const viewElement = document.getElementById(viewId); 6932 if (!viewElement) { 6933 console.error('Journey view element not found:', viewId); 6934 this.hideLoader(); 6935 return; 6936 } 6937 6938 // Fetch fresh content from server 6939 const baseUrl = window.location.origin; 6940 const url = baseUrl + '/chat/panel/journey/'; 6941 6942 fetch(url, { 6943 method: 'GET', 6944 headers: { 6945 'X-Requested-With': 'XMLHttpRequest', 6946 }, 6947 }) 6948 .then(response => { 6949 if (!response.ok) { 6950 throw new Error(`HTTP error! status: ${response.status}`); 6951 } 6952 return response.text(); 6953 }) 6954 .then(html => { 6955 // Parse the HTML response 6956 try { 6957 const parser = new DOMParser(); 6958 const doc = parser.parseFromString(html, 'text/html'); 6959 6960 // Extract body content 6961 const bodyContent = doc.body || doc.querySelector('body'); 6962 if (bodyContent && bodyContent.innerHTML) { 6963 viewElement.innerHTML = bodyContent.innerHTML; 6964 } else { 6965 // Fallback: try to extract content between body tags 6966 const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i); 6967 if (bodyMatch && bodyMatch[1]) { 6968 viewElement.innerHTML = bodyMatch[1]; 6969 } else { 6970 viewElement.innerHTML = html; 6971 } 6972 } 6973 } catch (error) { 6974 console.error('Error parsing HTML:', error); 6975 // Fallback: try to extract content between body tags 6976 const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i); 6977 if (bodyMatch && bodyMatch[1]) { 6978 viewElement.innerHTML = bodyMatch[1]; 6979 } else { 6980 viewElement.innerHTML = html; 6981 } 6982 } 6983 6984 // Show the view 6985 viewElement.style.display = 'block'; 6986 this.hideLoader(); 6987 6988 // Scroll to top 6989 if (this.panelBody) { 6990 this.panelBody.scrollTop = 0; 6991 } 6992 6993 // Update current view 6994 this.currentView = 'journey'; 6995 this.updateHeaderVisibility('journey'); 6996 }) 6997 .catch(error => { 6998 console.error('Error reloading journey view:', error); 6999 this.hideLoader(); 7000 // Show the existing view as fallback 7001 viewElement.style.display = 'block'; 7002 }); 7003 }, 7004 7005 loadSubViewContent: function(view, viewId, options = {}) { 7006 // Show loader 7007 this.showLoader(); 7008 7009 // Hide all views 7010 const allViews = this.panelBody.querySelectorAll('.nomados-panel-view'); 7011 allViews.forEach(v => { 7012 if (v && v.id) { 7013 v.style.display = 'none'; 7014 } 7015 }); 7016 7017 // Get the view element 7018 let viewElement = document.getElementById(viewId); 7019 if (!viewElement) { 7020 console.error('Sub-view element not found:', viewId); 7021 this.hideLoader(); 7022 return; 7023 } 7024 7025 // Healthcare view: reuse existing DOM (React widget can't be reloaded) 7026 if (view === 'market-healthcare' && viewElement.innerHTML.trim()) { 7027 this.showSubView(viewElement); 7028 this.hideLoader(); 7029 return; 7030 } 7031 7032 // Clear content for other sub-views to force fresh load every time 7033 if (view === 'market-visa') { 7034 delete window.initializeVisaView; 7035 delete window.loadVisaCountries; 7036 delete window.visaCountries; 7037 } else if (view === 'market-esim') { 7038 // Only delete the init function so it gets redefined by the template script. 7039 // Keep ESimService.esimCountries cached to avoid re-fetching countries.json. 7040 delete window.initializeESimView; 7041 if (typeof window.resetESimView === 'function') { 7042 try { window.resetESimView(); } catch (e) {} 7043 } 7044 } else if (view === 'market-lounge') { 7045 delete window.initializeLoungeView; 7046 if (typeof window.resetLoungeView === 'function') { 7047 try { window.resetLoungeView(); } catch (e) {} 7048 } 7049 } 7050 viewElement.innerHTML = ''; 7051 7052 // Map view to URL 7053 const baseUrl = window.location.origin; 7054 // Fall back to stored UUID for journey views when none provided in options 7055 const journeyUuid = options.journeyUuid || window._lastJourneyUuid; 7056 let journeyCreateUrl = options.citySlug ? baseUrl + '/chat/panel/journey/create/' + encodeURIComponent(options.citySlug) + '/' : baseUrl + '/chat/panel/journey/create/'; 7057 if (options.suggestedStartDate || options.suggestedEndDate) { 7058 const params = new URLSearchParams(); 7059 if (options.suggestedStartDate) params.set('start', options.suggestedStartDate); 7060 if (options.suggestedEndDate) params.set('end', options.suggestedEndDate); 7061 journeyCreateUrl += (journeyCreateUrl.indexOf('?') === -1 ? '?' : '&') + params.toString(); 7062 } 7063 const urlMap = { 7064 'journey-create': journeyCreateUrl, 7065 'journey-detail': journeyUuid ? baseUrl + '/chat/panel/journey/' + encodeURIComponent(journeyUuid) + '/' : null, 7066 'journey-preview': journeyUuid ? baseUrl + '/chat/panel/journey/' + encodeURIComponent(journeyUuid) + '/preview/' + (window.innerWidth < 768 ? '?mobile=1' : '') : null, 7067 'journey-edit': journeyUuid ? baseUrl + '/chat/panel/journey/' + encodeURIComponent(journeyUuid) + '/edit/' : null, 7068 'market-visa': baseUrl + '/chat/panel/market/visa/', 7069 'market-healthcare': baseUrl + '/chat/panel/market/healthcare/', 7070 'market-lounge': baseUrl + '/chat/panel/market/lounge/', 7071 'market-esim': baseUrl + '/chat/panel/market/esim/', 7072 'destination-detail': options.citySlug ? baseUrl + '/chat/panel/destination/' + encodeURIComponent(options.citySlug) + '/' : null 7073 }; 7074 7075 const url = urlMap[view]; 7076 if (!url) { 7077 console.error('No URL mapped for view:', view, 'Options:', options); 7078 this.hideLoader(); 7079
7079 return; 7080 } 7081 7082 // Always use fetch for sub-views to avoid HTMX parsing issues with dynamic content 7083 // HTMX can have issues with script tags and complex HTML structures 7084 this.fetchSubViewContent(url, viewElement); 7085 }, 7086 7087 fetchSubViewContent: function(url, viewElement) { 7088 if (!viewElement || !viewElement.id) { 7089 console.error('View element is invalid'); 7090 this.hideLoader(); 7091 return; 7092 } 7093 7094 const viewId = viewElement.id; // Get viewId from element 7095 7096 fetch(url, { 7097 headers: { 7098 'X-Requested-With': 'XMLHttpRequest' 7099 }, 7100 credentials: 'same-origin' 7101 }) 7102 .then(response => { 7103 if (!response.ok) { 7104 throw new Error(`HTTP error! status: ${response.status}`); 7105 } 7106 // Check content type to ensure we're getting HTML 7107 const contentType = response.headers.get('content-type') || ''; 7108 if (!contentType.includes('text/html') && !contentType.includes('text/plain')) { 7109 console.warn('Unexpected content type:', contentType); 7110 } 7111 return response.text(); 7112 }) 7113 .then(html => { 7114 // Extract content - handle both full HTML documents and partial content 7115 html = html.trim(); 7116 7117 // Check if response is an error page or empty 7118 if (!html || html.length === 0) { 7119 throw new Error('Empty response received'); 7120 } 7121 7122 // Check if response looks like an error page 7123 if (html.includes('<!DOCTYPE html>') && (html.includes('Error') || html.includes('error') || html.includes('404') || html.includes('500'))) { 7124 console.warn('Response might be an error page'); 7125 } 7126 7127 // Verify we're loading into the correct container 7128 if (!viewElement || viewElement.id !== viewId) { 7129 console.error('Container mismatch! Expected:', viewId, 'Got:', viewElement ? viewElement.id : 'null'); 7130 this.hideLoader(); 7131 return; 7132 } 7133 7134 // Check if it's a full HTML document (has <html> or <body> tags) 7135 if (html.includes('<body') || html.includes('<html')) { 7136 // Full HTML document - extract body content using DOMParser 7137 try { 7138 const parser = new DOMParser(); 7139 const doc = parser.parseFromString(html, 'text/html'); 7140 7141 // Check for parsing errors 7142 const parserError = doc.querySelector('parsererror'); 7143 if (parserError) { 7144 console.warn('DOMParser error, trying fallback method'); 7145 // Fallback: extract content between body tags 7146 const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i); 7147 if (bodyMatch && bodyMatch[1]) { 7148 viewElement.innerHTML = bodyMatch[1]; 7149 } else { 7150 // Try to find main content div or just use the HTML 7151 viewElement.innerHTML = html; 7152 } 7153 } else { 7154 const bodyContent = doc.body || doc.querySelector('body'); 7155 if (bodyContent && bodyContent.innerHTML) { 7156 viewElement.innerHTML = bodyContent.innerHTML; 7157 } else { 7158 // Fallback: try to extract content between body tags 7159 const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i); 7160 if (bodyMatch && bodyMatch[1]) { 7161 viewElement.innerHTML = bodyMatch[1]; 7162 } else { 7163 // Last resort: use the HTML as-is but log a warning 7164 console.warn('Could not extract body content, using full HTML'); 7165 viewElement.innerHTML = html; 7166 } 7167 } 7168 } 7169 } catch (error) { 7170 console.error('Error parsing HTML:', error); 7171 // Fallback: try to extract content between body tags 7172 const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i); 7173 if (bodyMatch && bodyMatch[1]) { 7174 viewElement.innerHTML = bodyMatch[1]; 7175 } else { 7176 // Last resort: use the HTML as-is 7177 console.warn('Using HTML as fallback after parsing error'); 7178 viewElement.innerHTML = html; 7179 } 7180 } 7181 } else { 7182 // Partial content - use directly 7183 viewElement.innerHTML = html; 7184 } 7185 7186 // Clean up any malformed script tags that might have HTML content 7187 // This prevents the "Unexpected token '<'" error 7188 const allScripts = viewElement.querySelectorAll('script');
7189 allScripts.forEach(script => { 7190 if (!script.src) { 7191 // For inline scripts, check if content looks like HTML 7192 const content = script.textContent || script.innerHTML || ''; 7193 if (content.trim().startsWith('<') && /<[a-z][\s\S]*>/i.test(content)) { 7194 console.warn('Removing script tag with HTML content:', content.substring(0, 50)); 7195 script.remove(); 7196 } 7197 } 7198 }); 7199 7200 // Show the view first, then initialize scripts 7201 // This ensures the DOM is visible and ready before scripts execute 7202 this.showSubView(viewElement); 7203 7204 // Wait for view to be visible before initializing scripts 7205 // showSubView sets display after 100ms, so wait a bit longer 7206 // For healthcare view, the initialization function will handle script loading 7207 var _currentView = this.currentView; 7208 setTimeout(() => { 7209 this.initializeSubViewScripts(viewElement); 7210 // Re-apply actioned service states when journey preview loads 7211 if (_currentView === 'journey-preview' && typeof window._applyActionedServicesToPreview === 'function') { 7212 window._applyActionedServicesToPreview(); 7213 } 7214 }, 200); 7215 }) 7216 .catch(error => { 7217 console.error('Error loading sub-view content:', error); 7218 viewElement.innerHTML = '<div class="text-center py-12"><p class="text-red-500">Error loading content. Please try again.</p></div>'; 7219 this.hideLoader(); 7220 }); 7221 }, 7222 7223 showSubView: function(viewElement) { 7224 if (!viewElement) { 7225 console.error('View element is null'); 7226 this.hideLoader(); 7227 return; 7228 } 7229 7230 // Hide all views first 7231 const allViews = this.panelBody.querySelectorAll('.nomados-panel-view'); 7232 allViews.forEach(v => { 7233 if (v && v.id) { 7234 v.style.display = 'none'; 7235 } 7236 }); 7237 7238 // Show the sub-view 7239 setTimeout(() => { 7240 // Double-check we're showing the right view 7241 if (viewElement && viewElement.id) { 7242 viewElement.style.display = 'block'; 7243 7244 // Scroll to top 7245 if (this.panelBody) { 7246 this.panelBody.scrollTop = 0; 7247 } 7248 } 7249 this.hideLoader(); 7250 }, 100); 7251 }, 7252 7253 initializeSubViewScripts: function(viewElement) { 7254 if (!viewElement) { 7255 console.warn('initializeSubViewScripts: viewElement is null'); 7256 return; 7257 } 7258 7259 // Ensure view is visible before initializing scripts 7260 if (viewElement.style.display === 'none') { 7261 console.warn('View is hidden, showing it first:', viewElement.id); 7262 viewElement.style.display = 'block'; 7263 } 7264 7265 // List of scripts that are already loaded globally - skip re-loading them 7266 const globalScripts = [ 7267 'maps.googleapis.com', 7268 'googleapis.com/maps', 7269 'google.com/maps' 7270 ]; 7271 7272 // Helper function to validate script content 7273 const isValidScriptContent = (content) => { 7274 if (!content || typeof content !== 'string') return false; 7275 const trimmed = content.trim(); 7276 // Check if content looks like HTML (starts with < and contains HTML tags) 7277 if (trimmed.startsWith('<') && /<[a-z][\s\S]*>/i.test(trimmed)) { 7278 return false; // This is HTML, not JavaScript 7279 } 7280 // Check if it's empty or just whitespace 7281 if (trimmed.length === 0) return false; 7282 return true; 7283 }; 7284 7285 // Re-run any script tags in the loaded content 7286 const scripts = Array.from(viewElement.querySelectorAll('script')); 7287 const scriptPromises = []; 7288
7289 scripts.forEach((oldScript, index) => { 7290 try { 7291 // Validate that oldScript is a valid script element 7292 if (!oldScript || !oldScript.tagName || oldScript.tagName.toLowerCase() !== 'script') { 7293 console.warn('Invalid script element found, skipping:', oldScript); 7294 oldScript.remove(); 7295 return; 7296 } 7297 7298 // Skip scripts that are already loaded globally 7299 if (oldScript.src) { 7300 const isGlobalScript = globalScripts.some(global => oldScript.src.includes(global)); 7301 if (isGlobalScript) { 7302 oldScript.remove(); // Remove from DOM but don't re-execute 7303 return; 7304 } 7305 7306 // Skip SafetyWing script - it's handled by initializeHealthcareView function 7307 if (oldScript.src.includes('safetywing.com')) { 7308 oldScript.remove(); 7309 return; 7310 } 7311 } 7312 7313 // For inline scripts, validate content before processing 7314 if (!oldScript.src) { 7315 const scriptContent = oldScript.textContent || oldScript.innerHTML || ''; 7316 if (!isValidScriptContent(scriptContent)) { 7317 console.warn('Invalid script content detected (contains HTML or is empty), skipping:', { 7318 content: scriptContent.substring(0, 100), 7319 length: scriptContent.length 7320 }); 7321 oldScript.remove(); 7322 return; 7323 } 7324 } 7325 7326 const newScript = document.createElement('script'); 7327 7328 // Copy all attributes safely 7329 try { 7330 Array.from(oldScript.attributes).forEach(attr => { 7331 try { 7332 // Skip src attribute for now, we'll set it separately 7333 if (attr.name === 'src') return; 7334 newScript.setAttribute(attr.name, attr.value); 7335 } catch (attrError) { 7336 console.warn('Error setting attribute:', attr.name, attrError); 7337 } 7338 }); 7339 } catch (attrError) { 7340 console.warn('Error copying attributes:', attrError); 7341 } 7342 7343 // Handle module scripts (like SafetyWing widget) 7344 if (oldScript.type === 'module') { 7345 newScript.type = 'module'; 7346 } 7347 7348 // Copy src or text content 7349 if (oldScript.src) { 7350 // For SafetyWing module scripts, add cache-busting to force reload 7351 let scriptSrc = oldScript.src; 7352 if (scriptSrc.includes('safetywing.com') && oldScript.type === 'module') { 7353 // Add cache-busting parameter to force fresh load 7354 const separator = scriptSrc.includes('?') ? '&' : '?'; 7355 scriptSrc = scriptSrc + separator + 'v=' + Date.now(); 7356 } 7357 newScript.src = scriptSrc; 7358 7359 const promise = new Promise((resolve, reject) => { 7360 newScript.onload = () => { 7361 const delay = oldScript.type === 'module' ? 500 : 100; 7362 setTimeout(resolve, delay); 7363 }; 7364 newScript.onerror = (error) => { 7365 console.warn('Script failed to load:', oldScript.src, error); 7366 reject(error); 7367 }; 7368 }); 7369 scriptPromises.push(promise); 7370 } else { 7371 // For inline scripts, copy text content safely 7372 try { 7373 const scriptContent = oldScript.textContent || oldScript.innerHTML || ''; 7374 // Double-check content is valid before setting 7375 if (isValidScriptContent(scriptContent)) { 7376 newScript.textContent = scriptContent; 7377 // For inline scripts, append immediately and create a promise 7378 // Inline scripts execute synchronously when appended 7379 try { 7380 document.body.appendChild(newScript); 7381 // Create a promise that resolves after execution 7382 const inlinePromise = new Promise((resolve) => { 7383 // Wait longer to ensure all function definitions are complete 7384 setTimeout(() => { 7385 resolve(); 7386 }, 300); 7387 }); 7388 scriptPromises.push(inlinePromise);
7389 // Mark this script as already appended so we don't append again below 7390 newScript.dataset.appended = 'true'; 7391 } catch (appendError) { 7392 console.error('Error appending inline script:', appendError); 7393 oldScript.remove(); 7394 return; 7395 } 7396 } else { 7397 console.warn('Skipping script with invalid content'); 7398 oldScript.remove(); 7399 return; 7400 } 7401 } catch (textError) { 7402 console.warn('Error copying script text content:', textError); 7403 oldScript.remove(); 7404 return; 7405 } 7406 } 7407 7408 // Validate newScript before appending 7409 if (!newScript) { 7410 console.error('Invalid script element created, skipping'); 7411 oldScript.remove(); 7412 return; 7413 } 7414 7415 // Ensure it's a valid Node 7416 if (!(newScript instanceof HTMLScriptElement)) { 7417 console.error('Script element is not a valid HTMLScriptElement:', newScript); 7418 oldScript.remove(); 7419 return; 7420 } 7421 7422 // Validate that document.body exists 7423 if (!document.body) { 7424 console.error('document.body does not exist, cannot append script'); 7425 oldScript.remove(); 7426 return; 7427 } 7428 7429 // Append to document body to ensure proper execution context and global scope 7430 // Skip if this is an inline script (already appended above) 7431 if (!newScript.dataset || newScript.dataset.appended !== 'true') { 7432 try { 7433 // Double-check the element is valid before appending
7434 if (newScript.nodeType === Node.ELEMENT_NODE && newScript.tagName === 'SCRIPT') { 7435 document.body.appendChild(newScript); 7436 } else { 7437 console.error('Script element is not a valid SCRIPT element:', newScript.nodeType, newScript.tagName); 7438 oldScript.remove(); 7439 return; 7440 } 7441 } catch (appendError) { 7442 console.error('Error appending script:', appendError, { 7443 script: newScript, 7444 src: newScript.src, 7445 type: newScript.type, 7446 nodeType: newScript.nodeType, 7447 tagName: newScript.tagName, 7448 hasContent: !!(newScript.textContent || newScript.src) 7449 }); 7450 oldScript.remove(); 7451 return; 7452 } 7453 } 7454 7455 // Remove old script from viewElement 7456 try { 7457 if (oldScript.parentNode) { 7458 oldScript.remove(); 7459 } 7460 } catch (removeError) { 7461 console.warn('Error removing old script:', removeError); 7462 } 7463 } catch (error) { 7464 console.error('Error processing script element:', error, oldScript); 7465 // Remove the problematic script 7466 try { 7467 if (oldScript && oldScript.parentNode) { 7468 oldScript.remove(); 7469 } 7470 } catch (e) { 7471 // Ignore removal errors 7472 } 7473 // Continue with next script 7474 } 7475 }); 7476 7477 // Wait for all scripts to load and execute, then initialize 7478 if (scriptPromises.length > 0) { 7479 Promise.all(scriptPromises).then(() => { 7480 // Give scripts time to define functions - wait longer for dynamic content 7481 setTimeout(() => { 7482 this.initializeViewSpecificFeatures(viewElement); 7483 }, 400); 7484 }).catch((error) => { 7485 console.warn('Base: Some scripts failed, continuing:', error); 7486 setTimeout(() => { 7487 this.initializeViewSpecificFeatures(viewElement); 7488 }, 600); 7489 }); 7490 } else { 7491 // For inline scripts, wait longer for execution 7492 setTimeout(() => { 7493 this.initializeViewSpecificFeatures(viewElement); 7494 }, 500); 7495 } 7496 }, 7497 7498 initializeViewSpecificFeatures: function(viewElement) { 7499 if (!viewElement) return; 7500 7501 const viewId = viewElement.id; 7502 7503 // Initialize Visa view 7504 if (viewId === 'nomados-panel-market-visa') { 7505 // Fix close button 7506 const closeButtons = viewElement.querySelectorAll('[onclick*="closeVisaModal"]'); 7507 closeButtons.forEach(btn => { 7508 btn.setAttribute('onclick', 'nomadosPanelGoBack()'); 7509 }); 7510 7511 // Wait for scripts to execute, then initialize 7512 const initVisa = async () => { 7513 // Wait for function to be available with longer timeout 7514 let retries = 0; 7515 const maxRetries = 25; 7516 7517 while (typeof window.initializeVisaView !== 'function' && retries < maxRetries) { 7518 await new Promise(resolve => setTimeout(resolve, 100)); 7519 retries++; 7520 } 7521 7522 if (typeof window.initializeVisaView === 'function') { 7523 try { 7524 await window.initializeVisaView(); 7525 } catch (err) { 7526 console.error('Base: Error initializing visa view:', err); 7527 } 7528 } else { 7529 console.error('Base: initializeVisaView not found after', maxRetries, 'retries'); 7530 } 7531 }; 7532 7533 // Start initialization after scripts have had time to execute 7534 setTimeout(initVisa, 300); 7535 } 7536 7537 // Initialize Healthcare view (SafetyWing widget) 7538 if (viewId === 'nomados-panel-market-healthcare') { 7539 // Use the initialization function from market_healthcare.html 7540 setTimeout(() => { 7541 if (typeof window.initializeHealthcareView === 'function') { 7542 try { 7543 window.initializeHealthcareView(); 7544 } catch (err) { 7545 console.error('Error initializing healthcare view:', err); 7546 } 7547 } else { 7548 console.warn('initializeHealthcareView function not found'); 7549 } 7550 }, 200); 7551 } 7552 7553 // Initialize Lounge view 7554 if (viewId === 'nomados-panel-market-lounge') { 7555 // Simple initialization - use window function if available 7556 setTimeout(() => { 7557 if (typeof window.initializeLoungeView === 'function') { 7558 try { 7559 window.initializeLoungeView(); 7560 } catch (err) { 7561 console.error('Error initializing lounge view:', err); 7562 } 7563 } 7564 7565 // Initialize CollectJS if available 7566 setTimeout(() => { 7567 if (typeof window.CollectJS !== 'undefined') { 7568 const collectScript = viewElement.querySelector('script[data-tokenization-key]'); 7569 if (collectScript) { 7570 const tokenizationKey = collectScript.getAttribute('data-tokenization-key'); 7571 if (tokenizationKey && CollectJS.configure) { 7572 CollectJS.configure({ tokenizationKey: tokenizationKey }); 7573 } 7574 } 7575 } 7576 }, 300); 7577 }, 300); 7578 } 7579 7580 7581 // Initialize eSIM view 7582 if (viewId === 'nomados-panel-market-esim') { 7583 // Wait for scripts to execute, then initialize 7584 const initESim = async () => { 7585 // Wait for function to be available with longer timeout 7586 let retries = 0; 7587 const maxRetries = 25; 7588 7589 while (typeof window.initializeESimView !== 'function' && retries < maxRetries) { 7590 await new Promise(resolve => setTimeout(resolve, 100)); 7591 retries++; 7592 } 7593 7594 if (typeof window.initializeESimView === 'function') { 7595 try { 7596 await window.initializeESimView(); 7597 } catch (err) { 7598 console.error('Base: Error initializing eSIM view:', err); 7599 } 7600 } else { 7601 console.error('Base: initializeESimView not found after', maxRetries, 'retries'); 7602 } 7603 }; 7604 7605 // Start initialization after scripts have had time to execute 7606 setTimeout(initESim, 300); 7607 } 7608 }, 7609 7610 showLoader: function(message) { 7611 if (!this.panelBody) return; 7612 if (this.panelElement) { 7613 this.panelElement.classList.add('open'); 7614 this.isOpen = true; 7615 if (typeof this.updateSidebarButtons === 'function') this.updateSidebarButtons(); 7616 } 7617 var text = message || 'Loading...'; 7618 var loader = document.getElementById('nomados-panel-loader'); 7619 if (!loader) { 7620 loader = document.createElement('div'); 7621 loader.id = 'nomados-panel-loader'; 7622 loader.className = 'nomados-panel-loader'; 7623 loader.innerHTML = ` 7624 <div class="nomados-panel-loader-spinner"></div> 7625 <div class="nomados-panel-loader-text">${text}</div> 7626 `; 7627 this.panelBody.appendChild(loader); 7628 } else { 7629 var textEl = loader.querySelector('.nomados-panel-loader-text'); 7630 if (textEl) textEl.textContent = text; 7631 } 7632 loader.style.display = 'flex'; 7633 }, 7634 7635 hideLoader: function() { 7636 const loader = document.getElementById('nomados-panel-loader'); 7637 if (loader) { 7638 loader.style.display = 'none'; 7639 } 7640 }, 7641 7642 updateSidebarButtons: function() { 7643 const buttons = document.querySelectorAll('.sidebar-btn'); 7644 7645 // Map sub-views to their main views (requirement #3) 7646 const subViewToMainView = { 7647 'journey-detail': 'journey', 7648 'journey-preview': 'journey', 7649 'journey-create': 'journey', 7650 'journey-edit': 'journey', // Edit view should keep journey icon active 7651 'market-visa': 'market', 7652 'market-healthcare': 'market', 7653 'market-lounge': 'market', 7654 'market-esim': 'market', 7655 'destination-detail': 'miles' // Destination detail should show miles as active 7656 }; 7657 7658 // Get the effective view (main view if current view is a sub-view) 7659 const effectiveView = subViewToMainView[this.currentView] || this.currentView; 7660 7661 buttons.forEach(btn => { 7662 const view = btn.getAttribute('data-panel-view'); 7663 // Check if button matches effective view (requirement #3) 7664 // Special case: destination-detail should always show miles as active 7665 const isActive = (view === effectiveView && this.isOpen) || (view === 'miles' && (!this.isOpen || this.currentView === 'destination-detail')); 7666 7667 if (isActive) { 7668 btn.classList.add('active'); 7669 7670 // Switch to filled icon 7671 const filledIcon = btn.querySelector('.miles-icon-filled, .launch-icon-filled, .market-icon-filled, .profile-icon-filled'); 7672 const blankIcon = btn.querySelector('.miles-icon-blank, .launch-icon-blank, .market-icon-blank, .profile-icon-blank');
7673 if (filledIcon) filledIcon.classList.remove('hidden'); 7674 if (blankIcon) blankIcon.classList.add('hidden'); 7675 7676 // Update text to font-medium 7677 const textSpan = btn.querySelector('.sidebar-label'); 7678 if (textSpan) { 7679 textSpan.classList.remove('font-normal'); 7680 textSpan.classList.add('font-bold'); 7681 } 7682 } else { 7683 btn.classList.remove('active'); 7684 7685 // Switch to blank icon 7686 const filledIcon = btn.querySelector('.miles-icon-filled, .launch-icon-filled, .market-icon-filled, .profile-icon-filled'); 7687 const blankIcon = btn.querySelector('.miles-icon-blank, .launch-icon-blank, .market-icon-blank, .profile-icon-blank'); 7688 if (filledIcon) filledIcon.classList.add('hidden'); 7689 if (blankIcon) blankIcon.classList.remove('hidden'); 7690 7691 // Update text to font-normal 7692 const textSpan = btn.querySelector('.sidebar-label'); 7693 if (textSpan) { 7694 textSpan.classList.remove('font-bold'); 7695 textSpan.classList.add('font-normal'); 7696 } 7697 } 7698 }); 7699 }, 7700 7701 switchView: function(view) { 7702 if (this.isOpen && this.currentView === view) { 7703 // Already showing this view, do nothing 7704 return; 7705 } 7706 7707 if (this.isOpen) { 7708 // Switch content without closing 7709 this.currentView = view; 7710 this.loadViewContent(view); 7711 this.updateHeaderVisibility(view); 7712 this.updateSidebarButtons(); 7713 } else { 7714 // Open panel with new view 7715 this.open(view); 7716 } 7717 } 7718 }; 7719 7720 // Global functions for easy access 7721 window.openNomadosPanel = function(view, options = {}) { 7722 NomadosPanel.open(view, options); 7723 }; 7724 7725 window.closeNomadosPanel = function() { 7726 // If a service was opened from journey-preview, go back to preview instead of closing 7727 if (NomadosPanel._returnToPreview) { 7728 NomadosPanel.goBack(); 7729 return; 7730 } 7731 NomadosPanel.close(); 7732 }; 7733 7734 window.switchNomadosPanelView = function(view) { 7735 NomadosPanel.switchView(view); 7736 }; 7737 7738 // Back button function 7739 window.nomadosPanelGoBack = function() { 7740 NomadosPanel.goBack(); 7741 }; 7742 7743 // Sub-view functions 7744 window.openJourneyCreate = function() { 7745 // Block if journey limit reached (non-Pro users) 7746 if (window._maxJourneys !== null && window._journeyCount >= window._maxJourneys) { 7747 if (typeof showNotification === 'function') { 7748 showNotification("You've reached your journey limit. Upgrade to Pro for unlimited journeys!"); 7749 } 7750 if (typeof openGoProModal === 'function') { 7751 openGoProModal(); 7752 } 7753 return; 7754 } 7755 NomadosPanel.open('journey-create', { 7756 isSubView: true, 7757 previousTitle: 'My Journeys' 7758 }); 7759 }; 7760 7761 window.openJourneyDetail = function(journeyUuid) { 7762 NomadosPanel.open('journey-detail', { 7763 isSubView: true, 7764 journeyUuid: journeyUuid, 7765 previousTitle: 'My Journeys' 7766 }); 7767 }; 7768 7769 window.openJourneyEdit = function(journeyUuid) { 7770 NomadosPanel.open('journey-edit', { 7771 isSubView: true, 7772 journeyUuid: journeyUuid, 7773 previousTitle: 'Journey Details' 7774 }); 7775 }; 7776 7777 window.openMarketVisa = function() { 7778 NomadosPanel.open('market-visa', { 7779 isSubView: true, 7780 previousTitle: 'Marketplace' 7781 }); 7782 }; 7783 7784 window.openMarketHealthcare = function() { 7785 NomadosPanel.open('market-healthcare', { 7786 isSubView: true, 7787 previousTitle: 'Marketplace' 7788 }); 7789 }; 7790 7791 window.openMarketLounge = function() { 7792 NomadosPanel.open('market-lounge', { 7793 isSubView: true, 7794 previousTitle: 'Marketplace' 7795 }); 7796 }; 7797 7798 window.openMarketESim = function() { 7799 NomadosPanel.open('market-esim', { 7800 isSubView: true, 7801 previousTitle: 'Marketplace' 7802 }); 7803 }; 7804 7805 window.openDestinationDetail = function(citySlug) { 7806 NomadosPanel.open('destination-detail', { 7807 isSubView: true, 7808 citySlug: citySlug, 7809 previousTitle: 'Destinations' 7810 }); 7811 }; 7812 7813 // Visual calendar for Plan my trip date selection (start or end) 7814 function renderPlanTripCalendar(widgetEl, onSelectCallback) { 7815 if (!widgetEl) return; 7816 const minDateStr = widgetEl.dataset.minDate || new Date().toISOString().slice(0, 10); 7817 const minDate = new Date(minDateStr); 7818 let viewYear = parseInt(widgetEl.dataset.viewYear || minDate.getFullYear(), 10);
7819 let viewMonth = parseInt(widgetEl.dataset.viewMonth || minDate.getMonth(), 10); 7820 let selectedDateStr = widgetEl.querySelector('.plan-trip-selected-date') ? widgetEl.querySelector('.plan-trip-selected-date').value : ''; 7821 7822 const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; 7823 const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 7824 7825 function render() { 7826 widgetEl.dataset.viewYear = viewYear; 7827 widgetEl.dataset.viewMonth = viewMonth; 7828 const monthLabel = widgetEl.querySelector('.plan-trip-calendar-month'); 7829 if (monthLabel) monthLabel.textContent = monthNames[viewMonth] + ' ' + viewYear; 7830 7831 const first = new Date(viewYear, viewMonth, 1); 7832 const last = new Date(viewYear, viewMonth + 1, 0); 7833 const startPad = first.getDay(); 7834 const daysInMonth = last.getDate(); 7835 7836 const weekdaysEl = widgetEl.querySelector('.plan-trip-calendar-weekdays'); 7837 if (weekdaysEl) { 7838 weekdaysEl.innerHTML = weekdays.map(function(w) { return '<span class="font-medium">' + w + '</span>'; }).join(''); 7839 } 7840 7841 const daysEl = widgetEl.querySelector('.plan-trip-calendar-days'); 7842 if (!daysEl) return; 7843 var html = ''; 7844 for (var i = 0; i < startPad; i++) html += '<span class="py-1.5 text-[#ccc]"> </span>'; 7845 for (var d = 1; d <= daysInMonth; d++) { 7846 var dateStr = viewYear + '-' + String(viewMonth + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); 7847 var cellDate = new Date(viewYear, viewMonth, d); 7848 var isPast = cellDate < minDate; 7849 var isSelected = dateStr === selectedDateStr; 7850 var cls = 'py-1.5 rounded-lg cursor-pointer '; 7851 if (isPast) cls += 'text-[#ccc] cursor-not-allowed'; 7852 else if (isSelected) cls += 'bg-[#3B3B3C] text-white font-medium'; 7853 else cls += 'text-dark hover:bg-[#F3F3F3]'; 7854 html += '<button type="button" class="plan-trip-day ' + cls + '" data-date="' + dateStr + '" ' + (isPast ? 'disabled' : '') + '>' + d + '</button>'; 7855 } 7856 daysEl.innerHTML = html; 7857 7858 daysEl.querySelectorAll('.plan-trip-day:not([disabled])').forEach(function(btn) { 7859 btn.addEventListener('click', function() { 7860 selectedDateStr = btn.dataset.date; 7861 var hidden = widgetEl.querySelector('.plan-trip-selected-date'); 7862 if (hidden) hidden.value = selectedDateStr; 7863 if (typeof onSelectCallback === 'function') onSelectCallback(selectedDateStr); 7864 render(); 7865 }); 7866 }); 7867 7868 var prevBtn = widgetEl.querySelector('.plan-trip-calendar-prev'); 7869 var nextBtn = widgetEl.querySelector('.plan-trip-calendar-next'); 7870 if (prevBtn) { 7871 prevBtn.onclick = function() { 7872 viewMonth--; 7873 if (viewMonth < 0) { viewMonth = 11; viewYear--; } 7874 render(); 7875 }; 7876 } 7877 if (nextBtn) { 7878 nextBtn.onclick = function() { 7879 viewMonth++; 7880 if (viewMonth > 11) { viewMonth = 0; viewYear++; } 7881 render(); 7882 }; 7883 } 7884 } 7885 7886 render(); 7887 } 7888 7889 // Create journey from chat and open journey-preview panel 7890 window.createJourneyFromPlanTrip = function(citySlug, cityName, startDate, endDate, createBtnEl, onSuccessCallback) { 7891 // Block if journey limit reached (non-Pro users) 7892 if (window._maxJourneys !== null && window._journeyCount >= window._maxJourneys) { 7893 if (typeof showNotification === 'function') { 7894 showNotification("You've reached your journey limit. Upgrade to Pro for unlimited journeys!"); 7895 } 7896 if (typeof openGoProModal === 'function') { 7897 openGoProModal(); 7898 } 7899 return; 7900 } 7901 7902 function getCookie(name) { 7903 var v = null; 7904 if (document.cookie && document.cookie !== '') { 7905 document.cookie.split(';').forEach(function(c) { 7906 c = c.trim(); 7907 if (c.substring(0, name.length + 1) === name + '=') v = decodeURIComponent(c.substring(name.length + 1)); 7908 }); 7909 } 7910 return v; 7911 } 7912 var name = 'My ' + (cityName || 'Trip') + ' Adventure'; 7913 var notes = 'Description for my ' + (cityName || 'trip') + ' adventure'; 7914 var formData = new FormData(); 7915 formData.append('name', name); 7916 formData.append('notes', notes); 7917 formData.append('city_slug', citySlug); 7918 formData.append('start_date', startDate); 7919 formData.append('end_date', endDate); 7920 formData.append('csrfmiddlewaretoken', getCookie('csrftoken')); 7921 if (createBtnEl) { createBtnEl.disabled = true; createBtnEl.style.opacity = '0.6'; } 7922 7923 // Open panel with loader immediately â hide all views so nothing flashes 7924 // On mobile: skip visual open so the chat stays visible for the essentials wizard 7925 var _isMobileCreation = window.innerWidth < 768; 7926 if (typeof NomadosPanel !== 'undefined') { 7927 NomadosPanel.currentView = 'journey-preview'; 7928 // Hide every panel view so the journey list doesn't flash 7929 var allPanelViews = NomadosPanel.panelBody ? NomadosPanel.panelBody.querySelectorAll('.nomados-panel-view') : []; 7930 allPanelViews.forEach(function(v) { if (v && v.id) v.style.display = 'none'; }); 7931 if (!_isMobileCreation) { 7932 NomadosPanel.isOpen = true; 7933 if (NomadosPanel.panelElement) NomadosPanel.panelElement.classList.add('open'); 7934 NomadosPanel.updateSidebarButtons(); 7935 NomadosPanel.updateHeaderVisibility('journey-preview'); 7936 NomadosPanel.showLoader('Creating your journey...'); 7937 } 7938 } 7939 7940 fetch('/journeys/create/', { 7941 method: 'POST', 7942 headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': getCookie('csrftoken') }, 7943 body: formData 7944 }) 7945 .then(function(r) { return r.json(); }) 7946 .then(function(data) { 7947 if (data && data.success && data.journey_uuid) { 7948 window._journeyCount++; 7949 // Store journey UUID so "Go to Journey" can reopen after close 7950 window._lastJourneyUuid = data.journey_uuid; 7951 // Directly load preview content into the already-open panel (no full open() call) 7952 var previewUrl = window.location.origin + '/chat/panel/journey/' + encodeURIComponent(data.journey_uuid) + '/preview/' + (_isMobileCreation ? '?mobile=1' : ''); 7953 var viewElement = document.getElementById('nomados-panel-journey-preview'); 7954 if (viewElement) { 7955 viewElement.innerHTML = '';
7956 NomadosPanel.fetchSubViewContent(previewUrl, viewElement); 7957 } 7958 // Silently refresh journey list HTML in background (don't show it or change currentView) 7959 var journeyViewId = NomadosPanel.getViewId('journey'); 7960 var journeyEl = journeyViewId ? document.getElementById(journeyViewId) : null; 7961 if (journeyEl) { 7962 fetch(window.location.origin + '/chat/panel/journey/', { 7963 headers: { 'X-Requested-With': 'XMLHttpRequest' }, 7964 credentials: 'same-origin' 7965 }) 7966 .then(function(r) { return r.text(); }) 7967 .then(function(html) { 7968 try { 7969 var parser = new DOMParser(); 7970 var doc = parser.parseFromString(html, 'text/html'); 7971 var body = doc.body || doc.querySelector('body'); 7972 if (body && body.innerHTML) journeyEl.innerHTML = body.innerHTML; 7973 } catch(e) { /* silent */ } 7974 }) 7975 .catch(function() { /* silent */ }); 7976 } 7977 if (typeof onSuccessCallback === 'function') onSuccessCallback(cityName); 7978 } else { 7979 if (typeof NomadosPanel !== 'undefined') NomadosPanel.close(); 7980 if (createBtnEl) { createBtnEl.disabled = false; createBtnEl.style.opacity = '1'; } 7981 if (data && data.limit_reached && typeof openGoProModal === 'function') { 7982 openGoProModal(); 7983 } 7984 showNotification(data && data.error ? data.error : 'Could not create journey.'); 7985 } 7986 }) 7987 .catch(function(err) { 7988 console.error(err); 7989 if (typeof NomadosPanel !== 'undefined') NomadosPanel.close(); 7990 if (createBtnEl) { createBtnEl.disabled = false; createBtnEl.style.opacity = '1'; } 7991 showNotification('Could not create journey. Please try again.'); 7992 }); 7993 }; 7994 7995 // Append "I've created your [City] journey! ð" and "Let's do it!" to chat after journey creation 7996 // Track whether visa is required for this journey 7997 var _visaRequired = false; 7998 7999 // Called by the journey preview template once visa data is fetched 8000 window._onVisaDataReady = function(visaRequired, visaDays) { 8001 _visaRequired = visaRequired; 8002 var visaLineEl = document.getElementById('essentials-visa-line'); 8003 if (visaLineEl) { 8004 if (visaRequired) { 8005 visaLineEl.innerHTML = 'â ï¸ Visa: <strong>Required</strong> â set this up in your essentials'; 8006 visaLineEl.className = 'text-dark mt-2'; 8007 } else { 8008 var days = visaDays || 90; 8009 visaLineEl.innerHTML = 'â Visa: <strong>' + days + ' days visa-free</strong> â you\'re all set!'; 8010 visaLineEl.className = 'text-dark mt-2'; 8011 } 8012 } 8013 // Enable "Let's do it!" now that visa data is ready â but not if already clicked 8014 var btn = document.getElementById('essentials-start-btn'); 8015 if (btn && btn.disabled && !btn.getAttribute('data-clicked')) { 8016 btn.disabled = false; 8017 btn.style.opacity = '1'; 8018 btn.style.cursor = 'pointer'; 8019 btn.classList.add('hover:border-[#3B3B3C]'); 8020 var label = btn.querySelector('.btn-label'); 8021 if (label) label.textContent = 'Let\'s do it!'; 8022 var gi = btn.querySelector('.btn-icon-gif'); 8023 var si = btn.querySelector('.btn-icon-svg'); 8024 if (gi) gi.style.display = 'block'; 8025 if (si) si.style.display = 'none'; 8026 } 8027 }; 8028 8029 // ââ Mobile Services Tracker Bar ââââââââââââââââââââââââââââââââââââââââââ 8030 var _trackerIconMap = { 8031 'visa': 'tracker-icon-visa', 8032 'health-insurance': 'tracker-icon-health', 8033 'internet': 'tracker-icon-internet', 8034 'airport-lounge': 'tracker-icon-lounge' 8035 }; 8036 window._showMobileServicesTracker = function() { 8037 var tracker = document.getElementById('mobile-services-tracker'); 8038 if (tracker && window.innerWidth < 768) tracker.style.display = ''; 8039 }; 8040 8041 window._hideMobileServicesTracker = function() { 8042 var tracker = document.getElementById('mobile-services-tracker'); 8043 if (tracker) tracker.style.display = 'none'; 8044 }; 8045 8046 window._updateMobileTracker = function(step, state) { 8047 var iconId = _trackerIconMap[step]; 8048 if (!iconId) return; 8049 var iconEl = document.getElementById(iconId); 8050 if (!iconEl) return; 8051 var wrap = iconEl.closest('.tracker-icon-wrap'); 8052 if (!wrap) return; 8053 wrap.classList.remove('tracker-active', 'tracker-done'); 8054 if (state === 'active') { 8055 wrap.classList.add('tracker-active'); 8056 } else if (state === 'done') { 8057 wrap.classList.add('tracker-done'); 8058 } 8059 }; 8060 8061 window.appendPlanMyTripSuccessMessage = function(cityName) { 8062 var chatMessages = document.getElementById('chatMessages'); 8063 if (!chatMessages) return; 8064 // Store city name and reset counter for essentials flow 8065 _essentialCityName = cityName || ''; 8066 _essentialsSetupCount = 0; 8067 _visaRequired = false;
8068 var escapedName = (cityName || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 8069 var html = '<div class="message bot"><div class="message-content w-full">' + 8070 '<span class="text-dark">I\'ve created your ' + escapedName + ' journey! ð</span>' + 8071 '<p id="essentials-visa-line" class="text-dark mt-2">â³ Checking visa requirements...</p>' + 8072 '<p class="text-dark mt-1">Now let\'s get the essentials sorted.</p>' + 8073 '<button type="button" id="essentials-start-btn" disabled class="w-full flex justify-between items-center mt-4 text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] opacity-50 cursor-not-allowed transition-all duration-200" ' + 8074 'onclick="this.setAttribute(\'data-clicked\',\'1\');this.disabled=true;this.style.opacity=\'0.6\';this.style.cursor=\'default\';var gi=this.querySelector(\'.btn-icon-gif\'),si=this.querySelector(\'.btn-icon-svg\');if(gi)gi.style.display=\'none\';if(si)si.style.display=\'block\';startEssentialsChecklist();">' + 8075 '<span class="btn-label">Creating your journey...</span>' + 8076 '<img src="/static/icons/chevron_right.gif" alt="" class="w-5 h-5 btn-icon-gif" style="display:none;" />' + 8077 '<img src="/static/icons/chevron_right_dark.svg" alt="" class="w-5 h-5 btn-icon-svg" />' + 8078 '</button></div></div>'; 8079 chatMessages.insertAdjacentHTML('beforeend', html); 8080 chatMessages.scrollTop = chatMessages.scrollHeight; 8081 // Safety: enable after 10s regardless (in case visa API is slow) â but not if already clicked 8082 setTimeout(function() { var b = document.getElementById('essentials-start-btn'); if (b && b.disabled && !b.getAttribute('data-clicked')) { b.disabled = false; b.style.opacity = '1'; b.style.cursor = 'pointer'; b.querySelector('.btn-label').textContent = 'Let\'s do it!'; var gi=b.querySelector('.btn-icon-gif'),si=b.querySelector('.btn-icon-svg');if(gi)gi.style.display='block';if(si)si.style.display='none'; } }, 10000); 8083 }; 8084 8085 // ââ Essentials Checklist Flow ââââââââââââââââââââââââââââââââââââââââââââ 8086 // Guided chat flow: [Visa if required] â Health Insurance â Internet â Airport Lounge 8087 var _essentialSteps = []; // built dynamically based on visa status 8088 var _essentialRowMap = { 8089 'visa': 'preview-checklist-visa', 8090 'health-insurance': 'preview-checklist-health', 8091 'internet': 'preview-checklist-internet', 8092 'airport-lounge': 'preview-checklist-lounge' 8093 }; 8094 // Pending next step â held until user closes/leaves the service panel 8095 var _pendingEssentialStep = null; 8096 // Track how many services the user set up (vs skipped) 8097 var _essentialsSetupCount = 0; 8098 // City name for the journey (set when essentials flow starts) 8099 var _essentialCityName = ''; 8100 8101 function _echoUserChoice(text) { 8102 var chatMessages = document.getElementById('chatMessages'); 8103 if (!chatMessages) return; 8104 var escaped = (text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 8105 chatMessages.insertAdjacentHTML('beforeend', '<div class="message user"><div class="message-content">' + escaped + '</div></div>'); 8106 chatMessages.scrollTop = chatMessages.scrollHeight; 8107 } 8108 8109 // Map essential step keys to their preview icon element IDs 8110 var _essentialIconMap = { 8111 'visa': 'preview-icon-visa', 8112 'health-insurance': 'preview-icon-health', 8113 'internet': 'preview-icon-internet', 8114 'airport-lounge': 'preview-icon-lounge' 8115 }; 8116 8117 // Track which services have been actioned (persists across panel open/close) 8118 window._actionedServices = []; 8119 8120 function _highlightPreviewRow(step) { 8121 // No border changes â keep borders as-is per design 8122 } 8123 8124 // Update service row icon and button after user acts (setup or remind-me-later) 8125 function _markServiceActioned(step) { 8126 // Store for re-applying when journey preview reloads 8127 if (window._actionedServices.indexOf(step) === -1) { 8128 window._actionedServices.push(step); 8129 } 8130 var reminderSrc = '/static/icons/service_reminder.png'; 8131 var iconId = _essentialIconMap[step]; 8132 if (iconId) { 8133 var iconEl = document.getElementById(iconId); 8134 if (iconEl) iconEl.src = reminderSrc; 8135 } 8136 var rowId = _essentialRowMap[step]; 8137 if (rowId) { 8138 var row = document.getElementById(rowId); 8139 if (row) { 8140 var btn = row.querySelector('.preview-service-btn'); 8141 if (btn) { 8142 btn.classList.remove('bg-[#3B3B3C]', 'hover:bg-[#2a2a2b]', 'text-white'); 8143 btn.classList.add('bg-[#FF735D]', 'hover:bg-[#e5654f]', 'text-white'); 8144 } 8145 } 8146 } 8147 } 8148
8149 // Re-apply actioned service states when journey preview is (re)loaded 8150 window._applyActionedServicesToPreview = function() { 8151 if (!window._actionedServices || !window._actionedServices.length) return; 8152 var reminderSrc = '/static/icons/service_reminder.png'; 8153 window._actionedServices.forEach(function(step) { 8154 var iconId = _essentialIconMap[step]; 8155 if (iconId) { 8156 var iconEl = document.getElementById(iconId); 8157 if (iconEl) iconEl.src = reminderSrc; 8158 } 8159 var rowId = _essentialRowMap[step]; 8160 if (rowId) { 8161 var row = document.getElementById(rowId); 8162 if (row) { 8163 var btn = row.querySelector('.preview-service-btn'); 8164 if (btn) { 8165 btn.classList.remove('bg-[#3B3B3C]', 'hover:bg-[#2a2a2b]'); 8166 btn.classList.add('bg-[#FF735D]', 'hover:bg-[#e5654f]'); 8167 } 8168 } 8169 } 8170 }); 8171 }; 8172 8173 function _getNextStep(current) { 8174 var idx = _essentialSteps.indexOf(current); 8175 return (idx >= 0 && idx < _essentialSteps.length - 1) ? _essentialSteps[idx + 1] : null; 8176 } 8177 8178 // Show the journey-ready summary after all essentials are done 8179 function _showEssentialsComplete() { 8180 8181 var chatMessages = document.getElementById('chatMessages'); 8182 if (!chatMessages) return; 8183 8184 // Count configured essentials: 8185 // If visa is NOT required, it's auto-configured (+1) 8186 // If visa IS required, it only counts if user clicked "Set up" (included in _essentialsSetupCount) 8187 var configured = _essentialsSetupCount + (_visaRequired ? 0 : 1); 8188 var escapedCity = (_essentialCityName || 'your').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 8189 8190 var _isMobileComplete = window.innerWidth < 768; 8191 var journeyAction = _isMobileComplete ? 8192 '<button type="button" class="w-full flex justify-between items-center mt-3 text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" ' + 8193 'onclick="if(typeof openNomadosPanel===\'function\') openNomadosPanel(\'journey-preview\');">Go to Journey' + 8194 '<img src="/static/icons/chevron_right_dark.svg" alt="" class="w-5 h-5" /></button>' : 8195 '<p class="text-dark mt-1">You can see vital information about your journey on the panel to the right â</p>'; 8196 8197 var html = '<div class="message bot"><div class="message-content w-full">' + 8198 '<span class="text-dark">Your ' + escapedCity + ' journey is ready! ð</span>' + 8199 '<p class="text-dark mt-1"><strong>' + configured + '/4 essentials configured.</strong></p>' + 8200 journeyAction + 8201 '</div></div>'; 8202 chatMessages.insertAdjacentHTML('beforeend', html); 8203 chatMessages.scrollTop = chatMessages.scrollHeight; 8204 8205 // Hide mobile tracker 8206 if (typeof _hideMobileServicesTracker === 'function') _hideMobileServicesTracker(); 8207 8208 // Reveal widgets panel with smooth animation (desktop) 8209 if (!_isMobileComplete) { 8210 setTimeout(function() { 8211 if (typeof window._revealWidgetsPanel === 'function') { 8212 window._revealWidgetsPanel(); 8213 } 8214 }, 400); 8215 } 8216 } 8217 8218 // Show the pending essential step (called when user leaves a service panel) 8219 window._flushPendingEssentialStep = function() { 8220 if (!_pendingEssentialStep) return; 8221 var step = _pendingEssentialStep; 8222 _pendingEssentialStep = null; 8223 if (step === '__done__') { 8224 // Last service step completed â show summary 8225 setTimeout(_showEssentialsComplete, 600); 8226 } else { 8227 setTimeout(function() { showEssentialStep(step); }, 600); 8228 } 8229 }; 8230 8231 window.startEssentialsChecklist = function() { 8232 _echoUserChoice("Let's do it!"); 8233 // Build steps dynamically: include visa if required 8234 _essentialSteps = []; 8235 if (_visaRequired) { 8236 _essentialSteps.push('visa'); 8237 } 8238 _essentialSteps.push('health-insurance', 'internet', 'airport-lounge'); 8239 // Show mobile services tracker and mark visa done if not required 8240 if (typeof _showMobileServicesTracker === 'function') _showMobileServicesTracker(); 8241 if (!_visaRequired && typeof _updateMobileTracker === 'function') _updateMobileTracker('visa', 'done'); 8242 setTimeout(function() { showEssentialStep(_essentialSteps[0]); }, 500); 8243 }; 8244
8245 window.showEssentialStep = function(step) { 8246 var chatMessages = document.getElementById('chatMessages'); 8247 if (!chatMessages) return; 8248 // Highlight current step in mobile tracker 8249 if (typeof _updateMobileTracker === 'function') _updateMobileTracker(step, 'active'); 8250 var chevronGif = '<img src="/static/icons/chevron_right.gif" alt="" class="w-5 h-5 flex-shrink-0 btn-icon-gif" /><img src="/static/icons/chevron_right_dark.svg" alt="" class="w-5 h-5 flex-shrink-0 btn-icon-svg" style="display:none;" />'; 8251 var chevronSvg = '<img src="/static/icons/chevron_right_dark.svg" alt="" class="w-5 h-5 flex-shrink-0" />'; 8252 var html = ''; 8253 8254 if (step === 'visa') { 8255 _highlightPreviewRow('visa'); 8256 html = '<div class="message bot"><div class="message-content w-full">' + 8257 '<span class="text-dark">A visa is required for this destination. Let\'s get that sorted first!</span>' + 8258 '<div class="flex flex-col md:flex-row gap-2 mt-3">' + 8259 '<button type="button" class="essential-btn flex-1 flex justify-between items-center text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" ' + 8260 'onclick="handleEssentialAction(\'visa\',\'setup\',this)">Set up Visa' + chevronGif + '</button>' + 8261 '<button type="button" class="essential-btn flex-1 flex justify-between items-center text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" ' + 8262 'onclick="handleEssentialAction(\'visa\',\'later\',this)">Remind me later' + chevronSvg + '</button>' + 8263 '</div></div></div>'; 8264 } else if (step === 'health-insurance') { 8265 _highlightPreviewRow('health-insurance'); 8266 html = '<div class="message bot"><div class="message-content w-full">' + 8267 '<span class="text-dark">Health insurance is essential for nomads. SafetyWing is $42/month, covers you worldwide, and you can cancel anytime.</span>' + 8268 '<div class="flex flex-col md:flex-row gap-2 mt-3">' + 8269 '<button type="button" class="essential-btn flex-1 flex justify-between items-center text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" ' + 8270 'onclick="handleEssentialAction(\'health-insurance\',\'setup\',this)">Set up SafetyWing' + chevronGif + '</button>' + 8271 '<button type="button" class="essential-btn flex-1 flex justify-between items-center text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" ' + 8272 'onclick="handleEssentialAction(\'health-insurance\',\'later\',this)">Remind me later' + chevronSvg + '</button>' + 8273 '</div></div></div>'; 8274 } else if (step === 'internet') { 8275 _highlightPreviewRow('internet'); 8276 html = '<div class="message bot"><div class="message-content w-full">' + 8277 '<span class="text-dark">Stay connected from the moment you land! An eSIM means no hunting for local SIM cards.</span>' + 8278 '<div class="flex flex-col md:flex-row gap-2 mt-3">' + 8279 '<button type="button" class="essential-btn flex-1 flex justify-between items-center text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" ' + 8280 'onclick="handleEssentialAction(\'internet\',\'setup\',this)">Get eSIM' + chevronGif + '</button>' + 8281 '<button type="button" class="essential-btn flex-1 flex justify-between items-center text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" ' + 8282 'onclick="handleEssentialAction(\'internet\',\'later\',this)">Remind me later' + chevronSvg + '</button>' + 8283 '</div></div></div>'; 8284 } else if (step === 'airport-lounge') { 8285 _highlightPreviewRow('airport-lounge'); 8286 html = '<div class="message bot"><div class="message-content w-full">' + 8287 '<span class="text-dark">Last thing â your <strong>Priority Pass membership</strong> is included <strong>free</strong>! ð</span>' + 8288 '<p class="text-dark mt-1">Access 1,300+ airport lounges worldwide for $39/visit (normally $50-80 walk-up).</p>' + 8289 '<p class="text-dark mt-1">Want to add lounge passes for this trip?</p>' + 8290 '<div class="flex flex-col md:flex-row gap-2 mt-3">' + 8291 '<button type="button" class="essential-btn flex-1 flex justify-between items-center text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#
8291EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" ' + 8292 'onclick="handleEssentialAction(\'airport-lounge\',\'setup\',this)">Add lounge pass ($39)' + chevronGif + '</button>' + 8293 '<button type="button" class="essential-btn flex-1 flex justify-between items-center text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" ' + 8294 'onclick="handleEssentialAction(\'airport-lounge\',\'later\',this)">Remind me later' + chevronSvg + '</button>' + 8295 '</div></div></div>'; 8296 } 8297 8298 if (html) { 8299 chatMessages.insertAdjacentHTML('beforeend', html); 8300 chatMessages.scrollTop = chatMessages.scrollHeight; 8301 } 8302 }; 8303 8304 window.handleEssentialAction = function(step, action, btnEl) { 8305 // Disable all buttons in this message 8306 var msgEl = btnEl.closest('.message'); 8307 if (msgEl) { 8308 msgEl.querySelectorAll('.essential-btn').forEach(function(b) { 8309 b.disabled = true; 8310 b.style.opacity = '0.6'; 8311 b.style.cursor = 'default'; 8312 // Swap gif to svg on disable 8313 var gif = b.querySelector('.btn-icon-gif'); 8314 var svg = b.querySelector('.btn-icon-svg'); 8315 if (gif) gif.style.display = 'none'; 8316 if (svg) svg.style.display = 'block'; 8317 }); 8318 } 8319 // Echo the user's choice 8320 var labels = { 8321 'visa': 'Set up Visa', 8322 'health-insurance': 'Set up SafetyWing', 8323 'internet': 'Get eSIM', 8324 'airport-lounge': 'Add lounge pass ($39)' 8325 }; 8326 _echoUserChoice(action === 'setup' ? (labels[step] || 'Set up') : 'Remind me later'); 8327 8328 // Update service row icon and button in preview panel 8329 _markServiceActioned(step); 8330 8331 // Update mobile tracker 8332 if (typeof _updateMobileTracker === 'function') _updateMobileTracker(step, 'done'); 8333 8334 var _isMobileEssential = window.innerWidth < 768; 8335 8336 // Open the marketplace if "setup" â show confirmation message and open panel 8337 if (action === 'setup') { 8338 var chatMessages = document.getElementById('chatMessages'); 8339 var confirmMessages = { 8340 'visa': _isMobileEssential ? 'Let\'s get your visa sorted.' : 'Let\'s get your visa sorted. Follow the steps on the right â', 8341 'health-insurance': _isMobileEssential ? 'Great choice! Let\'s get you covered.' : 'Great choice! Follow the steps on the right â', 8342 'internet': _isMobileEssential ? "Let's set up your eSIM now." : "Let's set up your eSIM now. Follow the steps on the right â", 8343 'airport-lounge': _isMobileEssential ? "Let's get you lounge access!" : "Let's get you lounge access! â" 8344 }; 8345 if (chatMessages && confirmMessages[step]) { 8346 chatMessages.insertAdjacentHTML('beforeend', 8347 '<div class="message bot"><div class="message-content"><span class="text-dark">' + 8348 confirmMessages[step] + '</span></div></div>' 8349 ); 8350 chatMessages.scrollTop = chatMessages.scrollHeight; 8351 } 8352 8353 if (step === 'visa' && typeof openMarketVisa === 'function') openMarketVisa(); 8354 else if (step === 'health-insurance' && typeof openMarketHealthcare === 'function') openMarketHealthcare(); 8355 else if (step === 'internet' && typeof openMarketESim === 'function') openMarketESim(); 8356 else if (step === 'airport-lounge' && typeof openMarketLounge === 'function') openMarketLounge(); 8357 } 8358 8359 // Advance to next step 8360 var next = _getNextStep(step); 8361 if (action === 'setup') { 8362 _essentialsSetupCount++; 8363 // Defer next step until user closes/leaves the service panel 8364 _pendingEssentialStep = next; 8365 if (!next) { 8366 _pendingEssentialStep = '__done__'; 8367 } 8368 } else { 8369 // "Remind me later" â advance immediately 8370 if (next) { 8371 setTimeout(function() { showEssentialStep(next); }, 600); 8372 } else { 8373 // Final step done â show journey-ready summary 8374 setTimeout(_showEssentialsComplete, 600); 8375 } 8376 } 8377 }; 8378 8379 8380 // End date step: inline calendar + "Not sure yet" button (1 month from start) 8381 window.showPlanMyTripEndDateStep = function(citySlug, cityName, startDateStr) { 8382 const chatMessages = document.getElementById('chatMessages'); 8383 if (!chatMessages) return; 8384 const uid = Date.now();
8385 const endWidgetId = 'plan-trip-end-' + uid; 8386 const containerId = 'plan-trip-end-container-' + uid; 8387 // Calculate start date + 1 month for "Not sure yet" 8388 const startParts = startDateStr.split('-'); 8389 const startDateObj = new Date(parseInt(startParts[0]), parseInt(startParts[1]) - 1, parseInt(startParts[2])); 8390 const oneMonthLater = new Date(startDateObj.getFullYear(), startDateObj.getMonth() + 1, startDateObj.getDate()); 8391 const oneMonthStr = oneMonthLater.getFullYear() + '-' + String(oneMonthLater.getMonth() + 1).padStart(2, '0') + '-' + String(oneMonthLater.getDate()).padStart(2, '0'); 8392 const block = ` 8393 <div class="message bot" id="${containerId}" data-city-slug="${citySlug}" data-city-name="${(cityName || '').replace(/"/g, '"')}" data-start-date="${startDateStr}"> 8394 <div class="message-content"> 8395 <span class="text-dark">Pick your end date:</span> 8396 <div class="mt-4 w-[320px] rounded-lg shadow-md p-3"> 8397 <div class="plan-trip-calendar-widget" id="${endWidgetId}" data-city-slug="${citySlug}" data-min-date="${startDateStr}"> 8398 <div class="flex justify-between items-center mb-3"> 8399 <button type="button" class="plan-trip-calendar-prev w-8 h-8 flex items-center justify-center rounded-lg border border-[#EEE] text-dark text-lg leading-none cursor-pointer" aria-label="Previous month">‹</button> 8400 <span class="plan-trip-calendar-month text-sm font-semibold text-dark"></span> 8401 <button type="button" class="plan-trip-calendar-next w-8 h-8 flex items-center justify-center rounded-lg border border-[#EEE] text-dark text-lg leading-none cursor-pointer" aria-label="Next month">›</button> 8402 </div> 8403 <div class="plan-trip-calendar-weekdays grid grid-cols-7 gap-0.5 text-center text-xs text-[#5D5D5D] mb-1"></div> 8404 <div class="plan-trip-calendar-days grid grid-cols-7 gap-0.5 text-center text-sm"></div> 8405 <input type="hidden" class="plan-trip-selected-date" value="" /> 8406 </div> 8407 </div> 8408 <button type="button" class="plan-trip-not-sure-btn flex items-center gap-1 text-dark ps-3 pe-1.5 py-2 rounded-lg border border-solid border-[#3B3B3C] cursor-pointer transition-all duration-200 mt-4"> 8409 Not sure yet 8410 <img src="/static/icons/chevron_right_dark.svg" alt="" class="w-5 h-5 flex-shrink-0" /> 8411 </button> 8412 <div class="plan-trip-confirm-end-wrap mt-4" style="display:none"> 8413 <button type="button" class="plan-trip-confirm-end-date flex items-center gap-1 text-dark ps-3 pe-1.5 py-2 rounded-lg border border-solid border-[#3B3B3C] cursor-pointer transition-all duration-200"> 8414 Confirm end date 8415 <img src="/static/icons/chevron_right_dark.svg" alt="" class="w-5 h-5" /> 8416 </button> 8417 </div> 8418 </div> 8419 </div> 8420 `; 8421 chatMessages.insertAdjacentHTML('beforeend', block); 8422 chatMessages.scrollTop = chatMessages.scrollHeight; 8423 const container = document.getElementById(containerId); 8424 const endWidget = document.getElementById(endWidgetId); 8425 const confirmWrap = container.querySelector('.plan-trip-confirm-end-wrap'); 8426 const confirmBtn = container.querySelector('.plan-trip-confirm-end-date'); 8427 const notSureBtn = container.querySelector('.plan-trip-not-sure-btn'); 8428 8429 renderPlanTripCalendar(endWidget, function() { 8430 if (confirmWrap) confirmWrap.style.display = 'block'; 8431 // Hide "Not sure yet" once a date is manually selected 8432 if (notSureBtn) notSureBtn.style.display = 'none'; 8433 setTimeout(function() { chatMessages.scrollTop = chatMessages.scrollHeight; }, 50); 8434 }); 8435 // Scroll after calendar grid renders 8436 setTimeout(function() { chatMessages.scrollTop = chatMessages.scrollHeight; }, 100); 8437 8438 // "Not sure yet" â set end date to start + 1 month and create journey 8439 if (notSureBtn) { 8440 notSureBtn.addEventListener('click', function() { 8441 notSureBtn.disabled = true; 8442 notSureBtn.style.opacity = '0.5'; 8443 endWidget.querySelectorAll('button').forEach(function(b) { b.disabled = true; }); 8444 endWidget.style.opacity = '0.6'; 8445 if (confirmWrap) confirmWrap.style.display = 'none'; 8446 const userEcho = '<div class="message user"><div class="message-content">Not sure yet â 1 month from start</div></div>'; 8447 chatMessages.insertAdjacentHTML('beforeend', userEcho); 8448 chatMessages.scrollTop = chatMessages.scrollHeight; 8449 const name = (container.dataset.cityName || '').replace(/"/g, '"'); 8450 const startVal = container.dataset.startDate || ''; 8451 createJourneyFromPlanTrip(citySlug, name, startVal, oneMonthStr, null, function(cn) { appendPlanMyTripSuccessMessage(cn); }); 8452 }); 8453 } 8454 8455 // Confirm end date from calendar 8456 if (confirmBtn) { 8457 confirmBtn.addEventListener('click', function() { 8458 const hidden = endWidget.querySelector('.plan-trip-selected-date'); 8459 const endDateVal = hidden ? hidden.value : ''; 8460 if (!endDateVal) return; 8461 confirmBtn.disabled = true; 8462 confirmBtn.style.opacity = '0.5'; 8463 endWidget.querySelectorAll('button').forEach(function(b) { b.disabled = true; }); 8464 endWidget.style.opacity = '0.6'; 8465 const formatted = new Date(endDateVal + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); 8466 const userEcho = '<div class="message user"><div class="message-content">' + formatted + '</div></div>'; 8467 chatMessages.insertAdjacentHTML('beforeend', userEcho); 8468 chatMessages.scrollTop = chatMessages.scrollHeight; 8469 const name = (container.dataset.cityName || '').replace(/"/g, '"'); 8470 const startVal = container.dataset.startDate || ''; 8471 createJourneyFromPlanTrip(citySlug, name, startVal, endDateVal, null, function(cn) { appendPlanMyTripSuccessMessage(cn); }); 8472 }); 8473 } 8474 }; 8475 8476
8477 // Plan my trip chat workflow: show welcome text + "Plan my Trip" button in chat when destination opens 8478 window.appendPlanMyTripPrompt = function(citySlug, cityName, countryName) { 8479 const chatMessages = document.getElementById('chatMessages'); 8480 if (!chatMessages) return; 8481 8482 // Same city already has a prompt â nothing to do 8483 if (chatMessages.querySelector('[data-plan-prompt-city="' + citySlug + '"]')) return; 8484 8485 const escapedName = (cityName || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 8486 const escapedSlug = (citySlug || '').replace(/&/g, '&').replace(/'/g, "\\'").replace(/"/g, '"'); 8487 const escapedCountry = (countryName || '').replace(/&/g, '&').replace(/'/g, "\\'").replace(/"/g, '"'); 8488 8489 // Find the most recent existing prompt (for a different city) 8490 const allPrompts = chatMessages.querySelectorAll('[data-plan-prompt-city]'); 8491 const lastPrompt = allPrompts.length > 0 ? allPrompts[allPrompts.length - 1] : null; 8492 8493 if (lastPrompt) { 8494 const planBtn = lastPrompt.querySelector('.plan-trip-btn'); 8495 const tripStarted = planBtn && planBtn.disabled; 8496 8497 if (!tripStarted) { 8498 // "Plan my Trip" hasn't been clicked yet â just update the existing prompt in-place 8499 lastPrompt.dataset.planPromptCity = citySlug; 8500 const textSpan = lastPrompt.querySelector('.text-dark'); 8501 if (textSpan) { 8502 textSpan.innerHTML = escapedName + ' \u2014 great choice! Check out the details and hit \u201cPlan my trip\u201d when you\u2019re ready. \uD83D\uDC47'; 8503 } 8504 if (planBtn) { 8505 planBtn.disabled = false; 8506 planBtn.style.opacity = ''; 8507 planBtn.onclick = function() { 8508 this.disabled = true; 8509 this.style.opacity = '0.6'; 8510 var si = this.querySelector('.pmt-icon-static'), hi = this.querySelector('.pmt-icon-hover'); 8511 if (si) si.style.display = 'block'; 8512 if (hi) hi.style.display = 'none'; 8513 handleJourneyClick(citySlug, cityName, countryName); 8514 }; 8515 } 8516 chatMessages.scrollTop = chatMessages.scrollHeight; 8517 return; 8518 } 8519 // Trip was already started â fall through to add a new prompt below 8520 } 8521 8522 const html = ` 8523 <div class="message bot" data-plan-prompt-city="${citySlug}"> 8524 <div class="message-content w-full"> 8525 <span class="text-dark">${escapedName} â great choice! Check out the details and hit "Plan my trip" when you're ready. ð</span> 8526 <button type="button" 8527 class="plan-trip-btn w-full flex justify-between items-center mt-4 text-dark px-2.5 py-1.5 rounded-lg border border-solid border-[#EEE] hover:border-[#3B3B3C] cursor-pointer transition-all duration-200" 8528 onclick="this.disabled=true;this.style.opacity='0.6';var si=this.querySelector('.pmt-icon-static'),hi=this.querySelector('.pmt-icon-hover');if(si)si.style.display='block';if(hi)hi.style.display='none';handleJourneyClick('${escapedSlug}','${escapedName}','${escapedCountry}');"> 8529 Plan my Trip 8530 <img src="/static/icons/chevron_right_dark.svg" alt="" class="w-5 h-5 pmt-icon-static" style="display:none;" /><img src="/static/icons/chevron_right.gif" alt="" class="w-5 h-5 pmt-icon-hover" /> 8531 </button> 8532 </div> 8533 </div> 8534 `; 8535 chatMessages.insertAdjacentHTML('beforeend', html); 8536 chatMessages.scrollTop = chatMessages.scrollHeight; 8537 }; 8538 8539 window.showPlanMyTripTimeframeStep = function(citySlug, cityName, countryName) { 8540 const chatMessages = document.getElementById('chatMessages'); 8541 if (!chatMessages) return; 8542 const escapedName = (cityName || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 8543 const today = new Date().toISOString().slice(0, 10); 8544 const uid = Date.now(); 8545 const startWidgetId = 'plan-trip-start-' + uid; 8546 const containerId = 'plan-trip-start-container-' + uid; 8547 const userBubble = ` 8548 <div class="message user"> 8549 <div class="message-content">Yes! Plan my ${escapedName} trip</div> 8550 </div> 8551 `; 8552 const botBlock = ` 8553 <div class="message bot" id="${containerId}" data-city-slug="${citySlug}" data-city-name="${(cityName || '').replace(/"/g, '"')}" data-country-name="${(countryName || '').replace(/"/g, '"')}"> 8554 <div class="message-content">
8555 <span class="text-dark">Pick your start date:</span> 8556 <div class="mt-4 w-[320px] rounded-lg shadow-md p-3"> 8557 <div class="plan-trip-calendar-widget" id="${startWidgetId}" data-city-slug="${citySlug}" data-min-date="${today}"> 8558 <div class="flex justify-between items-center mb-3"> 8559 <button type="button" class="plan-trip-calendar-prev w-8 h-8 flex items-center justify-center rounded-lg border border-[#EEE] text-dark text-lg leading-none cursor-pointer" aria-label="Previous month">‹</button> 8560 <span class="plan-trip-calendar-month text-sm font-semibold text-dark"></span> 8561 <button type="button" class="plan-trip-calendar-next w-8 h-8 flex items-center justify-center rounded-lg border border-[#EEE] text-dark text-lg leading-none cursor-pointer" aria-label="Next month">›</button> 8562 </div> 8563 <div class="plan-trip-calendar-weekdays grid grid-cols-7 gap-0.5 text-center text-xs text-[#5D5D5D] mb-1"></div> 8564 <div class="plan-trip-calendar-days grid grid-cols-7 gap-0.5 text-center text-sm"></div> 8565 <input type="hidden" class="plan-trip-selected-date" value="" /> 8566 </div> 8567 </div> 8568 <div class="plan-trip-confirm-start-wrap mt-4" style="display:none"> 8569 <button type="button" class="plan-trip-confirm-start-date flex items-center gap-1 text-dark ps-3 pe-1.5 py-2 rounded-lg border border-solid border-[#3B3B3C] cursor-pointer transition-all duration-200"> 8570 Confirm start date 8571 <img src="/static/icons/chevron_right_dark.svg" alt="" class="w-5 h-5" /> 8572 </button> 8573 </div> 8574 </div> 8575 </div> 8576 `; 8577 chatMessages.insertAdjacentHTML('beforeend', userBubble + botBlock); 8578 chatMessages.scrollTop = chatMessages.scrollHeight; 8579 const container = document.getElementById(containerId); 8580 const startWidget = document.getElementById(startWidgetId); 8581 const confirmWrap = container.querySelector('.plan-trip-confirm-start-wrap'); 8582 const confirmBtn = container.querySelector('.plan-trip-confirm-start-date'); 8583 renderPlanTripCalendar(startWidget, function(selectedStart) { 8584 if (confirmWrap) confirmWrap.style.display = 'block'; 8585 setTimeout(function() { chatMessages.scrollTop = chatMessages.scrollHeight; }, 50); 8586 }); 8587 // Scroll after calendar grid renders 8588 setTimeout(function() { chatMessages.scrollTop = chatMessages.scrollHeight; }, 100); 8589 if (confirmBtn) { 8590 confirmBtn.addEventListener('click', function() { 8591 const hidden = startWidget.querySelector('.plan-trip-selected-date'); 8592 const startDateVal = hidden ? hidden.value : ''; 8593 if (!startDateVal) return; 8594 // Disable calendar and confirm button 8595 confirmBtn.disabled = true; 8596 confirmBtn.style.opacity = '0.5'; 8597 startWidget.querySelectorAll('button').forEach(function(b) { b.disabled = true; }); 8598 startWidget.style.opacity = '0.6'; 8599 // Echo user selection 8600 const formatted = new Date(startDateVal + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); 8601 const userEcho = '<div class="message user"><div class="message-content">' + formatted + '</div></div>'; 8602 chatMessages.insertAdjacentHTML('beforeend', userEcho); 8603 chatMessages.scrollTop = chatMessages.scrollHeight; 8604 // Show end date step 8605 const name = (container.dataset.cityName || '').replace(/"/g, '"'); 8606 showPlanMyTripEndDateStep(citySlug, name, startDateVal); 8607 }); 8608 } 8609 }; 8610 8611 // Toggle Favorite Function (if not already defined) 8612 if (typeof window.toggleFavorite === 'undefined') { 8613 window.toggleFavorite = function(event, citySlug, button) { 8614 if (event) { 8615 event.preventDefault(); 8616 event.stopPropagation(); 8617 } 8618 // Get CSRF token from cookies 8619 function getCookie(name) { 8620 let cookieValue = null; 8621 if (document.cookie && document.cookie !== "") { 8622 const cookies = document.cookie.split(";"); 8623 for (let i = 0; i < cookies.length; i++) { 8624 const cookie = cookies[i].trim(); 8625 if (cookie.substring(0, name.length + 1) === name + "=") { 8626 cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); 8627 break; 8628 } 8629 } 8630 } 8631 return cookieValue; 8632 } 8633 8634 const csrftoken = getCookie("csrftoken"); 8635 8636 // Disable button during request 8637 button.disabled = true; 8638 8639 fetch(`/favorites/${citySlug}/toggle/`, { 8640 method: 'POST', 8641 headers: { 8642 "Content-Type": "application/json", 8643 "X-CSRFToken": csrftoken, 8644 "X-Requested-With": "XMLHttpRequest", 8645 }, 8646 }) 8647 .then((response) => response.json()) 8648 .then((data) => { 8649 if (data.success) { 8650 // Update button appearance 8651 const icon = button.querySelector(".favorite-icon"); 8652 const img = button.querySelector("img"); 8653 8654 if (data.is_favorited) { 8655 // Update SVG if it exists 8656 if (icon) { 8657 icon.innerHTML = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" fill="currentColor"></path>`; 8658 } 8659 8660 // Update img if it exists 8661 if (img) { 8662 img.src = "/static/icons/star_active.svg"; 8663 } 8664 } else { 8665 // Update SVG if it exists 8666 if (icon) { 8667 icon.innerHTML = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>`; 8668 } 8669 8670 // Update img if it exists 8671 if (img) { 8672 img.src = "/static/icons/star_default.svg"; 8673 } 8674 } 8675 8676 // Update data attribute 8677 button.setAttribute("data-favorited", data.is_favorited); 8678 } 8679 }) 8680 .catch((error) => { 8681 console.error("Error toggling favorite:", error); 8682 showNotification("Failed to update favorite status. Please try again."); 8683 }) 8684 .finally(() => { 8685 // Re-enable button 8686 button.disabled = false;
8687 }); 8688 }; 8689 } 8690 8691 // Initialize on DOM ready 8692 if (document.readyState === 'loading') { 8693 document.addEventListener('DOMContentLoaded', () => { 8694 NomadosPanel.init(); 8695 }); 8696 } else { 8697 NomadosPanel.init(); 8698 } 8699 </script>
8699 8700 <!-- Real-time membership status (profile, sidebar, Go Pro) --> 8701
8701<script> 8702 (function() { 8703 var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 8704 var wsUrl = protocol + '//' + window.location.host + '/ws/status/'; 8705 8706 // ââ Loader helpers ââ 8707 function showLoader(id, text) { 8708 var el = document.getElementById(id); 8709 if (!el) return; 8710 var txt = el.querySelector('[id$="-loader-text"]') || el.querySelector('span'); 8711 if (txt) txt.textContent = text || 'Processing...'; 8712 el.classList.remove('hidden'); 8713 el.classList.add('flex'); 8714 } 8715 function hideLoader(id) { 8716 var el = document.getElementById(id); 8717 if (!el) return; 8718 el.classList.add('hidden'); 8719 el.classList.remove('flex'); 8720 } 8721 function flashLoader(id, text, delayMs) { 8722 showLoader(id, text); 8723 setTimeout(function() { hideLoader(id); }, delayMs || 2000); 8724 } 8725 8726 // ââ Status badge helper ââ 8727 function updateStatusBadge(el, status, isPro) { 8728 if (!el) return; 8729 var isCanceled = (status === 'canceled'); 8730 var isPastDue = (status === 'past_due'); 8731 var isExpired = (status === 'expired'); 8732 // Reset classes 8733 el.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium'; 8734 if (isCanceled && isPro) { 8735 el.textContent = 'Canceled'; 8736 el.classList.add('bg-yellow-100', 'text-yellow-800'); 8737 } else if (isPastDue) { 8738 el.textContent = 'Past Due'; 8739 el.classList.add('bg-red-100', 'text-red-800'); 8740 } else if (isExpired || (!isPro && status !== 'active')) { 8741 el.textContent = 'Expired'; 8742 el.classList.add('bg-gray-100', 'text-gray-700'); 8743 } else { 8744 el.classList.add('hidden'); 8745 el.textContent = ''; 8746 } 8747 } 8748 8749 // ââ Go Pro card color helper ââ 8750 function updateGoProCardColors(el, titleEl, status) { 8751 if (!el) return; 8752 // Remove old border classes 8753 el.className = el.className 8754 .replace(/border-\[#3B3B3C\]/g, '') 8755 .replace(/border-\[#FF735D\]/g, '') 8756 .replace(/border-\[#D9D9D9\]/g, ''); 8757 // Add new border based on status (no bg changes â title color stays text-[#3B3B3C]) 8758 if (status === 'past_due') { 8759 el.classList.add('border-[#FF735D]'); 8760 if (titleEl) titleEl.innerHTML = '<span class="text-[#FF735D]">Pro</span> \u2014 Payment Failed !'; 8761 } else if (status === 'canceled') { 8762 el.classList.add('border-[#D9D9D9]'); 8763 if (titleEl) titleEl.innerHTML = '<span class="text-[#FF735D]">Pro</span> \u2014 Canceled'; 8764 } else { 8765 el.classList.add('border-[#3B3B3C]'); 8766 if (titleEl) titleEl.innerHTML = 'You\'re a <span class="text-[#FF735D]">Pro</span> member'; 8767 } 8768 } 8769 8770 try { 8771 var ws = new WebSocket(wsUrl); 8772 ws.onmessage = function(event) { 8773 var data = JSON.parse(event.data); 8774 if (data.type !== 'status_update' || !data.payload) return; 8775 var p = data.payload; 8776 8777 // ââ Loader events (cancel / switch / checkout processing) ââ 8778 if (p.event === 'cancel_processing') { 8779 showLoader('membership-processing-loader', 'Canceling your subscription...'); 8780 showLoader('go-pro-processing-loader', 'Canceling your subscription...'); 8781 return; 8782 } 8783 if (p.event === 'cancel_failed') { 8784 flashLoader('membership-processing-loader', 'Cancel failed. Try again.', 2500); 8785 flashLoader('go-pro-processing-loader', 'Cancel failed. Try again.', 2500); 8786 return; 8787 } 8788 if (p.event === 'switch_processing') { 8789 showLoader('membership-processing-loader', 'Switching your plan...'); 8790 showLoader('go-pro-processing-loader', 'Switching your plan...'); 8791 return; 8792 } 8793 if (p.event === 'switch_failed') { 8794 flashLoader('membership-processing-loader', 'Switch failed. Try again.', 2500); 8795 flashLoader('go-pro-processing-loader', 'Switch failed. Try again.', 2500); 8796 return; 8797 } 8798 8799 // ââ Derive state ââ 8800 var isRevoked = (p.event === 'revoked' || p.tier === 'registered_free');
8801 var isPro = (p.tier === 'pro') || (p.event === 'granted' || p.event === 'renewed' || p.event === 'canceled' || p.event === 'payment_failed' || p.event === 'delinquent' || p.event === 'recovered'); 8802 if (isRevoked) isPro = false; 8803 var subStatus = p.subscription_status || 'active'; 8804 var isCanceledStatus = (p.event === 'canceled' || subStatus === 'canceled'); 8805 var isPastDue = (subStatus === 'past_due' || p.event === 'payment_failed' || p.event === 'delinquent'); 8806 var planText = (p.plan_type === 'monthly') ? 'Monthly plan' : (p.plan_type === 'annual') ? 'Annual plan' : (isPro ? 'Pro' : 'Free account'); 8807 var renewalText = ''; 8808 var dateStr = ''; 8809 if (p.event === 'revoked' || !isPro) { 8810 renewalText = 'Upgrade to Pro for unlimited journeys and more.'; 8811 } else if (isPastDue) { 8812 renewalText = 'Payment failed â update your payment method to avoid losing access.'; 8813 } else if (p.current_period_end) { 8814 try { 8815 var d = new Date(p.current_period_end); 8816 dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); 8817 renewalText = isCanceledStatus ? ('Access until ' + dateStr) : ('Renews ' + dateStr); 8818 } catch (e) { 8819 renewalText = 'Active subscription'; 8820 } 8821 } else { 8822 renewalText = 'Active subscription'; 8823 } 8824 var alreadyProRenewalText = isPastDue ? renewalText : ((isCanceledStatus && dateStr) ? ('Access until ' + dateStr) : renewalText); 8825 8826 // ââ Loader transitions for completed events ââ 8827 if (p.event === 'canceled') { 8828 flashLoader('membership-processing-loader', 'Subscription canceled.', 2000); 8829 flashLoader('go-pro-processing-loader', 'Subscription canceled.', 2000); 8830 } else if (p.event === 'granted' || p.event === 'renewed' || p.event === 'recovered') { 8831 flashLoader('membership-processing-loader', 'Welcome to Pro!', 2000); 8832 flashLoader('go-pro-processing-loader', 'Welcome to Pro!', 2000); 8833 } else { 8834 hideLoader('membership-processing-loader'); 8835 hideLoader('go-pro-processing-loader'); 8836 } 8837 8838 // ââ Profile page ââ 8839 var tierEl = document.getElementById('membership-tier-badge'); 8840 var planEl = document.getElementById('membership-plan-text'); 8841 var renewalEl = document.getElementById('membership-renewal-text'); 8842 if (tierEl) { 8843 tierEl.textContent = isPro ? 'Pro' : 'Registered'; 8844 tierEl.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ' + (isPro ? 'bg-[#14B8A6]/20 text-[#0D9488]' : 'bg-gray-100 text-gray-700'); 8845 } 8846 if (planEl) planEl.textContent = planText; 8847 if (renewalEl) renewalEl.textContent = renewalText; 8848 var profileManageLink = document.getElementById('profile-manage-subscription-link'); 8849 if (profileManageLink) profileManageLink.style.display = isPro ? '' : 'none'; 8850 8851 // Profile status badge 8852 var statusBadge = document.getElementById('membership-status-badge'); 8853 updateStatusBadge(statusBadge, subStatus, isPro); 8854 8855 // Profile cancel button 8856 var cancelBtn = document.getElementById('profile-cancel-subscription-btn'); 8857 if (cancelBtn) cancelBtn.style.display = (isPro && (subStatus === 'active' || subStatus === 'past_due')) ? '' : 'none'; 8858 8859 // Profile status message block 8860 var statusMsg = document.getElementById('membership-status-msg'); 8861 if (statusMsg) { 8862 if (isCanceledStatus && isPro) { 8863 statusMsg.className = 'bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-2'; 8864 statusMsg.innerHTML = '<p class="text-sm text-yellow-800 mb-1"><strong>Subscription Canceled</strong></p><p class="text-sm text-yellow-700">' + (dateStr ? "You'll retain Pro access until " + dateStr + '.' : 'Your Pro access has ended.') + '</p>'; 8865 } else if (isPastDue) { 8866 statusMsg.className = 'bg-red-50 border border-red-200 rounded-lg p-3 mb-2'; 8867 statusMsg.innerHTML = '<p class="text-sm text-red-800 mb
8867-1"><strong>Payment Failed</strong></p><p class="text-sm text-red-700">Your recent payment couldn\'t be processed. Please update your payment method.</p>'; 8868 } else if (!isPro && p.event !== 'revoked') { 8869 statusMsg.className = 'bg-gray-50 border border-gray-200 rounded-lg p-3 mb-2'; 8870 statusMsg.innerHTML = '<p class="text-sm text-gray-800 mb-1"><strong>Subscription Expired</strong></p><p class="text-sm text-gray-700">Your Pro subscription has expired. Upgrade to regain Pro features!</p>'; 8871 } else { 8872 statusMsg.className = ''; 8873 statusMsg.innerHTML = '<p class="text-sm text-gray-500">' + renewalText + '</p>'; 8874 } 8875 } 8876 8877 // ââ Sidebar (desktop + mobile) ââ 8878 var sidebarBadge = document.getElementById('sidebar-membership-badge'); 8879 if (sidebarBadge) { 8880 sidebarBadge.textContent = isPro ? 'Pro' : 'Registered'; 8881 sidebarBadge.className = 'text-sm font-medium ' + (isPro ? 'text-[#14B8A6]' : 'text-gray-600'); 8882 } 8883 var mobileSidebar = document.getElementById('mobile-sidebar-membership-text'); 8884 if (mobileSidebar) mobileSidebar.textContent = isPro ? 'Pro member' : 'Registered'; 8885 8886 // ââ Go Pro content ââ 8887 var ctaBlock = document.getElementById('go-pro-cta-block'); 8888 var alreadyProBlock = document.getElementById('go-pro-already-pro'); 8889 var alreadyProRenewal = document.getElementById('go-pro-already-pro-renewal'); 8890 var alreadyProTitle = document.getElementById('go-pro-already-pro-title'); 8891 if (ctaBlock) ctaBlock.classList.toggle('hidden', isPro); 8892 if (alreadyProBlock) { 8893 alreadyProBlock.classList.toggle('hidden', !isPro); 8894 updateGoProCardColors(alreadyProBlock, alreadyProTitle, subStatus); 8895 } 8896 if (alreadyProRenewal) alreadyProRenewal.textContent = isPro ? alreadyProRenewalText : renewalText; 8897 8898 // Pro price display (static $9/month, shown only for Pro users) 8899 var priceProBlock = document.getElementById('go-pro-price-pro'); 8900 if (priceProBlock) priceProBlock.classList.toggle('hidden', !isPro); 8901 8902 // Manage membership block (shown for Pro users) 8903 var manageBlock = document.getElementById('go-pro-manage-block'); 8904 if (manageBlock) manageBlock.classList.toggle('hidden', !isPro); 8905 8906 // Manage membership subtext (changes for canceled status) 8907 var manageSubtext = document.getElementById('go-pro-manage-subtext'); 8908 if (manageSubtext) { 8909 manageSubtext.textContent = (isPro && isCanceledStatus) 8910 ? 'Reactivate your Pro membership on our secure portal' 8911 : 'Continue to our secure portal'; 8912 } 8913 }; 8914 ws.onclose = function() {}; 8915 } catch (e) {} 8916 8917 // ââ Cancel subscription (user-initiated via SamCart API) ââ 8918 function getCsrfToken() { 8919 try { return document.cookie.match(/csrftoken=([^;]+)/)[1]; } catch(e) { return ''; } 8920 } 8921 8922 // ââ Cancel confirmation modal helpers ââ 8923 window.cancelSubscription = function() { 8924 var modal = document.getElementById('cancelSubModal'); 8925 if (!modal) return; 8926 modal.classList.remove('hidden'); 8927 modal.classList.add('flex'); 8928 requestAnimationFrame(function() { modal.classList.remove('opacity-0'); }); 8929 }; 8930 8931 window.closeCancelSubModal = function() { 8932 var modal = document.getElementById('cancelSubModal'); 8933 if (!modal) return; 8934 modal.classList.add('opacity-0'); 8935 setTimeout(function() { modal.classList.add('hidden'); modal.classList.remove('flex'); }, 200); 8936 }; 8937 8938 document.addEventListener('click', function(e) { 8939 var modal = document.getElementById('cancelSubModal'); 8940 if (modal && modal.classList.contains('flex') && e.target === modal) closeCancelSubModal(); 8941 }); 8942 8943 window.confirmCancelSubscription = function() { 8944 closeCancelSubModal(); 8945 console.log('[Cancel Sub] Starting cancel request...'); 8946 var csrfToken = getCsrfToken(); 8947 showLoader('membership-processing-loader', 'Canceling your subscription...'); 8948 showLoader('go-pro-processing-loader', 'Canceling your subscription...'); 8949 fetch('/services/samcart/cancel-subscription/', { 8950 method: 'POST', 8951 headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, 8952 credentials: 'same-origin' 8953 }) 8954 .then(function(resp) { 8955 console.log('[Cancel Sub] Response status:', resp.status, resp.statusText); 8956 return resp.json().then(function(d) { return { ok: resp.ok, status: resp.status, data: d }; }); 8957 }) 8958 .then(function(result) { 8959 console.log('[Cancel Sub] Response data:', result.data); 8960 if (!result.ok) { 8961 hideLoader('membership-processing-loader'); 8962 hideLoader('go-pro-processing-loader'); 8963 var msg = result.data.error || 'Unable to cancel. Please try again.'; 8964 console.error('[Cancel Sub] Error:', msg, 'Status:', result.status); 8965 if (result.data.customer_hub_url) { 8966 msg += '\n\nYou can also cancel via the SamCart Customer Hub.'; 8967 } 8968 showNotification(msg); 8969 } else { 8970 console.log('[Cancel Sub] Canceled. refund_issued=', result.data.refund_issued, 'refund_amount=', result.data.refund_amount); 8971 // WebSocket 'revoked' event handles the UI downgrade to free 8972 } 8973 }) 8974 .catch(function(err) { 8975 console.error('[Cancel Sub] Network error:', err); 8976 hideLoader('membership-processing-loader'); 8977 hideLoader('go-pro-processing-loader'); 8978 showNotification('Network error. Please try again or c
8978ontact support.'); 8979 }); 8980 }; 8981 8982 // ââ Switch plan (active users: cancel + refund + open checkout for new plan) ââ 8983 window.switchPlan = function(targetPlan) { 8984 var planLabel = targetPlan === 'annual' ? 'Annual ($59/yr)' : 'Monthly ($9/mo)'; 8985 if (!confirm('Switch to ' + planLabel + '?\n\nYour current subscription will be canceled and a prorated refund issued. You will then check out on the new plan.')) return; 8986 console.log('[Switch Plan] Starting switch to', targetPlan); 8987 showLoader('membership-processing-loader', 'Switching your plan...'); 8988 showLoader('go-pro-processing-loader', 'Switching your plan...'); 8989 fetch('/services/samcart/switch-plan/', { 8990 method: 'POST', 8991 headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, 8992 credentials: 'same-origin', 8993 body: JSON.stringify({ target_plan: targetPlan }) 8994 }) 8995 .then(function(resp) { 8996 console.log('[Switch Plan] Response status:', resp.status, resp.statusText); 8997 return resp.json().then(function(d) { return { ok: resp.ok, status: resp.status, data: d }; }); 8998 }) 8999 .then(function(result) { 9000 console.log('[Switch Plan] Response data:', result.data); 9001 hideLoader('membership-processing-loader'); 9002 hideLoader('go-pro-processing-loader'); 9003 if (!result.ok) { 9004 var msg = result.data.error || 'Unable to switch plan. Please try again.'; 9005 console.error('[Switch Plan] Error:', msg, 'Status:', result.status); 9006 if (result.data.customer_hub_url) { 9007 msg += '\n\nYou can also manage your plan via the SamCart Customer Hub.'; 9008 } 9009 showNotification(msg); 9010 return; 9011 } 9012 console.log('[Switch Plan] Success! Opening checkout for', targetPlan); 9013 // Open the SamCart checkout modal for the new plan 9014 var modal = document.getElementById('goProModal'); 9015 if (modal) { 9016 modal.setAttribute('data-selected-plan', targetPlan); 9017 if (typeof window.submitGoProCheckout === 'function') { 9018 window.submitGoProCheckout(); 9019 } 9020 } 9021 }) 9022 .catch(function(err) { 9023 console.error('[Switch Plan] Network error:', err); 9024 hideLoader('membership-processing-loader'); 9025 hideLoader('go-pro-processing-loader'); 9026 showNotification('Network error. Please try again or contact support.'); 9027 }); 9028 }; 9029 })(); 9030 </script>
9030 9031
9031<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"00bcde813c7e4560bc694b3e7f874119","r":1,"spa":2}' crossorigin="anonymous"></script>
9031 9032</body> 9033</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.