1/** 2 * review-system.js â Marquee "Phản Há»i Từ Ngưá»i Dùng & Há»c Viên" (landing page, guest-visible) 3 * v3.0 â [Äánh Giá Cá»ng Äá»ng 2026-08-07] Thu gá»n thà nh module CHá» HIá»N THá». 4 * 5 * Trưá»c Äây file nà y còn kiêm cả modal viết/sá»a/xóa Äánh giá â toà n bá» phần Äó Äã chuyá»n hẳn 6 * sang trang danh-gia.html + js/danh-gia.js (bảng Äầy Äá»§: phân trang tháºt, Admin trả lá»i, 7 * sticker, khung avatar, thưá»ng 1.500 Linh Thạch). Giữ 1 nguá»n ghi DUY NHẤT (danh-gia.js) tránh 8 * 2 code path cùng ghi `reviews` có thá» lá»ch nhau. 9 * 10 * Architecture: 11 * - `feedbacks` collection: admin curated (hall-of-fame) â [01/10/2026] THÃI hiá»n thá» á» Äây theo yêu 12 * cầu Founder (chá» Äánh giá tháºt). hall-of-fame.html vẫn Äá»c collection nà y như cÅ©. 13 * - `reviews` collection: user-generated, quản lý bá»i danh-gia.js â marquee chá» Äá»C. 14 * - Thá»ng kê (Äiá»m trung bình / tá»ng Äánh giá) Äá»c từ `server/review_stats` (duy trì bá»i CF 15 * trigger syncReviewStats) â KHÃNG còn tÃnh trên tá»i Äa 20 bản ghi tải ÄÆ°á»£c như bản cÅ© (bug 16 * Äã sá»a, xem CLAUDE_debt.md). 17 * - Drag/swipe + auto-scroll marquee â giữ nguyên. 18 * - LocalStorage cache 15 min cho danh sách hiá»n thá» (không cache sá» liá»u thá»ng kê). 19 */ 20 21import { 22 db, auth, collection, getDocs, query, orderBy, limit, 23 doc, getDoc, onAuthStateChanged, 24} from './firebase-config.js'; 25 26// âââ CONFIG âââ 27const CACHE_KEY = 'ytt_reviews_v4'; // [01/10/2026] Äá»i khoá: bá» Äá»m v3 còn chứa thẻ mẫu `feedbacks` 28const CACHE_TTL = 15 * 60 * 1000; 29const MAX_DISPLAY = 20; // chá» giá»i hạn ná»i dung HIá»N THá» trong marquee, KHÃNG còn dùng Äá» tÃnh stats 30 31// âââ STATE âââ 32let currentUser = null; 33let allCards = []; 34let userReview = null; // { rating, comment... } â chá» dùng Äá» hiá»n trạng thái á» dashboard widget 35 36// âââ SANITIZER âââ 37const sanitize = (str) => window.DOMPurify 38 ? window.DOMPurify.sanitize(str || '') 39 : (str || '').replace(/</g, '<').replace(/>/g, '>'); 40 41// âââ HELPERS âââ 42function timeAgo(date) { 43 if (!date) return ''; 44 const d = date instanceof Date ? date : (date.toDate ? date.toDate() : new Date(date)); 45 if (isNaN(d.getTime())) return ''; 46 const now = new Date(); 47 const diff = Math.floor((now - d) / 1000); 48 if (diff < 60) return 'Vừa xong'; 49 if (diff < 3600) return `${Math.floor(diff/60)} phút trưá»c`; 50 if (diff < 86400) return `${Math.floor(diff/3600)} giá» trưá»c`; 51 if (diff < 2592000) return `${Math.floor(diff/86400)} ngà y trưá»c`; 52 return `Tháng ${String(d.getMonth()+1).padStart(2,'0')}, ${d.getFullYear()}`; 53} 54
55function renderStars(rating) { 56 return 'â '.repeat(Math.min(rating, 5)) + 'â'.repeat(Math.max(5 - rating, 0)); 57} 58 59// âââ THá»NG Kà THẬT â Äá»c server/review_stats (duy trì bá»i CF syncReviewStats) âââ 60async function loadRealStats() { 61 const statsEl = document.getElementById('reviewStats'); 62 if (!statsEl) return; 63 try { 64 const snap = await getDoc(doc(db, 'server', 'review_stats')); 65 const d = snap.exists() ? snap.data() : {}; 66 // [Fix 2026-08-08] Cùng clamp phòng thá»§ vá»i js/danh-gia.js â xem comment á» Äó (bug bootstrap 67 // trigger syncReviewStats gây count/sumRating âm tạm thá»i trên Firestore tháºt). 68 const count = Math.max(0, d.count || 0); 69 const sum = Math.max(0, d.sumRating || 0); 70 const avg = count > 0 ? (sum / count) : 5; 71 statsEl.innerHTML = count > 0 72 ? `<span class="rv-stat-avg">${avg.toFixed(1)}</span> 73 <span class="rv-stat-stars">${renderStars(Math.round(avg))}</span> 74 <span class="rv-stat-count">từ ${count} Äánh giá</span>` 75 : ''; 76 // [01/10/2026] à "Äánh giá" trong dải sá» liá»u Äầu trang (index.html #landing-stat-rating) â 77 // cùng má»t nguá»n tháºt; chưa có Äánh giá nà o thì giữ ẩn, KHÃNG bá»a sá». 78 const heroBox = document.getElementById('landing-stat-rating'); 79 if (heroBox && count > 0) { 80 document.getElementById('landing-stat-rating-val').textContent = avg.toFixed(1) + 'â '; 81 document.getElementById('landing-stat-rating-lbl').textContent = count + ' Äánh giá tháºt'; 82 heroBox.hidden = false; 83 } 84 } catch (e) { 85 console.warn('[ReviewSystem] Không Äá»c ÄÆ°á»£c review_stats:', e.message); 86 } 87} 88 89// âââ LOAD DATA (chá» Äá» hiá»n thá» marquee â KHÃNG dùng cho thá»ng kê) âââ 90async function loadReviews() { 91 try { 92 const cached = localStorage.getItem(CACHE_KEY); 93 if (cached) { 94 const { data, ts } = JSON.parse(cached); 95 if (Date.now() - ts < CACHE_TTL && data.length > 0) { 96 allCards = data; 97 renderMarquee(); 98 return; 99 } 100 } 101 } catch(e) { /* cache miss */ } 102 103 // [01/10/2026] Founder: CHá» hiá»n Äánh giá THẬT cá»§a ngưá»i dùng (collection `reviews`). 104 // `feedbacks` là 8 thẻ mẫu admin nháºp tay ("Nguyen Van A", "Tran Thi B"â¦) â trông giả, gây mất uy tÃn 105 // â thôi Äá»c hẳn (dữ liá»u vẫn còn trong Firestore, không xoá). Có ngưá»i viết Äánh giá má»i á» 106 // danh-gia.html là tá»± hiá»n á» Äây (bá» Äá»m 15 phút). 107 let reviews = []; 108 try { 109 const rvSnap = await getDocs(query( 110 collection(db, 'reviews'), orderBy('updatedAt', 'desc'), limit(MAX_DISPLAY) 111 )); 112 reviews = rvSnap.docs.map(d => { 113 const data = d.data(); 114 const createdAt = data.createdAt?.toDate ? data.createdAt.toDate().toISOString() : (data.createdAt || ''); 115 return { ...data, uid: d.id, source: 'review', createdAt }; 116 }); 117 } catch(e) { console.warn('[ReviewSystem] Reviews load error:', e); } 118 119 allCards = reviews; 120 121 try { 122 if (allCards.length > 0) { 123 localStorage.setItem(CACHE_KEY, JSON.stringify({ data: allCards, ts: Date.now() })); 124 } 125 } catch(e) { /* quota exceeded */ } 126 127 renderMarquee(); 128} 129 130// âââ RENDER MARQUEE (read-only â má»i thao tác viết/sá»a/xóa á» danh-gia.html) âââ 131function renderMarquee() { 132 const container = document.getElementById('reviewMarqueeTrack'); 133 if (!container) return; 134 135 if (allCards.length === 0) { 136 container.innerHTML = '<div style="color:rgba(255,255,255,0.3);padding:40px;text-align:center;">Chưa có Äánh giá nà o. Hãy là ngưá»i Äầu tiên!</div>'; 137 return; 138 } 139 140 const cardsHTML = allCards.map(r => buildCardHTML(r)).join(''); 141 // [01/10/2026] Ãt thẻ (hiá»n 2 Äánh giá tháºt) mà vẫn nhân Äôi + tá»± trượt thì cùng má»t ngưá»i hiá»n 2 142 // lần và vòng lặp không khép ÄÆ°á»£c (ná»a track hẹp hÆ¡n khung). Từ 4 thẻ trá» xuá»ng â ÄỨNG YÃN (xếp lưá»i, 143 // Äiá»n thoại tá»± xuá»ng hà ng â CSS .rv-static); nhiá»u hÆ¡n mà vẫn vừa khung cÅ©ng Äứng yên; chá» khi 144 // TRÃN khung má»i nhân Äôi + tá»± trượt như cÅ©. 145 const wrapper = container.closest('.rv-marquee-wrapper'); 146 if (wrapper) wrapper.classList.remove('rv-static'); 147 container.innerHTML = cardsHTML; 148 const vuaKhung = !wrapper || allCards.length <= 4 || container.scrollWidth <= wrapper.clientWidth + 1; 149 if (wrapper) wrapper.classList.toggle('rv-static', vuaKhung); 150 if (vuaKhung) return; 151 container.innerHTML = cardsHTML + cardsHTML; // duplicate cho vòng lặp vô hạn mượt 152 153 initDragScroll(); 154} 155 156function buildCardHTML(r) { 157 const safeName = sanitize(r.displayName || r.name || 'Ẩn danh'); 158 const safeComment = sanitize(r.comment || r.content || ''); 159 const safeAvatar = sanitize(r.photoURL || r.avatar || `https://ui-avatars.c
159om/api/?name=${encodeURIComponent(safeName)}&background=random&size=128`); 160 const safeLevel = sanitize(r.level || r.course || ''); 161 const safeJob = sanitize(r.job || ''); 162 const safeReply = sanitize((r.adminReply && r.adminReply.text) || ''); 163 // [01/10/2026] Ảnh Google lá»i â chữ cái Äầu cá»§a TÃN THẬT (trưá»c Äây gõ cứng "U" cho má»i ngưá»i). 164 // referrerpolicy=no-referrer: ảnh lh3.googleusercontent hay từ chá»i khi có Referer lạ. 165 // encodeURIComponent KHÃNG mã hoá dấu ' â tá»± thay, vì chuá»i nằm trong 'â¦' cá»§a thuá»c tÃnh onerror. 166 const fallbackAvatar = `https://ui-avatars.com/api/?name=${encodeURIComponent(r.displayName || r.name || 'U').replace(/'/g, '%27')}&background=3a2a12&color=ffd700&size=128`; 167 const rating = r.rating || 5; 168 const type = r.type === 'student' ? 'Há»c Viên' : 'Ngưá»i Dùng'; 169 const typeClass = r.type === 'student' ? 'rv-type-student' : 'rv-type-user'; 170 const time = timeAgo(r.createdAt || r.updatedAt); 171 172 const hasLink = r.postLink && r.postLink !== '#' && r.postLink !== ''; 173 const platformIcon = r.platform ? getPlatformIcon(r.platform) : ''; 174 const linkAttr = hasLink ? `data-link="${sanitize(r.postLink)}"` : ''; 175 176 const thumbHTML = r.postImage 177 ? `<div class="rv-thumb"><img src="${sanitize(r.postImage)}" alt="" loading="lazy"></div>` 178 : (platformIcon ? `<div class="rv-platform-icon">${platformIcon}</div>` : ''); 179 180 return ` 181 <div class="rv-card" ${linkAttr}> 182 <div class="rv-card-header"> 183 <img src="${safeAvatar}" alt="${safeName}" class="rv-avatar" referrerpolicy="no-referrer" 184 onerror="this.onerror=null;this.src='${fallbackAvatar}'"> 185 <div class="rv-user-info"> 186 <div class="rv-name">${safeName}</div> 187 <div class="rv-meta">${time}</div> 188 </div> 189 ${thumbHTML} 190 </div> 191 <div class="rv-stars">${renderStars(rating)}</div> 192 <p class="rv-comment">${safeComment}</p> 193 ${safeReply ? `<p class="rv-reply"><i class="fas fa-reply"></i> <b>Yếu TiếngTrung:</b> ${safeReply}</p>` : ''} 194 <div class="rv-footer"> 195 <span class="rv-type ${typeClass}">${type}</span> 196 ${safeLevel ? `<span class="rv-level">${safeLevel}</span>` : ''} 197 ${safeJob ? `<span class="rv-job">${safeJob}</span>` : ''} 198 </div> 199 </div>`; 200} 201 202function getPlatformIcon(platform) { 203 const icons = { 204 facebook: '<i class="fab fa-facebook" style="color:#1877f2"></i>', 205 threads: '<i class="fab fa-threads" style="color:#fff"></i>', 206 instagram: '<i class="fab fa-instagram" style="color:#e4405f"></i>', 207 zalo: '<i class="fas fa-comment-dots" style="color:#0068ff"></i>', 208 google: '<i class="fab fa-google" style="color:#ea4335"></i>' 209 }; 210 return icons[platform] || ''; 211} 212 213// âââ MARQUEE ENGINE â pure JS rAF, no CSS animation âââ 214function initDragScroll() { 215 const wrapper = document.querySelector('.rv-marquee-wrapper'); 216 const track = document.getElementById('reviewMarqueeTrack'); 217 if (!wrapper || !track) return; 218 219 const AUTO_SPEED = 0.6; 220 221 let isDragging = false; 222 let dragStartX = 0; 223 let dragStartScroll = 0; 224 let wasDragged = false; 225 let velocity = 0; 226 let lastX = 0; 227 let lastTime = 0; 228 let isCoasting = false; 229 230 function getHalfWidth() { 231 return track.scrollWidth / 2; 232 } 233 234 function autoScroll() { 235 if (isDragging || isCoasting) { 236 requestAnimationFrame(autoScroll); 237 return; 238 } 239 wrapper.scrollLeft += AUTO_SPEED; 240 if (wrapper.scrollLeft >= getHalfWidth()) { 241 wrapper.scrollLeft -= getHalfWidth(); 242 } 243 requestAnimationFrame(autoScroll); 244 } 245 246 function coast() { 247 isCoasting = true; 248 if (Math.abs(velocity) < 0.3) { isCoasting = false; return; } 249 velocity *= 0.94; 250 wrapper.scrollLeft -= velocity; 251 const half = getHalfWidth(); 252 if (wrapper.scrollLeft >= half) wrapper.scrollLeft -= half; 253 if (wrapper.scrollLeft < 0) wrapper.scrollLeft += half; 254 requestAnimationFrame(coast); 255 } 256 257 wrapper.addEventListener('mousedown', (e) => { 258 isDragging = true; wasDragged = false; isCoasting = false;
259 dragStartX = e.pageX; dragStartScroll = wrapper.scrollLeft; 260 lastX = e.pageX; lastTime = Date.now(); velocity = 0; 261 wrapper.style.cursor = 'grabbing'; 262 e.preventDefault(); 263 }); 264 265 document.addEventListener('mousemove', (e) => { 266 if (!isDragging) return; 267 const dx = e.pageX - dragStartX; 268 const now = Date.now(); 269 const dt = now - lastTime || 1; 270 velocity = (e.pageX - lastX) / dt * 10; 271 lastX = e.pageX; lastTime = now; 272 if (Math.abs(dx) > 4) wasDragged = true; 273 274 let newScroll = dragStartScroll - dx; 275 const half = getHalfWidth(); 276 if (newScroll < 0) newScroll += half; 277 if (newScroll >= half) newScroll -= half; 278 wrapper.scrollLeft = newScroll; 279 }); 280 281 document.addEventListener('mouseup', () => { 282 if (!isDragging) return; 283 isDragging = false; 284 wrapper.style.cursor = 'grab'; 285 if (Math.abs(velocity) > 0.5) coast(); 286 }); 287 288 wrapper.addEventListener('touchstart', (e) => { 289 isDragging = true; wasDragged = false; isCoasting = false; 290 dragStartX = e.touches[0].pageX; dragStartScroll = wrapper.scrollLeft; 291 lastX = dragStartX; lastTime = Date.now(); velocity = 0; 292 }, { passive: true }); 293 294 wrapper.addEventListener('touchmove', (e) => { 295 if (!isDragging) return; 296 const tx = e.touches[0].pageX; 297 const dx = tx - dragStartX; 298 const now = Date.now(); 299 const dt = now - lastTime || 1; 300 velocity = (tx - lastX) / dt * 10; 301 lastX = tx; lastTime = now; 302 if (Math.abs(dx) > 6) wasDragged = true; 303 304 let newScroll = dragStartScroll - dx; 305 const half = getHalfWidth(); 306 if (newScroll < 0) newScroll += half; 307 if (newScroll >= half) newScroll -= half; 308 wrapper.scrollLeft = newScroll; 309 }, { passive: true }); 310 311 wrapper.addEventListener('touchend', () => { 312 isDragging = false; 313 if (Math.abs(velocity) > 0.5) coast(); 314 }, { passive: true }); 315 316 wrapper.addEventListener('click', (e) => { 317 if (wasDragged) { e.preventDefault(); e.stopPropagation(); wasDragged = false; } 318 }, true); 319 320 autoScroll(); 321} 322 323// âââ EVENT DELEGATION â chá» còn: má» link mạng xã há»i (feedback) + Äiá»u hưá»ng sang danh-gia.html âââ 324function initEvents() { 325 document.addEventListener('click', (e) => { 326 const card = e.target.closest('.rv-card[data-link]'); 327 if (card) { window.open(card.dataset.link, '_blank'); return; } 328 329 // #rvWriteBtn chá» hiá»n trong guest-landing (chưa ÄÄng nháºp) â navigate thưá»ng là Äá»§. 330 if (e.target.closest('#rvWriteBtn')) { window.location.href = 'danh-gia.html'; return; } 331 // #myReviewWriteBtn nằm trong dashboard Äã ÄÄng nháºp â dùng SPA routing Äá» không rá»i khá»i 332 // dashboard shell (Äúng pattern showIframe dùng khắp nÆ¡i, xem components/navbar.html). 333 if (e.target.closest('#myReviewWriteBtn')) { 334 if (window.YTT_Dashboard) window.YTT_Dashboard.showIframe('Äánh Giá Cá»ng Äá»ng', 'danh-gia.html'); 335 else window.location.href = 'danh-gia.html'; 336 return; 337 } 338 }); 339} 340 341// âââ AUTH LISTENER â chá» Äá» biết trạng thái "Äã Äánh giá chưa" cho dashboard widget âââ 342function initAuth() { 343 onAuthStateChanged(auth, async (user) => { 344 currentUser = user; 345 const loginBtn = document.getElementById('rvLoginBtn'); 346 const writeBtn = document.getElementById('rvWriteBtn'); 347 348 if (user) { 349 if (loginBtn) loginBtn.style.display = 'none'; 350 try { 351 const reviewDoc = await getDoc(doc(db, 'reviews', user.uid)); 352 userReview = reviewDoc.exists() ? reviewDoc.data() : null; 353 } catch (e) { userReview = null; } 354 355 if (writeBtn) { 356 writeBtn.innerHTML = userReview 357 ? '<i class="fas fa-edit"></i> Chá»nh Sá»a Äánh Giá' 358 : '<i class="fas fa-pen"></i> Viết Äánh Giá'; 359 writeBtn.style.display = 'inline-flex'; 360 } 361 renderMyReviewWidget(); 362 } else { 363 if (writeBtn) writeBtn.style.display = 'none'; 364 if (loginBtn) loginBtn.style.display = 'inline-flex'; 365 renderMyReviewWidget(); 366 } 367 }); 368} 369 370// âââ DASHBOARD: WIDGET "Äánh giá" trong tab Cá»ng Äá»ng â chá» hiá»n trạng thái + Äiá»u hưá»ng sang 371// danh-gia.html (bảng Äầy Äá»§). Không còn sá»a/xóa inline á» Äây. âââ 372function renderMyReviewWidget() { 373 const statusEl = document.getElementById('ydh-review-status'); 374 const writeBtn = document.getElementById('myReviewWriteBtn'); 375 376 if (writeBtn) { 377 writeBtn.title = userReview ? 'Xem/Sá»a Äánh giá cá»§a bạn' : 'Viết Äánh giá â nháºn 1.500 Linh Thạch'; 378 writeBtn.innerHTML = userReview ? '<i class="fas fa-star"></i>' : '<i class="fas fa-pen"></i>'; 379 } 380 if (statusEl) { 381 if (!currentUser) { 382 statusEl.textContent = 'ÄÄng nháºp Äá» Äánh giá'; 383 } else if (userReview) { 384 const rating = userReview.rating || 5; 385 statusEl.textContent = 'Äã Äánh giá ' + 'â '.repeat(Math.min(rating, 5)) + 'â'.repeat(Math.max(5 - rating, 0)); 386 } else { 387 statusEl.textContent = 'Viết Äánh giá â nháºn +1.500 Linh Thạch'; 388 } 389 } 390} 391 392// âââ INIT âââ 393export function initReviewSystem() { 394 initEvents(); 395 initAuth(); 396 loadReviews(); 397 loadRealStats(); 398} 399 400function _shouldInit() { 401 return document.getElementById('reviewMarqueeTrack') 402 || document.getElementById('myReviewWriteBtn') 403 || document.getElementById('ydh-action-review'); 404} 405 406if (document.readyState === 'loading') { 407 document.addEventListener('DOMContentLoaded', () => { 408 if (_shouldInit()) initReviewSystem(); 409 }); 410} else { 411 if (_shouldInit()) initReviewSystem(); 412}
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.