diff --git a/.github/workflows/update-hotel-prices.yml b/.github/workflows/update-hotel-prices.yml
index bfbe936..a0f868c 100644
--- a/.github/workflows/update-hotel-prices.yml
+++ b/.github/workflows/update-hotel-prices.yml
@@ -17,6 +17,7 @@ on:
- main
paths:
- 'scripts/fetch-hotels.mjs'
+ - 'scripts/fetch-hotels-with-gallery.mjs'
- '.github/workflows/update-hotel-prices.yml'
schedule:
# 07:27 in Vietnam (UTC+7), after the flight tracker.
@@ -106,7 +107,7 @@ jobs:
echo "Trying SerpApi credential ${labels[$index]} for Google Hotels..."
set +e
- output=$(SERPAPI_API_KEY="$key" HOTEL_MAX_PAGES="$HOTEL_MAX_PAGES" node scripts/fetch-hotels.mjs 2>&1)
+ output=$(SERPAPI_API_KEY="$key" HOTEL_MAX_PAGES="$HOTEL_MAX_PAGES" node scripts/fetch-hotels-with-gallery.mjs 2>&1)
status=$?
set -e
printf '%s\n' "$output"
@@ -144,11 +145,13 @@ jobs:
const unique = Number(data?.search?.latest_unique_property_count || 0);
const priced = Number(data?.search?.priced_property_count || 0);
const pages = Number(data?.search?.pages_fetched || 0);
+ const galleries = Number(data?.search?.gallery_hotel_count || 0);
+ const images = Number(data?.search?.gallery_image_count || 0);
if (!raw || !unique || !pages) {
- console.error('Invalid hotel snapshot after fetch:', { raw, unique, priced, pages });
+ console.error('Invalid hotel snapshot after fetch:', { raw, unique, priced, pages, galleries, images });
process.exit(4);
}
- console.log(`Validated hotel snapshot: ${pages} page(s), ${raw} raw, ${unique} latest unique, ${priced} priced.`);
+ console.log(`Validated hotel snapshot: ${pages} page(s), ${raw} raw, ${unique} latest unique, ${priced} priced, ${galleries} galleries / ${images} images.`);
NODE
- name: Evaluate hotel price alert
diff --git a/hotel-detail.css b/hotel-detail.css
new file mode 100644
index 0000000..40abc51
--- /dev/null
+++ b/hotel-detail.css
@@ -0,0 +1 @@
+body.hotel-detail-open{overflow:hidden}.hotel-detail-backdrop{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:28px;background:rgba(15,23,42,.58);backdrop-filter:blur(8px)}.hotel-detail-backdrop[hidden]{display:none}.hotel-detail-modal{width:min(1040px,100%);max-height:min(900px,calc(100vh - 56px));overflow:auto;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:0 30px 90px rgba(15,23,42,.28)}.hotel-detail-header{position:sticky;top:0;z-index:4;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px 20px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--surface) 94%,transparent);backdrop-filter:blur(16px)}.hotel-detail-header h2{margin:4px 0 0;font-size:21px;letter-spacing:-.025em}.hotel-detail-close{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:11px;background:var(--surface);color:var(--text);font-size:25px;line-height:1;cursor:pointer}.hotel-detail-body{padding:20px}.hotel-detail-gallery{margin-bottom:18px}.hotel-detail-gallery-main{position:relative;display:grid;place-items:center;height:min(470px,52vw);min-height:300px;overflow:hidden;border-radius:15px;background:var(--surface-2)}.hotel-detail-gallery-main>img{width:100%;height:100%;object-fit:cover}.gallery-arrow{position:absolute;top:50%;transform:translateY(-50%);display:grid;place-items:center;width:42px;height:42px;border:0;border-radius:999px;background:rgba(15,23,42,.7);color:#fff;font-size:31px;cursor:pointer;backdrop-filter:blur(8px)}.gallery-arrow.prev{left:14px}.gallery-arrow.next{right:14px}.gallery-count{position:absolute;right:14px;bottom:13px;padding:6px 9px;border-radius:999px;background:rgba(15,23,42,.72);color:#fff;font-size:10px;font-weight:800}.hotel-detail-thumbs{display:grid;grid-auto-flow:column;grid-auto-columns:96px;gap:8px;margin-top:9px;overflow-x:auto;padding-bottom:4px}.hotel-detail-thumb{height:66px;padding:0;overflow:hidden;border:2px solid transparent;border-radius:9px;background:var(--surface-2);cursor:pointer}.hotel-detail-thumb.active{border-color:var(--primary)}.hotel-detail-thumb img{width:100%;height:100%;object-fit:cover}.hotel-detail-no-photo{display:flex;min-height:280px;flex-direction:column;align-items:center;justify-content:center;gap:8px;border-radius:15px;background:var(--surface-2);color:var(--muted);text-align:center;font-size:12px}.hotel-detail-no-photo:first-letter{font-size:42px}.hotel-detail-no-photo strong{color:var(--text);font-size:14px}.hotel-detail-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:17px}.hotel-detail-summary>div{padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2)}.hotel-detail-summary span,.hotel-detail-summary small{display:block;color:var(--muted);font-size:10px}.hotel-detail-summary strong{display:block;margin:5px 0 3px;font-size:16px}.hotel-detail-description{margin:0 0 17px;color:var(--muted);font-size:12px;line-height:1.65}.hotel-detail-info-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.hotel-detail-info{padding:12px 13px;border:1px solid var(--line);border-radius:11px}.hotel-detail-info span{display:block;margin-bottom:4px;color:var(--muted);font-size:9.5px;text-transform:uppercase;letter-spacing:.05em}.hotel-detail-info strong{font-size:12px;line-height:1.5}.hotel-detail-info a{color:var(--primary);text-decoration:none}.hotel-detail-section{margin-top:19px}.hotel-detail-section h3{margin:0 0 10px;font-size:13px}.detail-chips,.detail-sources{display:flex;flex-wrap:wrap;gap:7px}.detail-chip,.detail-source{display:inline-flex;padding:7px 9px;border:1px solid var(--line);border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:10px;text-decoration:none}.detail-source[href]{color:var(--primary)}.hotel-nearby-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0;padding:0;list-style:none}.hotel-nearby-list li{padding:11px 12px;border:1px solid var(--line);border-radius:10px}.hotel-nearby-list strong,.hotel-nearby-list span{display:block;font-size:10.5px}.hotel-nearby-list span{margin-top:4px;color:var(--muted)}.hotel-detail-footer{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:21px;padding-top:15px;border-top:1px solid var(--line)}.hotel-detail-footer>span{max-width:650px;color:var(--muted);font-size:9.5px;line-height:1.5}.hotel-detail-btn{white-space:nowrap}@media(max-width:760px){.hotel-detail-backdrop{padding:0;place-items:end stretch}.hotel-detail-modal{width:100%;max-height:94vh;border-radius:18px 18px 0 0}.hotel-detail-header{padding:15px}.hotel-detail-body{padding:14px}.hotel-detail-gallery-main{height:54vw;min-height:230px}.hotel-detail-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.hotel-detail-info-grid,.hotel-nearby-list{grid-template-columns:1fr}.hotel-detail-footer{align-items:stretch;flex-direction:column}.hotel-detail-footer .btn{justify-content:center}}
\ No newline at end of file
diff --git a/hotel-detail.js b/hotel-detail.js
new file mode 100644
index 0000000..dfb4bb4
--- /dev/null
+++ b/hotel-detail.js
@@ -0,0 +1,234 @@
+(() => {
+ const detailState = { properties: [], active: null, imageIndex: 0 };
+
+ function escapeHtml(value = '') {
+ return String(value).replace(/[&<>"']/g, ch => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ }[ch]));
+ }
+
+ function finiteNumber(value) {
+ if (value === null || value === undefined || value === '') return null;
+ const number = Number(value);
+ return Number.isFinite(number) ? number : null;
+ }
+
+ function formatVnd(value) {
+ const number = finiteNumber(value);
+ if (number === null) return 'Chưa có giá';
+ return new Intl.NumberFormat('vi-VN', {
+ style: 'currency', currency: 'VND', maximumFractionDigits: 0
+ }).format(number).replace('₫', 'đ');
+ }
+
+ function formatNumber(value) {
+ const number = finiteNumber(value);
+ return number === null ? '—' : new Intl.NumberFormat('vi-VN').format(number);
+ }
+
+ function formatDistance(value) {
+ const number = finiteNumber(value);
+ return number === null ? 'Chưa rõ' : `${number.toLocaleString('vi-VN', { maximumFractionDigits: 2 })} km`;
+ }
+
+ function imageList(property) {
+ const seen = new Set();
+ const list = [];
+ for (const item of Array.isArray(property?.images) ? property.images : []) {
+ const src = item?.original_image || item?.thumbnail;
+ if (!src || seen.has(src)) continue;
+ seen.add(src);
+ list.push({ src, thumb: item?.thumbnail || src });
+ }
+ if (property?.image_url && !seen.has(property.image_url)) {
+ list.push({ src: property.image_url, thumb: property.image_url });
+ }
+ return list.slice(0, 12);
+ }
+
+ function getPropertyByName(name) {
+ return detailState.properties.find(item => item.name === name) || null;
+ }
+
+ function ensureModal() {
+ if (document.getElementById('hotelDetailBackdrop')) return;
+ const backdrop = document.createElement('div');
+ backdrop.id = 'hotelDetailBackdrop';
+ backdrop.className = 'hotel-detail-backdrop';
+ backdrop.hidden = true;
+ backdrop.innerHTML = `
+ Khách sạn
${escapeHtml(property.description)}
` : ''} +