// Tile Mega Menu — Enhanced 2025 (Stagger animations, product counts, loading states)
// Includes safe-area hover handling (no backdrop flicker)
(function(){
const trigger = document.querySelector('[data-mm2="trigger"]');
const panel = document.querySelector('[data-mm2="panel"]');
const topView = document.querySelector('[data-mm2="top"]');
const detailView= document.querySelector('[data-mm2="detail"]');
const backdrop = document.querySelector('[data-mm2="backdrop"]');
if (!trigger || !panel || !topView || !detailView) return;
const apiUrl = '/api/categories_tree.php?top=16&child=18&grand=14';
const isDesktop = () => window.matchMedia('(min-width: 961px)').matches;
let data = [];
let hoverTimer, closeTimer;
let isLoading = false;
const esc = s => (s+'').replace(/&/g,'&').replace(//g,'>');
// Mobile close button HTML
const mobileCloseBtn = () => `
`;
// Category-specific gradient colors (16 unique combinations)
const categoryGradients = [
{ from: '#e0e7ff', to: '#fff', border: '#c7d2fe', color: '#4f46e5' }, // Indigo
{ from: '#dbeafe', to: '#fff', border: '#bfdbfe', color: '#1e40af' }, // Blue
{ from: '#d1fae5', to: '#fff', border: '#a7f3d0', color: '#059669' }, // Green
{ from: '#fef3c7', to: '#fff', border: '#fde68a', color: '#d97706' }, // Amber
{ from: '#fce7f3', to: '#fff', border: '#fbcfe8', color: '#db2777' }, // Pink
{ from: '#e9d5ff', to: '#fff', border: '#d8b4fe', color: '#9333ea' }, // Purple
{ from: '#ffedd5', to: '#fff', border: '#fed7aa', color: '#ea580c' }, // Orange
{ from: '#ccfbf1', to: '#fff', border: '#99f6e4', color: '#0d9488' }, // Teal
{ from: '#fef9c3', to: '#fff', border: '#fef08a', color: '#ca8a04' }, // Yellow
{ from: '#ffe4e6', to: '#fff', border: '#fecdd3', color: '#e11d48' }, // Rose
{ from: '#e0f2fe', to: '#fff', border: '#bae6fd', color: '#0284c7' }, // Sky
{ from: '#f3e8ff', to: '#fff', border: '#e9d5ff', color: '#a855f7' }, // Violet
{ from: '#dcfce7', to: '#fff', border: '#bbf7d0', color: '#16a34a' }, // Emerald
{ from: '#fef2f2', to: '#fff', border: '#fecaca', color: '#dc2626' }, // Red
{ from: '#eff6ff', to: '#fff', border: '#dbeafe', color: '#2563eb' }, // Blue-600
{ from: '#f5f3ff', to: '#fff', border: '#ede9fe', color: '#7c3aed' }, // Violet-600
];
const icon = () => ``;
// Recent (optional future use)
const REC_KEY='mm2_recent';
const saveRecent=(path,label)=>{ try{
let list=JSON.parse(localStorage.getItem(REC_KEY)||'[]').filter(x=>x&&x.path!==path);
list.unshift({path,label,t:Date.now()}); localStorage.setItem(REC_KEY, JSON.stringify(list.slice(0,8)));
}catch{} };
function isToSafeArea(e){
const t = e && e.relatedTarget ? e.relatedTarget : null;
if (!t) return false;
return (trigger.contains(t) || panel.contains(t) || (backdrop && backdrop.contains(t)));
}
function open(){
document.body.classList.add('mm2-open');
showTop();
// Add entrance animation class
setTimeout(() => panel.classList.add('mm2-animated'), 10);
}
function close(){
document.body.classList.remove('mm2-open');
panel.classList.remove('mm2-animated');
}
function showTop(){
topView.classList.add('is-active');
detailView.classList.remove('is-active');
}
// Loading skeleton
function showLoading() {
const skeletons = Array.from({length: 12}, (_, i) => `
`).join('');
topView.innerHTML = ``;
}
// Count total products in a category tree
function countProducts(node) {
let count = node.product_count || 0;
if (Array.isArray(node.children)) {
node.children.forEach(child => {
count += countProducts(child);
});
}
return count;
}
// Cache for fetched thumbnails to avoid duplicate requests
const thumbnailCache = new Map();
// Fetch product thumbnails for a category
function fetchCategoryThumbnails(categoryPath) {
// Check cache first
if (thumbnailCache.has(categoryPath)) {
injectThumbnails(categoryPath, thumbnailCache.get(categoryPath));
return;
}
const url = `/api/category_products_preview.php?path=${encodeURIComponent(categoryPath)}&limit=2`;
fetch(url)
.then(r => {
if (!r.ok) throw new Error('Network error');
return r.json();
})
.then(data => {
if (data.success && data.products && data.products.length > 0) {
thumbnailCache.set(categoryPath, data.products);
injectThumbnails(categoryPath, data.products);
} else {
// No products, remove skeleton
removeThumbSkeletons(categoryPath);
}
})
.catch(err => {
console.warn('Failed to load thumbnails for', categoryPath, err);
removeThumbSkeletons(categoryPath);
});
}
// Inject thumbnails into the DOM
function injectThumbnails(categoryPath, products) {
const links = detailView.querySelectorAll(`[data-cat-path="${categoryPath}"]`);
links.forEach(link => {
const thumbsContainer = link.querySelector('.mm2-thumbs');
if (!thumbsContainer) return;
const thumbsHtml = products.slice(0, 2).map(p =>
`
`
).join('');
thumbsContainer.innerHTML = thumbsHtml;
thumbsContainer.removeAttribute('data-loading');
});
}
// Remove skeleton loaders when no products
function removeThumbSkeletons(categoryPath) {
const links = detailView.querySelectorAll(`[data-cat-path="${categoryPath}"]`);
links.forEach(link => {
const thumbsContainer = link.querySelector('.mm2-thumbs');
if (thumbsContainer) {
thumbsContainer.remove();
}
});
}
function showDetail(node){
const title = node.label.split('/')[0];
const children = Array.isArray(node.children) ? node.children : [];
const hasChildren = children.length > 0;
const subnav = hasChildren ? `
` : '';
const subcontent = ``;
detailView.innerHTML = `
${hasChildren ? `
${subnav}
${subcontent}
` : `
`}
`;
topView.classList.remove('is-active');
detailView.classList.add('is-active');
if (hasChildren) {
const subEl = detailView.querySelector('.mm2-subnav');
const contEl = detailView.querySelector('[data-mm2="subcontent"]');
let activeIdx = 0;
const renderSubgrid = (idx) => {
const c2 = children[idx]; if (!c2) { contEl.innerHTML=''; return; }
const title2 = c2.label.split('/').slice(-1)[0];
const l3 = Array.isArray(c2.children) ? c2.children : [];
const l3Html = l3.map(c3 => `
${esc(c3.label.split('/').slice(-1)[0])}
`).join('');
contEl.innerHTML = `
`;
// Fetch product thumbnails for each level-3 category (desktop only)
if (isDesktop()) {
setTimeout(() => {
l3.forEach(c3 => {
fetchCategoryThumbnails(c3.path);
});
}, 100);
}
};
renderSubgrid(activeIdx);
// Desktop: hover ganti konten; Mobile & Desktop: click pilih
subEl.addEventListener('mousemove', (e)=>{
if (!isDesktop()) return;
const it = e.target.closest('.mm2-subitem'); if (!it) return;
const idx = +it.dataset.idx; if (!Number.isInteger(idx) || idx===activeIdx) return;
subEl.querySelectorAll('.mm2-subitem').forEach(el=>el.classList.remove('active'));
it.classList.add('active');
subEl.querySelectorAll('.mm2-subitem').forEach(el=>el.setAttribute('aria-selected', el===it ? 'true':'false'));
activeIdx = idx;
renderSubgrid(activeIdx);
});
subEl.addEventListener('click', (e)=>{
const it = e.target.closest('.mm2-subitem'); if (!it) return;
const idx = +it.dataset.idx; if (!Number.isInteger(idx)) return;
subEl.querySelectorAll('.mm2-subitem').forEach(el=>el.classList.remove('active'));
it.classList.add('active');
subEl.querySelectorAll('.mm2-subitem').forEach(el=>el.setAttribute('aria-selected', el===it ? 'true':'false'));
activeIdx = idx;
renderSubgrid(activeIdx);
});
// Delegate link clicks to add recent
detailView.addEventListener('click', (e)=>{
const nav = e.target.closest('[data-mm2="nav"]');
if (nav) { saveRecent(nav.dataset.path, nav.dataset.label); }
});
}
}
function buildTop(){
const html = data.map((n, idx) => {
const title = n.label.split('/')[0];
const childCount = (n.children||[]).length;
const productCount = countProducts(n);
const gradient = categoryGradients[idx % categoryGradients.length];
// Dynamic subtitle based on content - shorter for mobile
let sub = '';
if (productCount > 0) {
sub = `${productCount} produk`;
if (childCount > 0) sub += ` · ${childCount} kat`;
} else if (childCount > 0) {
sub = `${childCount} kategori`;
} else {
sub = 'Lihat semua';
}
const iconStyle = `background: linear-gradient(135deg, ${gradient.from}, ${gradient.to}); border-color: ${gradient.border}; color: ${gradient.color};`;
return `
${icon()}
${esc(title)}
${esc(sub)}
`;
}).join('');
const closeBtnHtml = !isDesktop() ? mobileCloseBtn() : '';
topView.innerHTML = ``;
}
// Open/close with safe-area
trigger.addEventListener('click', (e)=>{ e.preventDefault(); document.body.classList.contains('mm2-open') ? close() : open(); });
trigger.addEventListener('pointerenter', ()=>{ if (isDesktop()) { clearTimeout(closeTimer); hoverTimer=setTimeout(open, 80); }});
trigger.addEventListener('pointerleave', (e)=>{ if (isDesktop() && !isToSafeArea(e)) { clearTimeout(hoverTimer); closeTimer=setTimeout(close, 200); }});
panel.addEventListener('pointerenter', ()=>{ if (isDesktop()) clearTimeout(closeTimer); });
panel.addEventListener('pointerleave', (e)=>{ if (isDesktop() && !isToSafeArea(e)) closeTimer=setTimeout(close, 200); });
backdrop?.addEventListener('pointerenter', ()=>{ clearTimeout(closeTimer); });
backdrop?.addEventListener('click', close);
document.addEventListener('keydown', (e)=>{ if (e.key==='Escape') close(); });
// Card click → open detail (unless cmd/ctrl to open link)
panel.addEventListener('click', (e)=>{
const closeBtn = e.target.closest('[data-mm2="close"]'); if (closeBtn) { e.preventDefault(); close(); return; }
const back = e.target.closest('[data-mm2="back"]'); if (back) { e.preventDefault(); showTop(); return; }
const card = e.target.closest('[data-mm2="card"]');
if (card && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
const n = data.find(d => d.path === card.dataset.path);
if (n) showDetail(n);
}
const nav = e.target.closest('[data-mm2="nav"]');
if (nav) { saveRecent(nav.dataset.path, nav.dataset.label); }
});
// Swipe-down gesture for mobile (bottom sheet close)
if (!isDesktop()) {
let startY = 0, currentY = 0, isDragging = false, startTime = 0;
const SWIPE_THRESHOLD = 80;
const VELOCITY_THRESHOLD = 0.3;
panel.addEventListener('touchstart', (e) => {
if (!document.body.classList.contains('mm2-open')) return;
const touch = e.touches[0];
const scrollableContent = e.target.closest('.mm2-top, .mm2-detail');
// Prevent drag if content is scrolled
if (scrollableContent && scrollableContent.scrollTop > 5) return;
// Only start drag from top area or handle
const rect = panel.getBoundingClientRect();
if (touch.clientY - rect.top > 60) return;
startY = touch.clientY;
currentY = startY;
startTime = Date.now();
isDragging = true;
}, { passive: true });
panel.addEventListener('touchmove', (e) => {
if (!isDragging) return;
const touch = e.touches[0];
currentY = touch.clientY;
const deltaY = currentY - startY;
// Only allow downward swipe
if (deltaY > 0) {
const opacity = Math.max(0.4, 1 - (deltaY / 300));
panel.style.transform = `translateX(0) translateY(${deltaY}px)`;
panel.style.opacity = opacity;
}
}, { passive: true });
const endDrag = () => {
if (!isDragging) return;
const deltaY = currentY - startY;
const deltaTime = Date.now() - startTime;
const velocity = deltaY / deltaTime;
// Close if swiped down enough or fast swipe
if (deltaY > SWIPE_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
close();
}
// Reset position
panel.style.transform = '';
panel.style.opacity = '';
isDragging = false;
};
panel.addEventListener('touchend', endDrag, { passive: true });
panel.addEventListener('touchcancel', endDrag, { passive: true });
}
// Fetch data with loading state
if (!isLoading) {
isLoading = true;
showLoading();
fetch(apiUrl, { headers: { 'Accept': 'application/json' }})
.then(r => {
if (!r.ok) throw new Error('Network response was not ok');
return r.json();
})
.then(j => {
data = Array.isArray(j.data) ? j.data : [];
if (!data.length) {
topView.innerHTML = `
Tidak ada kategori tersedia
`;
return;
}
buildTop();
isLoading = false;
})
.catch(err => {
console.error('Failed to load categories:', err);
topView.innerHTML = `
Gagal memuat kategori. Silakan coba lagi.
`;
isLoading = false;
});
}
})();
// Add skeleton animation styles
const style = document.createElement('style');
style.textContent = `
.mm2-skeleton {
pointer-events: none;
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.skeleton-box, .skeleton-line {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`;
document.head.appendChild(style);