项目文件夹

文件
2026-07-13 12:58:18 +08:00

1777 行
54 KiB
HTML

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hotel Booking</title>
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
/* =============================================================================
DESIGN SYSTEM - CopilotKit Palette + Glassmorphism
============================================================================= */
:root {
/* Brand Colors */
--color-lilac: #bec2ff;
--color-lilac-light: #d4d7ff;
--color-lilac-dark: #9599cc;
--color-mint: #85e0ce;
--color-mint-light: #a8e9dc;
--color-mint-dark: #1b936f;
/* Surfaces */
--color-surface: #dedee9;
--color-surface-light: #f7f7f9;
--color-container: #ffffff;
/* Text */
--color-text-primary: #010507;
--color-text-secondary: #57575b;
--color-text-tertiary: #8e8e93;
/* Borders */
--color-border: #dbdbe5;
--color-border-light: #ebebf0;
--color-border-glass: rgba(255, 255, 255, 0.3);
/* Glassmorphism */
--color-glass: rgba(255, 255, 255, 0.7);
--color-glass-subtle: rgba(255, 255, 255, 0.5);
--color-glass-dark: rgba(255, 255, 255, 0.85);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04);
--shadow-lg:
0 10px 25px -3px rgba(0, 0, 0, 0.08),
0 4px 6px -2px rgba(0, 0, 0, 0.03);
--shadow-glass: 0 4px 30px rgba(0, 0, 0, 0.1);
/* Status Colors */
--color-success: #10b981;
--color-error: #ef4444;
--color-warning: #f59e0b;
--color-gold: #f59e0b;
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* Radii */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-full: 9999px;
/* Typography */
--font-family:
"Plus Jakarta Sans", system-ui, -apple-system, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background: linear-gradient(
135deg,
var(--color-surface-light) 0%,
var(--color-surface) 100%
);
color: var(--color-text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* Abstract background shapes */
body::before {
content: "";
position: fixed;
top: -100px;
right: -100px;
width: 300px;
height: 300px;
background: var(--color-lilac);
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
z-index: 0;
animation: float1 20s ease-in-out infinite;
}
body::after {
content: "";
position: fixed;
bottom: -100px;
left: -100px;
width: 250px;
height: 250px;
background: var(--color-mint);
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
z-index: 0;
animation: float2 25s ease-in-out infinite;
}
@keyframes float1 {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(-30px, 30px);
}
}
@keyframes float2 {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(30px, -30px);
}
}
#app {
position: relative;
z-index: 1;
padding: var(--space-4);
max-width: 600px;
margin: 0 auto;
}
/* =============================================================================
GLASS COMPONENTS
============================================================================= */
.glass {
background: var(--color-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-glass);
}
.glass-subtle {
background: var(--color-glass-subtle);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-lg);
}
/* =============================================================================
WIZARD STEPS INDICATOR
============================================================================= */
.wizard-steps {
display: flex;
justify-content: center;
gap: var(--space-2);
margin-bottom: var(--space-6);
padding: var(--space-3);
}
.step-indicator {
display: flex;
align-items: center;
gap: var(--space-2);
}
.step-dot {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
background: var(--color-glass-subtle);
border: 2px solid var(--color-border);
color: var(--color-text-tertiary);
}
.step-dot.active {
background: linear-gradient(
135deg,
var(--color-lilac),
var(--color-mint)
);
border-color: transparent;
color: white;
box-shadow: 0 4px 12px rgba(190, 194, 255, 0.4);
}
.step-dot.completed {
background: var(--color-mint);
border-color: transparent;
color: white;
}
.step-line {
width: 32px;
height: 2px;
background: var(--color-border);
transition: background 0.3s ease;
}
.step-line.active {
background: linear-gradient(
90deg,
var(--color-lilac),
var(--color-mint)
);
}
/* =============================================================================
FORM ELEMENTS
============================================================================= */
.form-group {
margin-bottom: var(--space-4);
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: var(--space-2);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-input,
.form-select {
width: 100%;
padding: var(--space-3) var(--space-4);
font-family: var(--font-family);
font-size: 15px;
color: var(--color-text-primary);
background: var(--color-glass-dark);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition: all 0.2s ease;
outline: none;
}
.form-input:focus,
.form-select:focus {
border-color: var(--color-lilac);
box-shadow: 0 0 0 3px rgba(190, 194, 255, 0.2);
}
.form-input::placeholder {
color: var(--color-text-tertiary);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
/* Counter input */
.counter-input {
display: flex;
align-items: center;
gap: var(--space-3);
}
.counter-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
background: var(--color-glass);
color: var(--color-text-primary);
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.counter-btn:hover:not(:disabled) {
background: var(--color-lilac-light);
border-color: var(--color-lilac);
}
.counter-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.counter-value {
font-size: 18px;
font-weight: 600;
min-width: 40px;
text-align: center;
}
/* =============================================================================
BUTTONS
============================================================================= */
button {
cursor: pointer;
border: none;
font-family: var(--font-family);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
font-size: 15px;
font-weight: 600;
border-radius: var(--radius-lg);
transition: all 0.2s ease;
}
.btn:focus-visible {
outline: 2px solid var(--color-lilac);
outline-offset: 2px;
}
.btn:active:not(:disabled) {
transform: scale(0.98);
}
.btn-primary {
background: linear-gradient(
135deg,
var(--color-lilac),
var(--color-mint)
);
color: var(--color-text-primary);
box-shadow: 0 4px 12px rgba(190, 194, 255, 0.3);
}
.btn-primary:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(190, 194, 255, 0.4);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--color-glass);
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-glass-dark);
border-color: var(--color-lilac);
}
.btn-block {
width: 100%;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* =============================================================================
STEP CONTAINERS
============================================================================= */
.step-container {
display: none;
}
.step-container.active {
display: block;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.step-title {
font-size: 24px;
font-weight: 700;
margin-bottom: var(--space-2);
background: linear-gradient(
135deg,
var(--color-text-primary),
var(--color-text-secondary)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.step-subtitle {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: var(--space-5);
}
/* =============================================================================
HOTEL CARDS
============================================================================= */
.hotels-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.hotel-card {
padding: var(--space-4);
cursor: pointer;
transition: all 0.2s ease;
}
.hotel-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
.hotel-card.selected {
border-color: var(--color-lilac);
background: rgba(190, 194, 255, 0.15);
}
.hotel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-3);
}
.hotel-info {
flex: 1;
}
.hotel-name {
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: var(--space-1);
}
.hotel-stars {
display: flex;
gap: 2px;
margin-bottom: var(--space-2);
}
.star {
color: var(--color-gold);
}
.hotel-location {
font-size: 13px;
color: var(--color-text-tertiary);
display: flex;
align-items: center;
gap: var(--space-1);
}
.hotel-rating {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.rating-score {
background: var(--color-mint);
color: white;
font-weight: 700;
font-size: 14px;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
}
.rating-count {
font-size: 12px;
color: var(--color-text-tertiary);
margin-top: var(--space-1);
}
.hotel-amenities {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.amenity-tag {
font-size: 12px;
color: var(--color-text-secondary);
background: var(--color-glass-subtle);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: var(--space-1);
}
.hotel-price {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-light);
}
.price-info {
text-align: right;
}
.price-amount {
font-size: 22px;
font-weight: 700;
color: var(--color-mint-dark);
}
.price-label {
font-size: 12px;
color: var(--color-text-tertiary);
}
/* =============================================================================
ROOM CARDS
============================================================================= */
.rooms-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.room-card {
padding: var(--space-4);
cursor: pointer;
transition: all 0.2s ease;
}
.room-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
.room-card.selected {
border-color: var(--color-lilac);
background: rgba(190, 194, 255, 0.15);
}
.room-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-3);
}
.room-type {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
}
.room-bed {
font-size: 13px;
color: var(--color-text-secondary);
margin-top: var(--space-1);
display: flex;
align-items: center;
gap: var(--space-1);
}
.room-capacity {
font-size: 12px;
color: var(--color-text-tertiary);
margin-top: var(--space-1);
}
.room-price {
text-align: right;
}
.room-amenities {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
/* =============================================================================
GUEST FORMS
============================================================================= */
.guests-form {
padding: var(--space-4);
margin-bottom: var(--space-4);
}
.guest-section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-2);
}
.guest-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: linear-gradient(
135deg,
var(--color-lilac),
var(--color-mint)
);
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.special-requests {
margin-top: var(--space-4);
}
.form-textarea {
width: 100%;
min-height: 80px;
padding: var(--space-3) var(--space-4);
font-family: var(--font-family);
font-size: 15px;
color: var(--color-text-primary);
background: var(--color-glass-dark);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
resize: vertical;
outline: none;
}
.form-textarea:focus {
border-color: var(--color-lilac);
box-shadow: 0 0 0 3px rgba(190, 194, 255, 0.2);
}
/* =============================================================================
CONFIRMATION
============================================================================= */
.confirmation-card {
padding: var(--space-6);
text-align: center;
}
.confirmation-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-full);
background: linear-gradient(
135deg,
var(--color-mint),
var(--color-mint-dark)
);
margin: 0 auto var(--space-4);
display: flex;
align-items: center;
justify-content: center;
color: white;
animation: scaleIn 0.5s ease;
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.confirmation-title {
font-size: 24px;
font-weight: 700;
margin-bottom: var(--space-2);
}
.confirmation-ref {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: var(--space-6);
}
.confirmation-ref strong {
color: var(--color-lilac-dark);
font-weight: 700;
font-size: 18px;
}
.booking-summary {
text-align: left;
padding: var(--space-4);
background: var(--color-glass-subtle);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
}
.summary-row {
display: flex;
justify-content: space-between;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-light);
}
.summary-row:last-child {
border-bottom: none;
}
.summary-label {
font-size: 14px;
color: var(--color-text-secondary);
}
.summary-value {
font-size: 14px;
font-weight: 600;
}
.summary-total {
font-size: 18px;
font-weight: 700;
color: var(--color-mint-dark);
}
/* =============================================================================
LOADING STATE
============================================================================= */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8);
gap: var(--space-4);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-lilac);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 14px;
color: var(--color-text-secondary);
}
/* =============================================================================
NAVIGATION
============================================================================= */
.nav-buttons {
display: flex;
gap: var(--space-3);
margin-top: var(--space-5);
}
.nav-buttons .btn {
flex: 1;
}
/* =============================================================================
SELECTED HOTEL SUMMARY
============================================================================= */
.selected-hotel-summary {
padding: var(--space-3);
margin-bottom: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-3);
}
.selected-hotel-info {
flex: 1;
}
.selected-hotel-name {
font-size: 14px;
font-weight: 600;
}
.selected-hotel-dates {
font-size: 12px;
color: var(--color-text-tertiary);
}
</style>
</head>
<body>
<div id="app">
<!-- Wizard Step Indicator -->
<div class="wizard-steps glass">
<div class="step-indicator">
<div class="step-dot active" id="dot-1">1</div>
<div class="step-line" id="line-1"></div>
<div class="step-dot" id="dot-2">2</div>
<div class="step-line" id="line-2"></div>
<div class="step-dot" id="dot-3">3</div>
<div class="step-line" id="line-3"></div>
<div class="step-dot" id="dot-4">4</div>
</div>
</div>
<!-- Loading State -->
<div id="loading" class="loading-container glass" style="display: none">
<div class="loading-spinner"></div>
<div class="loading-text">Searching for hotels...</div>
</div>
<!-- Step 1: Search -->
<div id="step-1" class="step-container active">
<div class="glass" style="padding: var(--space-5)">
<h2 class="step-title">Find Your Hotel</h2>
<p class="step-subtitle">Search for the perfect place to stay</p>
<div class="form-group">
<label class="form-label">Destination</label>
<select class="form-select" id="city">
<option value="">Select a city</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Check-in</label>
<input type="date" class="form-input" id="checkIn" />
</div>
<div class="form-group">
<label class="form-label">Check-out</label>
<input type="date" class="form-input" id="checkOut" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Guests</label>
<div class="counter-input">
<button class="counter-btn" id="decGuests" disabled></button>
<span class="counter-value" id="guestCount">2</span>
<button class="counter-btn" id="incGuests">+</button>
</div>
</div>
<div class="form-group">
<label class="form-label">Rooms</label>
<div class="counter-input">
<button class="counter-btn" id="decRooms" disabled></button>
<span class="counter-value" id="roomCount">1</span>
<button class="counter-btn" id="incRooms">+</button>
</div>
</div>
</div>
<button class="btn btn-primary btn-block" id="searchBtn">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
Search Hotels
</button>
</div>
</div>
<!-- Step 2: Hotel Results -->
<div id="step-2" class="step-container">
<h2 class="step-title">Select Your Hotel</h2>
<p class="step-subtitle" id="resultsSubtitle">5 hotels found</p>
<div class="hotels-list" id="hotelsList"></div>
<div class="nav-buttons">
<button class="btn btn-secondary" id="backToSearch">Back</button>
<button class="btn btn-primary" id="selectHotelBtn" disabled>
View Rooms
</button>
</div>
</div>
<!-- Step 3: Room Selection -->
<div id="step-3" class="step-container">
<h2 class="step-title">Choose Your Room</h2>
<p class="step-subtitle" id="roomSubtitle">Select a room type</p>
<div
class="selected-hotel-summary glass-subtle"
id="selectedHotelSummary"
></div>
<div class="rooms-list" id="roomsList"></div>
<div class="nav-buttons">
<button class="btn btn-secondary" id="backToHotels">Back</button>
<button class="btn btn-primary" id="selectRoomBtn" disabled>
Continue
</button>
</div>
</div>
<!-- Step 4: Guest Details & Confirmation -->
<div id="step-4" class="step-container">
<div id="guestFormContainer">
<h2 class="step-title">Guest Details</h2>
<p class="step-subtitle">
Enter your information to complete the booking
</p>
<div class="guests-form glass">
<div class="guest-section-title">
<div class="guest-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
Primary Guest
</div>
<div class="form-group">
<label class="form-label">Full Name</label>
<input
type="text"
class="form-input"
id="guestName"
placeholder="As shown on ID"
required
/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Email</label>
<input
type="email"
class="form-input"
id="guestEmail"
placeholder="email@example.com"
required
/>
</div>
<div class="form-group">
<label class="form-label">Phone</label>
<input
type="tel"
class="form-input"
id="guestPhone"
placeholder="+1 (555) 000-0000"
/>
</div>
</div>
<div class="special-requests">
<label class="form-label">Special Requests (Optional)</label>
<textarea
class="form-textarea"
id="specialRequests"
placeholder="Early check-in, late check-out, dietary requirements..."
></textarea>
</div>
</div>
<div class="nav-buttons">
<button class="btn btn-secondary" id="backToRooms">Back</button>
<button class="btn btn-primary" id="confirmBookingBtn">
Complete Booking
</button>
</div>
</div>
<!-- Confirmation (shown after booking) -->
<div id="confirmationContainer" style="display: none">
<div class="confirmation-card glass">
<div class="confirmation-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<h2 class="confirmation-title">Booking Confirmed!</h2>
<p class="confirmation-ref">
Confirmation: <strong id="confirmationCode">XYZ789</strong>
</p>
<div class="booking-summary" id="bookingSummary"></div>
<button class="btn btn-primary btn-block" id="addToCalendarBtn">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
<line x1="16" x2="16" y1="2" y2="6" />
<line x1="8" x2="8" y1="2" y2="6" />
<line x1="3" x2="21" y1="10" y2="10" />
</svg>
Add to Calendar
</button>
</div>
</div>
</div>
</div>
<script>
// =============================================================================
// MCP APP COMMUNICATION MODULE
// =============================================================================
const mcpApp = (() => {
let requestId = 1;
const pendingRequests = new Map();
const notificationHandlers = new Map();
window.addEventListener("message", (event) => {
const msg = event.data;
if (!msg || typeof msg !== "object") return;
if (msg.id !== undefined && pendingRequests.has(msg.id)) {
const { resolve, reject } = pendingRequests.get(msg.id);
pendingRequests.delete(msg.id);
if (msg.error) {
reject(new Error(msg.error.message || "Unknown error"));
} else {
resolve(msg.result);
}
return;
}
if (msg.method && !msg.id) {
const handlers = notificationHandlers.get(msg.method) || [];
handlers.forEach((h) => h(msg.params));
}
});
return {
sendRequest(method, params) {
const id = requestId++;
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
window.parent.postMessage(
{ jsonrpc: "2.0", id, method, params },
"*",
);
});
},
sendNotification(method, params) {
window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*");
},
onNotification(method, handler) {
if (!notificationHandlers.has(method)) {
notificationHandlers.set(method, []);
}
notificationHandlers.get(method).push(handler);
},
};
})();
// =============================================================================
// CITY DATA
// =============================================================================
const CITIES = [
{ name: "Paris", country: "France" },
{ name: "New York", country: "USA" },
{ name: "Tokyo", country: "Japan" },
{ name: "London", country: "UK" },
{ name: "Dubai", country: "UAE" },
{ name: "Singapore", country: "Singapore" },
{ name: "Barcelona", country: "Spain" },
{ name: "Sydney", country: "Australia" },
{ name: "Rome", country: "Italy" },
{ name: "Amsterdam", country: "Netherlands" },
];
// Amenity icons (SVG)
const AMENITY_ICONS = {
wifi: '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/></svg>',
pool: '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h20"/><path d="M2 16h20"/><path d="M12 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/></svg>',
gym: '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.4 14.4 9.6 9.6"/><path d="M18.657 21.485a2 2 0 1 1-2.829-2.828"/><path d="M6.343 2.515a2 2 0 0 1 2.829 2.829"/></svg>',
spa: '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c-4.97 0-9-2.239-9-5v-4h18v4c0 2.761-4.03 5-9 5Z"/></svg>',
restaurant:
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/></svg>',
parking:
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 17V7h4a3 3 0 0 1 0 6H9"/></svg>',
};
// =============================================================================
// STATE
// =============================================================================
let state = {
currentStep: 1,
guests: 2,
rooms: 1,
searchId: null,
hotels: [],
selectedHotelId: null,
selectedHotel: null,
selectedRoomId: null,
selectedRoom: null,
searchParams: null,
booking: null,
};
// =============================================================================
// DOM ELEMENTS
// =============================================================================
const $ = (id) => document.getElementById(id);
// =============================================================================
// WIZARD NAVIGATION
// =============================================================================
function goToStep(step) {
state.currentStep = step;
document.querySelectorAll(".step-container").forEach((el, i) => {
el.classList.toggle("active", i + 1 === step);
});
for (let i = 1; i <= 4; i++) {
const dot = $(`dot-${i}`);
const line = i < 4 ? $(`line-${i}`) : null;
dot.classList.remove("active", "completed");
if (i === step) {
dot.classList.add("active");
} else if (i < step) {
dot.classList.add("completed");
dot.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
} else {
dot.textContent = i;
}
if (line) {
line.classList.toggle("active", i < step);
}
}
reportSize();
}
function showLoading(text = "Loading...") {
$("loading").style.display = "flex";
$("loading").querySelector(".loading-text").textContent = text;
document
.querySelectorAll(".step-container")
.forEach((el) => el.classList.remove("active"));
}
function hideLoading() {
$("loading").style.display = "none";
}
// =============================================================================
// STEP 1: SEARCH
// =============================================================================
function initSearchForm() {
const citySelect = $("city");
CITIES.forEach((city) => {
citySelect.add(
new Option(`${city.name}, ${city.country}`, city.name),
);
});
// Set default dates
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const checkout = new Date(tomorrow);
checkout.setDate(checkout.getDate() + 3);
$("checkIn").value = tomorrow.toISOString().split("T")[0];
$("checkIn").min = new Date().toISOString().split("T")[0];
$("checkOut").value = checkout.toISOString().split("T")[0];
$("checkOut").min = tomorrow.toISOString().split("T")[0];
// Update checkout min when checkin changes
$("checkIn").addEventListener("change", () => {
const checkin = new Date($("checkIn").value);
checkin.setDate(checkin.getDate() + 1);
$("checkOut").min = checkin.toISOString().split("T")[0];
if (new Date($("checkOut").value) <= new Date($("checkIn").value)) {
$("checkOut").value = checkin.toISOString().split("T")[0];
}
});
// Guest counter
$("decGuests").addEventListener("click", () => {
if (state.guests > 1) {
state.guests--;
updateGuestCount();
}
});
$("incGuests").addEventListener("click", () => {
if (state.guests < 6) {
state.guests++;
updateGuestCount();
}
});
// Room counter
$("decRooms").addEventListener("click", () => {
if (state.rooms > 1) {
state.rooms--;
updateRoomCount();
}
});
$("incRooms").addEventListener("click", () => {
if (state.rooms < 4) {
state.rooms++;
updateRoomCount();
}
});
$("searchBtn").addEventListener("click", searchHotels);
}
function updateGuestCount() {
$("guestCount").textContent = state.guests;
$("decGuests").disabled = state.guests <= 1;
$("incGuests").disabled = state.guests >= 6;
}
function updateRoomCount() {
$("roomCount").textContent = state.rooms;
$("decRooms").disabled = state.rooms <= 1;
$("incRooms").disabled = state.rooms >= 4;
}
async function searchHotels() {
const city = $("city").value;
const checkIn = $("checkIn").value;
const checkOut = $("checkOut").value;
if (!city || !checkIn || !checkOut) {
alert("Please fill in all fields");
return;
}
showLoading("Searching for hotels...");
try {
const result = await mcpApp.sendRequest("tools/call", {
name: "search-hotels",
arguments: {
city,
checkIn,
checkOut,
guests: state.guests,
rooms: state.rooms,
},
});
// Use structuredContent directly - server wraps data in 'search' object
const data = result.structuredContent;
if (data?.search?.id && data?.search?.hotels) {
state.searchId = data.search.id;
state.hotels = data.search.hotels || [];
state.searchParams = data.search.searchParams;
renderHotelResults();
hideLoading();
goToStep(2);
} else {
throw new Error(data?.error || "No hotels found");
}
} catch (error) {
console.error("Search failed:", error);
hideLoading();
goToStep(1);
alert("Failed to search hotels. Please try again.");
}
}
// =============================================================================
// STEP 2: HOTEL RESULTS
// =============================================================================
function renderHotelResults() {
const list = $("hotelsList");
list.innerHTML = "";
$("resultsSubtitle").textContent =
`${state.hotels.length} hotels in ${state.searchParams.city}`;
state.hotels.forEach((hotel) => {
const card = document.createElement("div");
card.className = "hotel-card glass";
card.dataset.hotelId = hotel.id;
const stars = "★".repeat(hotel.stars) + "☆".repeat(5 - hotel.stars);
const amenitiesHtml = hotel.amenities
.slice(0, 4)
.map((a) => {
const icon = AMENITY_ICONS[a] || "";
return `<span class="amenity-tag">${icon} ${a}</span>`;
})
.join("");
card.innerHTML = `
<div class="hotel-header">
<div class="hotel-info">
<div class="hotel-name">${hotel.name}</div>
<div class="hotel-stars"><span class="star">${stars}</span></div>
<div class="hotel-location">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
${hotel.neighborhood}
</div>
</div>
<div class="hotel-rating">
<div class="rating-score">${hotel.rating.toFixed(1)}</div>
<div class="rating-count">${hotel.reviewCount} reviews</div>
</div>
</div>
<div class="hotel-amenities">${amenitiesHtml}</div>
<div class="hotel-price">
<div></div>
<div class="price-info">
<div class="price-amount">$${hotel.pricePerNight}</div>
<div class="price-label">per night · $${hotel.totalPrice} total</div>
</div>
</div>
`;
card.addEventListener("click", () => selectHotel(hotel.id));
list.appendChild(card);
});
reportSize();
}
function selectHotel(hotelId) {
state.selectedHotelId = hotelId;
state.selectedHotel = state.hotels.find((h) => h.id === hotelId);
document.querySelectorAll(".hotel-card").forEach((card) => {
card.classList.toggle("selected", card.dataset.hotelId === hotelId);
});
$("selectHotelBtn").disabled = false;
}
// =============================================================================
// STEP 3: ROOM SELECTION
// =============================================================================
async function loadRooms() {
showLoading("Loading room options...");
try {
const result = await mcpApp.sendRequest("tools/call", {
name: "select-hotel",
arguments: {
searchId: state.searchId,
hotelId: state.selectedHotelId,
},
});
// Use structuredContent directly
const data = result.structuredContent;
if (data?.success && data?.hotel) {
state.selectedHotel = data.hotel;
renderRooms();
hideLoading();
goToStep(3);
} else {
throw new Error(data?.error || "Failed to load rooms");
}
} catch (error) {
console.error("Failed to load rooms:", error);
hideLoading();
goToStep(2);
alert("Failed to load rooms. Please try again.");
}
}
function renderRooms() {
const hotel = state.selectedHotel;
// Summary
const summary = $("selectedHotelSummary");
summary.innerHTML = `
<div class="selected-hotel-info">
<div class="selected-hotel-name">${hotel.name}</div>
<div class="selected-hotel-dates">${formatDateRange($("checkIn").value, $("checkOut").value)} · ${state.searchParams.nights} nights</div>
</div>
`;
// Room subtitle
$("roomSubtitle").textContent =
`${hotel.rooms.length} room types available`;
// Room cards
const list = $("roomsList");
list.innerHTML = "";
hotel.rooms.forEach((room) => {
const card = document.createElement("div");
card.className = "room-card glass";
card.dataset.roomId = room.id;
const amenitiesHtml = room.amenities
.slice(0, 3)
.map((a) => {
return `<span class="amenity-tag">${a}</span>`;
})
.join("");
card.innerHTML = `
<div class="room-header">
<div>
<div class="room-type">${room.type}</div>
<div class="room-bed">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4v16"/><path d="M2 8h18a2 2 0 0 1 2 2v10"/><path d="M2 17h20"/><path d="M6 8v9"/></svg>
${room.bedType}
</div>
<div class="room-capacity">Up to ${room.maxGuests} guests · ${room.available} left</div>
</div>
<div class="room-price">
<div class="price-amount">$${room.pricePerNight}</div>
<div class="price-label">per night</div>
</div>
</div>
<div class="room-amenities">${amenitiesHtml}</div>
`;
card.addEventListener("click", () => selectRoom(room.id));
list.appendChild(card);
});
reportSize();
}
function selectRoom(roomId) {
state.selectedRoomId = roomId;
state.selectedRoom = state.selectedHotel.rooms.find(
(r) => r.id === roomId,
);
document.querySelectorAll(".room-card").forEach((card) => {
card.classList.toggle("selected", card.dataset.roomId === roomId);
});
$("selectRoomBtn").disabled = false;
}
async function confirmRoomSelection() {
if (!state.selectedRoomId) {
alert("Please select a room");
return;
}
showLoading("Confirming room selection...");
try {
const result = await mcpApp.sendRequest("tools/call", {
name: "select-room",
arguments: {
searchId: state.searchId,
hotelId: state.selectedHotelId,
roomId: state.selectedRoomId,
quantity: state.rooms,
},
});
const data = result.structuredContent;
if (data?.success) {
hideLoading();
goToStep(4);
} else {
throw new Error(data?.error || "Failed to confirm room selection");
}
} catch (error) {
console.error("Room selection failed:", error);
hideLoading();
alert("Failed to confirm room selection. Please try again.");
}
}
function formatDateRange(checkIn, checkOut) {
const options = { month: "short", day: "numeric" };
const inDate = new Date(checkIn).toLocaleDateString("en-US", options);
const outDate = new Date(checkOut).toLocaleDateString("en-US", options);
return `${inDate} - ${outDate}`;
}
// =============================================================================
// STEP 4: BOOKING
// =============================================================================
async function completeBooking() {
const name = $("guestName").value.trim();
const email = $("guestEmail").value.trim();
const phone = $("guestPhone").value.trim();
const specialRequests = $("specialRequests").value.trim();
if (!name || !email) {
alert("Please fill in required fields");
return;
}
showLoading("Completing your booking...");
try {
const result = await mcpApp.sendRequest("tools/call", {
name: "book-hotel",
arguments: {
searchId: state.searchId,
hotelId: state.selectedHotelId,
roomId: state.selectedRoomId,
guests: [{ name, email, phone }],
specialRequests: specialRequests || undefined,
},
});
// Use structuredContent directly
const data = result.structuredContent;
if (data?.success && data?.booking) {
state.booking = data.booking;
renderConfirmation();
hideLoading();
goToStep(4);
} else {
throw new Error(data?.error || "Booking failed");
}
} catch (error) {
console.error("Booking failed:", error);
hideLoading();
alert("Failed to complete booking. Please try again.");
}
}
function renderConfirmation() {
const booking = state.booking;
$("guestFormContainer").style.display = "none";
$("confirmationContainer").style.display = "block";
// Server uses 'confirmationNumber' not 'confirmationCode'
$("confirmationCode").textContent = booking.confirmationNumber;
const summary = $("bookingSummary");
summary.innerHTML = `
<div class="summary-row">
<span class="summary-label">Hotel</span>
<span class="summary-value">${state.selectedHotel.name}</span>
</div>
<div class="summary-row">
<span class="summary-label">Room</span>
<span class="summary-value">${state.selectedRoom.type}</span>
</div>
<div class="summary-row">
<span class="summary-label">Check-in</span>
<span class="summary-value">${new Date($("checkIn").value).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}</span>
</div>
<div class="summary-row">
<span class="summary-label">Check-out</span>
<span class="summary-value">${new Date($("checkOut").value).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}</span>
</div>
<div class="summary-row">
<span class="summary-label">Nights</span>
<span class="summary-value">${state.searchParams.nights}</span>
</div>
<div class="summary-row">
<span class="summary-label">Guests</span>
<span class="summary-value">${state.guests}</span>
</div>
<div class="summary-row">
<span class="summary-label">Total</span>
<span class="summary-value summary-total">$${booking.totalPrice}</span>
</div>
`;
reportSize();
}
// =============================================================================
// INITIALIZATION
// =============================================================================
function reportSize() {
requestAnimationFrame(() => {
const rect = document.body.getBoundingClientRect();
mcpApp.sendNotification("ui/notifications/size-change", {
width: Math.ceil(rect.width),
height: Math.ceil(rect.height),
});
});
}
async function initialize() {
initSearchForm();
// Navigation buttons
$("backToSearch").addEventListener("click", () => goToStep(1));
$("selectHotelBtn").addEventListener("click", loadRooms);
$("backToHotels").addEventListener("click", () => goToStep(2));
$("selectRoomBtn").addEventListener("click", confirmRoomSelection);
$("backToRooms").addEventListener("click", () => goToStep(3));
$("confirmBookingBtn").addEventListener("click", completeBooking);
$("addToCalendarBtn").addEventListener("click", () => {
mcpApp.sendRequest("ui/message", {
role: "user",
content: [
{ type: "text", text: "Add this hotel booking to my calendar" },
],
});
});
// Initialize MCP connection
try {
await mcpApp.sendRequest("ui/initialize", {
protocolVersion: "2025-06-18",
appInfo: { name: "Hotel Booking", version: "1.0.0" },
appCapabilities: {},
});
mcpApp.sendNotification("ui/notifications/initialized", {});
} catch (error) {
console.error("Failed to initialize:", error);
}
// Listen for tool input to prefill search form with AI's parameters
mcpApp.onNotification("ui/notifications/tool-input", (params) => {
const args = params?.arguments;
if (args) {
// Prefill city dropdown
if (args.city) {
$("city").value = args.city;
}
// Prefill dates
if (args.checkIn) {
$("checkIn").value = args.checkIn;
}
if (args.checkOut) {
$("checkOut").value = args.checkOut;
}
// Prefill guests
if (args.guests) {
state.guests = args.guests;
updateGuestCount();
}
// Prefill rooms
if (args.rooms) {
state.rooms = args.rooms;
updateRoomCount();
}
}
});
// Listen for tool results (if pre-populated search)
mcpApp.onNotification("ui/notifications/tool-result", (params) => {
const content = params?.structuredContent;
// Server wraps data in 'search' object: { search: { id, hotels, searchParams }, summary }
if (content?.search?.id && content?.search?.hotels) {
state.searchId = content.search.id;
state.hotels = content.search.hotels;
state.searchParams = content.search.searchParams;
state.guests = content.search.searchParams?.guests || 2;
state.rooms = content.search.searchParams?.rooms || 1;
updateGuestCount();
updateRoomCount();
renderHotelResults();
goToStep(2);
}
});
new ResizeObserver(reportSize).observe(document.body);
reportSize();
}
window.addEventListener("load", initialize);
</script>
</body>
</html>