Choose theme
Your preference is saved on this device.
Keyboard shortcuts
- / Focus search (Catalog, FAQ)
- f Toggle save to Fallows (Catalog)
- ? Open this help
- t Theme switcher
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(); } });
}