1/** 2 * Product Card Quick Actions 3 * Bottom sheet menu for quick add to cart & compare 4 * Integrates with existing product-compare.js and cart.js 5 */ 6 7(function() { 8 'use strict'; 9 10 let activeSheet = null; 11 let activeProductData = null; 12 13 // DOM elements (will be created dynamically) 14 let overlay = null; 15 let bottomSheet = null; 16 17 /** 18 * Initialize on DOM ready 19 */ 20 document.addEventListener('DOMContentLoaded', function() { 21 createBottomSheet(); 22 attachEventListeners(); 23 }); 24 25 /** 26 * Create bottom sheet HTML structure 27 */ 28 function createBottomSheet() { 29 // Create overlay 30 overlay = document.createElement('div'); 31 overlay.className = 'pca-overlay'; 32 overlay.addEventListener('click', closeBottomSheet); 33 34 // Create bottom sheet 35 bottomSheet = document.createElement('div'); 36 bottomSheet.className = 'pca-bottom-sheet'; 37 bottomSheet.innerHTML = ` 38 <div class="pca-sheet-handle"></div> 39 <div class="pca-sheet-product" id="pca-product-preview"> 40 <!-- Product info will be inserted here --> 41 </div> 42 <div class="pca-sheet-actions"> 43 <button type="button" class="pca-action-item cart" id="pca-add-cart"> 44 <svg class="pca-action-icon" viewBox="0 0 24 24" fill="currentColor"> 45 <path d="M17,18A2,2 0 0,1 19,20A2,2 0 0,1 17,22C15.89,22 15,21.1 15,20C15,18.89 15.89,18 17,18M1,2H4.27L5.21,4H20A1,1 0 0,1 21,5C21,5.17 20.95,5.34 20.88,5.5L17.3,11.97C16.96,12.58 16.3,13 15.55,13H8.1L7.2,14.63L7.17,14.75A0.25,0.25 0 0,0 7.42,15H19V17H7C5.89,17 5,16.1 5,15C5,14.65 5.09,14.32 5.24,14.04L6.6,11.59L3,4H1V2M7,18A2,2 0 0,1 9,20A2,2 0 0,1 7,22C5.89,22 5,21.1 5,20C5,18.89 5.89,18 7,18M16,11L18.78,6H6.14L8.5,11H16Z"/> 46 </svg> 47 <div class="pca-action-content"> 48 <div class="pca-action-label">Tambah ke Keranjang</div> 49 <div class="pca-action-desc">Lanjut belanja produk lain</div> 50 </div> 51 </button> 52 53 <button type="button" class="pca-action-item compare" id="pca-add-compare"> 54 <svg class="pca-action-icon" viewBox="0 0 24 24" fill="currentColor"> 55 <path d="M21,9L17,5V8H10V10H17V13M7,11L3,15L7,19V16H14V14H7V11Z"/> 56 </svg> 57 <div class="pca-action-content"> 58 <div class="pca-action-label">Bandingkan Produk</div> 59 <div class="pca-action-desc">Analisis dengan AI</div> 60 </div> 61 <span class="pca-action-badge compare" id="pca-compare-count" style="display:none;">0</span> 62 </button> 63 </div> 64 `; 65 66 // Append to body 67 document.body.appendChild(overlay); 68 document.body.appendChild(bottomSheet); 69 70 // Setup action buttons 71 document.getElementById('pca-add-cart').addEventListener('click', handleAddToCart); 72 document.getElementById('pca-add-compare').addEventListener('click', handleAddToCompare); 73 74 // Prevent clicks inside bottom sheet from closing it 75 bottomSheet.addEventListener('click', function(e) { 76 e.stopPropagation(); 77 }); 78 } 79 80 /** 81 * Attach event listeners to all 3-dot buttons 82 */ 83 function attachEventListeners() { 84 // Use event delegation for dynamically loaded product cards 85 document.addEventListener('click', function(e) { 86 const btn = e.target.closest('.prod-card-actions-btn'); 87 if (btn) { 88 e.preventDefault(); 89 e.stopPropagation(); 90 handleActionsButtonClick(btn); 91 } 92 }); 93 94 // Handle ESC key 95 document.addEventListener('keydown', function(e) { 96 if (e.key === 'Escape' && activeSheet) { 97 closeBottomSheet(); 98 } 99 }); 100 } 101 102 /** 103 * Handle 3-dot button click 104 */ 105 function handleActionsButtonClick(button) { 106 // Get product ID directly from button 107 const productId = parseInt(button.dataset.productId) || 0; 108 if (!productId) { 109 console.error('Product ID not found on button:', button); 110 showToast('Data produk tidak ditemukan', 'error'); 111 return; 112 } 113 114 // Find parent card for additional data 115 const card = button.closest('.prod-card, .pa-card'); 116 if (!card) { 117 console.error('Product card not found'); 118 showToast('Data produk tidak ditemukan', 'error'); 119 return; 120 } 121 122 // Extract product data from card 123 const productData = extractProductData(card, productId); 124 if (!productData) { 125 showToast('Data produk tidak ditemukan', 'error'); 126 return; 127 } 128 129 activeProductData = productData; 130 openBottomSheet(productData); 131 } 132 133 /** 134 * Extract product data from card element 135 */ 136 function extractProductData(card, productId) { 137 try { 138 // Product ID already passed from button 139 if (!productId) { 140 console.error('Product ID not provided'); 141 return null; 142 } 143 // Product ID already passed from button 144 if (!productId) { 145 console.error('Product ID not provided'); 146 return null; 147 } 148 149 const id = productId; 150 151 // Get product name 152 const nameEl = card.querySelector('.prod-name, .title'); 153 const name = nameEl ? nameEl.textContent.trim() : 'Produk'; 154 155 // Get product image 156 const imgEl = card.querySelector('.thumb img, img'); 157 const image = imgEl ? imgEl.src : '/assets/img/placeholder.png'; 158 159 // Get product price and detect variants 160 const priceEl = card.querySelector('.prod-price-final, .final, .prod-price'); 161 let price = 0; 162 let hasVariants = false; 163 let priceDisplay = ''; 164 165 if (priceEl) { 166 // Get text content and clean it 167 let priceText = priceEl.textContent.trim(); 168 priceDisplay = priceText; 169 170 // Check if it's a range (indicates product has variants) 171 if (priceText.includes('-')) { 172 hasVariants = true; 173 // Take the first price for calculation 174 priceText = priceText.split('-')[0].trim(); 175 } 176 177 // Remove all non-numeric characters 178 priceText = priceText.replace(/[^0-9]/g, ''); 179 price = parseInt(priceText) || 0; 180 } 181 182 // Get stock status 183 const stockDot = card.querySelector('.stock-dot'); 184 let stockStatus = 'unknown'; 185 let stockLabel = 'Cek stok'; 186 187 if (stockDot) { 188 if (stockDot.classList.contains('ready')) { 189 stockStatus = 'in'; 190 stockLabel = 'Stok tersedia'; 191 } else if (stockDot.classList.contains('low')) { 192 stockStatus = 'low'; 193 stockLabel = 'Stok terbatas'; 194 } else if (stockDot.classList.contains('out')) { 195 stockStatus = 'out'; 196 stockLabel = 'Stok habis'; 197 } 198 } 199 200 // Get category path (if available) 201 const categoryPath = card.dataset.category || ''; 202 203 // Get URL 204 let url = '#'; 205 if (card.tagName === 'A') { 206 url = card.href; 207 } else { 208 const link = card.querySelector('a[href*="/product/"]'); 209 if (link) url = link.href; 210 } 211 212 return { 213 id: id, 214 name: name, 215 image: image, 216 price: price, 217 priceDisplay: priceDisplay, 218 hasVariants: hasVariants, 219 stockStatus: stockStatus, 220 stockLabel: stockLabel, 221 categoryPath: categoryPath, 222 url: url 223 }; 224 } catch (error) { 225 console.error('Error extracting product data:', error); 226 return null; 227 } 228 } 229 230 /** 231 * Open bottom sheet 232 */ 233 function openBottomSheet(productData) { 234 if (!overlay || !bottomSheet) return; 235 236 // Update product preview 237 updateProductPreview(productData); 238 239 // Update cart button based on variant status
240 updateCartButtonForVariant(productData); 241 242 // Update compare button state 243 updateCompareButtonState(productData.id); 244 245 // Show overlay and sheet 246 overlay.classList.add('active'); 247 bottomSheet.classList.add('active'); 248 document.body.style.overflow = 'hidden'; 249 250 activeSheet = true; 251 252 // Google Analytics 253 if (typeof gtag === 'function') { 254 gtag('event', 'quick_actions_open', { 255 event_category: 'Product Card', 256 event_label: 'Quick Actions Menu Opened', 257 product_id: productData.id, 258 product_name: productData.name 259 }); 260 } 261 } 262 263 /** 264 * Close bottom sheet 265 */ 266 function closeBottomSheet() { 267 if (!overlay || !bottomSheet) return; 268 269 overlay.classList.remove('active'); 270 bottomSheet.classList.remove('active'); 271 document.body.style.overflow = ''; 272 273 activeSheet = false; 274 activeProductData = null; 275 } 276 277 /** 278 * Update product preview in bottom sheet 279 */ 280 function updateProductPreview(productData) { 281 const preview = document.getElementById('pca-product-preview'); 282 if (!preview) return; 283 284 // Use full price display (shows range for variants) 285 const priceDisplay = productData.priceDisplay || formatPrice(productData.price); 286 287 preview.innerHTML = ` 288 <img src="${escapeHtml(productData.image)}" 289 alt="${escapeHtml(productData.name)}" 290 class="pca-product-image" 291 loading="lazy"> 292 <div class="pca-product-info"> 293 <h4 class="pca-product-name">${escapeHtml(productData.name)}</h4> 294 <p class="pca-product-price">${escapeHtml(priceDisplay)}</p> 295 <p class="pca-product-stock ${productData.stockStatus}">${productData.stockLabel}</p> 296 </div> 297 `; 298 } 299 300 /** 301 * Update cart button for variant products 302 */ 303 function updateCartButtonForVariant(productData) { 304 const cartBtn = document.getElementById('pca-add-cart'); 305 if (!cartBtn) return; 306 307 if (productData.hasVariants) { 308 // Product has variants - change to "View Detail" 309 cartBtn.dataset.hasVariants = 'true'; 310 cartBtn.dataset.productUrl = productData.url; 311 312 // Update icon to eye/external 313 const iconSvg = cartBtn.querySelector('svg'); 314 if (iconSvg) { 315 iconSvg.innerHTML = '<path d="M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z"/>'; 316 } 317 318 // Update text 319 const label = cartBtn.querySelector('.pca-action-label'); 320 const desc = cartBtn.querySelector('.pca-action-desc'); 321 if (label) label.textContent = 'Lihat Detail Produk'; 322 if (desc) desc.textContent = 'Pilih varian terlebih dahulu'; 323 324 } else { 325 // Single product - normal cart button 326 cartBtn.dataset.hasVariants = 'false'; 327 328 // Restore cart icon 329 const iconSvg = cartBtn.querySelector('svg'); 330 if (iconSvg) { 331 iconSvg.innerHTML = '<path d="M17,18A2,2 0 0,1 19,20A2,2 0 0,1 17,22C15.89,22 15,21.1 15,20C15,18.89 15.89,18 17,18M1,2H4.27L5.21,4H20A1,1 0 0,1 21,5C21,5.17 20.95,5.34 20.88,5.5L17.3,11.97C16.96,12.58 16.3,13 15.55,13H8.1L7.2,14.63L7.17,14.75A0.25,0.25 0 0,0 7.42,15H19V17H7C5.89,17 5,16.1 5,15C5,14.65 5.09,14.32 5.24,14.04L6.6,11.59L3,4H1V2M7,18A2,2 0 0,1 9,20A2,2 0 0,1 7,22C5.89,22 5,21.1 5,20C5,18.89 5.89,18 7,18M16,11L18.78,6H6.14L8.5,11H16Z"/>'; 332 } 333 334 // Restore text 335 const label = cartBtn.querySelector('.pca-action-label'); 336 const desc = cartBtn.querySelector('.pca-action-desc'); 337 if (label) label.textContent = 'Tambah ke Keranjang'; 338 if (desc) desc.textContent = 'Lanjut belanja produk lain'; 339 } 340 } 341 342 /** 343 * Update compare button state 344 */ 345 function updateCompareButtonState(productId) { 346 const compareBtn = document.getElementById('pca-add-compare'); 347 const compareBadge = document.getElementById('pca-compare-count'); 348 349 if (!compareBtn) return; 350 351 // Check if product is in compare list (using global compareList if available) 352 let isInCompare = false;
353 let compareCount = 0; 354 355 // Try to get compare list from product-compare.js via window.productCompare 356 if (typeof window.productCompare !== 'undefined' && typeof window.productCompare.getCompareList === 'function') { 357 const compareList = window.productCompare.getCompareList(); 358 compareCount = compareList.length; 359 isInCompare = compareList.some(p => parseInt(p.id) === parseInt(productId)); 360 } else { 361 // Fallback: check badge count 362 const mainBadge = document.getElementById('compareCount'); 363 if (mainBadge) { 364 compareCount = parseInt(mainBadge.textContent) || 0; 365 } 366 } 367 368 // Update button state 369 if (isInCompare) { 370 compareBtn.classList.add('active'); 371 compareBtn.querySelector('.pca-action-label').textContent = 'Hapus dari Perbandingan'; 372 compareBtn.querySelector('.pca-action-desc').textContent = 'Sudah ada di daftar'; 373 } else { 374 compareBtn.classList.remove('active'); 375 compareBtn.querySelector('.pca-action-label').textContent = 'Bandingkan Produk'; 376 compareBtn.querySelector('.pca-action-desc').textContent = 'Analisis dengan AI'; 377 } 378 379 // Update badge 380 if (compareBadge && compareCount > 0) { 381 compareBadge.textContent = compareCount; 382 compareBadge.style.display = 'inline-flex'; 383 } else if (compareBadge) { 384 compareBadge.style.display = 'none'; 385 } 386 } 387 388 /** 389 * Handle add to cart 390 */ 391 async function handleAddToCart() { 392 if (!activeProductData) return; 393 394 const button = document.getElementById('pca-add-cart'); 395 if (!button) return; 396 397 // If product has variants, redirect to detail page 398 if (activeProductData.hasVariants) { 399 window.location.href = activeProductData.url; 400 return; 401 } 402 403 // Check stock 404 if (activeProductData.stockStatus === 'out') { 405 showToast('Produk sedang habis', 'warning'); 406 return; 407 } 408 409 // Show loading state 410 button.classList.add('loading'); 411 button.disabled = true; 412 413 // CSRF Token Helper 414 function getCsrfToken() { 415 return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; 416 } 417 418 try { 419 // Call cart API with proper format 420 const response = await fetch('/api/cart/add.php', { 421 method: 'POST', 422 headers: { 423 'Content-Type': 'application/json', 424 'X-CSRF-Token': getCsrfToken() 425 }, 426 body: JSON.stringify({ 427 csrf_token: getCsrfToken(), 428 product_id: activeProductData.id, 429 qty: 1, // Use 'qty' not 'quantity' 430 variant_id: 0, // Default variant 431 meta: { 432 name: activeProductData.name, 433 image_url: activeProductData.image 434 } 435 }), 436 credentials: 'same-origin' 437 }); 438 439 const result = await response.json(); 440 441 // Check both response status and result.ok 442 if (!response.ok || !result.ok) { 443 throw new Error(result.error || result.message || 'Gagal menambahkan ke keranjang'); 444 } 445 446 // Success animation 447 button.classList.remove('loading'); 448 button.classList.add('success'); 449 450 // Update cart badge 451 updateCartBadge(); 452 453 // Show success toast 454 showToast('Produk ditambahkan ke keranjang', 'success'); 455 456 // Google Analytics 457 if (typeof gtag === 'function') { 458 gtag('event', 'add_to_cart', { 459 event_category: 'Ecommerce', 460 event_label: 'Quick Add to Cart', 461 items: [{ 462 item_id: activeProductData.id, 463 item_name: activeProductData.name, 464 price: activeProductData.price, 465 quantity: 1 466 }] 467 }); 468 } 469 470 // Close sheet after short delay 471 setTimeout(() => { 472 closeBottomSheet(); 473 button.classList.remove('success'); 474 button.disabled = false; 475 }, 1000); 476 477 } catch (error) { 478 console.error('Add to cart error:', error); 479 button.classList.remove('loading'); 480 button.disabled = false; 481 showToast(error.message || 'Terjadi kesalahan', 'error'); 482 } 483 } 484 485 /** 486 * Handle add to compare 487 */ 488 async function handleAddToCompare() { 489 if (!activeProductData) return; 490 491 const button = document.getElementById('pca-add-compare'); 492 if (!button) return; 493 494 // Check if already in compare 495 const isActive = button.classList.contains('active'); 496 497 // Check 5-product limit before adding 498 if (!isActive) { 499 const mainBadge = document.getElementById('compareCount'); 500 const currentCount = mainBadge ? parseInt(mainBadge.textContent) || 0 : 0; 501 502 if (currentCount >= 5) { 503 showToast('Maksimal 5 produk untuk perbandingan', 'warning'); 504 return; 505 } 506 } 507 508 // Show loading state 509 button.classList.add('loading'); 510 button.disabled = true; 511 512 try { 513 if (isActive) { 514 // Remove from compare 515 await removeFromCompare(activeProductData.id); 516 showToast('Produk dihapus dari perbandingan', 'success'); 517 } else { 518 // Add to compare 519 await addToCompare(activeProductData.id); 520 showToast('Produk ditambahkan ke perbandingan', 'success'); 521 522 // Trigger fly animation 523 triggerFlyToCompareAnimation(); 524 } 525 526 // Success animation 527 button.classList.remove('loading'); 528 button.classList.add('success'); 529 530 // Update compare button state 531 updateCompareButtonState(activeProductData.id); 532 533 // Close sheet after short delay 534 setTimeout(() => { 535 closeBottomSheet(); 536 button.classList.remove('success'); 537 button.disabled = false; 538 }, 800); 539 540 } catch (error) { 541 console.error('Compare error:', error); 542 button.classList.remove('loading'); 543 button.disabled = false; 544 showToast(error.message || 'Terjadi kesalahan', 'error'); 545 } 546 } 547 548 /** 549 * Add to compare (calls existing API) 550 */ 551 async function addToCompare(productId) { 552 const response = await fetch('/api/compare/add.php', { 553 method: 'POST', 554 headers: { 555 'Content-Type': 'application/json', 556 }, 557 body: JSON.stringify({ 558 product_id: productId, 559 variant_id: 0 560 }), 561 credentials: 'same-origin' 562 });
563 564 const result = await response.json(); 565 566 if (!result.success) { 567 throw new Error(result.error || 'Gagal menambahkan produk'); 568 } 569 570 // Trigger compare list reload if function exists 571 if (typeof window.productCompare !== 'undefined' && typeof window.productCompare.loadCompareList === 'function') { 572 await window.productCompare.loadCompareList(); 573 } 574 575 return result; 576 } 577 578 /** 579 * Remove from compare (calls existing API) 580 */ 581 async function removeFromCompare(productId) { 582 const response = await fetch('/api/compare/remove.php', { 583 method: 'POST', 584 headers: { 585 'Content-Type': 'application/json', 586 }, 587 body: JSON.stringify({ 588 product_id: productId 589 }), 590 credentials: 'same-origin' 591 }); 592 593 const result = await response.json(); 594 595 if (!result.success) { 596 throw new Error(result.error || 'Gagal menghapus produk'); 597 } 598 599 // Trigger compare list reload if function exists 600 if (typeof window.productCompare !== 'undefined' && typeof window.productCompare.loadCompareList === 'function') { 601 await window.productCompare.loadCompareList(); 602 } 603 604 return result; 605 } 606 607 /** 608 * Update cart badge 609 */ 610 function updateCartBadge() { 611 // Try to call global cart update function if exists 612 if (typeof window.updateCartBadge === 'function') { 613 window.updateCartBadge(); 614 } else { 615 // Fallback: increment badge manually 616 const badge = document.getElementById('cartCount'); 617 if (badge) { 618 const currentCount = parseInt(badge.textContent) || 0; 619 badge.textContent = currentCount + 1; 620 badge.classList.add('active'); 621 } 622 } 623 } 624 625 /** 626 * Trigger fly to compare animation 627 */ 628 function triggerFlyToCompareAnimation() { 629 const compareBtn = document.getElementById('compareBtn'); 630 if (!compareBtn || !activeProductData) return; 631 632 // Create flying element 633 const flyingElement = document.createElement('div'); 634 flyingElement.className = 'flying-product'; 635 flyingElement.style.cssText = ` 636 position: fixed; 637 width: 60px; 638 height: 60px; 639 z-index: 99999; 640 pointer-events: none; 641 `; 642 643 flyingElement.innerHTML = ` 644 <img src="${activeProductData.image}" 645 style="width:100%;height:100%;object-fit:cover;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.3);"> 646 `; 647 648 // Position at bottom sheet 649 const sheetRect = bottomSheet.getBoundingClientRect(); 650 const targetRect = compareBtn.getBoundingClientRect(); 651 652 flyingElement.style.top = `${sheetRect.top + 20}px`; 653 flyingElement.style.left = `${sheetRect.left + 20}px`; 654 655 document.body.appendChild(flyingElement); 656 657 // Animate to target 658 requestAnimationFrame(() => { 659 flyingElement.style.transition = 'all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; 660 flyingElement.style.top = `${targetRect.top + 10}px`; 661 flyingElement.style.left = `${targetRect.left + 10}px`; 662 flyingElement.style.transform = 'scale(0.3)'; 663 flyingElement.style.opacity = '0'; 664 }); 665 666 // Clean up 667 setTimeout(() => { 668 if (flyingElement.parentNode) { 669 flyingElement.parentNode.removeChild(flyingElement); 670 } 671 }, 800); 672 } 673 674 /** 675 * Format price as Rupiah 676 */ 677 function formatPrice(price) { 678 if (!price || price === 0) return 'Rp 0'; 679 return 'Rp ' + parseInt(price).toLocaleString('id-ID'); 680 } 681 682 /** 683 * Escape HTML 684 */ 685 function escapeHtml(text) { 686 const div = document.createElement('div'); 687 div.textContent = text; 688 return div.innerHTML; 689 } 690 691 /** 692 * Show toast notification (uses global showToast if available) 693 */ 694 function showToast(message, type = 'info') { 695 if (typeof window.showToast === 'function') { 696 window.showToast({ 697 title: type === 'success' ? 'Berhasil' : type === 'error' ? 'Gagal' : 'Info', 698 message: message, 699 type: type, 700 duration: 3000 701 }); 702 } else { 703 // Fallback: console log 704 console.log(`[${type.toUpperCase()}] ${message}`); 705 } 706 } 707 708 /** 709 * Expose functions for external access 710 */ 711 window.ProductCardActions = {
712 openSheet: openBottomSheet, 713 closeSheet: closeBottomSheet, 714 updateCompareState: updateCompareButtonState 715 }; 716 717})();
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.