diff --git a/.github/workflows/update-flight-prices.yml b/.github/workflows/update-flight-prices.yml index 49c98c0..353e9c9 100644 --- a/.github/workflows/update-flight-prices.yml +++ b/.github/workflows/update-flight-prices.yml @@ -20,7 +20,7 @@ permissions: issues: write concurrency: - group: live-flight-prices-${{ github.ref }} + group: price-tracker-writes-${{ github.ref }} cancel-in-progress: false jobs: diff --git a/.github/workflows/update-hotel-prices.yml b/.github/workflows/update-hotel-prices.yml index 6bcb480..bfbe936 100644 --- a/.github/workflows/update-hotel-prices.yml +++ b/.github/workflows/update-hotel-prices.yml @@ -2,6 +2,16 @@ name: Update live hotel prices on: workflow_dispatch: + inputs: + max_pages: + description: 'Discovery depth (manual runs only)' + required: false + default: '5' + type: choice + options: + - '3' + - '5' + - '8' push: branches: - main @@ -18,7 +28,7 @@ permissions: issues: write concurrency: - group: live-hotel-prices-${{ github.ref }} + group: price-tracker-writes-${{ github.ref }} cancel-in-progress: false jobs: @@ -52,6 +62,26 @@ jobs: fi echo "enabled=true" >> "$GITHUB_OUTPUT" + - name: Resolve discovery depth + id: depth + shell: bash + env: + MANUAL_MAX_PAGES: ${{ inputs.max_pages }} + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + PAGES="${MANUAL_MAX_PAGES:-5}" + MODE="manual-deep-discovery" + elif [ "${GITHUB_EVENT_NAME}" = "push" ]; then + PAGES="5" + MODE="bootstrap-deep-discovery" + else + PAGES="3" + MODE="daily-light-refresh" + fi + echo "max_pages=$PAGES" >> "$GITHUB_OUTPUT" + echo "mode=$MODE" >> "$GITHUB_OUTPUT" + echo "Hotel search mode: $MODE · max pages: $PAGES" + - name: Fetch Google Hotels prices via SerpApi id: search if: steps.config.outputs.enabled == 'true' @@ -60,6 +90,7 @@ jobs: SERPAPI_API_KEY_2: ${{ secrets.SERPAPI_API_KEY_2 }} SERPAPI_API_KEY_3: ${{ secrets.SERPAPI_API_KEY_3 }} SERPAPI_BOOKING_API_KEY: ${{ secrets.SERPAPI_BOOKING_API_KEY }} + HOTEL_MAX_PAGES: ${{ steps.depth.outputs.max_pages }} shell: bash run: | set -euo pipefail @@ -75,7 +106,7 @@ jobs: echo "Trying SerpApi credential ${labels[$index]} for Google Hotels..." set +e - output=$(SERPAPI_API_KEY="$key" node scripts/fetch-hotels.mjs 2>&1) + output=$(SERPAPI_API_KEY="$key" HOTEL_MAX_PAGES="$HOTEL_MAX_PAGES" node scripts/fetch-hotels.mjs 2>&1) status=$? set -e printf '%s\n' "$output" @@ -87,6 +118,11 @@ jobs: break fi + if printf '%s' "$output" | grep -q 'ZERO_RESULTS:'; then + echo "::error::Google Hotels returned zero properties; keeping the previous good snapshot unchanged." + exit "$status" + fi + if printf '%s' "$output" | grep -Eqi '(^|[^0-9])429([^0-9]|$)|quota|rate[ -]?limit|monthly[[:space:]]+search|search(es)?[[:space:]]+limit|credit(s)?[[:space:]]+(exhausted|limit)'; then echo "::warning::Credential ${labels[$index]} has no usable quota/rate capacity. Trying the next credential." else @@ -102,6 +138,19 @@ jobs: test -f data/hotels.json || { echo "::error::Missing data/hotels.json after search."; exit 3; } test -f data/hotel-history.json || { echo "::error::Missing data/hotel-history.json after search."; exit 3; } + node - <<'NODE' + const data = require('./data/hotels.json'); + const raw = Number(data?.search?.raw_property_count || 0); + 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); + if (!raw || !unique || !pages) { + console.error('Invalid hotel snapshot after fetch:', { raw, unique, priced, pages }); + process.exit(4); + } + console.log(`Validated hotel snapshot: ${pages} page(s), ${raw} raw, ${unique} latest unique, ${priced} priced.`); + NODE + - name: Evaluate hotel price alert if: steps.config.outputs.enabled == 'true' && github.ref_name == 'main' && steps.search.outcome == 'success' env: @@ -115,8 +164,8 @@ jobs: exit 0 fi - CURRENT_AMOUNT=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_shortlisted_property_id||d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.rate_per_night?.amount?String(p.rate_per_night.amount):'')") - CURRENT_HOTEL=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_shortlisted_property_id||d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.name||'Unknown hotel')") + CURRENT_AMOUNT=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.rate_per_night?.amount?String(p.rate_per_night.amount):'')") + CURRENT_HOTEL=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.name||'Unknown hotel')") CHECKED_AT=$(node -p "require('./data/hotels.json').generated_at || new Date().toISOString()") CURRENT_CURRENCY=$(node -p "require('./data/hotels.json').search?.currency || 'VND'") ALERT_CURRENCY=${ALERT_CURRENCY:-$CURRENT_CURRENCY} @@ -134,7 +183,7 @@ jobs: ISSUE=$(gh issue list --state open --json number,title --jq '.[] | select(.title == "🏨 Hotel price alert · Shanghai 19–20 Oct 2026") | .number' | head -n 1) if node -e "process.exit(Number(process.argv[1]) <= Number(process.argv[2]) ? 0 : 1)" "$CURRENT_AMOUNT" "$ALERT_AMOUNT"; then - BODY=$(cat < Google Hotels is a price snapshot. Verify taxes, fees, cancellation policy, room type and final checkout total before paying. - EOF + EOF2 ) if [ -n "$ISSUE" ]; then gh issue edit "$ISSUE" --body "$BODY" @@ -175,7 +224,7 @@ jobs: run: node scripts/update-api-usage.mjs - name: Commit refreshed snapshots - if: always() && steps.config.outputs.enabled == 'true' + if: steps.search.outcome == 'success' id: commit shell: bash run: | @@ -189,11 +238,14 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add data/hotels.json data/hotel-history.json data/api-usage.json README.md git commit -m "chore: refresh Google Hotels and API usage [skip ci]" + + git fetch origin "${GITHUB_REF_NAME}" + git rebase "origin/${GITHUB_REF_NAME}" git push origin "HEAD:${GITHUB_REF_NAME}" echo "changed=true" >> "$GITHUB_OUTPUT" - name: Request GitHub Pages rebuild - if: steps.config.outputs.enabled == 'true' && steps.commit.outputs.changed == 'true' && github.ref_name == 'main' + if: steps.commit.outputs.changed == 'true' && github.ref_name == 'main' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/data/hotels.json b/data/hotels.json index 6a67153..9e87559 100644 --- a/data/hotels.json +++ b/data/hotels.json @@ -1,14 +1,16 @@ { - "status": "no_results", + "status": "pending_refresh", + "snapshot_state": "preview_pending_refresh", + "snapshot_schema": 2, "provider": "SerpApi", "source": "Google Hotels", - "generated_at": "2026-08-27T09:28:42.009Z", - "live_mode": true, - "disclaimer": "Rates are Google Hotels snapshots for one room with 2 adults. Taxes, fees, room type and final checkout price can differ. The 3-room figure is only a simple estimate for the 6-adult group and does not confirm availability of three identical rooms.", + "generated_at": null, + "live_mode": false, + "disclaimer": "Preview placeholder only. No hard-coded hotel fallback is shown. Run the live hotel workflow after merge to populate Google Hotels discovery data.", "search": { - "trip_key": "shanghai-jinling-2026-10-19__2026-10-20", - "query": "Jinling East Road Shanghai hotels", - "location_label": "Jinling East Road · Dashijie · The Bund · Yu Garden, Shanghai", + "trip_key": "shanghai-2026-10-19__2026-10-20", + "query": "Shanghai hotels", + "location_label": "Shanghai · lọc theo khoảng cách từ 531 Jinling East Road", "check_in_date": "2026-10-19", "check_out_date": "2026-10-20", "nights": 1, @@ -16,8 +18,20 @@ "children": 0, "currency": "VND", "group_rooms_estimate": 3, - "searches_per_refresh": 1, - "google_hotels_url": "https://www.google.com/travel/search?q=Jinling+East+Road+Shanghai+hotels&hl=en&gl=vn" + "anchor": { + "name": "531 Jinling East Road", + "latitude": 31.2285, + "longitude": 121.4808 + }, + "max_pages": 5, + "pages_fetched": 0, + "searches_per_refresh": 0, + "google_hotels_url": null, + "raw_property_count": 0, + "latest_unique_property_count": 0, + "preserved_catalog_count": 0, + "displayed_property_count": 0, + "priced_property_count": 0 }, "properties": [], "cheapest_property_id": null, diff --git a/flights.html b/flights.html index 934b32f..30a2cd1 100644 --- a/flights.html +++ b/flights.html @@ -18,6 +18,7 @@ Tổng quan Lịch trình Giá vé + Giá phòng Ngân sách Chuẩn bị Ghi chú @@ -25,7 +26,7 @@
-
Giá véHành trình Trung Quốc 2026
+
Giá véHành trình Trung Quốc 2026
TravelLog
@@ -59,7 +60,7 @@
- + diff --git a/hotel-booking.css b/hotel-booking.css index 7f14ee2..e629f11 100644 --- a/hotel-booking.css +++ b/hotel-booking.css @@ -1 +1 @@ -.hotel-content{width:min(1240px,calc(100% - 48px));padding:28px 0 72px}.hotel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.hotel-heading h1{margin:6px 0 8px;font-size:30px;letter-spacing:-.035em}.hotel-heading p{margin:0;max-width:760px;color:var(--muted);font-size:13px;line-height:1.65}.hotel-kicker{font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--primary)}.hotel-search-card{padding:18px;margin-bottom:16px}.hotel-search-top{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:15px}.hotel-stay strong{display:block;font-size:15px}.hotel-stay span{display:block;margin-top:4px;color:var(--muted);font-size:11px}.hotel-live-source{display:inline-flex;align-items:center;gap:7px;padding:7px 10px;border-radius:999px;background:var(--primary-soft);color:var(--primary);font-size:10.5px;font-weight:800}.hotel-live-source:before{content:"";width:7px;height:7px;border-radius:999px;background:currentColor}.hotel-controls{display:grid;grid-template-columns:minmax(220px,1fr) 210px auto;gap:10px;align-items:center}.hotel-search-input{height:40px;padding:0 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-sort{height:40px;padding:0 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.shortlist-toggle{display:flex;align-items:center;gap:8px;min-height:40px;padding:0 12px;border:1px solid var(--line);border-radius:10px;font-size:11px;font-weight:700;white-space:nowrap}.shortlist-toggle input{width:16px;height:16px}.hotel-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.hotel-stat{padding:16px}.hotel-stat span{display:block;color:var(--muted);font-size:10.5px}.hotel-stat strong{display:block;margin-top:8px;font-size:19px;letter-spacing:-.025em}.hotel-stat small{display:block;margin-top:4px;color:var(--muted);font-size:10px;line-height:1.45}.hotel-results-head{display:flex;align-items:end;justify-content:space-between;gap:12px;margin:10px 0 12px}.hotel-results-head h2{margin:4px 0 0;font-size:18px}.hotel-results-head span{color:var(--muted);font-size:11px}.hotel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.hotel-card{display:grid;grid-template-columns:180px minmax(0,1fr);overflow:hidden}.hotel-card.shortlisted{box-shadow:0 12px 32px rgba(37,99,235,.08),inset 0 0 0 1px color-mix(in srgb,var(--primary) 24%,transparent)}.hotel-image{position:relative;min-height:220px;background:var(--surface-2);overflow:hidden}.hotel-image img{width:100%;height:100%;object-fit:cover;display:block}.hotel-image-fallback{display:grid;place-items:center;width:100%;height:100%;min-height:220px;font-size:38px}.shortlist-badge{position:absolute;left:10px;top:10px;padding:6px 8px;border-radius:999px;background:rgba(15,23,42,.8);color:#fff;font-size:9.5px;font-weight:800;backdrop-filter:blur(8px)}.hotel-card-body{display:flex;flex-direction:column;padding:15px}.hotel-title-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px}.hotel-title-row h3{margin:0;font-size:14px;line-height:1.35;letter-spacing:-.015em}.hotel-meta{display:flex;flex-wrap:wrap;gap:6px 10px;margin-top:7px;color:var(--muted);font-size:10px}.hotel-rating{color:#a16207;font-weight:700}.hotel-rating.muted{color:var(--muted)}.hotel-price{text-align:right;white-space:nowrap}.hotel-price strong{display:block;font-size:18px;color:var(--primary)}.hotel-price span{display:block;margin-top:3px;color:var(--muted);font-size:9.5px}.price-delta{display:block;margin-top:5px;font-size:9.5px;font-weight:800}.price-delta.down{color:#15803d}.price-delta.up{color:#b91c1c}.price-delta.neutral{color:var(--muted)}.hotel-description{display:-webkit-box;margin:11px 0 0;color:var(--muted);font-size:10.5px;line-height:1.5;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hotel-group-estimate{display:flex;justify-content:space-between;gap:10px;margin-top:12px;padding:9px 10px;border-radius:9px;background:var(--surface-2);font-size:10px}.hotel-group-estimate span{color:var(--muted)}.hotel-group-estimate strong{font-size:11px}.hotel-sources{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.source-chip{padding:5px 7px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:9px;background:var(--surface)}.hotel-card-actions{display:flex;gap:7px;margin-top:auto;padding-top:13px}.empty-state{grid-column:1/-1;padding:34px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6}.hotel-history{margin-top:22px}.hotel-history-head{display:flex;align-items:end;justify-content:space-between;gap:14px;margin-bottom:12px}.hotel-history-head h2{margin:4px 0 0;font-size:19px}.hotel-history-head select{max-width:360px;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-history-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(280px,.7fr);gap:13px}.hotel-chart-card,.hotel-target-card{padding:17px}.hotel-chart-card h3,.hotel-target-card h3{margin:0 0 5px;font-size:13px}.hotel-chart-card p,.hotel-target-card p{margin:0;color:var(--muted);font-size:10.5px;line-height:1.5}.hotel-chart{width:100%;height:180px;margin-top:14px;overflow:visible}.hotel-chart polyline{stroke:var(--primary);stroke-width:2}.hotel-chart circle{fill:var(--surface);stroke:var(--primary);stroke-width:2}.hotel-range{margin-top:16px;font-size:20px;font-weight:800;letter-spacing:-.02em}.hotel-target-row{display:flex;gap:8px;margin-top:14px}.hotel-target-row input{min-width:0;flex:1;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-target-card .alert{margin-top:10px;padding:9px 10px;border-radius:9px;background:var(--surface-2);color:var(--muted);font-size:10px;line-height:1.45}.hotel-target-card .alert.success{background:rgba(22,163,74,.1);color:#15803d}.hotel-target-card .alert.danger{background:rgba(220,38,38,.09);color:#b91c1c}.hotel-note{margin-top:12px;padding:11px 13px;border:1px dashed var(--line);border-radius:10px;color:var(--muted);font-size:10px;line-height:1.55}@media(max-width:1060px){.hotel-grid{grid-template-columns:1fr}.hotel-stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:820px){.hotel-content{width:calc(100% - 24px);padding:18px 0 90px}.hotel-heading{display:block}.hotel-heading .btn{margin-top:12px}.hotel-heading h1{font-size:24px}.hotel-controls{grid-template-columns:1fr 1fr}.shortlist-toggle{grid-column:1/-1}.hotel-history-grid{grid-template-columns:1fr}.hotel-history-head{display:block}.hotel-history-head select{width:100%;max-width:none;margin-top:9px}}@media(max-width:560px){.hotel-content{width:calc(100% - 18px)}.hotel-stats{grid-template-columns:1fr 1fr;gap:8px}.hotel-stat{padding:13px}.hotel-stat strong{font-size:16px}.hotel-controls{grid-template-columns:1fr}.shortlist-toggle{grid-column:auto}.hotel-card{grid-template-columns:1fr}.hotel-image{min-height:180px;max-height:220px}.hotel-image-fallback{min-height:180px}.hotel-title-row{grid-template-columns:1fr}.hotel-price{text-align:left;margin-top:2px}.hotel-card-actions{flex-wrap:wrap}} \ No newline at end of file +.hotel-content{width:min(1240px,calc(100% - 48px));padding:28px 0 72px}.hotel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.hotel-heading h1{margin:6px 0 8px;font-size:30px;letter-spacing:-.035em}.hotel-heading p{margin:0;max-width:820px;color:var(--muted);font-size:13px;line-height:1.65}.hotel-kicker{font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--primary)}.hotel-search-card{padding:18px;margin-bottom:16px}.hotel-search-top{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:15px}.hotel-stay strong{display:block;font-size:15px}.hotel-stay span{display:block;margin-top:4px;color:var(--muted);font-size:11px}.hotel-live-source{display:inline-flex;align-items:center;gap:7px;padding:7px 10px;border-radius:999px;background:var(--primary-soft);color:var(--primary);font-size:10.5px;font-weight:800}.hotel-live-source:before{content:"";width:7px;height:7px;border-radius:999px;background:currentColor}.hotel-filter-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.filter-field{display:flex;flex-direction:column;gap:6px;min-width:0}.filter-field span{color:var(--muted);font-size:9.5px;font-weight:700}.filter-field input,.filter-field select{width:100%;height:40px;padding:0 11px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text);font-size:11px}.filter-search{grid-column:span 2}.hotel-filter-actions{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-top:12px}.shortlist-toggle{display:flex;align-items:center;gap:8px;min-height:38px;padding:0 11px;border:1px solid var(--line);border-radius:10px;font-size:10.5px;font-weight:700;white-space:nowrap}.shortlist-toggle input{width:16px;height:16px}.filter-hint{margin-left:auto;color:var(--muted);font-size:10px}.hotel-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.hotel-stat{padding:16px}.hotel-stat span{display:block;color:var(--muted);font-size:10.5px}.hotel-stat strong{display:block;margin-top:8px;font-size:19px;letter-spacing:-.025em}.hotel-stat small{display:block;margin-top:4px;color:var(--muted);font-size:10px;line-height:1.45}.hotel-results-head{display:flex;align-items:end;justify-content:space-between;gap:12px;margin:10px 0 12px}.hotel-results-head h2{margin:4px 0 0;font-size:18px}.hotel-results-head span{color:var(--muted);font-size:11px}.hotel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.hotel-card{display:grid;grid-template-columns:180px minmax(0,1fr);overflow:hidden}.hotel-card.shortlisted{box-shadow:0 12px 32px rgba(37,99,235,.08),inset 0 0 0 1px color-mix(in srgb,var(--primary) 24%,transparent)}.hotel-image{position:relative;min-height:220px;background:var(--surface-2);overflow:hidden}.hotel-image img{width:100%;height:100%;object-fit:cover;display:block}.hotel-image-fallback{display:grid;place-items:center;width:100%;height:100%;min-height:220px;font-size:38px}.shortlist-badge{position:absolute;left:10px;top:10px;padding:6px 8px;border-radius:999px;background:rgba(15,23,42,.8);color:#fff;font-size:9.5px;font-weight:800;backdrop-filter:blur(8px)}.hotel-card-body{display:flex;flex-direction:column;padding:15px}.hotel-title-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px}.hotel-title-row h3{margin:0;font-size:14px;line-height:1.35;letter-spacing:-.015em}.hotel-meta{display:flex;flex-wrap:wrap;gap:6px 10px;margin-top:7px;color:var(--muted);font-size:10px}.hotel-rating{color:#a16207;font-weight:700}.hotel-rating.muted{color:var(--muted)}.hotel-distance{font-weight:700;color:var(--primary)}.hotel-price{text-align:right;white-space:nowrap}.hotel-price strong{display:block;font-size:18px;color:var(--primary)}.hotel-price span{display:block;margin-top:3px;color:var(--muted);font-size:9.5px}.price-delta{display:block;margin-top:5px;font-size:9.5px;font-weight:800}.price-delta.down{color:#15803d}.price-delta.up{color:#b91c1c}.price-delta.neutral{color:var(--muted)}.hotel-description{display:-webkit-box;margin:11px 0 0;color:var(--muted);font-size:10.5px;line-height:1.5;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hotel-address{margin:8px 0 0;color:var(--muted);font-size:9.8px}.hotel-group-estimate{display:flex;justify-content:space-between;gap:10px;margin-top:12px;padding:9px 10px;border-radius:9px;background:var(--surface-2);font-size:10px}.hotel-group-estimate span{color:var(--muted)}.hotel-group-estimate strong{font-size:11px}.hotel-amenities,.hotel-sources{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.source-chip,.amenity-chip{padding:5px 7px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:9px;background:var(--surface)}.hotel-card-actions{display:flex;gap:7px;align-items:center;margin-top:auto;padding-top:13px;flex-wrap:wrap}.favorite-btn.active{background:var(--primary-soft);color:var(--primary);border-color:color-mix(in srgb,var(--primary) 35%,var(--line))}.empty-state{grid-column:1/-1;padding:34px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6}.hotel-history{margin-top:22px}.hotel-history-head{display:flex;align-items:end;justify-content:space-between;gap:14px;margin-bottom:12px}.hotel-history-head h2{margin:4px 0 0;font-size:19px}.hotel-history-head select{max-width:360px;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-history-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(280px,.7fr);gap:13px}.hotel-chart-card,.hotel-target-card{padding:17px}.hotel-chart-card h3,.hotel-target-card h3{margin:0 0 5px;font-size:13px}.hotel-chart-card p,.hotel-target-card p{margin:0;color:var(--muted);font-size:10.5px;line-height:1.5}.hotel-chart{width:100%;height:180px;margin-top:14px;overflow:visible}.hotel-chart polyline{stroke:var(--primary);stroke-width:2}.hotel-chart circle{fill:var(--surface);stroke:var(--primary);stroke-width:2}.hotel-range{margin-top:16px;font-size:20px;font-weight:800;letter-spacing:-.02em}.hotel-target-row{display:flex;gap:8px;margin-top:14px}.hotel-target-row input{min-width:0;flex:1;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.hotel-target-card .alert{margin-top:10px;padding:9px 10px;border-radius:9px;background:var(--surface-2);color:var(--muted);font-size:10px;line-height:1.45}.hotel-target-card .alert.success{background:rgba(22,163,74,.1);color:#15803d}.hotel-target-card .alert.danger{background:rgba(220,38,38,.09);color:#b91c1c}.hotel-note{margin-top:12px;padding:11px 13px;border:1px dashed var(--line);border-radius:10px;color:var(--muted);font-size:10px;line-height:1.55}@media(max-width:1180px){.hotel-filter-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.filter-search{grid-column:span 2}}@media(max-width:1060px){.hotel-grid{grid-template-columns:1fr}.hotel-stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:820px){.hotel-content{width:calc(100% - 24px);padding:18px 0 90px}.hotel-heading{display:block}.hotel-heading .btn{margin-top:12px}.hotel-heading h1{font-size:24px}.hotel-filter-grid{grid-template-columns:1fr 1fr}.filter-search{grid-column:1/-1}.filter-hint{width:100%;margin-left:0}.hotel-history-grid{grid-template-columns:1fr}.hotel-history-head{display:block}.hotel-history-head select{width:100%;max-width:none;margin-top:9px}}@media(max-width:560px){.hotel-content{width:calc(100% - 18px)}.hotel-stats{grid-template-columns:1fr 1fr;gap:8px}.hotel-stat{padding:13px}.hotel-stat strong{font-size:16px}.hotel-filter-grid{grid-template-columns:1fr}.filter-search{grid-column:auto}.hotel-filter-actions{align-items:stretch}.shortlist-toggle{width:100%}.hotel-card{grid-template-columns:1fr}.hotel-image{min-height:180px;max-height:220px}.hotel-image-fallback{min-height:180px}.hotel-title-row{grid-template-columns:1fr}.hotel-price{text-align:left;margin-top:2px}.hotel-card-actions{flex-wrap:wrap}} \ No newline at end of file diff --git a/hotel-booking.js b/hotel-booking.js index 7a8a915..733e8d3 100644 --- a/hotel-booking.js +++ b/hotel-booking.js @@ -1 +1,234 @@ -const DATA_URL='./data/hotels.json';const HISTORY_URL='./data/hotel-history.json';const TARGET_KEY='travel-hotel-target-price-v1';const HOTEL_KEY='travel-hotel-history-selected-v1';const $=id=>document.getElementById(id);function syncThemeUI(){const dark=document.documentElement.dataset.theme==='dark',icon=dark?'#i-sun':'#i-moon',label=dark?'Chuyển sang giao diện sáng':'Chuyển sang giao diện tối';document.querySelectorAll('[data-theme-toggle]').forEach(button=>{const use=button.querySelector('use');if(use)use.setAttribute('href',icon);button.setAttribute('aria-label',label);button.setAttribute('title',label)})}function setTheme(value){document.documentElement.dataset.theme=value;localStorage.setItem('travel-theme',value);syncThemeUI()}document.documentElement.dataset.theme=localStorage.getItem('travel-theme')==='dark'?'dark':'light';document.querySelectorAll('[data-theme-toggle]').forEach(button=>{button.addEventListener('click',()=>setTheme(document.documentElement.dataset.theme==='dark'?'light':'dark'))});syncThemeUI();const state={data:null,history:[],query:'',sort:'recommended',shortlistOnly:false,selectedHotelId:localStorage.getItem(HOTEL_KEY)||''};function formatVnd(value){const n=Number(value);if(!Number.isFinite(n))return'—';return new Intl.NumberFormat('vi-VN',{style:'currency',currency:'VND',maximumFractionDigits:0}).format(n).replace('₫','đ')}function formatNumber(value){const n=Number(value);return Number.isFinite(n)?new Intl.NumberFormat('vi-VN').format(n):'—'}function formatDateTime(value){if(!value)return'Chưa cập nhật';const date=new Date(value);if(Number.isNaN(date.getTime()))return value;return new Intl.DateTimeFormat('vi-VN',{dateStyle:'short',timeStyle:'short',timeZone:'Asia/Ho_Chi_Minh'}).format(date)}function relativeTime(value){if(!value)return'Chưa có';const then=new Date(value).getTime();if(!Number.isFinite(then))return'Chưa có';const minutes=Math.max(0,Math.round((Date.now()-then)/60000));if(minutes<60)return`${minutes} phút trước`;const hours=Math.round(minutes/60);if(hours<48)return`${hours} giờ trước`;return`${Math.round(hours/24)} ngày trước`}function escapeHtml(value=''){return String(value).replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]))}function propertyById(id){return state.data?.properties?.find(item=>item.id===id)||null}function priceDeltaText(delta){if(!Number.isFinite(Number(delta)))return{text:'Lần ghi nhận đầu',cls:'neutral'};const n=Number(delta);if(n===0)return{text:'Không đổi',cls:'neutral'};if(n<0)return{text:`Giảm ${formatVnd(Math.abs(n))}`,cls:'down'};return{text:`Tăng ${formatVnd(n)}`,cls:'up'}}function filteredProperties(){const list=[...(state.data?.properties||[])],q=state.query.trim().toLowerCase();const filtered=list.filter(item=>{if(state.shortlistOnly&&!item.shortlisted)return false;if(!q)return true;return[item.name,item.description,item.hotel_class,...(item.amenities||[])].filter(Boolean).join(' ').toLowerCase().includes(q)});filtered.sort((a,b)=>{if(state.sort==='price')return a.rate_per_night.amount-b.rate_per_night.amount;if(state.sort==='rating')return(b.overall_rating||0)-(a.overall_rating||0)||a.rate_per_night.amount-b.rate_per_night.amount;if(state.sort==='reviews')return(b.reviews||0)-(a.reviews||0)||a.rate_per_night.amount-b.rate_per_night.amount;if(a.shortlisted!==b.shortlisted)return a.shortlisted?-1:1;return a.rate_per_night.amount-b.rate_per_night.amount});return filtered}function hotelCard(item){const delta=priceDeltaText(item.price_delta);const sources=(item.price_sources||[]).slice(0,3).map(source=>`${escapeHtml(source.source)} · ${formatVnd(source.rate_per_night?.amount)}`).join('');const rating=item.overall_rating?`★ ${item.overall_rating}${item.reviews?` · ${formatNumber(item.reviews)} đánh giá`:''}`:'Chưa có điểm Google';const website=item.website_url?`Website`:'';const image=item.image_url?``:'
🏨
';return`
${image}${item.shortlisted?'Đang theo dõi':''}

${escapeHtml(item.name)}

${rating}${item.hotel_class?`${escapeHtml(item.hotel_class)}`:''}
${formatVnd(item.rate_per_night?.amount)}/ phòng / đêm${delta.text}
${item.description?`

${escapeHtml(item.description)}

`:''}
Ước tính 3 phòng cho 6 người${formatVnd(item.estimated_three_rooms_amount)}
${sources||'Google Hotels'}
${website}
`}function renderResults(){const list=filteredProperties();$('hotelResults').innerHTML=list.length?list.map(hotelCard).join(''):'
Không có khách sạn phù hợp với bộ lọc hiện tại.
';$('resultCount').textContent=`${list.length} khách sạn`;document.querySelectorAll('[data-history]').forEach(button=>{button.addEventListener('click',()=>{state.selectedHotelId=button.dataset.history;localStorage.setItem(HOTEL_KEY,state.selectedHotelId);renderHistoryControls();renderHistory();$('historySection').scrollIntoView({behavior:'smooth',block:'start'})})})}function renderSummary(){const properties=state.data?.properties||[];const cheapest=[...properties].sort((a,b)=>a.rate_per_night.amount-b.rate_per_night.amount)[0]||null;const shortlist=properties.filter(item=>item.shortlisted);const cheapestShort=[...shortlist].sort((a,b)=>a.rate_per_night.amount-b.rate_per_night.amount)[0]||cheapest;$('cheapestPrice').textContent=cheapestShort?formatVnd(cheapestShort.rate_per_night.amount):'—';$('cheapestName').textContent=cheapestShort?cheapestShort.name:'Chưa có dữ liệu';$('trackedCount').textContent=String(shortlist.length||properties.length);$('trackedMeta').textContent=shortlist.length?'khách sạn trong shortlist':'khách sạn có giá';$('updatedAgo').textContent=relativeTime(state.data?.generated_at);$('updatedAt').textContent=formatDateTime(state.data?.generated_at);$('groupEstimate').textContent=cheapestShort?formatVnd(cheapestShort.estimated_three_rooms_amount):'—';const target=Number(localStorage.getItem(TARGET_KEY)),alert=$('targetAlert');if(cheapestShort&&Number.isFinite(target)&&target>0){if(cheapestShort.rate_per_night.amount<=target){alert.className='alert success';alert.textContent=`Đã đạt mục tiêu: ${formatVnd(cheapestShort.rate_per_night.amount)} ≤ ${formatVnd(target)}.`}else{alert.className='alert';alert.textContent=`Còn cao hơn mục tiêu ${formatVnd(cheapestShort.rate_per_night.amount-target)}.`}}else{alert.className='alert';alert.textContent='Đặt mục tiêu giá để so nhanh với lần cập nhật mới nhất.'}}function historyRowsFor(item){if(!item)return[];return state.history.filter(row=>row.trip_key===state.data?.search?.trip_key).filter(row=>row.property_id===item.id||row.name===item.name).filter(row=>Number.isFinite(Number(row.rate_per_night_amount))).slice(-90)}function renderHistoryControls(){const properties=state.data?.properties||[];if(!state.selectedHotelId||!propertyById(state.selectedHotelId)){state.selectedHotelId=state.data?.cheapest_shortlisted_property_id||state.data?.cheapest_property_id||properties[0]?.id||''}$('historyHotel').innerHTML=properties.map(item=>``).join('')}function renderHistory(){const item=propertyById(state.selectedHotelId),rows=historyRowsFor(item),svg=$('hotelPriceChart');if(!item||!rows.length){svg.innerHTML='';$('historyMeta').textContent='Chưa có dữ liệu lịch sử cho khách sạn này.';$('observedRange').textContent='—';return}const values=rows.map(row=>Number(row.rate_per_night_amount)),min=Math.min(...values),max=Math.max(...values),width=760,height=150,padX=18,padY=18,span=Math.max(1,max-min);const points=rows.map((row,index)=>{const x=rows.length===1?width/2:padX+index*((width-padX*2)/(rows.length-1));const y=height-padY-((Number(row.rate_per_night_amount)-min)/span)*(height-padY*2);return{x,y,row}});const polyline=points.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');const circles=points.map(point=>`${formatDateTime(point.row.checked_at)} · ${formatVnd(point.row.rate_per_night_amount)}`).join('');svg.innerHTML=`${circles}`;$('historyMeta').textContent=`${rows.length} lần ghi nhận · mới nhất ${formatVnd(values.at(-1))}`;$('observedRange').textContent=min===max?formatVnd(min):`${formatVnd(min)} – ${formatVnd(max)}`}async function load(){try{const[dataResponse,historyResponse]=await Promise.all([fetch(`${DATA_URL}?v=${Date.now()}`,{cache:'no-store'}),fetch(`${HISTORY_URL}?v=${Date.now()}`,{cache:'no-store'})]);if(!dataResponse.ok)throw new Error(`hotels.json HTTP ${dataResponse.status}`);state.data=await dataResponse.json();state.history=historyResponse.ok?await historyResponse.json():[];$('stayDates').textContent=`${state.data.search?.check_in_date||'19/10/2026'} → ${state.data.search?.check_out_date||'20/10/2026'}`;$('occupancyNote').textContent=`${state.data.search?.adults_per_room||2} người lớn / phòng · ${state.data.search?.group_rooms_estimate||3} phòng ước tính`;$('sourceLabel').textContent=state.data.source||'Google Hotels';renderSummary();renderResults();renderHistoryControls();renderHistory()}catch(error){console.error(error);$('hotelResults').innerHTML=`
Không tải được dữ liệu khách sạn. Hãy chạy workflow cập nhật giá rồi thử lại.
${escapeHtml(error.message)}
`;$('updatedAgo').textContent='Lỗi dữ liệu'}}$('hotelSearch').addEventListener('input',event=>{state.query=event.target.value;renderResults()});$('sortHotels').addEventListener('change',event=>{state.sort=event.target.value;renderResults()});$('shortlistOnly').addEventListener('change',event=>{state.shortlistOnly=event.target.checked;renderResults()});$('historyHotel').addEventListener('change',event=>{state.selectedHotelId=event.target.value;localStorage.setItem(HOTEL_KEY,state.selectedHotelId);renderHistory()});$('saveTarget').addEventListener('click',()=>{const value=Number(String($('targetPrice').value).replace(/[^\d]/g,''));if(!Number.isFinite(value)||value<=0){$('targetAlert').className='alert danger';$('targetAlert').textContent='Nhập mục tiêu giá hợp lệ.';return}localStorage.setItem(TARGET_KEY,String(value));$('targetPrice').value=formatNumber(value);renderSummary()});const savedTarget=Number(localStorage.getItem(TARGET_KEY));if(Number.isFinite(savedTarget)&&savedTarget>0)$('targetPrice').value=formatNumber(savedTarget);load(); \ No newline at end of file +const DATA_URL = './data/hotels.json'; +const HISTORY_URL = './data/hotel-history.json'; +const TARGET_KEY = 'travel-hotel-target-price-v1'; +const HOTEL_KEY = 'travel-hotel-history-selected-v1'; +const FAVORITES_KEY = 'travel-hotel-favorites-v1'; +const $ = id => document.getElementById(id); + +function syncThemeUI() { + const dark = document.documentElement.dataset.theme === 'dark'; + const icon = dark ? '#i-sun' : '#i-moon'; + const label = dark ? 'Chuyển sang giao diện sáng' : 'Chuyển sang giao diện tối'; + document.querySelectorAll('[data-theme-toggle]').forEach(button => { + const use = button.querySelector('use'); + if (use) use.setAttribute('href', icon); + button.setAttribute('aria-label', label); + button.setAttribute('title', label); + }); +} + +function setTheme(value) { + document.documentElement.dataset.theme = value; + localStorage.setItem('travel-theme', value); + syncThemeUI(); +} + +document.documentElement.dataset.theme = localStorage.getItem('travel-theme') === 'dark' ? 'dark' : 'light'; +document.querySelectorAll('[data-theme-toggle]').forEach(button => button.addEventListener('click', () => setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'))); +syncThemeUI(); + +function loadFavorites() { + try { + const parsed = JSON.parse(localStorage.getItem(FAVORITES_KEY) || '[]'); + return new Set(Array.isArray(parsed) ? parsed : []); + } catch { return new Set(); } +} + +const state = { + data: null, + history: [], + query: '', + sort: 'recommended', + maxDistance: null, + area: '', + minPrice: null, + maxPrice: null, + minRating: null, + minReviews: null, + minStars: null, + amenity: '', + pricedOnly: false, + favoritesOnly: false, + favorites: loadFavorites(), + selectedHotelId: localStorage.getItem(HOTEL_KEY) || '' +}; + +function finiteNumber(value) { + if (value === null || value === undefined || value === '') return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} +function hotelPrice(item) { return finiteNumber(item?.rate_per_night?.amount); } +function hotelDistance(item) { return finiteNumber(item?.distance_from_anchor_km); } +function normalize(value = '') { return String(value).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim(); } +function parseMoney(value) { const digits = String(value || '').replace(/[^0-9]/g, ''); return digits ? Number(digits) : null; } +function formatVnd(value) { const n = finiteNumber(value); return n === null ? '—' : new Intl.NumberFormat('vi-VN',{style:'currency',currency:'VND',maximumFractionDigits:0}).format(n).replace('₫','đ'); } +function formatNumber(value) { const n = finiteNumber(value); return n === null ? '—' : new Intl.NumberFormat('vi-VN').format(n); } +function formatDistance(value) { const n = finiteNumber(value); return n === null ? 'Chưa rõ khoảng cách' : `${n.toLocaleString('vi-VN',{maximumFractionDigits:2})} km`; } +function formatDateTime(value) { if(!value)return'Chưa cập nhật';const d=new Date(value);if(Number.isNaN(d.getTime()))return value;return new Intl.DateTimeFormat('vi-VN',{dateStyle:'short',timeStyle:'short',timeZone:'Asia/Ho_Chi_Minh'}).format(d); } +function relativeTime(value) { if(!value)return'Chưa có';const then=new Date(value).getTime();if(!Number.isFinite(then))return'Chưa có';const m=Math.max(0,Math.round((Date.now()-then)/60000));if(m<60)return`${m} phút trước`;const h=Math.round(m/60);if(h<48)return`${h} giờ trước`;return`${Math.round(h/24)} ngày trước`; } +function escapeHtml(value='') { return String(value).replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); } +function propertyById(id) { return state.data?.properties?.find(item=>item.id===id)||null; } +function isFavorite(item) { return state.favorites.has(item.id); } +function persistFavorites() { localStorage.setItem(FAVORITES_KEY, JSON.stringify([...state.favorites])); } + +function priceDeltaText(delta) { + const n=finiteNumber(delta); + if(n===null)return{text:'Chưa có lịch sử giá',cls:'neutral'}; + if(n===0)return{text:'Không đổi',cls:'neutral'}; + if(n<0)return{text:`Giảm ${formatVnd(Math.abs(n))}`,cls:'down'}; + return{text:`Tăng ${formatVnd(n)}`,cls:'up'}; +} + +function compareNullableNumber(a,b,{desc=false,nullLast=true}={}) { + const left=finiteNumber(a),right=finiteNumber(b); + if(left===null&&right===null)return 0; + if(left===null)return nullLast?1:-1; + if(right===null)return nullLast?-1:1; + return desc?right-left:left-right; +} + +function filteredProperties() { + const q=normalize(state.query); + const list=[...(state.data?.properties||[])].filter(item=>{ + const price=hotelPrice(item),distance=hotelDistance(item),rating=finiteNumber(item.overall_rating),reviews=finiteNumber(item.reviews),stars=finiteNumber(item.stars); + if(state.favoritesOnly&&!isFavorite(item))return false; + if(state.pricedOnly&&price===null)return false; + if(state.maxDistance!==null&&(distance===null||distance>state.maxDistance))return false; + if(state.area&&item.area!==state.area)return false; + if(state.minPrice!==null&&(price===null||pricestate.maxPrice))return false; + if(state.minRating!==null&&(rating===null||ratingnormalize(a).includes(normalize(state.amenity)))))return false; + if(q){ + const haystack=normalize([item.name,item.address,item.area,item.description,item.hotel_class,...(item.amenities||[])].filter(Boolean).join(' ')); + if(!haystack.includes(q))return false; + } + return true; + }); + + list.sort((a,b)=>{ + if(state.sort==='distance')return compareNullableNumber(a.distance_from_anchor_km,b.distance_from_anchor_km)||compareNullableNumber(a.rate_per_night?.amount,b.rate_per_night?.amount); + if(state.sort==='price')return compareNullableNumber(a.rate_per_night?.amount,b.rate_per_night?.amount)||compareNullableNumber(a.distance_from_anchor_km,b.distance_from_anchor_km); + if(state.sort==='rating')return compareNullableNumber(a.overall_rating,b.overall_rating,{desc:true})||compareNullableNumber(a.reviews,b.reviews,{desc:true}); + if(state.sort==='reviews')return compareNullableNumber(a.reviews,b.reviews,{desc:true})||compareNullableNumber(a.overall_rating,b.overall_rating,{desc:true}); + if(state.sort==='stars')return compareNullableNumber(a.stars,b.stars,{desc:true})||compareNullableNumber(a.overall_rating,b.overall_rating,{desc:true}); + if(isFavorite(a)!==isFavorite(b))return isFavorite(a)?-1:1; + const pricedA=hotelPrice(a)!==null,pricedB=hotelPrice(b)!==null; + if(pricedA!==pricedB)return pricedA?-1:1; + return compareNullableNumber(a.distance_from_anchor_km,b.distance_from_anchor_km)||compareNullableNumber(a.overall_rating,b.overall_rating,{desc:true})||compareNullableNumber(a.rate_per_night?.amount,b.rate_per_night?.amount)||a.name.localeCompare(b.name); + }); + return list; +} + +function hotelCard(item) { + const amount=hotelPrice(item),hasPrice=amount!==null,favorite=isFavorite(item); + const delta=hasPrice?priceDeltaText(item.price_delta):{text:'Google Hotels chưa trả giá',cls:'neutral'}; + const sources=(item.price_sources||[]).slice(0,3).map(source=>`${escapeHtml(source.source)} · ${formatVnd(source.rate_per_night?.amount)}`).join(''); + const amenities=(item.amenities||[]).slice(0,4).map(value=>`${escapeHtml(value)}`).join(''); + const rating=item.overall_rating?`★ ${item.overall_rating}${item.reviews?` · ${formatNumber(item.reviews)} review`:''}`:'Chưa có rating'; + const distance=`${formatDistance(item.distance_from_anchor_km)}`; + const area=item.area?`${escapeHtml(item.area)}`:''; + const stars=item.stars?`${item.stars} sao`:''; + const website=item.website_url?`Website`:''; + const image=item.image_url?``:'
🏨
'; + const priceHtml=hasPrice?`${formatVnd(amount)}/ phòng / đêm`:'Chưa có giáđang chờ Google Hotels'; + const group=hasPrice?`
Ước tính 3 phòng cho 6 người${formatVnd(item.estimated_three_rooms_amount)}
`:'
Ước tính 3 phòng
'; + const historyButton=hasPrice?``:''; + return `
${image}${favorite?'Đang quan tâm':''}

${escapeHtml(item.name)}

${rating}${distance}${area}${stars}
${priceHtml}${delta.text}
${item.address?`

${escapeHtml(item.address)}

`:''}${item.description?`

${escapeHtml(item.description)}

`:''}${group}${amenities?`
${amenities}
`:''}
${sources||'Google Hotels'}
${historyButton}${website}
`; +} + +function renderResults() { + const list=filteredProperties(); + $('hotelResults').innerHTML=list.length?list.map(hotelCard).join(''):'
Không có khách sạn phù hợp với bộ lọc hiện tại.
Thử xóa bớt điều kiện giá, khoảng cách hoặc rating.
'; + const priced=list.filter(item=>hotelPrice(item)!==null).length; + $('resultCount').textContent=`${list.length} khách sạn · ${priced} có giá`; + document.querySelectorAll('[data-favorite]').forEach(button=>button.addEventListener('click',()=>{ + const id=button.dataset.favorite; + if(state.favorites.has(id))state.favorites.delete(id);else state.favorites.add(id); + persistFavorites();renderSummary();renderResults(); + })); + document.querySelectorAll('[data-history]').forEach(button=>button.addEventListener('click',()=>{ + state.selectedHotelId=button.dataset.history;localStorage.setItem(HOTEL_KEY,state.selectedHotelId);renderHistoryControls();renderHistory();$('historySection').scrollIntoView({behavior:'smooth',block:'start'}); + })); +} + +function renderSummary() { + const visible=filteredProperties(),priced=visible.filter(item=>hotelPrice(item)!==null); + const cheapest=[...priced].sort((a,b)=>hotelPrice(a)-hotelPrice(b))[0]||null; + $('cheapestPrice').textContent=cheapest?formatVnd(hotelPrice(cheapest)):'—'; + $('cheapestName').textContent=cheapest?cheapest.name:(visible.length?'Không có giá trong bộ lọc':'Không có kết quả'); + $('groupEstimate').textContent=cheapest?formatVnd(cheapest.estimated_three_rooms_amount):'—'; + $('trackedCount').textContent=String(state.favorites.size); + $('trackedMeta').textContent=`khách sạn quan tâm · ${priced.length} kết quả có giá`; + $('updatedAgo').textContent=relativeTime(state.data?.generated_at); + $('updatedAt').textContent=formatDateTime(state.data?.generated_at); + const target=finiteNumber(localStorage.getItem(TARGET_KEY)),alert=$('targetAlert'); + if(cheapest&&target!==null&&target>0){const current=hotelPrice(cheapest);if(current<=target){alert.className='alert success';alert.textContent=`Đã đạt mục tiêu: ${formatVnd(current)} ≤ ${formatVnd(target)}.`}else{alert.className='alert';alert.textContent=`Giá rẻ nhất đang cao hơn mục tiêu ${formatVnd(current-target)}.`}}else{alert.className='alert';alert.textContent='Đặt mục tiêu giá để so nhanh với kết quả đang lọc.'} +} + +function populateFilters() { + const properties=state.data?.properties||[]; + const areas=[...new Set(properties.map(item=>item.area).filter(Boolean))].sort((a,b)=>a.localeCompare(b)); + $('areaFilter').innerHTML=''+areas.map(area=>``).join(''); + const counts=new Map(); + properties.forEach(item=>(item.amenities||[]).forEach(a=>counts.set(a,(counts.get(a)||0)+1))); + const amenities=[...counts.entries()].sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0])).slice(0,30).map(([name])=>name); + $('amenityFilter').innerHTML=''+amenities.map(a=>``).join(''); + const s=state.data?.search||{}; + $('discoveryMeta').textContent=`${s.pages_fetched||1}/${s.max_pages||1} trang API · ${s.raw_property_count??properties.length} kết quả thô · ${properties.length} khách sạn duy nhất`; +} + +function historyRowsFor(item) { if(!item)return[];return state.history.filter(row=>row.trip_key===state.data?.search?.trip_key).filter(row=>row.property_id===item.id||row.name===item.name).filter(row=>finiteNumber(row.rate_per_night_amount)!==null).slice(-90); } +function renderHistoryControls() { + const properties=(state.data?.properties||[]).filter(item=>hotelPrice(item)!==null); + if(!properties.length){state.selectedHotelId='';$('historyHotel').innerHTML='';$('historyHotel').disabled=true;return} + $('historyHotel').disabled=false; + if(!state.selectedHotelId||!properties.some(item=>item.id===state.selectedHotelId))state.selectedHotelId=state.data?.cheapest_property_id||properties[0]?.id||''; + $('historyHotel').innerHTML=properties.map(item=>``).join(''); +} +function renderHistory() { + const item=propertyById(state.selectedHotelId),rows=historyRowsFor(item),svg=$('hotelPriceChart'); + if(!item||!rows.length){svg.innerHTML='';$('historyMeta').textContent=item?'Chưa có dữ liệu lịch sử cho khách sạn này.':'Chưa có khách sạn nào có giá để ghi lịch sử.';$('observedRange').textContent='—';return} + const values=rows.map(r=>Number(r.rate_per_night_amount)),min=Math.min(...values),max=Math.max(...values),width=760,height=150,padX=18,padY=18,span=Math.max(1,max-min); + const points=rows.map((row,index)=>{const x=rows.length===1?width/2:padX+index*((width-padX*2)/(rows.length-1));const y=height-padY-((Number(row.rate_per_night_amount)-min)/span)*(height-padY*2);return{x,y,row}}); + const polyline=points.map(p=>`${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '),circles=points.map(p=>`${formatDateTime(p.row.checked_at)} · ${formatVnd(p.row.rate_per_night_amount)}`).join(''); + svg.innerHTML=`${circles}`;$('historyMeta').textContent=`${rows.length} lần ghi nhận · mới nhất ${formatVnd(values.at(-1))}`;$('observedRange').textContent=min===max?formatVnd(min):`${formatVnd(min)} – ${formatVnd(max)}`; +} + +function applyFiltersFromUi() { + state.query=$('hotelSearch').value;state.sort=$('sortHotels').value;state.maxDistance=finiteNumber($('maxDistance').value);state.area=$('areaFilter').value;state.minPrice=parseMoney($('minPrice').value);state.maxPrice=parseMoney($('maxPrice').value);state.minRating=finiteNumber($('minRating').value);state.minReviews=finiteNumber($('minReviews').value);state.minStars=finiteNumber($('minStars').value);state.amenity=$('amenityFilter').value;state.pricedOnly=$('pricedOnly').checked;state.favoritesOnly=$('shortlistOnly').checked;renderSummary();renderResults(); +} + +function bindFilters() { + ['hotelSearch','minPrice','maxPrice'].forEach(id=>$(id).addEventListener('input',applyFiltersFromUi)); + ['sortHotels','maxDistance','areaFilter','minRating','minReviews','minStars','amenityFilter','pricedOnly','shortlistOnly'].forEach(id=>$(id).addEventListener('change',applyFiltersFromUi)); + $('clearHotelFilters').addEventListener('click',()=>{ + ['hotelSearch','minPrice','maxPrice'].forEach(id=>$(id).value=''); + ['maxDistance','areaFilter','minRating','minReviews','minStars','amenityFilter'].forEach(id=>$(id).value=''); + $('sortHotels').value='recommended';$('pricedOnly').checked=false;$('shortlistOnly').checked=false;applyFiltersFromUi(); + }); + ['minPrice','maxPrice'].forEach(id=>$(id).addEventListener('blur',()=>{const n=parseMoney($(id).value);$(id).value=n===null?'':formatNumber(n)})); +} + +async function load() { + try { + const[dataResponse,historyResponse]=await Promise.all([fetch(`${DATA_URL}?v=${Date.now()}`,{cache:'no-store'}),fetch(`${HISTORY_URL}?v=${Date.now()}`,{cache:'no-store'})]); + if(!dataResponse.ok)throw new Error(`hotels.json HTTP ${dataResponse.status}`); + state.data=await dataResponse.json();state.history=historyResponse.ok?await historyResponse.json():[]; + $('stayDates').textContent=`${state.data.search?.check_in_date||'2026-10-19'} → ${state.data.search?.check_out_date||'2026-10-20'}`; + $('occupancyNote').textContent=`${state.data.search?.adults_per_room||2} người lớn / phòng · ${state.data.search?.group_rooms_estimate||3} phòng ước tính`; + $('sourceLabel').textContent=state.data.source||'Google Hotels'; + populateFilters();renderSummary();renderResults();renderHistoryControls();renderHistory(); + } catch(error) { + console.error(error);$('hotelResults').innerHTML=`
Không tải được dữ liệu khách sạn.
${escapeHtml(error.message)}
`;$('updatedAgo').textContent='Lỗi dữ liệu'; + } +} + +bindFilters(); +$('historyHotel').addEventListener('change',event=>{state.selectedHotelId=event.target.value;localStorage.setItem(HOTEL_KEY,state.selectedHotelId);renderHistory()}); +$('saveTarget').addEventListener('click',()=>{const value=parseMoney($('targetPrice').value);if(value===null||value<=0){$('targetAlert').className='alert danger';$('targetAlert').textContent='Nhập mục tiêu giá hợp lệ.';return}localStorage.setItem(TARGET_KEY,String(value));$('targetPrice').value=formatNumber(value);renderSummary()}); +const savedTarget=finiteNumber(localStorage.getItem(TARGET_KEY));if(savedTarget!==null&&savedTarget>0)$('targetPrice').value=formatNumber(savedTarget); +load(); diff --git a/hotel-data-status.js b/hotel-data-status.js new file mode 100644 index 0000000..2fda602 --- /dev/null +++ b/hotel-data-status.js @@ -0,0 +1,93 @@ +(() => { + const DATA_URL = './data/hotels.json'; + const $ = id => document.getElementById(id); + + function ensureStatusBox() { + let box = $('hotelDataStatus'); + if (box) return box; + const searchCard = document.querySelector('.hotel-search-card'); + if (!searchCard) return null; + box = document.createElement('div'); + box.id = 'hotelDataStatus'; + box.className = 'hotel-note'; + box.style.marginTop = '-4px'; + box.style.marginBottom = '16px'; + searchCard.insertAdjacentElement('afterend', box); + return box; + } + + function applyPendingState(data) { + const box = ensureStatusBox(); + if (box) { + box.innerHTML = '⚠️ Chưa có snapshot live cho flow mới. Preview không dùng danh sách fallback giả. Sau khi merge, workflow Hotel sẽ tự discovery Google Hotels và ghi dữ liệu thật.'; + } + + if ($('discoveryMeta')) { + const maxPages = Number(data?.search?.max_pages || 0); + $('discoveryMeta').textContent = `0/${maxPages || '—'} trang API · chưa chạy live refresh`; + } + if ($('hotelResults')) { + $('hotelResults').innerHTML = '
Chưa có dữ liệu Google Hotels live.
Danh sách khách sạn sẽ xuất hiện sau lần workflow cập nhật đầu tiên.
'; + } + if ($('resultCount')) $('resultCount').textContent = '0 khách sạn · chờ refresh'; + if ($('cheapestPrice')) $('cheapestPrice').textContent = '—'; + if ($('cheapestName')) $('cheapestName').textContent = 'Chưa có snapshot live'; + if ($('groupEstimate')) $('groupEstimate').textContent = '—'; + if ($('updatedAgo')) $('updatedAgo').textContent = 'Chưa chạy'; + if ($('updatedAt')) $('updatedAt').textContent = 'Workflow sẽ chạy sau khi merge'; + } + + function applyLegacyWarning(data) { + const properties = Array.isArray(data?.properties) ? data.properties : []; + const legacy = properties.some(item => item?.catalogue_fallback || String(item?.id || '').startsWith('fallback:')); + if (!legacy && data?.search?.query === 'Shanghai hotels') return false; + const box = ensureStatusBox(); + if (box) { + box.innerHTML = '⚠️ Snapshot cũ. Dữ liệu này chưa được tạo từ discovery “Shanghai hotels”; không nên dùng để đánh giá giá hoặc khoảng cách.'; + } + return true; + } + + async function init() { + let data; + try { + const response = await fetch(`${DATA_URL}?guard=${Date.now()}`, { cache: 'no-store' }); + if (!response.ok) return; + data = await response.json(); + } catch { + return; + } + + const pending = data?.status === 'pending_refresh' || data?.snapshot_state === 'preview_pending_refresh'; + if (!pending) { + applyLegacyWarning(data); + return; + } + + const results = $('hotelResults'); + const meta = $('discoveryMeta'); + let done = false; + const applyOnceMainRenderFinishes = () => { + if (done) return; + if (meta && /Đang tải/i.test(meta.textContent || '')) return; + done = true; + observer.disconnect(); + applyPendingState(data); + }; + + const observer = new MutationObserver(applyOnceMainRenderFinishes); + if (results) observer.observe(results, { childList: true, subtree: true }); + if (meta) observer.observe(meta, { childList: true, characterData: true, subtree: true }); + + setTimeout(applyOnceMainRenderFinishes, 150); + setTimeout(() => { + if (!done) { + done = true; + observer.disconnect(); + applyPendingState(data); + } + }, 1500); + } + + init(); +})(); diff --git a/hotels.html b/hotels.html index 01d0021..4f5f815 100644 --- a/hotels.html +++ b/hotels.html @@ -4,7 +4,7 @@ - + @@ -26,13 +26,37 @@
-
Giá phòngThượng Hải · 19–20/10/2026
+
Giá phòngShanghai · 19–20/10/2026
TravelLog
-
Theo dõi giá khách sạn

Jinling East Road · Dashijie · The Bund

So sánh snapshot giá Google Hotels cho đúng đêm 19 → 20/10/2026. Giá chuẩn trên trang là một phòng cho 2 người lớn; hệ thống chỉ nhân ×3 để tham khảo nhanh cho nhóm 6 người.

Cập nhật giá
-
19/10/2026 → 20/10/20262 người lớn / phòng · 3 phòng ước tính
Google Hotels
-
Rẻ nhất trong shortlistĐang tải dữ liệu…
Ước tính 3 phòng6 người lớn · chỉ để so nhanh
Đang theo dõikhách sạn
Cập nhật gần nhấtChưa có thời gian cập nhật
-
Kết quả

Khách sạn quanh khu trung tâm

Đang tải…
Đang tải snapshot Google Hotels…
Giá hiển thị lấy từ Google Hotels qua SerpApi và có thể khác khi bấm sang OTA/website do thuế, phí, hạng phòng, chính sách hủy hoặc tồn phòng thay đổi. Luôn kiểm tra tổng tiền cuối cùng trước khi thanh toán.
+
Khám phá + theo dõi giá

Shanghai Hotels · 19 → 20/10/2026

Hệ thống lấy danh sách rộng từ Google Hotels cho Shanghai rồi mới lọc trên giao diện. Mốc khoảng cách là 531 Jinling East Road; bạn có thể tự chọn khoảng cách, giá, rating, số review, sao, khu vực, tiện nghi và đánh dấu khách sạn quan tâm.

Cập nhật giá
+ +
+
19/10/2026 → 20/10/20262 người lớn / phòng · 3 phòng ước tính
Google Hotels
+
+ + + + + + + + + + +
+
+ + + + Đang tải dữ liệu khám phá… +
+
+ +
Rẻ nhất đang hiển thịĐang tải dữ liệu…
Ước tính 3 phòng6 người lớn · chỉ để so nhanh
Đã đánh dấu0khách sạn quan tâm
Cập nhật gần nhấtChưa có thời gian cập nhật
+ +
Kết quả

Khách sạn Shanghai

Đang tải…
Đang tải snapshot Google Hotels…
Danh sách được lấy rộng từ Google Hotels rồi lọc ngay trên trình duyệt. Khoảng cách là đường chim bay từ 531 Jinling East Road, chỉ dùng để sàng lọc nhanh. Giá có thể khác khi bấm sang OTA/website do thuế, phí, hạng phòng, chính sách hủy hoặc tồn phòng thay đổi.
+
Lịch sử giá

Biến động giá đã ghi nhận

Giá một phòng / đêm

Mỗi điểm là một lần workflow cập nhật giá.

Khoảng giá đã ghi nhận

Chỉ so với đúng khách sạn và ngày ở hiện tại.

Đặt mục tiêu giá để so nhanh với lần cập nhật mới nhất.
@@ -40,5 +64,6 @@ + diff --git a/index.html b/index.html index 77637ea..4db3ea6 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,7 @@ Tổng quan Lịch trình Giá vé + Giá phòng Ngân sách Chuẩn bị Ghi chú @@ -39,6 +40,7 @@ + Giá phòng Xem giá vé @@ -61,7 +63,7 @@

Trung Quốc · 19–26/10/2026

TP.HCM → Thượng Hải → Bắc Kinh → TP.HCM
8 ngày 7 đêm · 6 người lớn + 1 em bé
- + @@ -127,7 +129,7 @@

Trung Quốc · 19–26/10/2026

Tổng quan Lịch trình Giá vé - Chuẩn bị + Giá phòng