// booking-app.jsx — Customer-facing booking site for Windy Hill Homestay
// Views: home → detail → checkout → confirm
// ─── Helpers ────────────────────────────────────────────
const dateInputISO = (d) => {
if (!d) return '';
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
};
const parseDateInput = (s) => {
if (!s) return null;
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
};
const isRoomAvailable = (room, ci, co, bookings) => {
if (room.status === 'maintenance') return false;
if (!ci || !co) return true;
const ciD = startOfDay(ci), coD = startOfDay(co);
return !bookings.some(b => {
if (b.roomId !== room.id) return false;
if (b.status === 'cancelled') return false;
const bi = startOfDay(b.checkIn), bo = startOfDay(b.checkOut);
return ciD < bo && coD > bi; // intervals overlap
});
};
// Find the next available start date for a room (within 60 days)
const nextAvailableDate = (room, fromDate, nights, bookings) => {
if (!fromDate || !nights) return null;
for (let i = 1; i <= 60; i++) {
const candidateIn = window.HotelData.addDays(fromDate, i);
const candidateOut = window.HotelData.addDays(candidateIn, nights);
if (isRoomAvailable(room, candidateIn, candidateOut, bookings)) {
return candidateIn;
}
}
return null;
};
// Map room type/theme to placeholder SVG art
const ROOM_ART = {
'suoi': <> >,
'la': <> >,
'da': <> >,
'co': <> >,
'may': <> >,
'gio': <> >,
'suong': <> >,
'nang': <> >,
'trang': <> >,
'mountain': <> >,
'thong': <> >,
'sao': <> >,
};
function artForRoom(room) {
const name = (room.name || '').toLowerCase();
if (name.includes('suối')) return ROOM_ART.suoi;
if (name.includes('lá')) return ROOM_ART.la;
if (name.includes('đá')) return ROOM_ART.da;
if (name.includes('cỏ')) return ROOM_ART.co;
if (name.includes('mây')) return ROOM_ART.may;
if (name.includes('gió')) return ROOM_ART.gio;
if (name.includes('sương')) return ROOM_ART.suong;
if (name.includes('nắng')) return ROOM_ART.nang;
if (name.includes('trăng')) return ROOM_ART.trang;
if (name.includes('đỉnh núi')) return ROOM_ART.mountain;
if (name.includes('thông')) return ROOM_ART.thong;
if (name.includes('sao')) return ROOM_ART.sao;
return ROOM_ART.may; // default
}
function PlaceholderArt({ room, kind = 'card', showName = true }) {
const cls = room.type.startsWith('Suite') ? 'suite' : room.type === 'Deluxe' ? 'deluxe' : 'standard';
return (
{artForRoom(room)}
{showName &&
{room.name || ('Phòng ' + room.number)}
}
);
}
// Amenity icons
const AMENITY_ICONS = {
'View núi': '⛰️', 'View vườn': '🌿', 'View thung lũng': '🏞️', 'View rừng thông': '🌲',
'Hướng vườn': '🌳', 'View toàn cảnh': '🌅', 'View hồ': '🌊', 'View rừng': '🌲',
'Ban công': '🪟', 'Ban công lớn': '🪟', 'Sân riêng': '🌱', 'Sân thượng': '🏠',
'Sân thượng ngắm sao': '✨',
'Bồn tắm': '🛁', 'Bồn tắm ngoài trời': '🛁', 'Jacuzzi': '♨️', 'Vòi sen mưa': '🚿',
'Bếp nhỏ': '🍳', 'Bếp riêng': '🍳',
'Lò sưởi điện': '🔥', 'Lò sưởi củi': '🪵', 'Lò sưởi': '🔥',
'Tivi 4K': '📺', 'Máy lạnh': '❄️', 'Máy pha cà phê': '☕',
'Wi-Fi nhanh': '📶', 'Bàn làm việc': '💼',
};
const amenityIcon = (a) => AMENITY_ICONS[a] || '✨';
// ─── Nav ─────────────────────────────────────────────────
function Nav({ goHome, onBookNow }) {
const [scrolled, setScrolled] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 30);
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
);
}
// ─── Hero ────────────────────────────────────────────────
function Hero({ search, setSearch, onSearch }) {
return (
Đà Lạt · Tháng Năm 2026 · 16–22°C
Nghỉ dưỡng giữa rừng thông Đà Lạt,
mỗi đêm là một kỷ niệm.
Windy Hill là homestay 12 phòng nằm bên sườn đồi, đón gió rừng thông buổi sáng và ngắm sao buổi tối.
Đặt phòng trực tiếp để nhận giá tốt nhất, không qua trung gian.
★
4.9 · Superhost từ 2023
);
}
function SearchBar({ search, setSearch, onSearch }) {
const todayISO = dateInputISO(new Date());
return (
Số khách
setSearch(s => ({ ...s, guests: +e.target.value }))}
>
{[1, 2, 3, 4, 5, 6].map(n => {n} khách )}
Tìm phòng
);
}
// ─── Rooms grid ──────────────────────────────────────────
function RoomsGrid({ rooms, bookings, search, onSelect }) {
const [favs, setFavs] = React.useState(new Set());
const toggleFav = (id) => {
setFavs(s => {
const n = new Set(s);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
};
const nights = search.checkIn && search.checkOut
? Math.max(1, daysBetween(search.checkIn, search.checkOut))
: 1;
// Compute availability for each room once
const datesSelected = !!(search.checkIn && search.checkOut);
const roomsWithAvail = rooms.map(r => ({
r,
available: isRoomAvailable(r, search.checkIn, search.checkOut, bookings),
fitsGuests: r.cap >= search.guests,
}));
const availableCount = roomsWithAvail.filter(x => x.available && x.fitsGuests).length;
return (
Phòng & Suite
Chọn không gian của bạn .
12 phòng với tên gọi và tinh thần riêng — từ Phòng Suối nhỏ xinh đến Suite Sao Trời ngắm trọn dải ngân hà.
{/* Availability summary */}
{datesSelected && (
{availableCount}/{rooms.length}
phòng còn trống
{formatDateShort(search.checkIn)} → {formatDateShort(search.checkOut)}
· {nights} đêm
{search.guests} khách
)}
{roomsWithAvail.map(({ r, available, fitsGuests }) => {
const fav = favs.has(r.id);
const slotId = 'room-img-' + r.id;
const bookable = available && fitsGuests;
const nextAvail = !available && datesSelected
? nextAvailableDate(r, search.checkIn, nights, bookings)
: null;
return (
bookable && onSelect(r)}
>
{ e.stopPropagation(); toggleFav(r.id); }}
aria-label="Yêu thích"
>
{datesSelected && (
{bookable ? 'Còn trống' : !available ? 'Hết phòng' : `Tối đa ${r.cap} khách`}
)}
{!bookable && datesSelected && (
{!available ? '🚫 Hết phòng cho ngày này' : `👥 Phòng tối đa ${r.cap} khách`}
{nextAvail && (
Còn trống từ {formatDateShort(nextAvail)}
)}
)}
P.{r.number}
{r.name || ('Phòng ' + r.number)}
{r.type} · Tầng {r.floor}
★
{(4.6 + (parseInt(r.number) % 4) * 0.1).toFixed(1)}
{r.features && (
{r.features.slice(0, 3).map((f, i) => (
{f}
))}
{r.features.length > 3 && +{r.features.length - 3} }
)}
{VND(r.price)}
/đêm
{nights > 1 && bookable && (
{VND(r.price * nights)} cho {nights} đêm
)}
{ e.stopPropagation(); if (bookable) onSelect(r); }}
disabled={!bookable}
>
{bookable ? 'Đặt phòng' : !available ? 'Đổi ngày' : 'Quá tải'}
{bookable && (
)}
);
})}
);
}
// ─── Room detail ─────────────────────────────────────────
function RoomDetail({ room, search, setSearch, onContinue, onBack }) {
const nights = search.checkIn && search.checkOut
? Math.max(1, daysBetween(search.checkIn, search.checkOut))
: 2;
const roomTotal = room.price * nights;
const cleaning = 100000;
const serviceFee = Math.round(roomTotal * 0.05);
const total = roomTotal + cleaning + serviceFee;
const canContinue = search.checkIn && search.checkOut && nights > 0;
const rating = (4.6 + (parseInt(room.number) % 4) * 0.1).toFixed(1);
return (
Tất cả phòng
{[2, 3, 4, 5].map(i => (
))}
{room.type} · Tầng {room.floor} · Phòng {room.number}
{room.name || ('Phòng ' + room.number)}
{room.bed}
Tối đa {room.cap} khách
★ {rating} · 47 đánh giá
{room.name} là không gian {room.type === 'Suite' || room.type.startsWith('Suite') ? 'sang trọng nhất' : room.type === 'Deluxe' ? 'rộng rãi' : 'ấm cúng'} của Windy Hill —
{room.features?.find(f => f.startsWith('View')) ? ` đón ánh sáng tự nhiên qua cửa sổ lớn hướng ${room.features.find(f => f.startsWith('View')).replace('View ', '')}, ` : ' '}
{room.features?.includes('Lò sưởi củi') || room.features?.includes('Lò sưởi điện') ? 'có lò sưởi để ấm áp những đêm Đà Lạt se lạnh, ' : ''}
cùng đầy đủ tiện nghi cho kỳ nghỉ thư thái.
Mỗi sáng, bạn sẽ thức dậy bên tiếng chim và mùi thông sau mưa.
Tiện nghi phòng
{(room.features || []).map((f, i) => (
{amenityIcon(f)}
{f}
))}
☕
Bữa sáng homemade
📶
Wi-Fi tốc độ cao
🅿️
Bãi đỗ xe miễn phí
Chính sách
Nhận phòng từ 14:00 · Trả phòng trước 12:00
Hủy miễn phí trong vòng 48 giờ trước check-in
Không hút thuốc trong phòng · Có khu hút thuốc riêng
Cho phép thú cưng nhỏ (báo trước · phụ thu 150.000đ/đêm)
{/* Sticky booking card */}
{VND(room.price)}
/đêm
★ {rating}
Số khách
setSearch(s => ({ ...s, guests: +e.target.value }))}
>
{[1, 2, 3, 4, 5, 6].slice(0, room.cap).map(n => (
{n} khách
))}
Đặt phòng
Chưa trừ tiền — chỉ giữ chỗ
{VND(room.price)} × {nights} đêm
{VND(roomTotal)}
Phí dọn phòng
{VND(cleaning)}
Phí dịch vụ (5%)
{VND(serviceFee)}
Tổng cộng
{VND(total)}
);
}
// ─── Checkout ─────────────────────────────────────────────
function Checkout({ room, search, guestInfo, setGuestInfo, payment, setPayment, onBack, onConfirm }) {
const nights = Math.max(1, daysBetween(search.checkIn, search.checkOut));
const roomTotal = room.price * nights;
const cleaning = 100000;
const serviceFee = Math.round(roomTotal * 0.05);
const total = roomTotal + cleaning + serviceFee;
const canConfirm = guestInfo.name && guestInfo.phone && guestInfo.email && payment.method;
return (
Quay lại
Xác nhận đặt phòng
Sắp xong rồi! Điền thông tin để chúng tôi giữ chỗ.
1
Chọn phòng
2
Thông tin & Thanh toán
3
Xác nhận
Thông tin khách
Email *
setGuestInfo(g => ({ ...g, email: e.target.value }))}
/>
CCCD / Hộ chiếu
setGuestInfo(g => ({ ...g, idNo: e.target.value }))}
/>
Yêu cầu đặc biệt
Phương thức thanh toán
{[
{ k: 'card', l: 'Thẻ ngân hàng', emoji: '💳' },
{ k: 'transfer', l: 'Chuyển khoản', emoji: '🏦' },
{ k: 'momo', l: 'Momo · ZaloPay', emoji: '📱' },
{ k: 'pay_later', l: 'Trả tại chỗ', emoji: '💵' },
].map(m => (
setPayment(p => ({ ...p, method: m.k }))}
>
{m.emoji}
{m.l}
))}
Quay lại
canConfirm && onConfirm()}
disabled={!canConfirm}
>
Hoàn tất đặt phòng · {VND(total)}
{/* Summary */}
{room.name || ('Phòng ' + room.number)}
{room.type}
{formatDateShort(search.checkIn)} → {formatDateShort(search.checkOut)}
{nights} đêm · {search.guests} khách
{VND(room.price)} × {nights} đêm
{VND(roomTotal)}
Phí dọn phòng
{VND(cleaning)}
Phí dịch vụ (5%)
{VND(serviceFee)}
Tổng cộng
{VND(total)}
Bạn sẽ được trừ tiền sau khi xác nhận. Hủy miễn phí trong 48 giờ trước check-in.
);
}
// ─── Confirmation ─────────────────────────────────────────
function Confirmation({ booking, room, search, total, guestInfo, onHome }) {
const nights = Math.max(1, daysBetween(search.checkIn, search.checkOut));
return (
Cảm ơn {guestInfo.name.split(' ').pop()} ! Phòng đã được giữ chỗ.
Chúng tôi đã gửi email xác nhận tới {guestInfo.email} .
Nhân viên sẽ liên hệ qua số {guestInfo.phone} trong 24h để xác nhận lại.
{booking.code}
Phòng {room.name || ('Phòng ' + room.number)} · {room.type}
Nhận phòng {formatDateVN(search.checkIn)} · 14:00
Trả phòng {formatDateVN(search.checkOut)} · 12:00
{nights} đêm · {search.guests} khách {VND(room.price)}/đêm
Tổng cộng {VND(total)}
Về trang chủ
Hẹn gặp bạn ở Windy Hill 🌲
);
}
// ─── About + Footer ──────────────────────────────────────
function About() {
return (
Về Windy Hill
Một góc Đà Lạt yên ả .
Chỉ 12 phòng, đội ngũ ít hơn 10 người. Chúng tôi tin vào sự đơn giản, chân thật và những bữa sáng tự nấu.
{[
{ ico: '🌲', t: 'Trong rừng thông', d: '5 phút đi bộ tới đường mòn rừng thông Tuyền Lâm. Sáng dậy với mùi nhựa thông và tiếng chim.' },
{ ico: '☕', t: 'Bữa sáng nhà nấu', d: 'Phở, bún, trứng ốp, bánh mì tự nướng — bữa sáng đổi món mỗi ngày, làm bằng nguyên liệu địa phương.' },
{ ico: '🚗', t: '20 phút từ trung tâm', d: 'Đủ gần để đi chợ Đà Lạt buổi tối, đủ xa để nghe tiếng thông reo cả ngày.' },
].map((b, i) => (
))}
);
}
function Footer() {
return (
);
}
// ─── Main App ───────────────────────────────────────────
function BookingApp() {
const data = window.HotelData;
const [view, setView] = React.useState('home'); // 'home' | 'detail' | 'checkout' | 'confirm'
const [selectedRoom, setSelectedRoom] = React.useState(null);
const [search, setSearch] = React.useState(() => {
// Default: today + 1 → today + 3. This overlaps existing demo bookings
// so customer immediately sees the "Còn trống / Hết phòng" UI.
const ci = window.HotelData.addDays(window.HotelData.today, 1);
const co = window.HotelData.addDays(window.HotelData.today, 3);
return { checkIn: ci, checkOut: co, guests: 2 };
});
const [guestInfo, setGuestInfo] = React.useState({
name: '', phone: '', email: '', idNo: '', note: '',
});
const [payment, setPayment] = React.useState({ method: 'card' });
const [confirmedBooking, setConfirmedBooking] = React.useState(null);
const [toast, setToast] = React.useState(null);
// Scroll to top on view change
React.useEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
}, [view]);
const handleSelect = (room) => {
setSelectedRoom(room);
setView('detail');
};
const handleSearchSubmit = () => {
setToast('Đã tìm phòng cho ' + (search.checkIn ? formatDateShort(search.checkIn) : '?') + ' → ' + (search.checkOut ? formatDateShort(search.checkOut) : '?'));
setTimeout(() => setToast(null), 2500);
// Scroll to rooms section
document.getElementById('rooms')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleConfirm = () => {
const code = 'WH' + Math.floor(2500 + Math.random() * 7499);
const nights = Math.max(1, daysBetween(search.checkIn, search.checkOut));
const roomTotal = selectedRoom.price * nights;
const cleaning = 100000;
const serviceFee = Math.round(roomTotal * 0.05);
const total = roomTotal + cleaning + serviceFee;
setConfirmedBooking({ code, total });
setView('confirm');
};
return (
<>
setView('home')} onBookNow={() => {
if (view === 'home') document.getElementById('rooms')?.scrollIntoView({ behavior: 'smooth' });
else setView('home');
}} />
{view === 'home' && (
<>
>
)}
{view === 'detail' && selectedRoom && (
setView('checkout')}
onBack={() => setView('home')}
/>
)}
{view === 'checkout' && selectedRoom && (
setView('detail')}
onConfirm={handleConfirm}
/>
)}
{view === 'confirm' && selectedRoom && confirmedBooking && (
{ setView('home'); setConfirmedBooking(null); setGuestInfo({ name:'', phone:'', email:'', idNo:'', note:'' }); }}
/>
)}
{toast && {toast}
}
>
);
}
window.BookingApp = BookingApp;