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();
})();