Course catalog

Search and filter to find the exact technique or pathway you need. Save courses to Fallows for later.

`; } } setTimeout(() => { const cartCount = document.getElementById('tbi-cart-count'); if (cartCount) { try { const cart = JSON.parse(localStorage.getItem('tbi_cart') || '{}'); const count = Object.values(cart).reduce((a, b) => a + Number(b || 0), 0); cartCount.textContent = String(count); } catch(e){} } }, 800); })(); const state = { all: [], filtered: [], page: 1, perPage: 9, query: '', levels: new Set(), formats: new Set(), minPrice: null, maxPrice: null, maxDur: null, compare: [] }; const els = {}; document.addEventListener('DOMContentLoaded', init); async function init(){ cacheEls(); bindUI(); await loadData(); applyFilters(); focusShortcut(); } function cacheEls(){ els.q = document.getElementById('q'); els.grid = document.getElementById('grid'); els.pager = document.getElementById('pager'); els.resultsInfo = document.getElementById('resultsInfo'); els.minPrice = document.getElementById('minPrice'); els.maxPrice = document.getElementById('maxPrice'); els.maxDur = document.getElementById('maxDur'); els.levels = Array.from(document.querySelectorAll('input.level')); els.formats = Array.from(document.querySelectorAll('input.format')); els.apply = document.getElementById('applyFilters'); els.reset = document.getElementById('resetFilters'); els.details = { root: document.getElementById('detailsModal'), title: document.getElementById('dmTitle'), short: document.getElementById('dmShort'), syllabus: document.getElementById('dmSyllabus'), outcomes: document.getElementById('dmOutcomes'), prereq: document.getElementById('dmPrereq'), meta: document.getElementById('dmMeta'), addFallows: document.getElementById('dmAddFallows'), addCart: document.getElementById('dmAddCart'), close: document.getElementById('dmClose') }; els.compare = { root: document.getElementById('compareDrawer'), body: document.getElementById('compareBody'), close: document.getElementById('compareClose') }; } function bindUI(){ els.apply.onclick = ()=>{ collectFilters(); applyFilters(); }; els.reset.onclick = ()=>{ resetFilters(); applyFilters(); }; els.q.oninput = ()=>{ state.query = els.q.value.trim(); state.page = 1; applyFilters(); }; els.levels.forEach(cb=> cb.onchange = ()=>{}); els.formats.forEach(cb=> cb.onchange = ()=>{}); els.details.close.onclick = ()=> toggle(els.details.root,false); els.details.root.addEventListener('click', (e)=>{ if (e.target.dataset && e.target.dataset.close==='details'){ toggle(els.details.root,false); } }); els.compare.close.onclick = ()=> toggle(els.compare.root,false); } function toggle(el, show){ if (!el) return; el.classList[show?'remove':'add']('hidden'); } function collectFilters(){ state.levels = new Set(els.levels.filter(x=>x.checked).map(x=>x.value)); state.formats = new Set(els.formats.filter(x=>x.checked).map(x=>x.value)); state.minPrice = Number(els.minPrice.value||'')||null; state.maxPrice = Number(els.maxPrice.value||'')||null; state.maxDur = Number(els.maxDur.value||'')||null; } function resetFilters(){ els.q.value = ''; els.minPrice.value = ''; els.maxPrice.value = ''; els.maxDur.value = ''; els.levels.forEach(x=> x.checked=false); els.formats.forEach(x=> x.checked=false); state.query = ''; state.levels.clear(); state.formats.clear(); state.minPrice = state.maxPrice = state.maxDur = null; state.page = 1; } async function loadData(){ const res = await fetch('./catalog.json'); const data = await res.json(); state.all = data; } function applyFilters(){ const q = state.query.toLowerCase(); state.filtered = state.all.filter(c=>{ if (q){ const hay = (c.title+' '+c.short+' '+(c.tags||[]).join(' ')).toLowerCase(); if (!hay.includes(q)) return false; } if (state.levels.size && !state.levels.has(c.level)) return false; if (state.formats.size && !state.formats.has(c.format)) return false; if (state.minPrice!==null && c.price < state.minPrice) return false; if (state.maxPrice!==null && c.price > state.maxPrice) return false; if (state.maxDur!==null && c.duration_hours > state.maxDur) return false; return true; }); render(); } function render(){ const total = state.filtered.length; const pages = Math.max(1, Math.ceil(total / state.perPage)); if (state.page > pages) state.page = pages; const start = (state.page - 1) * state.perPage; const items = state.filtered.slice(start, start + state.perPage); els.resultsInfo.textContent = `${total} course${total===1?'':'s'} found`; els.grid.innerHTML = items.map(renderCard).join(''); items.forEach(c=>{ const favBtn = document.getElementById(`fav-${c.id}`); const detBtn = document.getElementById(`det-${c.id}`); const cmpBtn = document.getElementById(`cmp-${c.id}`); const addBtn = document.getElementById(`add-${c.id}`); if (favBtn) favBtn.onclick = ()=> toggleFallows(c.id); if (detBtn) detBtn.onclick = ()=> openDetails(c); if (cmpBtn) cmpBtn.onclick = ()=> addCompare(c); if (addBtn) addBtn.onclick = ()=> addToCart(c.id,1); }); let pagerHTML = ''; for (let i=1;i<=pages;i++){ pagerHTML += ``; } els.pager.innerHTML = pagerHTML; Array.from(els.pager.querySelectorAll('button')).forEach(b=> b.onclick = ()=>{ state.page = Number(b.dataset.page); render(); }); const favs = getFallows(); items.forEach(c=>{ const favBtn = document.getElementById(`fav-${c.id}`); if (favBtn) favBtn.setAttribute('aria-pressed', favs.includes(c.id)?'true':'false'); }); } function renderCard(c){ const favs = getFallows(); const saved = favs.includes(c.id); return `

${c.title}

${c.short}

Level: ${c.level} • ${c.format} • ${c.duration_hours}h • ⭐ ${c.rating.toFixed(1)}
$${c.price.toFixed(2)}
`; } function getFallows(){ try { return JSON.parse(localStorage.getItem('tbi_fallows')||'[]'); } catch(e){ return []; } } function setFallows(arr){ try { localStorage.setItem('tbi_fallows', JSON.stringify(arr)); } catch(e){} } function toggleFallows(id){ const arr = getFallows(); const i = arr.indexOf(id); if (i>=0) arr.splice(i,1); else arr.push(id); setFallows(arr); render(); } function openDetails(c){ els.details.title.textContent = c.title; els.details.short.textContent = c.short; els.details.syllabus.innerHTML = c.syllabus.map(s=> `
  • ${s}
  • `).join(''); els.details.outcomes.innerHTML = c.outcomes.map(s=> `
  • ${s}
  • `).join(''); els.details.prereq.textContent = 'Prerequisites: ' + c.prerequisites; els.details.meta.textContent = `${c.level} • ${c.format} • ${c.duration_hours}h • ⭐ ${c.rating.toFixed(1)} • $${c.price.toFixed(2)}`; els.details.addFallows.onclick = ()=> toggleFallows(c.id); els.details.addCart.onclick = ()=> addToCart(c.id,1); toggle(els.details.root, true); } function addToCart(id, qty){ try { const cart = JSON.parse(localStorage.getItem('tbi_cart')||'{}'); cart[id] = (cart[id]||0) + qty; localStorage.setItem('tbi_cart', JSON.stringify(cart)); alert('Added to cart'); const el = document.getElementById('tbi-cart-count'); if (el){ const count = Object.values(cart).reduce((a,b)=>a+Number(b||0),0); el.textContent = String(count); } } catch(e){} } function addCompare(c){ const i = state.compare.findIndex(x=>x.id===c.id); if (i>=0) state.compare.splice(i,1); else { state.compare.push(c); if (state.compare.length>3) state.compare.shift(); } const body = state.compare.map(x=> `
    ${x.title}
    ${x.level} • ${x.format} • ${x.duration_hours}h • $${x.price.toFixed(2)}
    ${x.outcomes.slice(0,2).map(o=>'• '+o).join('
    ')}
    `).join(''); els.compare.body.innerHTML = body || '

    Add up to 3 courses to compare.

    '; toggle(els.compare.root, true); } function focusShortcut(){ document.addEventListener('keydown', (e)=>{ if (e.key === '/'){ e.preventDefault(); if(els.q) els.q.focus(); } }); }