Catalog Builder
· assemble a contractor’s product catalog → live JSON-LD
Save
Publish
✦ Guide
⬡ Explorer
Copy JSON-LD
Sign in
Create account
Sign in
Cancel
1 · Contractor
Business name
Trade (schema type)
Website
City / Region
Brand accent (published page)
colour for your live /p/ page
2 · Select lines
✨ Auto-select
Loading…
0
products in catalog
Products
(from selected lines · click ✕ to exclude)
Select lines to build the catalog.
JSON-LD preview
JSON-LD
Microdata HTML
{ }
the user pastes this into (standard mitigation). : JSON.stringify(doc,null,2).replace(/`
${esc(m.name)}
${m.categories.reduce((a,c)=>a+c.lines.length,0)} lines
${m.categories.map(c=>`
${esc(tc(c.category))}
${c.lines.map(l=>`
${esc(l.name)}
${l.product_count}
`).join('')}
`).join('')}
`).join(''); $('#tree').addEventListener('change', e=>{ const id=+e.target.dataset.line; if(!id)return; e.target.checked?selectedLines.add(id):selectedLines.delete(id); refresh(); }); ['c-name','c-trade','c-web','c-city','c-region'].forEach(id=>$('#'+id).addEventListener('input', ()=>renderJSON())); $('#nlgo').onclick=nlBuild; $('#nlq').addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); nlBuild(); } }); } // B10 — natural-language → auto-selected lines. Parse the query into facets via // /api/explore/nl, then for each detected home area pull its lines and check them. async function nlBuild(){ const q=$('#nlq').value.trim(); if(!q) return; const nl=await (await fetch('/api/explore/nl?q='+encodeURIComponent(q))).json(); const matched=(nl&&nl.matched)||[]; $('#nlmatched').innerHTML = matched.length ? 'Detected: '+matched.map(m=>`
${esc(m.label)}
`).join('') : '
No areas detected — try “roofing”, “siding”, “kitchen”, “deck”…
'; let added=0; for(const m of matched.filter(m=>m.kind==='area')){ const d=await (await fetch('/api/explore/area/'+encodeURIComponent(m.value))).json(); for(const l of (d.lines||[])){ if(!selectedLines.has(l.id)){ selectedLines.add(l.id); added++; } const cb=$(`#tree input[data-line="${l.id}"]`); if(cb){ cb.checked=true; cb.closest('details.cat')?.setAttribute('open',''); cb.closest('details.mfr')?.setAttribute('open',''); } } } if(added) refresh(); } async function refresh(){ // gather products from selected lines (via each manufacturer payload, cached) const wanted={}; for (const m of NAV.manufacturers){ const lineIds=m.categories.flatMap(c=>c.lines).filter(l=>selectedLines.has(l.id)).map(l=>l.id); if(!lineIds.length) continue; if(!productCache[m.slug]) productCache[m.slug]=(await (await fetch('/api/manufacturer/'+m.slug)).json()).products; for (const p of productCache[m.slug]) if(lineIds.includes(p.line_id)) wanted[p.id]={...p, mfr:m.name}; } window.__wanted=wanted; const ids=Object.keys(wanted).map(Number).filter(id=>!excluded.has(id)); $('#cnt').innerHTML=ids.length+'
products in catalog
'; $('#prods').innerHTML = Object.values(wanted).length ? Object.values(wanted).map(p=>{ const hero=(p.media||[])[0]; const off=excluded.has(p.id); return `
${hero?`
`:'
'}
${esc(p.name)}
${esc(p.mfr)}
${off?'↺':'✕'}
`; }).join('') : '
Select lines to build the catalog.
'; $('#prods').querySelectorAll('[data-x]').forEach(b=>b.onclick=()=>{ const id=+b.dataset.x; excluded.has(id)?excluded.delete(id):excluded.add(id); refresh(); }); renderJSON(); } let jsonTimer=null; function renderJSON(){ clearTimeout(jsonTimer); jsonTimer=setTimeout(async()=>{ const body={ contractor:{ name:$('#c-name').value, trade:$('#c-trade').value||'RoofingContractor', website:$('#c-web').value, city:$('#c-city').value, region:$('#c-region').value, accent:$('#c-accent').value }, lines:[...selectedLines], products:{ include:[...included], exclude:[...excluded] } }; const res=await (await fetch('/api/builder/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json(); window.__graph=res.jsonld; // the fetched @graph, shared by both output formats $('#valid').innerHTML = res.product_count ? `✓ ${res.product_count} offers · valid @graph` : 'JSON-LD preview'; paintOut(); // render in the currently selected format (no refetch on toggle) },250); } // Output-format toggle: re-encode the already-fetched @graph, no server call. $('#fmt').addEventListener('click', e=>{ const f=e.target.dataset.fmt; if(!f||f===outFormat) return; outFormat=f; $('#fmt').querySelectorAll('.seg').forEach(b=>b.classList.toggle('on', b.dataset.fmt===f)); paintOut(); }); $('#copy').onclick=()=>{ navigator.clipboard?.writeText(window.__out||$('#out').textContent||''); $('#copy').textContent='Copied ✓'; setTimeout(()=>{ $('#copy').textContent = outFormat==='microdata' ? 'Copy Microdata' : 'Copy JSON-LD'; },1200); }; /* ── accounts + saved catalogs (jo/SQLite backend) ── */ let AUTH=null, authTab='login', currentSlug=''; function currentSelection(){ return { contractor:{ name:$('#c-name').value, trade:$('#c-trade').value||'RoofingContractor', website:$('#c-web').value, city:$('#c-city').value, region:$('#c-region').value, accent:$('#c-accent').value }, lines:[...selectedLines], products:{ include:[...included], exclude:[...excluded] } }; } async function loadMe(){ try{ AUTH=(await (await fetch('/api/auth/me')).json()).user; }catch{ AUTH=null; } paintAuth(); } function paintAuth(){ const el=$('#account'); if(AUTH){ el.innerHTML=`
${esc(AUTH.email)}
Sign out
`; $('#logoutbtn').onclick=async()=>{ await fetch('/api/auth/logout',{method:'POST'}); AUTH=null; paintAuth(); }; $('#savebtn').style.display=''; $('#pubbtn').style.display=''; $('#catalogs').style.display=''; loadCatalogs(); loadPublished(); } else { el.innerHTML=`
Sign in
`; $('#signinbtn').onclick=openAuth; $('#savebtn').style.display='none'; $('#pubbtn').style.display='none'; $('#catalogs').style.display='none'; $('#mysites').innerHTML=''; } } function loadPublished(){ Promise.all([ fetch('/api/published').then(r=>r.json()).catch(()=>[]), fetch('/api/analytics').then(r=>r.json()).catch(()=>[]), fetch('/api/domains').then(r=>r.json()).catch(()=>[]), fetch('/api/leads').then(r=>r.json()).catch(()=>[]) ]).then(([list,stats,domains,leads])=>{ const el=$('#mysites'); if(!el) return; if(!Array.isArray(list)||!list.length){ el.innerHTML=''; return; } const st={}; (Array.isArray(stats)?stats:[]).forEach(s=>st[s.slug]={v:+s.views||0,c:+s.clicks||0}); // domains + leads are keyed by the PUB (u
-
); the list gives the bare slug const dom={}; (Array.isArray(domains)?domains:[]).forEach(d=>{ const k=String(d.pub||'').replace(/^u\d+-/,''); dom[k]=d.domain; }); const ld={}; (Array.isArray(leads)?leads:[]).forEach(l=>{ const k=String(l.pub||'').replace(/^u\d+-/,''); ld[k]=(ld[k]||0)+1; }); el.innerHTML=`
My published sites
`+ list.map(s=>{const a=st[s.slug]||{v:0,c:0}; const n=ld[s.slug]||0; return `
↗ ${esc(s.slug)}
· ${s.products||0} products · 👁 ${a.v} · 🖱 ${a.c}${n?` · ✉
${n} lead${n>1?'s':''}
`:''}
Save
`;}).join('') + (Object.keys(ld).length?`
View all leads →
`:''); el.querySelectorAll('.domsave').forEach(b=>b.onclick=async()=>{ const slug=b.dataset.slug, inp=el.querySelector(`.dom[data-slug="${CSS.escape(slug)}"]`); b.textContent='…'; const r=await fetch(`/api/catalogs/${encodeURIComponent(slug)}/domain`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain:inp.value})}); const j=await r.json().catch(()=>({})); b.textContent = r.ok ? 'Saved' : (j.error||'Error'); setTimeout(()=>{b.textContent='Save';},1800); }); const lv=$('#leadsview'); if(lv) lv.onclick=async(e)=>{ e.preventDefault(); const rows=await (await fetch('/api/leads')).json(); alert((Array.isArray(rows)?rows:[]).map(l=>`${l.created_at} · ${l.name||'(no name)'} · ${l.email||l.phone||''}${l.product_name?' · '+l.product_name:''}\n ${(l.message||'').slice(0,140)}`).join('\n\n')||'No leads yet.'); }; }).catch(()=>{}); } function openAuth(){ $('#authmodal').style.display='flex'; $('#a-err').textContent=''; setTab('login'); } function setTab(t){ authTab=t; const su=t==='signup'; $('#authtabs').querySelectorAll('.seg').forEach(b=>b.classList.toggle('on', b.dataset.tab===t)); $('#a-name').style.display=su?'':'none'; $('#a-submit').textContent=su?'Create account':'Sign in'; } $('#authtabs').addEventListener('click', e=>{ if(e.target.dataset.tab) setTab(e.target.dataset.tab); }); $('#a-cancel').onclick=()=>$('#authmodal').style.display='none'; $('#a-submit').onclick=async()=>{ const path=authTab==='signup'?'/api/auth/signup':'/api/auth/login'; const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:$('#a-email').value, password:$('#a-pass').value, name:$('#a-name').value})}); const j=await r.json(); if(!r.ok){ $('#a-err').textContent=j.error||'error'; return; } AUTH=j.user; $('#authmodal').style.display='none'; paintAuth(); }; async function loadCatalogs(){ const list=await (await fetch('/api/catalogs')).json(); $('#catalogs').innerHTML=`
My catalogs (${(list||[]).length})…
`+(list||[]).map(c=>`
${esc(c.name)}
`).join(''); } $('#catalogs').onchange=async e=>{ const slug=e.target.value; if(!slug) return; currentSlug=slug; const c=await (await fetch('/api/catalogs/'+encodeURIComponent(slug))).json(); const s=c.selection||{}; $('#c-name').value=s.contractor?.name||''; $('#c-trade').value=s.contractor?.trade||'RoofingContractor'; $('#c-web').value=s.contractor?.website||''; $('#c-city').value=s.contractor?.city||''; $('#c-region').value=s.contractor?.region||''; if(s.contractor?.accent)$('#c-accent').value=s.contractor.accent; selectedLines=new Set(s.lines||[]); included=new Set(s.products?.include||[]); excluded=new Set(s.products?.exclude||[]); document.querySelectorAll('#tree input[data-line]').forEach(cb=>{ cb.checked=selectedLines.has(+cb.dataset.line); }); await refresh(); renderJSON(); }; $('#savebtn').onclick=async()=>{ if(!AUTH) return openAuth(); const nm=prompt('Save catalog as:', $('#c-name').value.trim()||'My catalog'); if(nm===null) return; const r=await fetch('/api/catalogs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:nm, name:nm, selection:currentSelection()})}); const j=await r.json(); if(!r.ok){ alert(j.error||'save failed'); return; } currentSlug=j.slug; $('#savebtn').textContent='Saved ✓'; setTimeout(()=>$('#savebtn').textContent='Save',1200); loadCatalogs(); }; $('#pubbtn').onclick=async()=>{ if(!AUTH) return openAuth(); if(!currentSlug){ alert('Save your catalog first, then publish it.'); return; } // save the latest selection under the current slug, then publish await fetch('/api/catalogs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:currentSlug, name:currentSlug, selection:currentSelection()})}); const r=await fetch('/api/catalogs/'+encodeURIComponent(currentSlug)+'/publish',{method:'POST'}); const j=await r.json(); if(!r.ok){ alert(j.error||'publish failed'); return; } $('#pubbtn').textContent='Published ✓'; setTimeout(()=>$('#pubbtn').textContent='Publish',1500); loadPublished(); const full=location.origin+j.url; if(confirm('Published '+j.products+' products to a live page:\n\n'+full+'\n\nOpen it now?')) window.open(j.url,'_blank'); }; loadMe(); boot();