PageSourceSearch

https://yeu-tiengtrung.com/js/review-system.js?v=23.80

js yeu-tiengtrung.com collected 2026-10-02 01:51:18 UTC 17,428 bytes, 412 lines download raw bytes

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, '&lt;').replace(/>/g, '&gt;');
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.