| <!DOCTYPE html> |
| <html lang="vi"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>VietFly — Đặt vé máy bay</title> |
| <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| :root { |
| --blue: #1a56db; |
| --blue-lt: #e8f0ff; |
| --green: #0e9f6e; |
| --red: #e02424; |
| --gray-50: #f9fafb; |
| --gray-100:#f3f4f6; |
| --gray-200:#e5e7eb; |
| --gray-400:#9ca3af; |
| --gray-600:#4b5563; |
| --gray-800:#1f2937; |
| --shadow: 0 1px 3px rgba(0,0,0,.12); |
| --radius: 10px; |
| } |
| |
| body { |
| font-family: 'Segoe UI', system-ui, sans-serif; |
| background: var(--gray-100); |
| color: var(--gray-800); |
| height: 100vh; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| |
| |
| header { |
| background: var(--blue); |
| color: #fff; |
| padding: 0 20px; |
| height: 54px; |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| flex-shrink: 0; |
| box-shadow: var(--shadow); |
| } |
| header .logo { font-size: 1.15rem; font-weight: 700; letter-spacing: .3px; white-space: nowrap; } |
| header .logo span { opacity: .75; font-weight: 400; font-size: .9rem; margin-left: 6px; } |
| header .spacer { flex: 1; } |
| |
| |
| #backendUrlWrap { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| background: rgba(255,255,255,.12); |
| border: 1px solid rgba(255,255,255,.3); |
| border-radius: 6px; |
| padding: 3px 8px; |
| max-width: 280px; |
| } |
| #backendUrlWrap label { |
| font-size: .75rem; |
| opacity: .8; |
| white-space: nowrap; |
| } |
| #backendUrl { |
| background: transparent; |
| border: none; |
| outline: none; |
| color: #fff; |
| font-size: .8rem; |
| width: 200px; |
| font-family: 'Consolas', monospace; |
| } |
| #backendUrl::placeholder { opacity: .5; } |
| #backendStatus { |
| width: 8px; height: 8px; |
| border-radius: 50%; |
| background: var(--gray-400); |
| flex-shrink: 0; |
| transition: background .3s; |
| } |
| #backendStatus.ok { background: #4ade80; } |
| #backendStatus.err { background: #f87171; } |
| |
| #providerSelect { |
| background: rgba(255,255,255,.15); |
| color: #fff; |
| border: 1px solid rgba(255,255,255,.3); |
| border-radius: 6px; |
| padding: 4px 8px; |
| font-size: .85rem; |
| cursor: pointer; |
| } |
| #providerSelect option { color: var(--gray-800); background: #fff; } |
| #resetBtn { |
| background: rgba(255,255,255,.15); |
| color: #fff; |
| border: 1px solid rgba(255,255,255,.3); |
| border-radius: 6px; |
| padding: 5px 12px; |
| font-size: .85rem; |
| cursor: pointer; |
| transition: background .2s; |
| white-space: nowrap; |
| } |
| #resetBtn:hover { background: rgba(255,255,255,.25); } |
| |
| |
| .main { |
| display: flex; |
| flex: 1; |
| overflow: hidden; |
| gap: 0; |
| } |
| |
| |
| .chat-pane { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| background: #fff; |
| border-right: 1px solid var(--gray-200); |
| min-width: 0; |
| } |
| |
| #messages { |
| flex: 1; |
| overflow-y: auto; |
| padding: 16px; |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| } |
| |
| .bubble { |
| max-width: 80%; |
| padding: 10px 14px; |
| border-radius: var(--radius); |
| font-size: .92rem; |
| line-height: 1.5; |
| word-break: break-word; |
| } |
| .bubble.user { |
| align-self: flex-end; |
| background: var(--blue); |
| color: #fff; |
| border-bottom-right-radius: 3px; |
| white-space: pre-wrap; |
| } |
| .bubble.bot { |
| align-self: flex-start; |
| background: var(--gray-100); |
| color: var(--gray-800); |
| border-bottom-left-radius: 3px; |
| } |
| .bubble.error { |
| align-self: flex-start; |
| background: #fee2e2; |
| color: var(--red); |
| } |
| |
| .bubble.bot p, .bubble.error p { margin: 0 0 6px; } |
| .bubble.bot p:last-child, .bubble.error p:last-child { margin-bottom: 0; } |
| .bubble.bot ul, .bubble.bot ol { margin: 4px 0 6px 18px; } |
| .bubble.bot li { margin-bottom: 2px; } |
| .bubble.bot strong { font-weight: 700; } |
| .bubble.bot em { font-style: italic; } |
| .bubble.bot code { |
| background: rgba(0,0,0,.07); |
| padding: 1px 5px; |
| border-radius: 4px; |
| font-size: .88em; |
| font-family: 'Consolas', monospace; |
| } |
| .bubble.bot h1, .bubble.bot h2, .bubble.bot h3 { |
| margin: 8px 0 4px; |
| font-size: 1em; |
| font-weight: 700; |
| } |
| .bubble.bot hr { |
| border: none; |
| border-top: 1px solid var(--gray-200); |
| margin: 8px 0; |
| } |
| .bubble.bot table { |
| border-collapse: collapse; |
| font-size: .85em; |
| margin: 6px 0; |
| width: 100%; |
| } |
| .bubble.bot th, .bubble.bot td { |
| border: 1px solid var(--gray-200); |
| padding: 4px 8px; |
| text-align: left; |
| } |
| .bubble.bot th { background: var(--gray-100); font-weight: 700; } |
| |
| .typing-indicator { |
| align-self: flex-start; |
| background: var(--gray-100); |
| border-radius: var(--radius); |
| border-bottom-left-radius: 3px; |
| padding: 10px 16px; |
| display: flex; |
| gap: 5px; |
| align-items: center; |
| } |
| .typing-indicator span { |
| width: 7px; height: 7px; |
| background: var(--gray-400); |
| border-radius: 50%; |
| animation: bounce .8s infinite; |
| } |
| .typing-indicator span:nth-child(2) { animation-delay: .15s; } |
| .typing-indicator span:nth-child(3) { animation-delay: .30s; } |
| @keyframes bounce { |
| 0%,80%,100% { transform: translateY(0); } |
| 40% { transform: translateY(-6px); } |
| } |
| |
| |
| .confirm-bar { |
| display: none; |
| flex-direction: column; |
| gap: 6px; |
| padding: 10px 14px; |
| border-top: 2px solid var(--blue); |
| background: var(--blue-lt); |
| } |
| .confirm-bar.visible { display: flex; } |
| .confirm-bar-label { |
| font-size: .75rem; |
| font-weight: 700; |
| color: var(--blue); |
| text-transform: uppercase; |
| letter-spacing: .4px; |
| } |
| .confirm-bar-row { display: flex; gap: 8px; flex-wrap: wrap; } |
| .btn-confirm { |
| background: var(--green); |
| color: #fff; |
| border: none; |
| border-radius: 7px; |
| padding: 8px 16px; |
| font-size: .88rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: opacity .2s; |
| white-space: nowrap; |
| } |
| .btn-confirm:hover { opacity: .88; } |
| .btn-cancel { |
| background: #fff; |
| color: var(--red); |
| border: 1.5px solid var(--red); |
| border-radius: 7px; |
| padding: 8px 16px; |
| font-size: .88rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: background .15s; |
| white-space: nowrap; |
| } |
| .btn-cancel:hover { background: #fee2e2; } |
| .btn-modify-toggle { |
| background: #fff; |
| color: var(--blue); |
| border: 1.5px solid var(--blue); |
| border-radius: 7px; |
| padding: 8px 16px; |
| font-size: .88rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: background .15s; |
| white-space: nowrap; |
| } |
| .btn-modify-toggle:hover { background: var(--blue-lt); } |
| .modify-submenu { |
| display: none; |
| flex-wrap: wrap; |
| gap: 6px; |
| margin-top: 2px; |
| } |
| .modify-submenu.visible { display: flex; } |
| .btn-modify-field { |
| background: #fff; |
| color: var(--gray-800); |
| border: 1px solid var(--gray-200); |
| border-radius: 6px; |
| padding: 5px 12px; |
| font-size: .82rem; |
| cursor: pointer; |
| transition: border-color .15s, background .15s; |
| white-space: nowrap; |
| } |
| .btn-modify-field:hover { border-color: var(--blue); background: var(--blue-lt); color: var(--blue); } |
| |
| |
| .input-bar { |
| display: flex; |
| gap: 8px; |
| padding: 10px 14px; |
| border-top: 1px solid var(--gray-200); |
| background: #fff; |
| } |
| #userInput { |
| flex: 1; |
| border: 1px solid var(--gray-200); |
| border-radius: 8px; |
| padding: 9px 14px; |
| font-size: .92rem; |
| outline: none; |
| resize: none; |
| min-height: 40px; |
| max-height: 120px; |
| font-family: inherit; |
| transition: border-color .2s; |
| } |
| #userInput:focus { border-color: var(--blue); } |
| #sendBtn { |
| background: var(--blue); |
| color: #fff; |
| border: none; |
| border-radius: 8px; |
| padding: 0 18px; |
| font-size: .92rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: opacity .2s; |
| align-self: flex-end; |
| height: 40px; |
| } |
| #sendBtn:hover { opacity: .88; } |
| #sendBtn:disabled { opacity: .5; cursor: not-allowed; } |
| |
| |
| .sidebar { |
| width: 380px; |
| flex-shrink: 0; |
| display: flex; |
| flex-direction: column; |
| background: var(--gray-50); |
| overflow: hidden; |
| } |
| .sidebar-header { |
| padding: 12px 16px; |
| border-bottom: 1px solid var(--gray-200); |
| font-size: .8rem; |
| font-weight: 600; |
| color: var(--gray-600); |
| text-transform: uppercase; |
| letter-spacing: .5px; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| } |
| .stage-badge { |
| background: var(--blue-lt); |
| color: var(--blue); |
| padding: 2px 8px; |
| border-radius: 12px; |
| font-size: .72rem; |
| font-weight: 700; |
| text-transform: uppercase; |
| } |
| #sidebarContent { |
| flex: 1; |
| overflow-y: auto; |
| padding: 14px; |
| } |
| |
| |
| .card { |
| background: #fff; |
| border: 1px solid var(--gray-200); |
| border-radius: var(--radius); |
| padding: 14px; |
| margin-bottom: 10px; |
| box-shadow: var(--shadow); |
| } |
| .card-title { |
| font-size: .8rem; |
| font-weight: 700; |
| color: var(--gray-600); |
| text-transform: uppercase; |
| letter-spacing: .4px; |
| margin-bottom: 10px; |
| } |
| |
| |
| .flight-card { cursor: default; } |
| .flight-meta { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| margin-bottom: 8px; |
| } |
| .airline-badge { |
| background: var(--blue); |
| color: #fff; |
| font-size: .72rem; |
| font-weight: 700; |
| padding: 2px 7px; |
| border-radius: 4px; |
| } |
| .flight-id { font-weight: 600; font-size: .9rem; } |
| .duration { margin-left: auto; font-size: .78rem; color: var(--gray-600); } |
| .flight-times { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| margin-bottom: 10px; |
| } |
| .time { font-size: 1.1rem; font-weight: 700; } |
| .arrow { color: var(--gray-400); font-size: .8rem; flex: 1; text-align: center; } |
| .city { font-size: .75rem; color: var(--gray-600); } |
| .class-buttons { |
| display: flex; |
| gap: 6px; |
| } |
| .class-btn { |
| flex: 1; |
| border: 1.5px solid var(--blue); |
| background: #fff; |
| color: var(--blue); |
| border-radius: 7px; |
| padding: 7px 4px; |
| font-size: .75rem; |
| font-weight: 600; |
| cursor: pointer; |
| text-align: center; |
| transition: background .15s, color .15s; |
| line-height: 1.3; |
| } |
| .class-btn:hover { background: var(--blue); color: #fff; } |
| .class-btn .price { font-size: .78rem; font-weight: 400; } |
| .class-btn.sold-out { |
| border-color: var(--gray-200); |
| color: var(--gray-400); |
| cursor: not-allowed; |
| } |
| |
| |
| .price-row { |
| display: flex; |
| justify-content: space-between; |
| font-size: .85rem; |
| padding: 3px 0; |
| color: var(--gray-600); |
| } |
| .price-row.total { |
| border-top: 1px solid var(--gray-200); |
| margin-top: 4px; |
| padding-top: 7px; |
| font-weight: 700; |
| color: var(--gray-800); |
| font-size: .92rem; |
| } |
| |
| |
| .pax-item { |
| border-bottom: 1px solid var(--gray-100); |
| padding: 6px 0; |
| font-size: .83rem; |
| } |
| .pax-item:last-child { border-bottom: none; } |
| .pax-label { font-weight: 600; color: var(--blue); font-size: .78rem; } |
| .pax-field { display: flex; gap: 6px; margin-top: 2px; } |
| .pax-field .fk { color: var(--gray-600); min-width: 80px; } |
| |
| |
| .extras-item { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| font-size: .83rem; |
| padding: 4px 0; |
| border-bottom: 1px solid var(--gray-100); |
| } |
| .extras-item:last-child { border-bottom: none; } |
| |
| |
| .ref-box { |
| background: var(--green); |
| color: #fff; |
| text-align: center; |
| padding: 16px; |
| border-radius: 8px; |
| margin-bottom: 12px; |
| } |
| .ref-box .ref-label { font-size: .8rem; opacity: .85; } |
| .ref-box .ref-code { font-size: 1.5rem; font-weight: 800; letter-spacing: 2px; } |
| |
| |
| .welcome-step { |
| display: flex; |
| gap: 10px; |
| align-items: flex-start; |
| padding: 8px 0; |
| border-bottom: 1px solid var(--gray-100); |
| } |
| .welcome-step:last-child { border-bottom: none; } |
| .step-num { |
| background: var(--blue); |
| color: #fff; |
| width: 22px; |
| height: 22px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: .72rem; |
| font-weight: 700; |
| flex-shrink: 0; |
| margin-top: 2px; |
| } |
| .step-text { font-size: .84rem; line-height: 1.45; } |
| .step-text strong { color: var(--gray-800); } |
| |
| |
| .pax-form-tabs { |
| display: flex; |
| gap: 4px; |
| margin-bottom: 12px; |
| flex-wrap: wrap; |
| } |
| .pax-tab { |
| padding: 5px 10px; |
| border-radius: 6px; |
| border: 1.5px solid var(--gray-200); |
| background: #fff; |
| font-size: .78rem; |
| font-weight: 600; |
| cursor: pointer; |
| color: var(--gray-600); |
| transition: all .15s; |
| } |
| .pax-tab.active { |
| border-color: var(--blue); |
| background: var(--blue-lt); |
| color: var(--blue); |
| } |
| .pax-tab.done { |
| border-color: var(--green); |
| background: #f0fdf4; |
| color: var(--green); |
| } |
| .pax-form-panel { display: none; } |
| .pax-form-panel.active { display: block; } |
| .form-group { margin-bottom: 10px; } |
| .form-group label { |
| display: block; |
| font-size: .78rem; |
| font-weight: 600; |
| color: var(--gray-600); |
| margin-bottom: 3px; |
| } |
| .form-group input, |
| .form-group select { |
| width: 100%; |
| border: 1px solid var(--gray-200); |
| border-radius: 6px; |
| padding: 7px 10px; |
| font-size: .85rem; |
| font-family: inherit; |
| outline: none; |
| transition: border-color .2s; |
| background: #fff; |
| } |
| .form-group input:focus, |
| .form-group select:focus { border-color: var(--blue); } |
| .form-group .hint { font-size: .72rem; color: var(--gray-400); margin-top: 2px; } |
| .pax-form-nav { display: flex; gap: 8px; margin-top: 12px; } |
| .btn-pax-submit { |
| flex: 1; |
| background: var(--blue); |
| color: #fff; |
| border: none; |
| border-radius: 7px; |
| padding: 9px 12px; |
| font-size: .88rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: opacity .2s; |
| } |
| .btn-pax-submit:hover { opacity: .88; } |
| .btn-pax-next { |
| flex: 1; |
| background: var(--gray-100); |
| color: var(--gray-800); |
| border: 1px solid var(--gray-200); |
| border-radius: 7px; |
| padding: 9px 12px; |
| font-size: .88rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: background .15s; |
| } |
| .btn-pax-next:hover { background: var(--gray-200); } |
| .form-group input.invalid { border-color: var(--red); } |
| .form-group .err-msg { font-size: .72rem; color: var(--red); margin-top: 2px; display: none; } |
| .form-group .err-msg.show { display: block; } |
| |
| |
| .extras-option-group { margin-bottom: 12px; } |
| .extras-option-group label.group-label { |
| display: block; |
| font-size: .78rem; |
| font-weight: 700; |
| color: var(--gray-600); |
| text-transform: uppercase; |
| letter-spacing: .4px; |
| margin-bottom: 6px; |
| } |
| .extras-options { display: flex; flex-direction: column; gap: 5px; } |
| .extras-radio-label { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 7px 10px; |
| border: 1.5px solid var(--gray-200); |
| border-radius: 7px; |
| cursor: pointer; |
| transition: border-color .15s, background .15s; |
| font-size: .83rem; |
| } |
| .extras-radio-label:hover { border-color: var(--blue); background: var(--blue-lt); } |
| .extras-radio-label input[type=radio] { accent-color: var(--blue); width: 15px; height: 15px; flex-shrink: 0; } |
| .extras-radio-label.selected { border-color: var(--blue); background: var(--blue-lt); } |
| .extras-radio-label .opt-name { flex: 1; font-weight: 500; } |
| .extras-radio-label .opt-price { font-weight: 700; color: var(--blue); font-size: .8rem; } |
| .extras-radio-label .opt-free { font-weight: 600; color: var(--green); font-size: .8rem; } |
| |
| |
| ::-webkit-scrollbar { width: 5px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: var(--gray-200); border-radius: 3px; } |
| |
| @media (max-width: 700px) { |
| .sidebar { display: none; } |
| #backendUrlWrap { display: none; } |
| } |
| </style> |
| </head> |
| <body> |
|
|
| <header> |
| <div class="logo">✈ VietFly <span>Trợ lý đặt vé</span></div> |
| <div class="spacer"></div> |
|
|
| |
| <div id="backendUrlWrap" title="Nhập ngrok URL của backend"> |
| <label for="backendUrl">Backend:</label> |
| <input id="backendUrl" type="text" placeholder="https://xxxx.ngrok-free.app" spellcheck="false" /> |
| <div id="backendStatus" title="Trạng thái kết nối"></div> |
| </div> |
|
|
| <select id="providerSelect" title="Chọn AI provider"> |
| <option value="anthropic">Anthropic Claude</option> |
| <option value="openai">OpenAI GPT</option> |
| <option value="gemini">Google Gemini</option> |
| <option value="openrouter">OpenRouter</option> |
| </select> |
| <button id="resetBtn">↺ Đặt lại</button> |
| </header> |
|
|
| <div class="main"> |
|
|
| |
| <div class="chat-pane"> |
| <div id="messages"></div> |
|
|
| |
| <div class="confirm-bar" id="confirmBar"> |
| <div class="confirm-bar-label">⚡ Hành động nhanh</div> |
| <div class="confirm-bar-row"> |
| <button class="btn-confirm" onclick="quickAction('confirm')">✔ Xác nhận thanh toán</button> |
| <button class="btn-cancel" onclick="quickAction('cancel')">✕ Huỷ đặt vé</button> |
| <button class="btn-modify-toggle" id="modifyToggleBtn" onclick="toggleModifyMenu()">✎ Sửa thông tin ▾</button> |
| </div> |
| <div class="modify-submenu" id="modifySubmenu"> |
| <button class="btn-modify-field" onclick="quickModify('seat_type','window')">🪑 Ghế cửa sổ</button> |
| <button class="btn-modify-field" onclick="quickModify('seat_type','middle')">🪑 Ghế giữa</button> |
| <button class="btn-modify-field" onclick="quickModify('seat_type','aisle')">🪑 Ghế lối đi</button> |
| <button class="btn-modify-field" onclick="quickModify('baggage_kg','0')">🧳 Không hành lý</button> |
| <button class="btn-modify-field" onclick="quickModify('baggage_kg','20')">🧳 Hành lý 20kg</button> |
| <button class="btn-modify-field" onclick="quickModify('baggage_kg','30')">🧳 Hành lý 30kg</button> |
| <button class="btn-modify-field" onclick="quickModify('meal','standard')">🍱 Suất ăn tiêu chuẩn</button> |
| <button class="btn-modify-field" onclick="quickModify('meal','vegetarian')">🍱 Suất ăn chay</button> |
| <button class="btn-modify-field" onclick="quickModify('meal','none')">🍱 Không suất ăn</button> |
| <button class="btn-modify-field" onclick="promptModify('email')">✉ Sửa email</button> |
| <button class="btn-modify-field" onclick="promptModify('dien_thoai')">📞 Sửa điện thoại</button> |
| <button class="btn-modify-field" onclick="promptModify('dia_chi')">📍 Sửa địa chỉ</button> |
| </div> |
| </div> |
|
|
| <div class="input-bar"> |
| <textarea id="userInput" placeholder="Nhập tin nhắn..." rows="1"></textarea> |
| <button id="sendBtn">Gửi</button> |
| </div> |
| </div> |
|
|
| |
| <div class="sidebar"> |
| <div class="sidebar-header"> |
| Thông tin đặt vé |
| <span id="stageBadge" class="stage-badge">idle</span> |
| </div> |
| <div id="sidebarContent"></div> |
| </div> |
|
|
| </div> |
|
|
| <script> |
| |
| const backendUrlEl = document.getElementById('backendUrl'); |
| const backendStatus = document.getElementById('backendStatus'); |
| |
| const STORAGE_KEY = 'vietfly_backend_url'; |
| |
| |
| backendUrlEl.value = localStorage.getItem(STORAGE_KEY) || ''; |
| |
| function getBase() { |
| return backendUrlEl.value.trim().replace(/\/$/, ''); |
| } |
| |
| function apiUrl(path) { |
| const base = getBase(); |
| return base ? base + path : path; |
| } |
| |
| |
| let _pingTimer = null; |
| backendUrlEl.addEventListener('input', () => { |
| localStorage.setItem(STORAGE_KEY, backendUrlEl.value.trim()); |
| clearTimeout(_pingTimer); |
| backendStatus.className = ''; |
| _pingTimer = setTimeout(pingBackend, 600); |
| }); |
| |
| async function pingBackend() { |
| const base = getBase(); |
| if (!base) { backendStatus.className = ''; return; } |
| try { |
| const r = await fetch(base + '/session', { signal: AbortSignal.timeout(4000) }); |
| backendStatus.className = r.ok ? 'ok' : 'err'; |
| } catch { |
| backendStatus.className = 'err'; |
| } |
| } |
| |
| |
| if (backendUrlEl.value) pingBackend(); |
| |
| |
| let currentStage = 'idle'; |
| let isLoading = false; |
| |
| |
| const messagesEl = document.getElementById('messages'); |
| const userInput = document.getElementById('userInput'); |
| const sendBtn = document.getElementById('sendBtn'); |
| const resetBtn = document.getElementById('resetBtn'); |
| const providerSel = document.getElementById('providerSelect'); |
| const sidebarEl = document.getElementById('sidebarContent'); |
| const stageBadge = document.getElementById('stageBadge'); |
| |
| const STAGE_LABELS = { |
| idle: 'Chờ', |
| searching: 'Tìm vé', |
| select_ticket: 'Chọn vé', |
| collect_pax: 'Hành khách', |
| select_extras: 'Dịch vụ', |
| confirming: 'Xác nhận', |
| done: 'Hoàn tất', |
| }; |
| |
| |
| function addBubble(text, type) { |
| const div = document.createElement('div'); |
| div.className = `bubble ${type}`; |
| if (type === 'bot' || type === 'error') { |
| div.innerHTML = marked.parse(text, { breaks: true, gfm: true }); |
| } else { |
| div.textContent = text; |
| } |
| messagesEl.appendChild(div); |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| return div; |
| } |
| |
| function addTyping() { |
| const div = document.createElement('div'); |
| div.className = 'typing-indicator'; |
| div.id = 'typing'; |
| div.innerHTML = '<span></span><span></span><span></span>'; |
| messagesEl.appendChild(div); |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| } |
| |
| function removeTyping() { |
| document.getElementById('typing')?.remove(); |
| } |
| |
| |
| function fmtMoney(n) { |
| return Number(n).toLocaleString('vi-VN') + 'đ'; |
| } |
| |
| function fmtDuration(mins) { |
| const h = Math.floor(mins / 60), m = mins % 60; |
| return h ? `${h}g${m > 0 ? m + 'p' : ''}` : `${m}p`; |
| } |
| |
| |
| |
| function renderWelcome() { |
| sidebarEl.innerHTML = ` |
| <div class="card"> |
| <div class="card-title">Quy trình đặt vé</div> |
| ${[ |
| ['Tìm chuyến bay', 'Cho biết điểm đi, điểm đến, ngày bay và số hành khách'], |
| ['Chọn hạng vé', 'Xem danh sách chuyến bay và chọn hạng phù hợp'], |
| ['Thông tin hành khách', 'Điền đầy đủ thông tin cho từng hành khách'], |
| ['Dịch vụ bổ sung', 'Chọn chỗ ngồi, hành lý và suất ăn'], |
| ['Xác nhận & thanh toán', 'Kiểm tra và xác nhận đặt vé'], |
| ].map(([t, d], i) => ` |
| <div class="welcome-step"> |
| <div class="step-num">${i+1}</div> |
| <div class="step-text"><strong>${t}</strong><br>${d}</div> |
| </div> |
| `).join('')} |
| </div> |
| <div class="card" style="font-size:.82rem;color:var(--gray-600);line-height:1.6"> |
| <div class="card-title">Sân bay hỗ trợ</div> |
| HAN · Hà Nội<br>SGN · Hồ Chí Minh<br>DAD · Đà Nẵng<br> |
| CXR · Nha Trang<br>PQC · Phú Quốc<br>HUI · Huế<br> |
| VCA · Cần Thơ<br>VII · Vinh<br>BMV · Buôn Ma Thuột |
| </div>`; |
| } |
| |
| function renderFlightList(data) { |
| const params = data.search_params || {}; |
| const leg = data.leg || 'outbound'; |
| const legLabel = leg === 'return' ? '✈ Chiều về' : '✈ Chiều đi'; |
| const legColor = leg === 'return' ? 'var(--green)' : 'var(--blue)'; |
| let html = ` |
| <div class="card"> |
| <div class="card-title" style="color:${legColor}">${legLabel} — Kết quả tìm kiếm</div> |
| <div style="font-size:.82rem;color:var(--gray-600);margin-bottom:8px"> |
| ${params.departure_city || params.departure} → ${params.destination_city || params.destination} |
| | ${params.dep_date || ''} |
| | ${(params.num_adults||1)}NL${params.num_children?','+params.num_children+'TE':''}${params.num_infants?','+params.num_infants+'EB':''} |
| </div> |
| </div>`; |
| |
| for (const f of (data.flights || [])) { |
| const classesHTML = Object.entries(f.classes || {}).map(([cls, info]) => { |
| const sold = info.seats_available === 0; |
| return `<button class="class-btn${sold?' sold-out':''}" |
| onclick="${sold ? '' : `selectFlight('${f.flight_id}','${cls}','${leg}')`}" |
| ${sold ? 'disabled' : ''}> |
| ${cls}<br><span class="price">${fmtMoney(info.price_per_adult)}</span> |
| ${sold ? '<br><span style="font-size:.65rem;color:var(--gray-400)">Hết chỗ</span>' : ''} |
| </button>`; |
| }).join(''); |
| |
| const dateLabel = f.dep_day ? `${f.dep_day}, ${f.dep_date||''}` : (f.dep_date||''); |
| html += `<div class="card flight-card"> |
| <div class="flight-meta"> |
| <span class="airline-badge">${f.airline_code}</span> |
| <span class="flight-id">${f.flight_id}</span> |
| <span class="duration">${fmtDuration(f.duration_minutes)}</span> |
| </div> |
| <div class="flight-times"> |
| <div><div class="time">${f.dep_time}</div><div class="city">${f.dep_city||f.departure}</div></div> |
| <div class="arrow">──────→</div> |
| <div style="text-align:right"><div class="time">${f.arr_time}</div><div class="city">${f.dst_city||f.destination}</div></div> |
| </div> |
| ${dateLabel ? `<div style="font-size:.75rem;color:var(--gray-600);margin-bottom:8px">📅 ${dateLabel}</div>` : ''} |
| <div class="class-buttons">${classesHTML}</div> |
| </div>`; |
| } |
| sidebarEl.innerHTML = html; |
| } |
| |
| function _ticketCard(data) { |
| const pb = data.price_breakdown || {}; |
| const leg = data.leg || 'outbound'; |
| const legLabel = leg === 'return' ? '✈ Chiều về' : '✈ Chiều đi'; |
| const legColor = leg === 'return' ? 'var(--green)' : 'var(--blue)'; |
| let rows = ''; |
| if (pb.num_adults > 0) |
| rows += `<div class="price-row"><span>${pb.num_adults} người lớn × ${fmtMoney(pb.adult_unit)}</span><span>${fmtMoney(pb.adults_subtotal)}</span></div>`; |
| if (pb.num_children > 0) |
| rows += `<div class="price-row"><span>${pb.num_children} trẻ em × ${fmtMoney(pb.child_unit)}</span><span>${fmtMoney(pb.children_subtotal)}</span></div>`; |
| if (pb.num_infants > 0) |
| rows += `<div class="price-row"><span>${pb.num_infants} em bé × ${fmtMoney(pb.infant_unit)}</span><span>${fmtMoney(pb.infants_subtotal)}</span></div>`; |
| return ` |
| <div class="card"> |
| <div class="card-title" style="color:${legColor}">${legLabel}</div> |
| <div class="flight-meta"> |
| <span class="airline-badge">${(data.airline||'').slice(0,2).toUpperCase()}</span> |
| <span class="flight-id">${data.flight_id || ''}</span> |
| <span style="margin-left:auto;font-size:.78rem;background:var(--blue-lt);color:${legColor};padding:2px 8px;border-radius:12px;font-weight:600">${data.ticket_class||''}</span> |
| </div> |
| <div class="flight-times"> |
| <div><div class="time">${data.dep_time||''}</div><div class="city">${data.dep_city||data.departure||''}</div></div> |
| <div class="arrow">──────→</div> |
| <div style="text-align:right"><div class="time">${data.arr_time||''}</div><div class="city">${data.dst_city||data.destination||''}</div></div> |
| </div> |
| <div style="font-size:.78rem;color:var(--gray-600);margin-bottom:10px">${data.dep_date||''} | ${data.passengers_label||''}</div> |
| ${rows} |
| <div class="price-row total"><span>Tổng tiền vé ${legLabel.replace('✈ ','')}</span><span>${fmtMoney(pb.flight_total||0)}</span></div> |
| </div>`; |
| } |
| |
| function renderTicketSummary(data) { |
| const leg = data.leg || 'outbound'; |
| const card = _ticketCard(data); |
| if (leg === 'return') { |
| sidebarEl.innerHTML += card; |
| } else { |
| sidebarEl.innerHTML = card; |
| } |
| } |
| |
| function renderPaxSummary(data) { |
| const paxCards = (data.passengers || []).map(p => ` |
| <div class="pax-item"> |
| <div class="pax-label">${p.label}</div> |
| <div class="pax-field"><span class="fk">Họ tên:</span><span>${p.ten_day_du}</span></div> |
| <div class="pax-field"><span class="fk">Ngày sinh:</span><span>${p.ngay_sinh}</span></div> |
| <div class="pax-field"><span class="fk">Giới tính:</span><span>${p.gioi_tinh}</span></div> |
| <div class="pax-field"><span class="fk">Giấy tờ:</span><span>${p.so_giay_to}</span></div> |
| </div>`).join(''); |
| sidebarEl.innerHTML = `<div class="card"><div class="card-title">Thông tin hành khách</div>${paxCards}</div>`; |
| } |
| |
| |
| let _paxFormData = []; |
| let _paxFormSpec = []; |
| |
| function _buildPaxSpec(numAdults, numChildren, numInfants) { |
| const spec = []; |
| for (let i = 0; i < numAdults; i++) spec.push({ type: 'adult', label: `Người lớn ${i+1}`, idx: spec.length }); |
| for (let i = 0; i < numChildren; i++) spec.push({ type: 'child', label: `Trẻ em ${i+1}`, idx: spec.length }); |
| for (let i = 0; i < numInfants; i++) spec.push({ type: 'infant', label: `Em bé ${i+1}`, idx: spec.length }); |
| return spec; |
| } |
| |
| function _paxFormFieldsHTML(pax, adultCount) { |
| const isAdult = pax.type === 'adult'; |
| const isInfant = pax.type === 'infant'; |
| const adultOptions = Array.from({length: adultCount}, (_, i) => |
| `<option value="${i}">Người lớn ${i+1}</option>`).join(''); |
| |
| return ` |
| <div class="form-group"> |
| <label>Họ <span style="color:var(--red)">*</span></label> |
| <input type="text" name="ho" placeholder="VD: NGUYEN" style="text-transform:uppercase"> |
| <div class="hint">Không dấu, viết hoa</div> |
| <div class="err-msg">Vui lòng nhập họ (không dấu).</div> |
| </div> |
| <div class="form-group"> |
| <label>Tên đệm và tên <span style="color:var(--red)">*</span></label> |
| <input type="text" name="ten_dem_ten" placeholder="VD: VAN AN" style="text-transform:uppercase"> |
| <div class="hint">Không dấu, viết hoa</div> |
| <div class="err-msg">Vui lòng nhập tên đệm và tên (không dấu).</div> |
| </div> |
| <div class="form-group"> |
| <label>Giới tính <span style="color:var(--red)">*</span></label> |
| <select name="gioi_tinh"> |
| <option value="">-- Chọn --</option> |
| <option value="Nam">Nam</option> |
| <option value="Nữ">Nữ</option> |
| <option value="Khác">Khác</option> |
| </select> |
| <div class="err-msg">Vui lòng chọn giới tính.</div> |
| </div> |
| <div class="form-group"> |
| <label>Ngày sinh <span style="color:var(--red)">*</span></label> |
| <input type="text" name="ngay_sinh" placeholder="DD/MM/YYYY"> |
| <div class="err-msg">Vui lòng nhập ngày sinh đúng định dạng DD/MM/YYYY.</div> |
| </div> |
| ${!isInfant ? ` |
| <div class="form-group"> |
| <label>Số CCCD / Hộ chiếu <span style="color:var(--red)">*</span></label> |
| <input type="text" name="so_giay_to" placeholder="VD: 012345678"> |
| <div class="err-msg">Vui lòng nhập số giấy tờ.</div> |
| </div>` : ''} |
| ${isAdult ? ` |
| <div class="form-group"> |
| <label>Quốc tịch <span style="color:var(--red)">*</span></label> |
| <input type="text" name="quoc_tich" placeholder="VD: Việt Nam" value="Việt Nam"> |
| <div class="err-msg">Vui lòng nhập quốc tịch.</div> |
| </div> |
| <div class="form-group"> |
| <label>Số điện thoại <span style="color:var(--red)">*</span></label> |
| <input type="text" name="dien_thoai" placeholder="VD: 0901234567"> |
| <div class="err-msg">Vui lòng nhập số điện thoại.</div> |
| </div> |
| <div class="form-group"> |
| <label>Email <span style="color:var(--red)">*</span></label> |
| <input type="email" name="email" placeholder="VD: example@email.com"> |
| <div class="err-msg">Vui lòng nhập email hợp lệ.</div> |
| </div> |
| <div class="form-group"> |
| <label>Địa chỉ hiện tại <span style="color:var(--red)">*</span></label> |
| <input type="text" name="dia_chi" placeholder="VD: 123 Đường ABC, Hà Nội"> |
| <div class="err-msg">Vui lòng nhập địa chỉ.</div> |
| </div>` : ''} |
| ${isInfant ? ` |
| <div class="form-group"> |
| <label>Người lớn đi kèm <span style="color:var(--red)">*</span></label> |
| <select name="cu_hang_voi">${adultOptions}</select> |
| </div>` : ''} |
| `; |
| } |
| |
| function renderPaxForm(numAdults, numChildren, numInfants) { |
| _paxFormSpec = _buildPaxSpec(numAdults, numChildren, numInfants); |
| _paxFormData = _paxFormSpec.map(() => null); |
| |
| const tabs = _paxFormSpec.map((p, i) => |
| `<button class="pax-tab${i===0?' active':''}" onclick="switchPaxTab(${i})" id="pax-tab-${i}">${p.label}</button>` |
| ).join(''); |
| |
| const panels = _paxFormSpec.map((p, i) => ` |
| <div class="pax-form-panel${i===0?' active':''}" id="pax-panel-${i}"> |
| <div style="font-size:.8rem;font-weight:700;color:var(--gray-600);text-transform:uppercase;margin-bottom:10px;letter-spacing:.4px"> |
| ${p.label} |
| <span style="font-weight:400;text-transform:none;color:var(--gray-400)"> |
| (${p.type === 'adult' ? 'Người lớn' : p.type === 'child' ? 'Trẻ em' : 'Em bé'}) |
| </span> |
| </div> |
| ${_paxFormFieldsHTML(p, numAdults)} |
| <div class="pax-form-nav"> |
| ${i < _paxFormSpec.length - 1 |
| ? `<button class="btn-pax-next" onclick="savePaxAndNext(${i})">Tiếp theo →</button>` |
| : `<button class="btn-pax-submit" onclick="submitAllPax()">✔ Xác nhận tất cả</button>` |
| } |
| </div> |
| </div> |
| `).join(''); |
| |
| sidebarEl.innerHTML = ` |
| <div class="card"> |
| <div class="card-title">Thông tin hành khách</div> |
| <div class="pax-form-tabs">${tabs}</div> |
| ${panels} |
| </div>`; |
| } |
| |
| function switchPaxTab(idx) { |
| document.querySelectorAll('.pax-form-panel').forEach((p, i) => p.classList.toggle('active', i === idx)); |
| document.querySelectorAll('.pax-tab').forEach((t, i) => t.classList.toggle('active', i === idx)); |
| document.getElementById(`pax-tab-${idx}`)?.classList.remove('done'); |
| } |
| |
| function _validatePaxPanel(panelEl, spec) { |
| let valid = true; |
| panelEl.querySelectorAll('.form-group').forEach(grp => { |
| const inp = grp.querySelector('input, select'); |
| const err = grp.querySelector('.err-msg'); |
| if (!inp || !err) return; |
| const val = inp.value.trim(); |
| let fieldOk = true; |
| if (inp.name === 'ngay_sinh') { |
| fieldOk = /^\d{2}\/\d{2}\/\d{4}$/.test(val); |
| } else if (inp.name === 'email') { |
| fieldOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val); |
| } else if (inp.name === 'so_giay_to') { |
| fieldOk = val.length >= 6; |
| } else if (inp.tagName === 'SELECT') { |
| fieldOk = val !== ''; |
| } else { |
| fieldOk = val.length > 0; |
| } |
| if ((inp.name === 'ho' || inp.name === 'ten_dem_ten') && val.length > 0) { |
| const hasVietnamese = /[àáảãạăắằẳẵặâấầẩẫậđèéẻẽẹêếềểễệìíỉĩịòóỏõọôốồổỗộơớờởỡợùúủũụưứừửữựỳýỷỹỵ]/i.test(val); |
| if (hasVietnamese) { |
| fieldOk = false; |
| err.textContent = 'Tên phải không dấu, viết hoa (VD: NGUYEN VAN AN).'; |
| } |
| } |
| inp.classList.toggle('invalid', !fieldOk); |
| err.classList.toggle('show', !fieldOk); |
| if (!fieldOk) valid = false; |
| }); |
| return valid; |
| } |
| |
| function _collectPaxData(panelEl, spec) { |
| const data = { pax_type: spec.type }; |
| panelEl.querySelectorAll('input, select').forEach(inp => { |
| if (inp.name) data[inp.name] = inp.value.trim(); |
| }); |
| if (data.ho) data.ho = data.ho.toUpperCase(); |
| if (data.ten_dem_ten) data.ten_dem_ten = data.ten_dem_ten.toUpperCase(); |
| return data; |
| } |
| |
| function savePaxAndNext(idx) { |
| const panelEl = document.getElementById(`pax-panel-${idx}`); |
| if (!_validatePaxPanel(panelEl, _paxFormSpec[idx])) return; |
| _paxFormData[idx] = _collectPaxData(panelEl, _paxFormSpec[idx]); |
| const tab = document.getElementById(`pax-tab-${idx}`); |
| tab.classList.remove('active'); |
| tab.classList.add('done'); |
| switchPaxTab(idx + 1); |
| } |
| |
| function submitAllPax() { |
| const lastIdx = _paxFormSpec.length - 1; |
| const panelEl = document.getElementById(`pax-panel-${lastIdx}`); |
| if (!_validatePaxPanel(panelEl, _paxFormSpec[lastIdx])) return; |
| _paxFormData[lastIdx] = _collectPaxData(panelEl, _paxFormSpec[lastIdx]); |
| for (let i = 0; i < _paxFormSpec.length - 1; i++) { |
| if (!_paxFormData[i]) { |
| switchPaxTab(i); |
| addBubble(`Vui lòng điền đầy đủ thông tin cho ${_paxFormSpec[i].label}.`, 'error'); |
| return; |
| } |
| } |
| const lines = ['Thông tin hành khách:']; |
| _paxFormData.forEach((d, i) => { |
| const spec = _paxFormSpec[i]; |
| lines.push(`\n--- ${spec.label} (${spec.type}) ---`); |
| lines.push(`Họ: ${d.ho}`); |
| lines.push(`Tên đệm và tên: ${d.ten_dem_ten}`); |
| lines.push(`Giới tính: ${d.gioi_tinh}`); |
| lines.push(`Ngày sinh: ${d.ngay_sinh}`); |
| if (d.so_giay_to) lines.push(`Số CCCD/Hộ chiếu: ${d.so_giay_to}`); |
| if (d.quoc_tich) lines.push(`Quốc tịch: ${d.quoc_tich}`); |
| if (d.dien_thoai) lines.push(`Điện thoại: ${d.dien_thoai}`); |
| if (d.email) lines.push(`Email: ${d.email}`); |
| if (d.dia_chi) lines.push(`Địa chỉ: ${d.dia_chi}`); |
| if (d.cu_hang_voi !== undefined && d.cu_hang_voi !== '') |
| lines.push(`Đi cùng Người lớn ${parseInt(d.cu_hang_voi) + 1}`); |
| }); |
| lines.push('\nVui lòng xác nhận và lưu thông tin này.'); |
| sendMessage(lines.join('\n')); |
| } |
| |
| |
| let _extrasFormSpec = null; |
| |
| function _fmtPrice(n) { |
| return n > 0 ? `+${fmtMoney(n)}` : ''; |
| } |
| |
| function _extrasOptionsHTML(groupName, options, paxIdx, leg) { |
| return options.map(({ value, label, price }, i) => { |
| const priceHtml = price > 0 |
| ? `<span class="opt-price">${_fmtPrice(price)}</span>` |
| : `<span class="opt-free">Miễn phí</span>`; |
| const id = `extras-${leg}-${paxIdx}-${groupName}-${i}`; |
| const checked = i === 0 ? 'checked' : ''; |
| return ` |
| <label class="extras-radio-label${i===0?' selected':''}" for="${id}"> |
| <input type="radio" id="${id}" name="extras-${leg}-${paxIdx}-${groupName}" |
| value="${value}" ${checked} onchange="onExtrasRadioChange(this)"> |
| <span class="opt-name">${label}</span> |
| ${priceHtml} |
| </label>`; |
| }).join(''); |
| } |
| |
| function _extrasLegPanelHTML(passengers, pricing, leg) { |
| const legLabel = leg === 'return' ? '✈ Chiều về' : '✈ Chiều đi'; |
| const legColor = leg === 'return' ? 'var(--green)' : 'var(--blue)'; |
| const tabs = passengers.map((p, i) => |
| `<button class="pax-tab${i===0?' active':''}" onclick="switchExtrasPaxTab('${leg}',${i})" id="etab-${leg}-${i}">${p.label}</button>` |
| ).join(''); |
| const panels = passengers.map((p, i) => { |
| const seatOptions = [ |
| { value: 'middle', label: 'Ghế giữa', price: pricing.seat_middle || 0 }, |
| { value: 'window', label: 'Cửa sổ', price: pricing.seat_window || 0 }, |
| { value: 'aisle', label: 'Lối đi', price: pricing.seat_aisle || 0 }, |
| ]; |
| const bagOptions = [ |
| { value: '0', label: 'Không có', price: 0 }, |
| { value: '20', label: '20 kg', price: pricing.baggage_20kg || 0 }, |
| { value: '30', label: '30 kg', price: pricing.baggage_30kg || 0 }, |
| ]; |
| const mealOptions = [ |
| { value: 'standard', label: 'Tiêu chuẩn', price: 0 }, |
| { value: 'vegetarian', label: 'Suất chay', price: pricing.meal_vegetarian || 0 }, |
| { value: 'none', label: 'Không có', price: 0 }, |
| ]; |
| return ` |
| <div class="pax-form-panel${i===0?' active':''}" id="epanel-${leg}-${i}"> |
| <div style="font-size:.8rem;font-weight:700;color:var(--gray-600);margin-bottom:10px"> |
| ${p.label}: <span style="font-weight:400;color:var(--gray-800)">${p.ten_day_du}</span> |
| </div> |
| <div class="extras-option-group"> |
| <label class="group-label">🪑 Chỗ ngồi</label> |
| <div class="extras-options">${_extrasOptionsHTML('seat', seatOptions, i, leg)}</div> |
| </div> |
| <div class="extras-option-group"> |
| <label class="group-label">🧳 Hành lý ký gửi</label> |
| <div class="extras-options">${_extrasOptionsHTML('bag', bagOptions, i, leg)}</div> |
| </div> |
| <div class="extras-option-group"> |
| <label class="group-label">🍱 Suất ăn</label> |
| <div class="extras-options">${_extrasOptionsHTML('meal', mealOptions, i, leg)}</div> |
| </div> |
| <div class="pax-form-nav"> |
| ${i < passengers.length - 1 |
| ? `<button class="btn-pax-next" onclick="switchExtrasPaxTab('${leg}',${i+1})">Tiếp theo →</button>` |
| : (leg === 'outbound' && _extrasFormSpec?.flight_type === 'round_trip' |
| ? `<button class="btn-pax-submit" onclick="submitExtrasLeg('outbound')">✔ Xác nhận chiều đi</button>` |
| : `<button class="btn-pax-submit" onclick="submitAllExtras()">✔ Xác nhận dịch vụ</button>`) |
| } |
| </div> |
| </div>`; |
| }).join(''); |
| return ` |
| <div class="card" id="extras-card-${leg}"> |
| <div class="card-title" style="color:${legColor}">${legLabel} — Dịch vụ bổ sung</div> |
| <div class="pax-form-tabs">${tabs}</div> |
| ${panels} |
| </div>`; |
| } |
| |
| function renderExtrasForm(data) { |
| _extrasFormSpec = { |
| passengers: data.passengers || [], |
| flight_type: data.flight_type || 'one_way', |
| outbound_pricing: data.outbound_extras_pricing || {}, |
| return_pricing: data.return_extras_pricing || {}, |
| }; |
| let html = _extrasLegPanelHTML(_extrasFormSpec.passengers, _extrasFormSpec.outbound_pricing, 'outbound'); |
| if (_extrasFormSpec.flight_type === 'round_trip' && Object.keys(_extrasFormSpec.return_pricing).length) |
| html += _extrasLegPanelHTML(_extrasFormSpec.passengers, _extrasFormSpec.return_pricing, 'return'); |
| sidebarEl.innerHTML = html; |
| _syncExtrasHighlights(); |
| } |
| |
| function onExtrasRadioChange(radio) { |
| const group = radio.closest('.extras-options'); |
| group?.querySelectorAll('.extras-radio-label').forEach(lbl => { |
| lbl.classList.toggle('selected', lbl.querySelector('input') === radio); |
| }); |
| } |
| |
| function _syncExtrasHighlights() { |
| document.querySelectorAll('.extras-options').forEach(group => { |
| const checked = group.querySelector('input[type=radio]:checked'); |
| group.querySelectorAll('.extras-radio-label').forEach(lbl => { |
| lbl.classList.toggle('selected', lbl.querySelector('input') === checked); |
| }); |
| }); |
| } |
| |
| function switchExtrasPaxTab(leg, idx) { |
| const card = document.getElementById(`extras-card-${leg}`); |
| if (!card) return; |
| card.querySelectorAll('.pax-form-panel').forEach((p, i) => p.classList.toggle('active', i === idx)); |
| card.querySelectorAll('.pax-tab').forEach((t, i) => t.classList.toggle('active', i === idx)); |
| } |
| |
| function _collectExtrasLeg(leg) { |
| return _extrasFormSpec.passengers.map((p, i) => { |
| const seat = document.querySelector(`input[name="extras-${leg}-${i}-seat"]:checked`)?.value || 'middle'; |
| const bag = document.querySelector(`input[name="extras-${leg}-${i}-bag"]:checked`)?.value || '0'; |
| const meal = document.querySelector(`input[name="extras-${leg}-${i}-meal"]:checked`)?.value || 'standard'; |
| return { pax_index: i, seat_type: seat, baggage_kg: parseInt(bag), meal, label: p.label, name: p.ten_day_du }; |
| }); |
| } |
| |
| function _extrasToText(extrasArr, legLabel) { |
| const lines = [`Dịch vụ bổ sung ${legLabel}:`]; |
| const seatLabels = { window: 'Cửa sổ', middle: 'Ghế giữa', aisle: 'Lối đi' }; |
| const mealLabels = { standard: 'Tiêu chuẩn', vegetarian: 'Chay', none: 'Không có' }; |
| extrasArr.forEach(e => { |
| lines.push(`${e.label} (${e.name}): ghế ${seatLabels[e.seat_type]||e.seat_type}, hành lý ${e.baggage_kg}kg, suất ăn ${mealLabels[e.meal]||e.meal}`); |
| }); |
| return lines.join('\n'); |
| } |
| |
| function submitExtrasLeg(leg) { |
| const msg = _extrasToText(_collectExtrasLeg(leg), 'chiều đi') + '\nVui lòng lưu dịch vụ chiều đi và chờ tôi chọn chiều về.'; |
| sendMessage(msg); |
| } |
| |
| function submitAllExtras() { |
| if (_extrasFormSpec?.flight_type === 'round_trip') { |
| const msg = _extrasToText(_collectExtrasLeg('outbound'), 'chiều đi') + '\n\n' |
| + _extrasToText(_collectExtrasLeg('return'), 'chiều về') + '\nVui lòng lưu dịch vụ cho cả hai chiều.'; |
| sendMessage(msg); |
| } else { |
| sendMessage(_extrasToText(_collectExtrasLeg('outbound'), '') + '\nVui lòng lưu dịch vụ này.'); |
| } |
| } |
| |
| |
| function renderExtrasSummary(data) { |
| const seatLabel = {window:'Cửa sổ', middle:'Giữa', aisle:'Lối đi'}; |
| const mealLabel = {standard:'Tiêu chuẩn', vegetarian:'Chay', none:'Không có'}; |
| const rows = (data.per_pax || []).map(p => ` |
| <div class="extras-item"><span style="font-weight:600;font-size:.8rem">${p.pax_label}: ${(p.pax_name||'').trim()}</span></div> |
| <div class="extras-item"><span>Ghế: ${seatLabel[p.seat_type]||p.seat_type}</span><span>${p.seat_cost>0?fmtMoney(p.seat_cost):'Miễn phí'}</span></div> |
| <div class="extras-item"><span>Hành lý: ${p.baggage_kg}kg</span><span>${p.baggage_cost>0?fmtMoney(p.baggage_cost):'Miễn phí'}</span></div> |
| <div class="extras-item"><span>Suất ăn: ${mealLabel[p.meal]||p.meal}</span><span>${p.meal_cost>0?fmtMoney(p.meal_cost):'Miễn phí'}</span></div>`).join(''); |
| sidebarEl.innerHTML = ` |
| <div class="card"> |
| <div class="card-title">Dịch vụ bổ sung</div> |
| ${rows} |
| <div class="price-row total"><span>Tổng dịch vụ</span><span>${fmtMoney(data.extras_total||0)}</span></div> |
| <div class="price-row total"><span>Tổng cộng</span><span style="color:var(--blue)">${fmtMoney(data.grand_total||0)}</span></div> |
| </div>`; |
| } |
| |
| function renderBookingConfirmation(data) { |
| const seatLabel = {window:'Cửa sổ', middle:'Giữa', aisle:'Lối đi'}; |
| const mealLabel = {standard:'Tiêu chuẩn', vegetarian:'Chay', none:'Không có'}; |
| const isRound = data.flight_type === 'round_trip'; |
| const pb = data.price_breakdown || {}; |
| |
| const paxCards = (data.passengers || []).map(p => ` |
| <div class="pax-item"> |
| <div class="pax-label">${p.label}: ${p.ten_day_du}</div> |
| <div class="pax-field"><span class="fk">Ngày sinh:</span><span>${p.ngay_sinh}</span></div> |
| <div class="pax-field"><span class="fk">Ghế:</span><span>${seatLabel[p.seat_type]||p.seat_type}</span></div> |
| <div class="pax-field"><span class="fk">Hành lý:</span><span>${p.baggage_kg}kg</span></div> |
| <div class="pax-field"><span class="fk">Suất ăn:</span><span>${mealLabel[p.meal_label?.toLowerCase()]||p.meal_label}</span></div> |
| ${p.email?`<div class="pax-field"><span class="fk">Email:</span><span>${p.email}</span></div>`:''} |
| </div>`).join(''); |
| |
| const outboundCard = ` |
| <div class="card"> |
| <div class="card-title" style="color:var(--blue)">✈ Chiều đi</div> |
| <div class="price-row"><span>Chuyến bay</span><span>${data.flight_id} (${data.ticket_class})</span></div> |
| <div class="price-row"><span>Hành trình</span><span>${data.departure} → ${data.destination}</span></div> |
| <div class="price-row"><span>Ngày bay</span><span>${data.dep_date}</span></div> |
| ${isRound ? `<div class="price-row"><span>Tiền vé chiều đi</span><span>${fmtMoney(pb.outbound_flight_total||0)}</span></div>` : ''} |
| </div>`; |
| |
| const returnCard = isRound && data.return_flight_id ? ` |
| <div class="card"> |
| <div class="card-title" style="color:var(--green)">✈ Chiều về</div> |
| <div class="price-row"><span>Chuyến bay</span><span>${data.return_flight_id} (${data.return_ticket_class})</span></div> |
| <div class="price-row"><span>Hành trình</span><span>${data.destination} → ${data.departure}</span></div> |
| <div class="price-row"><span>Ngày bay</span><span>${data.ret_date}</span></div> |
| <div class="price-row"><span>Tiền vé chiều về</span><span>${fmtMoney(pb.return_flight_total||0)}</span></div> |
| </div>` : ''; |
| |
| const priceRows = isRound ? ` |
| <div class="price-row"><span>Vé chiều đi</span><span>${fmtMoney(pb.outbound_flight_total||0)}</span></div> |
| <div class="price-row"><span>Vé chiều về</span><span>${fmtMoney(pb.return_flight_total||0)}</span></div> |
| <div class="price-row"><span>Dịch vụ</span><span>${fmtMoney(pb.extras_total||0)}</span></div>` : ` |
| <div class="price-row"><span>Tiền vé</span><span>${fmtMoney(pb.flight_total||0)}</span></div> |
| <div class="price-row"><span>Dịch vụ</span><span>${fmtMoney(pb.extras_total||0)}</span></div>`; |
| |
| sidebarEl.innerHTML = ` |
| <div class="ref-box"> |
| <div class="ref-label">Mã đặt vé${isRound ? ' — Khứ hồi' : ''}</div> |
| <div class="ref-code">${data.booking_reference}</div> |
| </div> |
| ${outboundCard}${returnCard} |
| <div class="card"><div class="card-title">Hành khách</div>${paxCards}</div> |
| <div class="card"> |
| <div class="card-title">Thanh toán</div> |
| ${priceRows} |
| <div class="price-row total"><span>Tổng cộng</span><span style="color:var(--green)">${fmtMoney(data.total_price||0)}</span></div> |
| </div>`; |
| } |
| |
| function renderModifyConfirm(data) { |
| sidebarEl.innerHTML += ` |
| <div class="card" style="border-color:var(--blue)"> |
| <div class="card-title" style="color:var(--blue)">Đã cập nhật</div> |
| <div style="font-size:.85rem">${data.message||''}</div> |
| </div>`; |
| } |
| |
| |
| function selectFlight(flightId, cls, leg) { |
| const legHint = leg === 'return' ? ' (chiều về)' : ''; |
| sendMessage(`Tôi chọn chuyến ${flightId} hạng ${cls}${legHint}`); |
| } |
| |
| |
| async function syncSidebarFromSession(stage) { |
| try { |
| const res = await fetch(apiUrl('/session')); |
| const s = await res.json(); |
| if (stage === 'select_ticket' && s.search_results?.length) { |
| renderFlightList({ flights: s.search_results, search_params: { |
| departure: s.departure, departure_city: s.departure, |
| destination: s.destination, destination_city: s.destination, |
| dep_date: s.dep_date, num_adults: s.num_adults, |
| num_children: s.num_children, num_infants: s.num_infants, |
| }}); |
| } else if (stage === 'collect_pax') { |
| renderPaxForm(s.num_adults || 1, s.num_children || 0, s.num_infants || 0); |
| } else if (stage === 'select_extras' && s.passengers?.length) { |
| renderExtrasForm({ |
| passengers: s.passengers.map((p, i) => ({ |
| index: i, |
| label: ({adult:'Người lớn',child:'Trẻ em',infant:'Em bé'}[p.pax_type]||'') + ' ' + (i+1), |
| pax_type: p.pax_type, |
| ten_day_du: (p.ho + ' ' + p.ten_dem_ten).trim(), |
| })), |
| flight_type: s.flight_type, |
| outbound_extras_pricing: s.selected_flight?.flight_data?.extras_pricing || {}, |
| return_extras_pricing: s.selected_return_flight?.flight_data?.extras_pricing || {}, |
| }); |
| } else if (stage === 'idle') { |
| renderWelcome(); |
| } |
| } catch (e) { } |
| } |
| |
| |
| function updateSidebar(structuredData, stage) { |
| if (!structuredData) return; |
| const rt = structuredData.render_type; |
| if (rt === 'flight_list') renderFlightList(structuredData); |
| else if (rt === 'ticket_summary') { |
| renderTicketSummary(structuredData); |
| if (stage === 'collect_pax' && structuredData.num_adults !== undefined) |
| renderPaxForm(structuredData.num_adults, structuredData.num_children || 0, structuredData.num_infants || 0); |
| } |
| else if (rt === 'collect_pax_form') renderPaxForm(structuredData.num_adults, structuredData.num_children || 0, structuredData.num_infants || 0); |
| else if (rt === 'pax_summary') renderPaxSummary(structuredData); |
| else if (rt === 'extras_form') renderExtrasForm(structuredData); |
| else if (rt === 'extras_summary') renderExtrasSummary(structuredData); |
| else if (rt === 'booking_confirmation') renderBookingConfirmation(structuredData); |
| else if (rt === 'modify_confirm') renderModifyConfirm(structuredData); |
| else if (rt === 'cancelled') renderWelcome(); |
| } |
| |
| function updateStageBadge(stage) { |
| const prev = currentStage; |
| currentStage = stage; |
| stageBadge.textContent = STAGE_LABELS[stage] || stage; |
| _setConfirmBar(stage === 'confirming'); |
| if (prev !== stage) _pendingSidebarSync = stage; |
| } |
| |
| let _pendingSidebarSync = null; |
| |
| |
| async function sendMessage(text) { |
| const msg = (text || userInput.value).trim(); |
| if (!msg || isLoading) return; |
| |
| if (!getBase()) { |
| addBubble('Vui lòng nhập Backend URL (ngrok) ở thanh trên trước khi chat.', 'error'); |
| backendUrlEl.focus(); |
| return; |
| } |
| |
| userInput.value = ''; |
| userInput.style.height = 'auto'; |
| addBubble(msg, 'user'); |
| addTyping(); |
| isLoading = true; |
| sendBtn.disabled = true; |
| |
| try { |
| const res = await fetch(apiUrl('/chat'), { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ message: msg, provider: providerSel.value }), |
| }); |
| const data = await res.json(); |
| removeTyping(); |
| |
| if (data.assistant_text) |
| addBubble(data.assistant_text, data.status === 'error' ? 'error' : 'bot'); |
| |
| if (data.stage) updateStageBadge(data.stage); |
| if (data.structured_data) { |
| updateSidebar(data.structured_data, data.stage); |
| _pendingSidebarSync = null; |
| } else if (_pendingSidebarSync) { |
| await syncSidebarFromSession(_pendingSidebarSync); |
| _pendingSidebarSync = null; |
| } |
| } catch (err) { |
| removeTyping(); |
| addBubble('Lỗi kết nối. Kiểm tra lại Backend URL và ngrok.', 'error'); |
| } finally { |
| isLoading = false; |
| sendBtn.disabled = false; |
| userInput.focus(); |
| } |
| } |
| |
| |
| const confirmBar = document.getElementById('confirmBar'); |
| const modifySubmenu = document.getElementById('modifySubmenu'); |
| |
| function _setConfirmBar(visible) { |
| confirmBar.classList.toggle('visible', visible); |
| if (!visible) modifySubmenu.classList.remove('visible'); |
| } |
| |
| function toggleModifyMenu() { |
| modifySubmenu.classList.toggle('visible'); |
| } |
| |
| function quickAction(action) { |
| const labels = { confirm: 'Xác nhận thanh toán', cancel: 'Huỷ đặt vé' }; |
| sendMessage(labels[action]); |
| } |
| |
| function quickModify(field, value) { |
| const desc = { |
| seat_type: { window: 'đổi ghế cửa sổ', middle: 'đổi ghế giữa', aisle: 'đổi ghế lối đi' }, |
| baggage_kg: { '0': 'bỏ hành lý ký gửi', '20': 'đổi hành lý 20kg', '30': 'đổi hành lý 30kg' }, |
| meal: { standard: 'đổi suất ăn tiêu chuẩn', vegetarian: 'đổi suất ăn chay', none: 'bỏ suất ăn' }, |
| }; |
| sendMessage(`Tôi muốn ${desc[field]?.[value] || field + ' ' + value}`); |
| modifySubmenu.classList.remove('visible'); |
| } |
| |
| function promptModify(field) { |
| const labels = { email: 'email mới', dien_thoai: 'số điện thoại mới', dia_chi: 'địa chỉ mới' }; |
| const val = prompt(`Nhập ${labels[field] || field}:`); |
| if (val && val.trim()) { |
| sendMessage(`Tôi muốn đổi ${labels[field]} thành: ${val.trim()}`); |
| modifySubmenu.classList.remove('visible'); |
| } |
| } |
| |
| |
| sendBtn.addEventListener('click', () => sendMessage()); |
| |
| userInput.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } |
| }); |
| |
| userInput.addEventListener('input', () => { |
| userInput.style.height = 'auto'; |
| userInput.style.height = Math.min(userInput.scrollHeight, 120) + 'px'; |
| }); |
| |
| resetBtn.addEventListener('click', async () => { |
| if (!confirm('Đặt lại sẽ xoá toàn bộ phiên đặt vé hiện tại. Tiếp tục?')) return; |
| await fetch(apiUrl('/reset'), { method: 'POST' }); |
| messagesEl.innerHTML = ''; |
| renderWelcome(); |
| updateStageBadge('idle'); |
| addBubble('Xin chào! Tôi là VietFly Assistant — trợ lý đặt vé máy bay nội địa Việt Nam. Tôi có thể giúp bạn tìm và đặt vé. Bạn muốn đặt vé loại nào: một chiều hay khứ hồi?', 'bot'); |
| }); |
| |
| providerSel.addEventListener('change', async () => { |
| await fetch(apiUrl('/provider'), { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ provider: providerSel.value }), |
| }); |
| }); |
| |
| |
| renderWelcome(); |
| addBubble('Xin chào! Tôi là VietFly Assistant — trợ lý đặt vé máy bay nội địa Việt Nam. Tôi có thể giúp bạn tìm và đặt vé. Bạn muốn đặt vé loại nào: một chiều hay khứ hồi?', 'bot'); |
| userInput.focus(); |
| </script> |
| </body> |
| </html> |
|
|