Sign in

Create account

Theme

alvertra.pro

Courses Catalog

Search, filter, and compare courses. Click a card for full details.

    alvertra.pro
    ' : ''; } } function initHeaderFooterInteractions(){ document.body.addEventListener('click', (e)=>{ if(e.target.matches('[data-open]')){ e.preventDefault(); const sel = e.target.getAttribute('data-open'); const dlg = document.querySelector(sel); if(dlg) dlg.showModal(); } if(e.target.matches('[data-close]')){ e.preventDefault(); e.target.closest('dialog')?.close(); } if(e.target.matches('[data-theme-toggle]')){ e.preventDefault(); const current = localStorage.getItem('theme') || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); const next = current === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', next); applyTheme(next); } }); const cookieConsent = localStorage.getItem('cookie-consent'); if(!cookieConsent) document.getElementById('cookieConsent')?.showModal(); document.getElementById('acceptCookies')?.addEventListener('click', ()=>{ localStorage.setItem('cookie-consent','1'); document.getElementById('cookieConsent')?.close(); }); document.getElementById('declineCookies')?.addEventListener('click', ()=> document.getElementById('cookieConsent')?.close()); document.body.addEventListener('submit', (e)=>{ if(e.target.matches('form')){ e.preventDefault(); const d=document.createElement('dialog'); d.className='backdrop:bg-black/40 rounded-lg w-full max-w-md'; d.innerHTML='

    Form Sent

    Captured locally for demo.

    '; document.body.appendChild(d); d.showModal(); } }); } function applyTheme(mode){ const root = document.documentElement; if(mode === 'dark'){ root.classList.add('dark'); } else { root.classList.remove('dark'); } } const App = (()=>{ const state = { items: [], filtered: [], page: 1, perPage: 9, q: '', category: '', difficulty: '', format: '', province: '', priceMax: '' }; const els = { grid: null, count: null, prev: null, next: null, page: null, tpl: null, inputs: {}, modal: {}, ld: null }; const keys = { fav: 'avs-favourites', cart: 'avs-cart' }; function getFavs(){ try{ return JSON.parse(localStorage.getItem(keys.fav)) || []; }catch(e){ return []; } } function setFavs(arr){ localStorage.setItem(keys.fav, JSON.stringify(arr)); updateHeaderCounts(); } function getCart(){ try{ return JSON.parse(localStorage.getItem(keys.cart)) || []; }catch(e){ return []; } } function setCart(arr){ localStorage.setItem(keys.cart, JSON.stringify(arr)); updateHeaderCounts(); } function updateHeaderCounts(){ const fc = getFavs().length; const cc = getCart().length; const favEl = document.querySelector('[data-fav-count]'); const cartEl = document.querySelector('[data-cart-count]'); if(favEl) favEl.textContent = fc; if(cartEl) cartEl.textContent = cc; document.dispatchEvent(new CustomEvent('fav:change', {detail:{count:fc}})); document.dispatchEvent(new CustomEvent('cart:change', {detail:{count:cc}})); } function money(n){ return new Intl.NumberFormat('en-CA', {style:'currency', currency:'CAD', maximumFractionDigits:0}).format(n); } function plural(n, s){ return n + ' ' + (n===1 ? s : s+'s'); } function debounce(fn, t=250){ let id; return (...a)=>{ clearTimeout(id); id=setTimeout(()=>fn(...a), t); } } function readURL(){ const p = new URLSearchParams(location.search); state.q = p.get('q') || ''; state.category = p.get('category') || ''; state.difficulty = p.get('difficulty') || ''; state.format = p.get('format') || ''; state.province = p.get('province') || ''; state.priceMax = p.get('priceMax') || ''; state.page = Math.max(1, parseInt(p.get('page')||'1', 10)); } function writeURL(){ const p = new URLSearchParams(); if(state.q) p.set('q', state.q); if(state.category) p.set('category', state.category); if(state.difficulty) p.set('difficulty', state.difficulty); if(state.format) p.set('format', state.format); if(state.province) p.set('province', state.province); if(state.priceMax) p.set('priceMax', state.priceMax); if(state.page>1) p.set('page', String(state.page)); const q = p.toString(); history.pushState({}, '', q ? ('?'+q) : location.pathname); } function bindControls(){ els.inputs.q.value = state.q; els.inputs.category.value = state.category; els.inputs.difficulty.value = state.difficulty; els.inputs.format.value = state.format; els.inputs.province.value = state.province; els.inputs.priceMax.value = state.priceMax; els.inputs.q.addEventListener('input', debounce((e)=>{ state.q = e.target.value.trim(); state.page = 1; writeURL(); render(); }, 200)); els.inputs.q.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ e.preventDefault(); state.q = e.target.value.trim(); state.page=1; writeURL(); render(); }}); $('#clearQ').addEventListener('click', (e)=>{ e.preventDefault(); els.inputs.q.value=''; state.q=''; state.page=1; writeURL(); render(); els.inputs.q.focus(); }); ['category','difficulty','format','province'].forEach(id=>{ els.inputs[id].addEventListener('change', ()=>{ state[id] = els.inputs[id].value; state.page=1; writeURL(); render(); }); }); els.inputs.priceMax.addEventListener('change', ()=>{ const v = els.inputs.priceMax.value; state.priceMax = v ? Math.max(0, parseInt(v,10)) : ''; state.page=1; writeURL(); render(); }); els.prev.addEventListener('click', ()=>{ if(state.page>1){ state.page--; writeURL(); renderPage(); }}); els.next.addEventListener('click', ()=>{ const pages = Math.max(1, Math.ceil(state.filtered.length/state.perPage)); if(state.page{ readURL(); bindControls(); render(); }); // Hash deep-link to open modal window.addEventListener('hashchange', ()=>{ const id = location.hash.replace('#',''); if(id){ openDetails(id); } }); } function filterItems(){ let arr = state.items.slice(); const q = state.q.toLowerCase(); if(q){ const tokens = q.split(/\s+/).filter(Boolean); arr = arr.filter(it=>{ const hay = (it.title+' '+(it.shortDescription||'')+' '+(it.tags||[]).join(' ')).toLowerCase(); return tokens.every(t => hay.includes(t)); }); } if(state.category) arr = arr.filter(it=>it.category===state.category); if(state.difficulty) arr = arr.filter(it=>it.difficulty===state.difficulty); if(state.format) arr = arr.filter(it=>it.format===state.format); if(state.province) arr = arr.filter(it=>it.province===state.province); if(state.priceMax!=='') arr = arr.filter(it=> Number(it.price) <= Number(state.priceMax)); // Optional sort by rating desc, then price asc arr.sort((a,b)=> b.rating - a.rating || a.price - b.price); state.filtered = arr; } function renderCount(){ const total = state.filtered.length; const pages = Math.max(1, Math.ceil(total/state.perPage)); state.page = Math.min(Math.max(1, state.page), pages); const start = total ? ( (state.page-1)*state.perPage + 1 ) : 0; const end = Math.min(total, state.page*state.perPage); els.count.textContent = total ? `Showing ${start}–${end} of ${total} courses` : 'No courses match your filters'; els.page.textContent = `Page ${state.page} / ${pages}`; els.prev.disabled = state.page<=1; els.next.disabled = state.page>=pages; } function star(r){ const full = Math.floor(r); const half = (r - full) >= 0.5 ? 1 : 0; const empty = 5 - full - half; return '★'.repeat(full) + (half?'½':'') + '☆'.repeat(empty); } function renderGrid(){ els.grid.innerHTML = ''; const favs = new Set(getFavs()); const cart = new Set(getCart()); const slice = state.filtered.slice((state.page-1)*state.perPage, state.page*state.perPage); if(slice.length===0){ const div = document.createElement('div'); div.className='col-span-full text-center text-gray-600 dark:text-gray-400 py-12'; div.textContent='Try adjusting filters or search query.'; els.grid.appendChild(div); return; } slice.forEach(it=>{ const n = els.tpl.content.cloneNode(true); const root = n.querySelector('article'); const bOpen = n.querySelector('[data-role="open"]'); const img = n.querySelector('[data-role="img"]'); const ttl = n.querySelector('[data-role="title"]'); const dsc = n.querySelector('[data-role="desc"]'); const meta = n.querySelector('[data-role="meta"]'); const price = n.querySelector('[data-role="price"]'); const fav = n.querySelector('[data-role="fav"]'); const cartBtn = n.querySelector('[data-role="cart"]'); img.src = it.image; img.alt = it.title + ' — ' + it.shortDescription; ttl.textContent = it.title; dsc.textContent = it.shortDescription; meta.textContent = `${it.category} • ${it.difficulty} • ${it.format} • ${it.province} • ${it.rating.toFixed(1)}★`; price.textContent = money(it.price); bOpen.addEventListener('click', ()=>{ openDetails(it.id); }); function syncFav(){ fav.textContent = favs.has(it.id) ? 'Unfavourite' : 'Favourite'; } function syncCart(){ cartBtn.textContent = cart.has(it.id) ? 'In cart' : 'Add to cart'; } syncFav(); syncCart(); fav.addEventListener('click', (e)=>{ e.preventDefault(); if(favs.has(it.id)){ favs.delete(it.id); } else { favs.add(it.id); } setFavs(Array.from(favs)); syncFav(); syncModalFavIfSame(it.id); }); cartBtn.addEventListener('click', (e)=>{ e.preventDefault(); if(cart.has(it.id)){ cart.delete(it.id); } else { cart.add(it.id); } setCart(Array.from(cart)); syncCart(); syncModalCartIfSame(it.id); toast(cart.has(it.id) ? 'Added to cart' : 'Removed from cart'); }); els.grid.appendChild(n); }); } function renderPage(){ renderCount(); renderGrid(); } function render(){ filterItems(); renderPage(); buildLD(); } function toast(msg){ const t = document.createElement('div'); t.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 bg-black text-white dark:bg-white dark:text-black px-4 py-2 rounded shadow z-50'; t.textContent = msg; document.body.appendChild(t); setTimeout(()=>{ t.classList.add('opacity-0'); t.style.transition='opacity .4s'; }, 10); setTimeout(()=> t.remove(), 600); } function openDetails(id){ const it = state.items.find(x=>x.id===id); if(!it) return; const favs = new Set(getFavs()); const cart = new Set(getCart()); els.modal.root.dataset.id = id; els.modal.title.textContent = it.title; els.modal.desc.textContent = it.shortDescription; els.modal.img.src = it.image; els.modal.img.alt = it.title + ' course cover'; els.modal.price.textContent = money(it.price); els.modal.meta.innerHTML = [ `Instructor: ${it.instructor}`, `Duration: ${plural(it.durationWeeks,'week')}`, `Level: ${it.difficulty}`, `Format: ${it.format}`, `Location: ${it.province}`, `Rating: ${it.rating.toFixed(1)} / 5 (${star(it.rating)})`, `Tags: ${(it.tags||[]).map(t=>`${t}`).join(' ')}` ].join(''); els.modal.syllabus.innerHTML = ''; (it.syllabus||[]).forEach(s=>{ const li = document.createElement('li'); li.textContent = s; els.modal.syllabus.appendChild(li); }); function syncButtons(){ els.modal.fav.textContent = favs.has(id) ? 'Remove from Favourites' : 'Add to Favourites'; els.modal.cart.textContent = cart.has(id) ? 'In Cart' : 'Add to Cart'; } syncButtons(); els.modal.fav.onclick = ()=>{ if(favs.has(id)) favs.delete(id); else favs.add(id); setFavs(Array.from(favs)); syncButtons(); renderGrid(); // update visible card buttons }; els.modal.cart.onclick = ()=>{ if(cart.has(id)) cart.delete(id); else cart.add(id); setCart(Array.from(cart)); syncButtons(); renderGrid(); toast(cart.has(id) ? 'Added to cart' : 'Removed from cart'); }; els.modal.root.showModal(); if(location.hash.replace('#','')!==id){ history.replaceState({}, '', location.pathname + location.search + '#'+id); } els.modal.root.addEventListener('close', clearHashOnce, {once:true}); } function clearHashOnce(){ if(location.hash){ history.replaceState({}, '', location.pathname + location.search); } } function syncModalFavIfSame(id){ if(els.modal.root.open && els.modal.root.dataset.id===id){ const favs = new Set(getFavs()); els.modal.fav.textContent = favs.has(id) ? 'Remove from Favourites' : 'Add to Favourites'; } } function syncModalCartIfSame(id){ if(els.modal.root.open && els.modal.root.dataset.id===id){ const cart = new Set(getCart()); els.modal.cart.textContent = cart.has(id) ? 'In Cart' : 'Add to Cart'; } } function buildLD(){ try{ const base = location.origin + location.pathname.replace(/[^/]+$/,'') + 'catalog.html'; const list = state.filtered.slice(0, 30).map((it, i)=>({ '@type':'ListItem', position: i+1, url: base + '#' + it.id, name: it.title })); const ld = { '@context':'https://schema.org', '@type':'ItemList', itemListElement: list }; els.ld.textContent = JSON.stringify(ld); }catch(e){} } async function load(){ els.grid.innerHTML = '
    Loading courses…
    '; try{ const res = await fetch('./catalog.json', {cache:'no-cache'}); if(!res.ok) throw new Error('Failed to load catalog'); const data = await res.json(); if(!Array.isArray(data)) throw new Error('Invalid data'); state.items = data; filterItems(); render(); }catch(e){ els.grid.innerHTML = '
    Unable to load catalog. Please try again later.
    '; console.error(e); } } function initElements(){ els.grid = $('#grid'); els.count = $('#countInfo'); els.prev = $('#prev'); els.next = $('#next'); els.page = $('#page'); els.tpl = $('#cardTpl'); els.ld = $('#ldList'); els.inputs = { q: $('#q'), category: $('#category'), difficulty: $('#difficulty'), format: $('#format'), province: $('#province'), priceMax: $('#priceMax') }; els.modal = { root: $('#detailsModal'), title: $('#dTitle'), desc: $('#dDesc'), syllabus: $('#dSyllabus'), meta: $('#dMeta'), img: $('#dImg'), price: $('#dPrice'), fav: $('#dFav'), cart: $('#dCart') }; // Adaptive perPage depending on viewport width const w = window.innerWidth; if(w>=1024) state.perPage = 9; else if(w>=640) state.perPage = 6; else state.perPage = 6; } function init(){ initElements(); readURL(); bindControls(); load(); updateHeaderCounts(); // Open modal from hash if present after load const hashId = location.hash.replace('#',''); if(hashId){ const obs = new MutationObserver(()=>{ if(state.items.length){ openDetails(hashId); obs.disconnect(); } }); obs.observe(document.body, {subtree:true, childList:true}); } } return { init }; })(); (async function bootstrap(){ const preferred = localStorage.getItem('theme') || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); applyTheme(preferred); await injectPartial('header', './header.html'); await injectPartial('footer', './footer.html'); initHeaderFooterInteractions(); App.init(); })();