async function getJSON(path){ const r = await fetch(path); return await r.json(); }
// Directory rendering
async function renderDirectory(){
const q = (document.getElementById('q')||{}).value?.toLowerCase()||'';
const cat = (document.getElementById('cat')||{}).value||'all';
const sort = (document.getElementById('sort')||{}).value||'karma-desc';
const resEl = document.getElementById('results');
if(!resEl) return;
const [cats, conf, data] = await Promise.all([
getJSON('./data/categories.json'),
getJSON('./data/karma-config.json'),
getJSON('./data/listings.json')
]);
// populate category select once
const catSel = document.getElementById('cat');
if(catSel && catSel.options.length===0){
catSel.append(new Option("All", "all"));
cats.categories.forEach(c => catSel.append(new Option(c,c)));
}
// compute karma for each listing
const W = conf.weights;
function score(k){ if(!k) return 0; return (k.profile*W.profile + k.reviews*W.reviews + k.velocity*W.velocity + k.verification*W.verification) }
let items = data.listings.map(x => ({...x, _karma: score(x.karma)}));
// filter
if(cat !== 'all') items = items.filter(x => x.category === cat);
if(q){
items = items.filter(x => (x.name+x.city+x.category+(x.tags||[]).join(' ')+(x.desc||'')).toLowerCase().includes(q));
}
// sort
if(sort==='karma-desc') items.sort((a,b)=>b._karma - a._karma);
if(sort==='name-asc') items.sort((a,b)=>a.name.localeCompare(b.name));
if(sort==='reviews-desc') items.sort((a,b)=> (b.reviews||0) - (a.reviews||0));
// render
resEl.innerHTML = items.map(x => `
Karma ${(x._karma*100).toFixed(0)}
${x.desc||''}
? ${x.stars||'-'} • ${x.reviews||0} reviews ${x.verified? '• Verified':''}
Tags: ${(x.tags||[]).map(t=>`${t}`).join(' ')}
`).join('') || 'No results.
';
}
document.addEventListener('DOMContentLoaded', ()=>{ renderDirectory(); renderKarma(); renderResources(); bindCheckout(); });
// Listing JSON generator
function generateListingJSON(){
const name = document.getElementById('new_name').value.trim();
const category = document.getElementById('new_category').value.trim();
const city = document.getElementById('new_city').value.trim();
const url = document.getElementById('new_url').value.trim();
const tags = document.getElementById('new_tags').value.split(',').map(s=>s.trim()).filter(Boolean);
const desc = document.getElementById('new_desc').value.trim();
const id = name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'');
const obj = {
"id": id, "name": name, "category": category, "city": city, "url": url,
"tags": tags, "desc": desc, "logo":"./img/placeholder.svg",
"reviews": 0, "stars": 0, "verified": false,
"karma": {"profile":0.5,"reviews":0.5,"velocity":0.5,"verification":0.0}
};
document.getElementById('new_output').value = JSON.stringify(obj, null, 2);
}
// Digital Karma page
async function renderKarma(){
const kconf = document.getElementById('kconf');
const kboard = document.getElementById('kboard');
if(!kconf && !kboard) return;
const [conf, data] = await Promise.all([getJSON('./data/karma-config.json'), getJSON('./data/listings.json')]);
const W = conf.weights;
if(kconf){
kconf.innerHTML = `
| Signal | Weight | Description |
| Profile | ${(W.profile*100).toFixed(0)}% | Completeness, images, links, schema |
| Reviews | ${(W.reviews*100).toFixed(0)}% | Stars & recent review velocity |
| Velocity | ${(W.velocity*100).toFixed(0)}% | Update cadence / freshness |
| Verification | ${(W.verification*100).toFixed(0)}% | Ownership & identity proof |
`;
}
if(kboard){
function score(k){ if(!k) return 0; return (k.profile*W.profile + k.reviews*W.reviews + k.velocity*W.velocity + k.verification*W.verification) }
const items = data.listings.map(x=>({...x,_karma:score(x.karma)})).sort((a,b)=>b._karma-a._karma).slice(0,10);
kboard.innerHTML = items.map((x,i)=>`
${(x._karma*100).toFixed(0)}
`).join('');
}
}
// Health Check logic
async function runHealthCheck(){
const [conf] = await Promise.all([getJSON('./data/karma-config.json')]);
const W = conf.weights;
const gbp = document.getElementById('f_gbp').value==='yes' ? 1 : 0;
const cwvSel = document.getElementById('f_cwv').value;
const stars = Math.max(1, Math.min(5, parseFloat(document.getElementById('f_stars').value||"0")));
const starsNorm = (stars-1)/(5-1); // 0..1
const reviews = Math.max(0, parseInt(document.getElementById('f_reviews').value||"0",10));
const reviewsNorm = Math.min(1, reviews/25); // cap at 25 in 90 days
const last = parseInt(document.getElementById('f_update').value,10);
const velocity = last<=30 ? 1 : (last<=90 ? 0.6 : 0.2);
const schema = document.getElementById('f_schema').value==='yes' ? 1 : 0;
const profile = (gbp*0.5 + schema*0.5);
const reviewsSignal = (starsNorm*0.6 + reviewsNorm*0.4);
const verification = gbp;
const score = (profile*W.profile + reviewsSignal*W.reviews + velocity*W.velocity + verification*W.verification);
document.getElementById('hc_score').textContent = `Karma ${(score*100).toFixed(0)}`;
const snapshot = {
date: new Date().toISOString(),
inputs: { gbp: gbp===1, cwv: cwvSel, stars, reviews, lastDays: last, schema: schema===1 },
mapped: { profile, reviewsSignal, velocity, verification },
score: score
};
document.getElementById('hc_json').textContent = JSON.stringify(snapshot,null,2);
}
// Resources
async function renderResources(){
const tbl = document.querySelector('#resources_table tbody');
if(!tbl) return;
const data = await getJSON('./data/resources.json');
tbl.innerHTML = data.items.map(x=>`
| ${x.title} | ${x.type} | Open | ${(x.tags||[]).join(', ')} |
`).join('');
}
// v04: Stripe Checkout wiring
async function bindCheckout(){
try{
const map = await getJSON('./data/checkout.json');
document.querySelectorAll('[data-checkout]').forEach(btn=>{
btn.addEventListener('click', ()=>{
const key = btn.getAttribute('data-checkout');
const prod = map.products && map.products[key];
if(prod && prod.checkout_url && !prod.checkout_url.includes('REPLACE_ME')){
window.location.href = prod.checkout_url;
}else{
alert('Checkout not configured yet. Replace the checkout_url in /data/checkout.json for \"'+key+'\".');
}
});
});
}catch(e){ console.warn('Checkout map missing', e); }
}
//Toggle Mobile Menu
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('nav-toggle');
const menu = document.getElementById('nav-menu');
if (!toggle || !menu) return;
toggle.addEventListener('click', (e) => {
e.preventDefault();
const isOpen = menu.classList.toggle('open'); // matches your CSS
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
// optional niceties:
menu.addEventListener('click', (e) => {
if (e.target.closest('a')) {
menu.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
menu.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
}
});
});