1(function (window, document) { 2 "use strict"; 3 4 var endpoint = "/analytics/track"; 5 var storagePrefix = "ohdidi_analytics_"; 6 var eventQueue = []; 7 var batchTimer = null; 8 var batchInterval = 3000; 9 var maxQueueSize = 10; 10 var isSubmittingSearch = false; 11 var sentNoResults = {}; 12 13 var criticalEvents = { 14 booking_confirmed: true, 15 payment_success: true, 16 payment_failed: true, 17 lead_submitted: true, 18 guest_info_submitted: true 19 }; 20 21 function uuid() { 22 if (window.crypto && window.crypto.randomUUID) { 23 return window.crypto.randomUUID(); 24 } 25 return "id-" + Date.now() + "-" + Math.random().toString(16).slice(2); 26 } 27 28 function getStored(storage, key) { 29 try { 30 var value = storage.getItem(storagePrefix + key); 31 if (!value) { 32 value = uuid(); 33 storage.setItem(storagePrefix + key, value); 34 } 35 return value; 36 } catch (e) { 37 return uuid(); 38 } 39 } 40 41 function getDeviceType() { 42 var width = window.innerWidth || document.documentElement.clientWidth || 0; 43 if (width <= 767) return "mobile"; 44 if (width <= 1024) return "tablet"; 45 return "desktop"; 46 } 47 48 function cleanText(val) { 49 return String(val == null ? "" : val).replace(/\s+/g, " ").trim(); 50 } 51 52 function getAttribution() { 53 var params = new URLSearchParams(window.location.search || ""); 54 var source = params.get("utm_source") || params.get("source") || ""; 55 var medium = params.get("utm_medium") || ""; 56 var campaign = params.get("utm_campaign") || ""; 57 var gclid = params.get("gclid") || params.get("gbraid") || params.get("wbraid"); 58 59 if (!source) { 60 if (gclid) { 61 source = "google"; 62 medium = medium || "cpc"; 63 } else if (document.referrer) { 64 try { 65 var refHost = new URL(document.referrer).hostname.replace(/^www\./, ""); 66 if (refHost && refHost !== window.location.hostname.replace(/^www\./, "")) { 67 source = refHost.slice(0, 120); 68 medium = medium || "referral"; 69 } 70 } catch (e) {} 71 } 72 } 73 74 return { 75 source: cleanText(source).toLowerCase() || "direct", 76 medium: cleanText(medium).toLowerCase() || (source ? "referral" : "none"), 77 campaign: cleanText(campaign) 78 }; 79 } 80 81 function pageType() { 82 var path = window.location.pathname; 83 if (path.indexOf("/blog") === 0) return "blog"; 84 if (path.indexOf("/tim-kiem") === 0) return "search"; 85 if (document.body.classList.contains("home-detail") || document.querySelector("[data-analytics-property-detail]")) return "listing_detail"; 86 if (document.querySelector(".box-form__booking") || document.querySelector(".payment__page")) return "booking"; 87 if (document.querySelector(".home-item")) return "listing"; 88 return "page"; 89 } 90 91 function sendGa4(eventName, params) { 92 if (typeof window.gtag === "function") { 93 try { 94 window.gtag("event", eventName, params); 95 } catch (e) {} 96 } 97 } 98
99 // Tá»± Äá»ng Äá»ng bá» các sá»± kiá»n hà nh vi chuẩn sang Google Analytics 4 (Dual-Tracking Best Practice) 100 function syncToGa4(eventName, payload) { 101 if (typeof window.gtag !== "function") return; 102 try { 103 payload = payload || {}; 104 switch (eventName) { 105 case "search_submitted": 106 sendGa4("search", { 107 search_term: payload.search_keyword || payload.province_code || "tat_ca" 108 }); 109 break; 110 case "search_results_viewed": 111 sendGa4("view_search_results", { 112 search_term: payload.search_keyword || payload.province_code || "tat_ca" 113 }); 114 break; 115 case "listing_detail_viewed": 116 sendGa4("view_item", { 117 currency: "VND", 118 value: payload.price || undefined, 119 items: [{ 120 item_id: String(payload.property_id || ""), 121 item_name: (payload.metadata && payload.metadata.title) || "", 122 item_category: (payload.metadata && payload.metadata.rental_type) || "", 123 item_location_id: payload.province_code || undefined, 124 price: payload.price || undefined, 125 quantity: 1 126 }] 127 }); 128 break; 129 case "listing_clicked": 130 sendGa4("select_item", { 131 items: [{ 132 item_id: String(payload.property_id || ""), 133 item_name: (payload.metadata && payload.metadata.title) || "", 134 item_category: (payload.metadata && payload.metadata.rental_type) || "", 135 item_location_id: payload.province_code || undefined, 136 price: payload.price || undefined, 137 quantity: 1 138 }] 139 }); 140 break; 141 case "date_selected": 142 sendGa4("select_content", { 143 content_type: "date_selection", 144 item_id: "check_available_dates" 145 }); 146 break; 147 case "booking_started": 148 sendGa4("begin_checkout", { 149 currency: "VND", 150 value: payload.price || undefined, 151 items: payload.property_id ? [{ 152 item_id: String(payload.property_id), 153 quantity: 1 154 }] : [] 155 }); 156 break; 157 case "guest_info_submitted": 158 sendGa4("add_shipping_info", { 159 currency: "VND", 160 value: payload.price || undefined 161 }); 162 break; 163 case "payment_started": 164 sendGa4("add_payment_info", { 165 currency: "VND", 166 payment_type: payload.payment_method || "online" 167 }); 168 break; 169 case "booking_confirmed": 170 sendGa4("purchase", { 171 transaction_id: String(payload.booking_id || ("BK-" + Date.now())), 172 value: parseFloat(payload.value || payload.price || 0), 173 currency: "VND", 174 items: payload.property_id ? [{ 175 item_id: String(payload.property_id), 176 quantity: 1 177 }] : [] 178 }); 179 break; 180 case "cta_clicked": 181 var mKey = String(payload.metric_key || "").toLowerCase(); 182 if (mKey.indexOf("hotline") !== -1 || mKey.indexOf("phone") !== -1) { 183 sendGa4("generate_lead", { lead_source: "hotline", cta: mKey }); 184 } else if (mKey.indexOf("zalo") !== -1) { 185 sendGa4("generate_lead", { lead_source: "zalo", cta: mKey }); 186 } else { 187 sendGa4("select_content", { content_type: "cta", item_id: mKey }); 188 } 189 break; 190 default: 191 break; 192 } 193 } catch (e) {} 194 } 195 196 function sendTikTok(eventName, params) { 197 if (window.ttq && typeof window.ttq.track === "function") { 198 try { 199 window.ttq.track(eventName, params); 200 } catch (e) {} 201 } 202 } 203
204 // Tá»± Äá»ng Äá»ng bá» các sá»± kiá»n chuẩn sang TikTok Pixel (Chuẩn Video Shopping Ads - VSA & Catalog) 205 function syncToTikTok(eventName, payload) { 206 if (!window.ttq || typeof window.ttq.track !== "function") return; 207 try { 208 payload = payload || {}; 209 var propId = payload.property_id ? String(payload.property_id) : ""; 210 var price = payload.price ? parseFloat(payload.price) : 0; 211 var title = (payload.metadata && payload.metadata.title) || ""; 212 var category = (payload.metadata && payload.metadata.rental_type) || "homestay"; 213 214 switch (eventName) { 215 case "listing_detail_viewed": 216 if (propId) { 217 sendTikTok("ViewContent", { 218 content_id: propId, 219 content_ids: [propId], 220 content_type: "product", 221 content_name: title, 222 content_category: category, 223 price: price || undefined, 224 value: price || undefined, 225 currency: "VND", 226 contents: [{ 227 content_id: propId, 228 content_type: "product", 229 content_name: title, 230 price: price || undefined, 231 quantity: 1 232 }] 233 }); 234 } 235 break; 236 237 case "booking_started": 238 if (propId) { 239 sendTikTok("InitiateCheckout", { 240 content_id: propId, 241 content_ids: [propId], 242 content_type: "product", 243 content_name: title, 244 price: price || undefined, 245 value: price || undefined, 246 currency: "VND", 247 quantity: 1, 248 contents: [{ 249 content_id: propId, 250 content_type: "product", 251 content_name: title, 252 price: price || undefined, 253 quantity: 1 254 }] 255 }); 256 } 257 break; 258 259 case "booking_confirmed": 260 var finalVal = parseFloat(payload.value || payload.price || 0); 261 sendTikTok("CompletePayment", { 262 content_id: propId || undefined, 263 content_ids: propId ? [propId] : [], 264 content_type: "product", 265 value: finalVal || undefined, 266 currency: "VND", 267 quantity: 1, 268 contents: propId ? [{ 269 content_id: propId, 270 content_type: "product", 271 price: finalVal || undefined, 272 quantity: 1 273 }] : [] 274 }); 275 break; 276 277 case "search_submitted": 278 case "search_results_viewed": 279 var searchKw = payload.search_keyword || payload.province_code || "tat_ca"; 280 sendTikTok("Search", { 281 content_id: searchKw, 282 content_ids: [searchKw], 283 content_type: "product_group", 284 query: searchKw, 285 search_string: searchKw, 286 contents: [{ 287 content_id: searchKw, 288 content_type: "product_group", 289 content_name: searchKw 290 }] 291 }); 292 break; 293 294 case "cta_clicked": 295 var mKey = String(payload.metric_key || "").toLowerCase(); 296 if (mKey.indexOf("hotline") !== -1 || mKey.indexOf("phone") !== -1 || mKey.indexOf("zalo") !== -1) { 297 sendTikTok("Contact", { 298 content_id: propId || "hotline_zalo", 299 content_ids: [propId || "hotline_zalo"], 300 content_name: mKey 301 }); 302 } 303 break; 304 305 default: 306 break; 307 } 308 } catch (e) {} 309 } 310 311 function basePayload(extra) { 312 var attr = getAttribution(); 313 return Object.assign({ 314 session_id: getStored(window.sessionStorage, "session_id"), 315 anonymous_id: getStored(window.localStorage, "anonymous_id"), 316 user_id: window.currentUserId || null, 317 platform: "web", 318 device_type: getDeviceType(), 319 source: attr.source, 320 medium: attr.medium, 321 campaign: attr.campaign, 322 page_type: pageType(), 323 page_url: window.location.pathname + window.location.search 324 }, extra || {}); 325 } 326 327 function post(payload, useBeacon) { 328 var body = JSON.stringify(payload); 329 if (navigator.sendBeacon && (useBeacon || body.length < 64000)) { 330 var blob = new Blob([body], { type: "application/json" }); 331 if (navigator.sendBeacon(endpoint, blob)) return; 332 } 333 fetch(endpoint, { 334 method: "POST", 335 headers: { "Content-Type": "application/json" }, 336 body: body, 337 credentials: "same-origin", 338 keepalive: true 339 }).catch(function () {}); 340 } 341 342 function flushQueue(useBeacon) { 343 if (batchTimer) { 344 clearTimeout(batchTimer); 345 batchTimer = null; 346 } 347 if (!eventQueue.length) return; 348 var events = eventQueue.splice(0, eventQueue.length); 349 post({ events: events }, !!useBeacon); 350 } 351 352 function scheduleFlush() { 353 if (batchTimer) return; 354 batchTimer = setTimeout(function () { 355 flushQueue(false); 356 }, batchInterval); 357 } 358 359 function track(eventName, payload) { 360 eventQueue.push(basePayload(Object.assign({ event_name: eventName }, payload || {}))); 361
362 // Äá»ng bá» tức thá»i sang GA4 theo chuẩn E-Commerce 363 syncToGa4(eventName, payload); 364 365 // Äá»ng bá» tức thá»i sang TikTok Pixel (chuẩn VSA & Catalog) 366 syncToTikTok(eventName, payload); 367 368 if (criticalEvents[eventName] || eventQueue.length >= maxQueueSize) { 369 flushQueue(false); 370 return; 371 } 372 scheduleFlush(); 373 } 374 375 function propertyPayload(el) { 376 var rawPrice = el.getAttribute("data-analytics-price"); 377 var price = rawPrice ? parseFloat(rawPrice) : null; 378 return { 379 property_id: parseInt(el.getAttribute("data-analytics-property-id") || "0", 10) || null, 380 province_code: el.getAttribute("data-analytics-province") || null, 381 price: price && !isNaN(price) ? price : null, 382 metadata: { 383 title: el.getAttribute("data-analytics-title") || "", 384 rental_type: el.getAttribute("data-analytics-property-type") || "" 385 } 386 }; 387 } 388 389 function setupSearchSubmit() { 390 document.addEventListener("submit", function (event) { 391 var form = event.target; 392 if (!form || !form.querySelector || isSubmittingSearch) return; 393 394 var kwInput = form.querySelector('input[name="keyword"], input[name="location"], #keyword, .js-search-keyword'); 395 var keyword = kwInput ? cleanText(kwInput.value) : ""; 396 var provInput = form.querySelector('input[name="province"], select[name="province"]'); 397 var province = provInput ? cleanText(provInput.value) : ""; 398 399 isSubmittingSearch = true; 400 track("search_submitted", { 401 search_keyword: keyword, 402 province_code: province, 403 metadata: { form_id: form.id || form.className || "" } 404 }); 405 sendGa4("search", { 406 search_term: keyword || province || "tat_ca", 407 search_location: province || undefined 408 }); 409 setTimeout(function () { isSubmittingSearch = false; }, 1500); 410 }, true); 411 } 412 413 function setupClicks() { 414 document.addEventListener("click", function (event) { 415 var listing = event.target.closest("[data-analytics-property-id]"); 416 if (listing && event.target.closest("a")) { 417 var clickedProp = propertyPayload(listing); 418 track("listing_clicked", clickedProp); 419 sendGa4("select_item", { 420 item_list_name: pageType(), 421 items: [{ 422 item_id: String(clickedProp.property_id || ""), 423 item_name: clickedProp.metadata.title, 424 item_category: clickedProp.metadata.rental_type, 425 item_location_id: clickedProp.province_code || undefined 426 }] 427 }); 428 } 429 430 var detailEl = document.querySelector("[data-analytics-property-detail]"); 431 var propData = detailEl ? propertyPayload(detailEl) : {}; 432 433 // 1. Bắt tất cả các loại nút CTA (Hotline, Zalo, Xem phòng...) 434 var cta = event.target.closest("[data-analytics-cta], .support-item.hotline, .support-item.zalo, .btn-zalo, a[href^='tel:'], a[href*='zalo.me']"); 435 if (cta) { 436 var ctaName = cleanText(cta.getAttribute("data-analytics-cta") || cta.textContent || cta.className || "cta"); 437 if (cta.closest(".support-item.hotline, a[href^='tel:']")) ctaName = "hotline"; 438 if (cta.closest(".support-item.zalo, .btn-zalo, a[href*='zalo.me']")) ctaName = "chat_zalo"; 439 440 track("cta_clicked", Object.assign({ 441 metric_key: ctaName.slice(0, 120), 442 entity_type: "cta", 443 entity_id: ctaName.slice(0, 120) 444 }, propData)); 445 } 446 447 // 2. Bắt tất cả các loại nút Kiá»m tra phòng trá»ng (Desktop, Mobile sheet, Sticky header) 448 var checkAvailBtn = event.target.closest("#btn-check-avail, .js-header-btn-check-avail, .btn-check-avail-header, .btn-check-avail, .js-sheet-apply-btn, [data-check-avail]"); 449 if (checkAvailBtn) { 450 track("date_selected", Object.assign({ 451 metric_key: "booking_date_selected" 452 }, propData)); 453 } 454 455 // 3. Bắt tất cả các loại nút Äặt phòng (Äặt ngay, Äặt nguyên cÄn, Äặt phòng lẻ, Sticky mobile) 456 var bookBtn = event.target.closest(".btn-booking, .bookNow, .btn-booking-room, .btn-booking-wh
456ole, .btn-booking-villa, .btn-booking-pro, #btn-booking, [data-booking-btn]"); 457 if (bookBtn) { 458 track("booking_started", Object.assign({ 459 metric_key: "booking_started" 460 }, propData)); 461 462 sendGa4("begin_checkout", { 463 currency: "VND", 464 value: propData.price || undefined, 465 items: propData.property_id ? [{ 466 item_id: String(propData.property_id || ""), 467 item_name: propData.metadata && propData.metadata.title, 468 item_category: propData.metadata && propData.metadata.rental_type, 469 item_location_id: propData.province_code || undefined, 470 price: propData.price || undefined, 471 quantity: 1 472 }] : [] 473 }); 474 } 475 }, { passive: true }); 476 } 477 478 function setupSearchNoResults() { 479 if (window.jQuery) { 480 window.jQuery(document).on("hostingSearchManager:searchCompleted.analytics", function (_, totalCount) { 481 if (Number(totalCount) === 0) { 482 var params = new URLSearchParams(window.location.search || ""); 483 var key = window.location.pathname + "|" + (params.get("keyword") || ""); 484 if (sentNoResults[key]) return; 485 sentNoResults[key] = true; 486 track("search_no_results", { 487 search_keyword: cleanText(params.get("keyword") || params.get("location")), 488 province_code: cleanText(params.get("province")), 489 entity_type: "supply_gap" 490 }); 491 } 492 }); 493 } 494 } 495 496 function inferInitialEvents() { 497 track("page_view"); 498 499 var detail = document.querySelector("[data-analytics-property-detail]"); 500 if (detail) { 501 var propData = propertyPayload(detail); 502 track("listing_detail_viewed", propData); 503 sendGa4("view_item", { 504 currency: "VND", 505 value: propData.price || undefined, 506 items: [{ 507 item_id: String(propData.property_id || ""), 508 item_name: propData.metadata.title, 509 item_category: propData.metadata.rental_type, 510 item_location_id: propData.province_code || undefined, 511 price: propData.price || undefined, 512 quantity: 1 513 }] 514 }); 515 } 516 517 if (window.location.pathname.indexOf("/tim-kiem") === 0 || document.querySelector("[data-analytics-page-kind]")) { 518 var params = new URLSearchParams(window.location.search || ""); 519 var kw = cleanText(params.get("keyword") || params.get("location")); 520 var prov = cleanText(params.get("province")); 521 track("search_results_viewed", { 522 search_keyword: kw, 523 province_code: prov, 524 metadata: { checkin: params.get("checkin"), checkout: params.get("checkout"), guests: params.get("person") } 525 }); 526 if (kw || prov) { 527 sendGa4("view_search_results", { 528 search_term: kw || prov, 529 search_location: prov || undefined 530 }); 531 } 532 } 533 } 534 535 window.OhdidiAnalytics = { track: track }; 536 537 window.addEventListener("pagehide", function () { flushQueue(true); }); 538 document.addEventListener("visibilitychange", function () { 539 if (document.visibilityState === "hidden") flushQueue(true); 540 }); 541 542 document.addEventListener("DOMContentLoaded", function () { 543 inferInitialEvents(); 544 setupSearchSubmit(); 545 setupClicks(); 546 setupSearchNoResults(); 547 }); 548})(window, document);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.