1/** 2 * Product Reviews - Frontend JavaScript 3 * Handles review display, submission, and helpful voting 4 */ 5 6const ProductReviews = (function() { 7 'use strict'; 8 9 let currentPage = 1; 10 let currentSort = 'helpful'; 11 let productId = null; 12 let isLoading = false; 13 14 /** 15 * Public API: Initialize reviews for a product 16 */ 17 function init(pid) { 18 productId = pid; 19 20 if (!productId) { 21 console.warn('Product ID not provided to ProductReviews.init()'); 22 return; 23 } 24 25 initializeReviewTab(); 26 loadReviews(); 27 } 28 29 /** 30 * Initialize review tab functionality 31 */ 32 function initializeReviewTab() { 33 // Tab switching 34 const tabButtons = document.querySelectorAll('.tab-btn'); 35 tabButtons.forEach(btn => { 36 btn.addEventListener('click', function() { 37 const tabName = this.dataset.tab; 38 switchTab(tabName); 39 }); 40 }); 41 42 // Sort dropdown 43 const sortSelect = document.getElementById('review-sort'); 44 if (sortSelect) { 45 sortSelect.addEventListener('change', function() { 46 currentSort = this.value; 47 currentPage = 1; 48 loadReviews(); 49 }); 50 } 51 52 // Load more button
53 const loadMoreBtn = document.getElementById('load-more-reviews'); 54 if (loadMoreBtn) { 55 loadMoreBtn.addEventListener('click', function() { 56 currentPage++; 57 loadReviews(true); // Append mode 58 }); 59 } 60 61 // Write review button 62 const writeReviewBtn = document.getElementById('write-review-btn'); 63 if (writeReviewBtn) { 64 writeReviewBtn.addEventListener('click', showReviewModal); 65 } 66 } 67 68 /** 69 * Switch between product tabs 70 */ 71 function switchTab(tabName) { 72 // Update button states 73 document.querySelectorAll('.tab-btn').forEach(btn => { 74 btn.classList.toggle('active', btn.dataset.tab === tabName); 75 }); 76 77 // Update content visibility 78 document.querySelectorAll('.tab-content').forEach(content => { 79 content.style.display = content.id === `${tabName}-tab` ? 'block' : 'none'; 80 }); 81 } 82 83 /** 84 * Load reviews from API 85 */ 86 async function loadReviews(append = false) { 87 if (isLoading) return; 88 isLoading = true; 89 90 const container = document.getElementById('reviews-list');
91 const loadMoreBtn = document.getElementById('load-more-reviews'); 92 93 // Check if review section exists (product might not have reviews section) 94 if (!container) { 95 console.log('Review section not found - product may not have reviews yet'); 96 isLoading = false; 97 return; 98 } 99 100 try { 101 if (!append) { 102 container.innerHTML = '<div class="review-loading">Memuat ulasan...</div>'; 103 } else { 104 loadMoreBtn.disabled = true; 105 loadMoreBtn.textContent = 'Memuat...'; 106 } 107 108 const response = await fetch(`/api/reviews/list.php?product_id=${productId}&sort=${currentSort}&page=${currentPage}&limit=10`); 109 const data = await response.json(); 110 111 if (!data.success) { 112 throw new Error(data.message || 'Gagal memuat ulasan'); 113 } 114 115 // Update statistics (only on initial load) 116 if (!append && data.stats) { 117 updateReviewStats(data.stats); 118 } 119 120 // Render reviews 121 if (!append) { 122 container.innerHTML = ''; 123 } 124 125 const reviews = data.reviews || []; 126 127 if (reviews.length === 0 && !append) { 128 container.innerHTML = '<div class="no-reviews">Belum ada ulasan untuk produk ini. Jadilah yang pertama!</div>'; 129 } else { 130 reviews.forEach(review => { 131 container.appendChild(createReviewElement(review)); 132 }); 133 } 134 135 // Update load more button 136 if (loadMoreBtn) { 137 if (data.pagination.has_more) { 138 loadMoreBtn.style.display = 'block'; 139 loadMoreBtn.disabled = false; 140 loadMoreBtn.textContent = 'Muat Lebih Banyak'; 141 } else { 142 loadMoreBtn.style.display = 'none'; 143 } 144 } 145 146 } catch (error) { 147 console.error('Load reviews error:', error); 148 container.innerHTML = '<div class="review-error">Gagal memuat ulasan. Silakan coba lagi.</div>'; 149 } finally { 150 isLoading = false; 151 } 152 } 153 154 /** 155 * Update review statistics display 156 */ 157 function updateReviewStats(stats) { 158 const avgRatingEl = document.getElementById('avg-rating'); 159 const totalReviewsEl = document.getElementById('total-reviews'); 160 const ratingBarsContainer = document.getElementById('rating-bars'); 161 162 if (avgRatingEl) { 163 avgRatingEl.textContent = stats.average_rating.toFixed(1); 164 } 165 166 if (totalReviewsEl) { 167 totalReviewsEl.textContent = `${stats.total_reviews} ulasan`; 168 } 169 170 // Update rating breakdown bars 171 if (ratingBarsContainer) { 172 ratingBarsContainer.innerHTML = ''; 173 stats.rating_breakdown.forEach(item => { 174 const bar = document.createElement('div'); 175 bar.className = 'rating-bar'; 176 bar.innerHTML = ` 177 <span class="rating-label">${item.rating}âËâ¦</span> 178 <div class="bar-container"> 179 <div class="bar-fill" style="width: ${item.percentage}%"></div> 180 </div> 181 <span class="rating-count">${item.count}</span> 182 `; 183 ratingBarsContainer.appendChild(bar); 184 }); 185 } 186 187 // Update tab count 188 const reviewTab = document.querySelector('[data-tab="reviews"]'); 189 if (reviewTab && stats.total_reviews > 0) { 190 reviewTab.textContent = `Ulasan (${stats.total_reviews})`; 191 } 192 } 193 194 /** 195 * Create review element 196 */ 197 function createReviewElement(review) { 198 const div = document.createElement('div'); 199 div.className = 'review-item'; 200 div.dataset.reviewId = review.ID; 201 202 // Build stars 203 const stars = 'âËâ¦'.repeat(review.RATING) + 'âËâ '.repeat(5 - review.RATING); 204 205 // Build images if available 206 let imagesHtml = ''; 207 if (review.images && review.images.length > 0) { 208 imagesHtml = '<div class="review-images">'; 209 review.images.forEach(img => { 210 imagesHtml += `<img src="${escapeHtml(img)}" alt="Review image" class="review-img" loading="lazy">`; 211 }); 212 imagesHtml += '</div>'; 213 } 214 215 // Verified purchase badge 216 const verifiedBadge = review.IS_VERIFIED_PURCHASE === 'Y' 217 ? '<span class="verified-badge">âÅâ Pembelian Terverifikasi</span>' 218 : ''; 219 220 // Use DISPLAY_NAME if available (for anonymous reviews), otherwise use FULL_NAME 221 const displayName = review.DISPLAY_NAME || review.FULL_NAME || 'Anonymous'; 222 const anonymousIndicator = review.IS_ANONYMOUS === 'Y' 223 ? '<span class="anonymous-badge" title="Nama pengguna disamark
223an untuk privasi">ðŸââ</span>' 224 : ''; 225 226 div.innerHTML = ` 227 <div class="review-header"> 228 <div class="review-author"> 229 <strong>${escapeHtml(displayName)}</strong> 230 ${anonymousIndicator} 231 ${verifiedBadge} 232 </div> 233 <div class="review-rating"> 234 <span class="stars">${stars}</span> 235 </div> 236 </div> 237 <div class="review-date">${review.created_ago}</div> 238 <div class="review-text">${escapeHtml(review.REVIEW_TEXT).replace(/\n/g, '<br>')}</div> 239 ${imagesHtml} 240 <div class="review-actions"> 241 <button class="btn-helpful" data-review-id="${review.ID}"> 242 <span class="helpful-icon">ðŸâÂ</span> 243 <span class="helpful-text">Membantu (${review.HELPFUL_COUNT || 0})</span> 244 </button> 245 </div> 246 `; 247 248 // Add helpful button listener 249 const helpfulBtn = div.querySelector('.btn-helpful'); 250 helpfulBtn.addEventListener('click', () => markAsHelpful(review.ID)); 251 252 return div; 253 } 254 255 /** 256 * Get CSRF token from meta tag 257 */ 258 function getCsrfToken() { 259 const metaTag = document.querySelector('meta[name="csrf-token"]'); 260 return metaTag ? metaTag.getAttribute('content') : ''; 261 } 262 263 /** 264 * Mark review as helpful 265 */ 266 async function markAsHelpful(reviewId) { 267 // Check if logged in 268 const isLoggedIn = document.body.classList.contains('logged-in') || 269 document.querySelector('meta[name="user-logged-in"]')?.content === 'true'; 270 271 if (!isLoggedIn) { 272 showModal({ 273 title: 'Login Diperlukan', 274 message: 'Silakan login terlebih dahulu untuk memberikan vote pada ulasan ini.', 275 icon: 'info', 276 buttons: [ 277 { text: 'Batal', className: 'modal-btn-secondary' }, 278 { text: 'Login', className: 'modal-btn-primary', callback: () => window.location.href = '/login' } 279 ] 280 }); 281 return; 282 } 283 284 // Get current button element 285 const reviewItem = document.querySelector(`[data-review-id="${reviewId}"]`); 286 const helpfulBtn = reviewItem?.querySelector('.btn-helpful'); 287 288 if (!helpfulBtn) return; 289 290 // Disable button during request 291 helpfulBtn.disabled = true; 292 const originalText = helpfulBtn.querySelector('.helpful-text').textContent; 293 294 try { 295 const response = await fetch('/api/reviews/helpful.php', { 296 method: 'POST', 297 headers: { 298 'Content-Type': 'application/json', 299 'X-CSRF-Token': getCsrfToken() 300 }, 301 credentials: 'same-origin', 302 body: JSON.stringify({ 303 csrf_token: getCsrfToken(), 304 review_id: reviewId 305 }) 306 }); 307 308 const data = await response.json(); 309 310 if (data.success) { 311 // Update button count immediately based on action 312 const currentCount = parseInt(originalText.match(/\d+/)?.[0] || 0); 313 const newCount = data.action === 'added' ? currentCount + 1 : currentCount - 1; 314 helpfulBtn.querySelector('.helpful-text').textContent = `Membantu (${newCount})`; 315 316 // Show success message 317 showToast(data.message, 'success'); 318 319 // Re-enable button 320 helpfulBtn.disabled = false; 321 } else { 322 showToast(data.message, 'error'); 323 helpfulBtn.disabled = false; 324 } 325 326 } catch (error) { 327 console.error('Mark helpful error:', error); 328 showToast('Gagal memberikan vote. Silakan coba lagi.', 'error'); 329 helpfulBtn.disabled = false; 330 } 331 } 332 333 /** 334 * Show review submission modal (placeholder) 335 */ 336 function showReviewModal() { 337 // TODO: Implement review modal 338 // For now, redirect to account page 339 window.location.href = '/account/orders'; 340 } 341 342 /** 343 * Show toast notification 344 */ 345 function showToast(message, type = 'info') { 346 const toast = document.createElement('div'); 347 toast.className = `review-toast toast-${type}`; 348 toast.textContent = message; 349 350 document.body.appendChild(toast); 351 352 setTimeout(() => { 353 toast.classList.add('show'); 354 }, 100); 355 356 setTimeout(() => { 357 toast.classList.remove('show'); 358 setTimeout(() => toast.remove(), 300); 359 }, 3000); 360 } 361 362 /** 363 * Show modal dialog 364 * @param {Object} options - Modal options 365 * @param {string} options.title - Modal title 366 * @param {string} options.message - Modal message 367 * @param {string} options.icon - Icon type (info, success, warning, danger) 368 * @param {Array} options.buttons - Array of button objects {text, className, callback} 369 */ 370 function showModal(options) { 371 const {
372 title = 'Konfirmasi', 373 message = '', 374 icon = 'info', 375 buttons = [{ text: 'OK', className: 'modal-btn-primary' }] 376 } = options; 377 378 // Icon emoji mapping 379 const iconEmojis = { 380 info: 'ââ¹ï¸Â', 381 success: 'âÅâ', 382 warning: 'âš ï¸Â', 383 danger: 'âÅâ¢' 384 }; 385 386 // Create modal HTML 387 const modalHTML = ` 388 <div class="modal-overlay" id="helpful-vote-modal" style="display: flex !important; align-items: center !important; justify-content: center !important;"> 389 <div class="modal-dialog"> 390 <div class="modal-header"> 391 <h3 class="modal-title"> 392 <span class="modal-icon ${icon}">${iconEmojis[icon] || iconEmojis.info}</span> 393 ${escapeHtml(title)} 394 </h3> 395 </div> 396 <div class="modal-body"> 397 <p class="modal-message">${escapeHtml(message)}</p> 398 <div class="modal-actions"> 399 ${buttons.map((btn, index) => 400 `<button class="modal-btn ${btn.className || 'modal-btn-secondary'}" data-index="${index}">${escapeHtml(btn.text)}</button>` 401 ).join('')} 402 </div> 403 </div> 404 </div> 405 </div> 406 `; 407 408 // Append to body 409 const tempDiv = document.createElement('div'); 410 tempDiv.innerHTML = modalHTML; 411 const modal = tempDiv.firstElementChild; 412 document.body.appendChild(modal); 413 414 // Add event listeners to buttons 415 buttons.forEach((btn, index) => { 416 const btnElement = modal.querySelector(`[data-index="${index}"]`); 417 btnElement.addEventListener('click', () => { 418 closeModal(modal); 419 if (btn.callback && typeof btn.callback === 'function') { 420 btn.callback(); 421 } 422 }); 423 }); 424 425 // Close on overlay click 426 modal.addEventListener('click', (e) => { 427 if (e.target === modal) { 428 closeModal(modal); 429 } 430 }); 431 432 // Show modal with animation 433 setTimeout(() => modal.classList.add('show'), 10); 434 } 435 436 /** 437 * Close modal dialog 438 */ 439 function closeModal(modal) { 440 modal.classList.remove('show'); 441 setTimeout(() => modal.remove(), 200); 442 } 443 444 /** 445 * Escape HTML for XSS protection 446 */ 447 function escapeHtml(text) { 448 const div = document.createElement('div'); 449 div.textContent = text; 450 return div.innerHTML; 451 } 452 453 /** 454 * Get product ID from URL 455 */ 456 function getProductIdFromUrl() { 457 const match = window.location.pathname.match(/\/product\/(\d+)/); 458 return match ? match[1] : null; 459 } 460 461 // Public API 462 return { 463 init: init 464 }; 465 466})();
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.