VIetFly / index.html
ntphuc149's picture
Update index.html
86b8cb9 verified
Raw
History Blame Contribute Delete
61.1 kB
<!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 ── */
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; }
/* Backend URL input */
#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 layout ── */
.main {
display: flex;
flex: 1;
overflow: hidden;
gap: 0;
}
/* ── Chat pane (left) ── */
.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);
}
/* Markdown styles inside bot bubbles */
.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 action bar */
.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 */
.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 (right) ── */
.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;
}
/* ── Cards ── */
.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 */
.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 */
.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;
}
/* Passenger summary */
.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 */
.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; }
/* Booking confirmation */
.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 panel */
.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); }
/* Passenger form */
.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 form */
.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; }
/* Scrollbars */
::-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>
<!-- ── Ngrok / backend URL input (HF-specific) ── -->
<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">
<!-- Chat pane -->
<div class="chat-pane">
<div id="messages"></div>
<!-- Confirm / cancel / modify action bar (visible only at confirming stage) -->
<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>
<!-- Sidebar -->
<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>
// ─── Backend URL (ngrok) ──────────────────────────────────────────────────
const backendUrlEl = document.getElementById('backendUrl');
const backendStatus = document.getElementById('backendStatus');
const STORAGE_KEY = 'vietfly_backend_url';
// Load saved URL from localStorage
backendUrlEl.value = localStorage.getItem(STORAGE_KEY) || '';
function getBase() {
return backendUrlEl.value.trim().replace(/\/$/, '');
}
function apiUrl(path) {
const base = getBase();
return base ? base + path : path; // fallback to relative (local use)
}
// Ping backend when URL changes
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';
}
}
// Ping on load if URL already set
if (backendUrlEl.value) pingBackend();
// ─── State ───────────────────────────────────────────────────────────────
let currentStage = 'idle';
let isLoading = false;
// ─── DOM refs ────────────────────────────────────────────────────────────
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',
};
// ─── Chat helpers ─────────────────────────────────────────────────────────
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();
}
// ─── Format helpers ───────────────────────────────────────────────────────
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`;
}
// ─── Sidebar renderers ────────────────────────────────────────────────────
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}
&nbsp;|&nbsp; ${params.dep_date || ''}
&nbsp;|&nbsp; ${(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||''} &nbsp;|&nbsp; ${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>`;
}
// ─── Passenger input form ──────────────────────────────────────────────────
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'));
}
// ─── Extras form ──────────────────────────────────────────────────────────
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.');
}
}
// ─── Booking confirmation ─────────────────────────────────────────────────
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>`;
}
// ─── Quick-select helper ──────────────────────────────────────────────────
function selectFlight(flightId, cls, leg) {
const legHint = leg === 'return' ? ' (chiều về)' : '';
sendMessage(`Tôi chọn chuyến ${flightId} hạng ${cls}${legHint}`);
}
// ─── Sidebar snapshot sync ────────────────────────────────────────────────
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) { /* silent */ }
}
// ─── Update sidebar by render_type ───────────────────────────────────────
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;
// ─── Send message ─────────────────────────────────────────────────────────
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();
}
}
// ─── Confirm action bar ───────────────────────────────────────────────────
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');
}
}
// ─── Event listeners ──────────────────────────────────────────────────────
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 }),
});
});
// ─── Init ─────────────────────────────────────────────────────────────────
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>