/* Noirparts.com storefront — vanilla JS SPA driven by /data/*.json (exported from the NoirParts_Master sheet) */
const DATA_URLS = {
categories: '/web/content/1965/np_categories.json',
products: '/web/content/1970/np_products.json',
vehicles: '/web/content/1967/np_vehicles.json',
compatByMod: '/web/content/1968/np_compat_by_mod.json',
compatByProduct: '/web/content/1969/np_compat_by_product.json',
};
const state = {
categories: [],
products: [],
productsById: {},
categoriesById: {},
vehicles: [],
compatByMod: {},
compatByProduct: {},
cart: loadCart(),
selectedVehicle: loadVehicle(), // {makeName, modelLineName, modelName, mod:{id,name,engine,...}}
loaded: false,
};
const CURRENCY = '₹';
const MAKE_ACRONYMS = new Set(['BMW','BYD','VW']);
function cleanProductName(name){
if(!name || !name.includes(';')) return name;
const parts = name.split(';').map(s => s.trim());
if(parts.length !== 2 || !parts[0] || !parts[1]) return name.replace(/;\s*/g, ' - ').trim();
let [base, axle] = parts;
const sideMatch = base.match(/\s+(Left|Right)$/i);
if(sideMatch){
const side = sideMatch[1];
base = base.slice(0, sideMatch.index).trim();
return `${axle} ${side} ${base}`;
}
return `${axle} ${base}`;
}
const SITE_TITLE_SUFFIX = ' | NoirParts';
const DEFAULT_META_DESCRIPTION = 'Shop 1800+ verified brake pads, discs, drums, calipers, cylinders and more from Bosch, Brembo, Ferodo, HELLA and other trusted brands. Instant fitment check by make, model and engine. COD available across India.';
function setPageMeta(title, description){
document.title = title ? `${title}${SITE_TITLE_SUFFIX}` : 'NoirParts — Genuine Brake Parts for 2700+ Indian Vehicles';
let m = document.querySelector('meta[name="description"]');
if(!m){ m = document.createElement('meta'); m.setAttribute('name','description'); document.head.appendChild(m); }
m.setAttribute('content', description || DEFAULT_META_DESCRIPTION);
}
function updateProductJsonLd(ld){
let el = document.getElementById('productJsonLd');
if(!ld){ if(el) el.remove(); return; }
if(!el){ el = document.createElement('script'); el.type = 'application/ld+json'; el.id = 'productJsonLd'; document.head.appendChild(el); }
el.textContent = JSON.stringify(ld);
}
function properMake(name){
const str = (name==null?'':String(name));
if (MAKE_ACRONYMS.has(str.toUpperCase())) return str.toUpperCase();
return str.toLowerCase().replace(/(^|[\s-])([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase());
}
function fixOrdinals(name){
const str = (name==null?'':String(name));
return str.replace(/(\d+)(st|nd|rd|th)\b/gi, (m, num, suf) => num + suf.toLowerCase());
}
const PLACEHOLDER_SVG = `
`;
function productPlaceholder(p){
const cat = p && state.categoriesById && state.categoriesById[p.category_id];
const icon = cat ? categoryIcon(cat.name) : CAT_ICONS.default;
const label = cat ? esc(cat.name) : 'Photo coming soon';
return ``;
}
/* ---------------- persistence ---------------- */
function loadCart(){
try{ return JSON.parse(localStorage.getItem('np_cart')||'[]'); }catch(e){ return []; }
}
function saveCart(){
localStorage.setItem('np_cart', JSON.stringify(state.cart));
updateCartCount();
}
function loadVehicle(){
try{ return JSON.parse(localStorage.getItem('np_vehicle')||'null'); }catch(e){ return null; }
}
function saveVehicle(v){
state.selectedVehicle = v;
if(v) localStorage.setItem('np_vehicle', JSON.stringify(v));
else localStorage.removeItem('np_vehicle');
renderVehiclePickerLabel();
}
/* ---------------- boot ---------------- */
async function boot(){
const [categories, products, vehicles, compatByMod, compatByProduct] = await Promise.all(
Object.values(DATA_URLS).map(u => fetch(u).then(r => r.json()))
);
state.categories = categories;
state.products = products;
products.forEach(p => { p.name = cleanProductName(p.name); });
state.vehicles = vehicles;
state.compatByMod = compatByMod;
state.compatByProduct = compatByProduct;
state.productsById = Object.fromEntries(products.map(p => [p.id, p]));
state.categoriesById = Object.fromEntries(categories.map(c => [c.id, c]));
state.loaded = true;
renderCatNav();
renderFooter();
populateVehicleMakes();
renderVehiclePickerLabel();
updateCartCount();
document.getElementById('year').textContent = new Date().getFullYear();
window.addEventListener('hashchange', route);
route();
}
/* ---------------- helpers ---------------- */
function leafCategories(){
return state.categories.filter(c => c.leaf);
}
function topCategory(){
// Prefer the level-1 category that actually has products under it (the catalog may only
// cover a subset of the full Category_Master taxonomy), falling back to the first level-1
// category if none have products yet.
const l1 = state.categories.filter(c => c.level === 1);
let best = null, bestCount = -1;
l1.forEach(c => { const n = productCountInScope(c.id); if(n > bestCount){ best = c; bestCount = n; } });
return best || l1[0];
}
function catChildren(id){
return state.categories.filter(c => c.parent === id).sort((a,b) => (a.order||0)-(b.order||0));
}
function catBreadcrumb(id){
const chain = [];
let cur = state.categoriesById[id];
while(cur){ chain.unshift(cur); cur = cur.parent ? state.categoriesById[cur.parent] : null; }
return chain;
}
/* Categories that actually have products directly assigned — used for nav & homepage tiles
so the site adapts to however deep/shallow the category tree is, instead of assuming a fixed level. */
/* All descendant category ids (including itself) for a given category. */
function categoryScopeIds(catId){
const ids = new Set([catId]);
let frontier = [catId];
while(frontier.length){
const next = [];
state.categories.forEach(c => { if(c.parent && frontier.includes(c.parent)){ ids.add(c.id); next.push(c.id); } });
frontier = next;
}
return ids;
}
/* Product count for a category INCLUDING all of its descendants. */
function productCountInScope(catId){
const ids = categoryScopeIds(catId);
let n = 0;
for(let i=0;i productCountInScope(c.id) > 0);
}
function imgFallback(imgEl, productId){
if(imgEl && imgEl.parentElement) imgEl.parentElement.innerHTML = productPlaceholder(productId ? state.productsById[productId] : null);
}
function fmtPrice(n){ return CURRENCY + Number(n).toLocaleString('en-IN'); }
function discountPct(price, mrp){
if(!mrp || mrp <= price) return 0;
return Math.round((1 - price/mrp) * 100);
}
function esc(s){ return (s==null?'':String(s)).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function qs(sel, root=document){ return root.querySelector(sel); }
function qsa(sel, root=document){ return Array.from(root.querySelectorAll(sel)); }
function showToast(msg){
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(showToast._tm);
showToast._tm = setTimeout(() => t.classList.remove('show'), 2200);
}
/* ---------------- category nav / footer ---------------- */
function renderCatNav(){
const nav = document.getElementById('catNav');
const cats = shoppableTopCategories();
nav.innerHTML = `All Products ` +
cats.map(c => `${esc(c.name)} `).join('');
}
function renderFooter(){
const leaves = leafCategories().filter(c => productCountInScope(c.id) > 0).slice(0, 6);
document.getElementById('footerCats').innerHTML = leaves.map(c =>
`${esc(c.name)} `).join('');
const brands = [...new Set(state.products.map(p => p.brand))].sort();
document.getElementById('footerBrands').innerHTML = brands.map(b => `${esc(b)} `).join('');
}
/* ---------------- vehicle picker ---------------- */
function populateVehicleMakes(){
const sel = document.getElementById('vpMake');
sel.innerHTML = `Select make ` +
[...state.vehicles].sort((a,b) => properMake(a.name).localeCompare(properMake(b.name))).map(m => `${esc(properMake(m.name))} `).join('');
}
function renderVehiclePickerLabel(){
const label = document.getElementById('vehiclePickerLabel');
if (!label) return;
const v = state.selectedVehicle;
label.textContent = v ? `${properMake(v.makeName)} ${fixOrdinals(v.modelName)} · ${v.mod.name}` : 'Select your vehicle';
}
function wireVehiclePicker(){
const modal = document.getElementById('vehicleModal');
const openBtn = document.getElementById('vehiclePickerBtn');
const closeBtn = document.getElementById('vehicleModalClose');
const makeSel = document.getElementById('vpMake');
const lineSel = document.getElementById('vpModelLine');
const modelSel = document.getElementById('vpModel');
const modSel = document.getElementById('vpMod');
const submitBtn = document.getElementById('vpSubmit');
const clearBtn = document.getElementById('vpClear');
if (openBtn) openBtn.onclick = () => modal.classList.add('open');
closeBtn.onclick = () => modal.classList.remove('open');
modal.onclick = (e) => { if(e.target === modal) modal.classList.remove('open'); };
makeSel.onchange = () => {
const make = state.vehicles.find(m => m.name === makeSel.value);
lineSel.innerHTML = `Select model line ` +
(make ? [...make.model_lines].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(l => `${esc(fixOrdinals(l.name))} `).join('') : '');
lineSel.disabled = !make;
modelSel.innerHTML = `Select model `; modelSel.disabled = true;
modSel.innerHTML = `Select modification `; modSel.disabled = true;
submitBtn.disabled = true;
};
lineSel.onchange = () => {
const make = state.vehicles.find(m => m.name === makeSel.value);
const line = make && make.model_lines.find(l => l.name === lineSel.value);
modelSel.innerHTML = `Select model ` +
(line ? [...line.models].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(md => `${esc(fixOrdinals(md.name))} `).join('') : '');
modelSel.disabled = !line;
modSel.innerHTML = `Select modification `; modSel.disabled = true;
submitBtn.disabled = true;
};
modelSel.onchange = () => {
const make = state.vehicles.find(m => m.name === makeSel.value);
const line = make && make.model_lines.find(l => l.name === lineSel.value);
const model = line && line.models.find(md => md.name === modelSel.value);
modSel.innerHTML = `Select modification ` +
(model ? [...model.modifications].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(md => `${esc(fixOrdinals(md.name))} (${esc(md.engine||'')} ${md.start||''}-${md.end||''}) `).join('') : '');
modSel.disabled = !model;
submitBtn.disabled = true;
};
modSel.onchange = () => { submitBtn.disabled = !modSel.value; };
submitBtn.onclick = () => {
const make = state.vehicles.find(m => m.name === makeSel.value);
const line = make.model_lines.find(l => l.name === lineSel.value);
const model = line.models.find(md => md.name === modelSel.value);
const mod = model.modifications.find(md => md.id === modSel.value);
saveVehicle({ makeName: make.name, modelLineName: line.name, modelName: model.name, mod });
modal.classList.remove('open');
location.hash = `#/vehicle/${mod.id}`;
};
clearBtn.onclick = () => { saveVehicle(null); modal.classList.remove('open'); };
}
function wireSidebarVehiclePicker(){
const makeSel = document.getElementById('lsMake');
if(!makeSel) return;
const lineSel = document.getElementById('lsModelLine');
const modelSel = document.getElementById('lsModel');
const modSel = document.getElementById('lsMod');
const submitBtn = document.getElementById('lsSubmit');
makeSel.onchange = () => {
const make = state.vehicles.find(m => m.name === makeSel.value);
lineSel.innerHTML = `Select model line ` +
(make ? [...make.model_lines].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(l => `${esc(fixOrdinals(l.name))} `).join('') : '');
lineSel.disabled = !make;
modelSel.innerHTML = `Select model `;
modelSel.disabled = true;
modSel.innerHTML = `Select modification `;
modSel.disabled = true;
submitBtn.disabled = true;
};
lineSel.onchange = () => {
const make = state.vehicles.find(m => m.name === makeSel.value);
const line = make && make.model_lines.find(l => l.name === lineSel.value);
modelSel.innerHTML = `Select model ` +
(line ? [...line.models].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(md => `${esc(fixOrdinals(md.name))} `).join('') : '');
modelSel.disabled = !line;
modSel.innerHTML = `Select modification `;
modSel.disabled = true;
submitBtn.disabled = true;
};
modelSel.onchange = () => {
const make = state.vehicles.find(m => m.name === makeSel.value);
const line = make && make.model_lines.find(l => l.name === lineSel.value);
const model = line && line.models.find(md => md.name === modelSel.value);
modSel.innerHTML = `Select modification ` +
(model ? [...model.modifications].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(md => `${esc(fixOrdinals(md.name))} (${esc(md.engine||'')} ${md.start||''}-${md.end||''}) `).join('') : '');
modSel.disabled = !model;
submitBtn.disabled = true;
};
modSel.onchange = () => { submitBtn.disabled = !modSel.value; };
submitBtn.onclick = () => {
const make = state.vehicles.find(m => m.name === makeSel.value);
const line = make.model_lines.find(l => l.name === lineSel.value);
const model = line.models.find(md => md.name === modelSel.value);
const mod = model.modifications.find(md => md.id === modSel.value);
saveVehicle({ makeName: make.name, modelLineName: line.name, modelName: model.name, mod });
location.hash = `#/vehicle/${mod.id}`;
};
}
/* ---------------- cart ---------------- */
function addToCart(productId, qty=1){
const existing = state.cart.find(i => i.id === productId);
if(existing) existing.qty += qty;
else state.cart.push({ id: productId, qty });
saveCart();
showToast('Added to cart');
}
function removeFromCart(productId){
state.cart = state.cart.filter(i => i.id !== productId);
saveCart();
route();
}
function setQty(productId, qty){
const item = state.cart.find(i => i.id === productId);
if(!item) return;
item.qty = Math.max(1, qty);
saveCart();
route();
}
function updateCartCount(){
const count = state.cart.reduce((s,i) => s+i.qty, 0);
document.getElementById('cartCount').textContent = count;
}
function cartTotal(){
return state.cart.reduce((s,i) => {
const p = state.productsById[i.id];
return s + (p ? p.price*i.qty : 0);
}, 0);
}
/* ---------------- product card / grid ---------------- */
function productCardHtml(p){
const disc = discountPct(p.price, p.mrp);
const pCat = state.categoriesById[p.category_id];
return `
`;
}
/* ---------------- routing ---------------- */
const app = document.getElementById('app');
function route(){
const hash = location.hash || '#/';
const parts = hash.replace(/^#\//,'').split('/').filter(Boolean);
window.scrollTo(0,0);
qsa('.cat-nav-inner a').forEach(a => a.classList.remove('active'));
updateProductJsonLd(null);
if(parts.length === 0) return renderHome();
if(parts[0] === 'category') return renderCategory(parts[1]);
if(parts[0] === 'product') return renderProduct(parts[1]);
if(parts[0] === 'search') return renderSearch(decodeURIComponent(parts[1]||''));
if(parts[0] === 'vehicle') return renderVehicleResults(parts[1]);
if(parts[0] === 'cart') return renderCart();
if(parts[0] === 'checkout') return renderCheckout();
if(parts[0] === 'order-success') return renderOrderSuccess();
return renderHome();
}
/* ---------------- home ---------------- */
function renderHome(){
setPageMeta(null, null);
const top = topCategory();
const children = shoppableTopCategories();
const bestValue = [...state.products].sort((a,b)=> discountPct(b.price,b.mrp)-discountPct(a.price,a.mrp)).slice(0,10);
const newest = state.products.slice(0,10);
app.innerHTML = `
Genuine brake parts, matched to your exact vehicle.
Shop ${state.products.length}+ verified brake pads and components from trusted brands. Confirm fitment in seconds before you buy.
${state.products.length}+ Parts in catalog
${new Set(state.products.map(p=>p.brand)).size} Trusted brands
${Object.keys(state.compatByMod).length}+ Vehicles supported
Find parts for your vehicle
Select your car to see guaranteed-fit parts only.
Select Make / Model / Engine
Verified fitment — match by exact modification
Trusted brands — Bosch, Brembo, Ferodo & more
${bestValue.map(productCardHtml).join('')}
${newest.map(productCardHtml).join('')}
`;
}
function icon(paths){
return `${paths} `;
}
const ICON_CUT = 'var(--paper)';
const CAT_ICONS = {
brake_pads: icon(`
`),
brake_disc: icon(`
`),
brake_drums: icon(`
`),
brake_calipers: icon(`
`),
brake_cylinder: icon(`
`),
vaccum_booster: icon(`
`),
repair_kits: icon(`
`),
abs_system: icon(`
`),
brake_electronic: icon(`
`),
brake_fluid: icon(`
`),
brake_hoses: icon(`
`),
hand_brakes: icon(`
`),
braking_system: icon(`
`),
default: icon(`
`),
};
function categoryIcon(name){
const n = (name || '').toLowerCase();
let key = 'default';
if(n.includes('pad')) key = 'brake_pads';
else if(n.includes('drum')) key = 'brake_drums';
else if(n.includes('disc') || n.includes('disk')) key = 'brake_disc';
else if(n.includes('caliper')) key = 'brake_calipers';
else if(n.includes('cylinder')) key = 'brake_cylinder';
else if(n.includes('vaccum') || n.includes('vacuum') || n.includes('booster')) key = 'vaccum_booster';
else if(n.includes('repair') || n.includes('kit')) key = 'repair_kits';
else if(n.includes('abs')) key = 'abs_system';
else if(n.includes('electronic')) key = 'brake_electronic';
else if(n.includes('fluid')) key = 'brake_fluid';
else if(n.includes('hose') || n.includes('line')) key = 'brake_hoses';
else if(n.includes('hand brake') || n.includes('handbrake')) key = 'hand_brakes';
else if(n.includes('braking system')) key = 'braking_system';
return CAT_ICONS[key] || CAT_ICONS.default;
}
/* ---------------- category listing ---------------- */
function renderCategory(catId){
const cat = state.categoriesById[catId];
if(!cat){ app.innerHTML = ``; return; }
// include this category + descendants
const idsInScope = new Set([catId]);
let frontier = [catId];
while(frontier.length){
const next = [];
state.categories.forEach(c => { if(c.parent && frontier.includes(c.parent)){ idsInScope.add(c.id); next.push(c.id); } });
frontier = next;
}
let list = state.products.filter(p => idsInScope.has(p.category_id));
renderListingPage(cat.name, catBreadcrumb(catId), list, {
subcats: catChildren(catId).filter(c => productCountInScope(c.id) > 0),
activeCatId: catId,
metaTitle: cat.seo_title || cat.name,
metaDescription: cat.seo_desc,
});
}
function emptyStateHtml(hasFilters){
return `
${hasFilters ? 'No products match your selected filters.' : 'No products found here yet.'}
`;
}
function renderListingPage(title, breadcrumbChain, list, opts={}){
setPageMeta(opts.metaTitle || title, opts.metaDescription || `Browse ${list.length} result${list.length!==1?'s':''} for ${title} at NoirParts — genuine, verified-fitment brake parts with fast dispatch.`);
const brands = [...new Set(state.products.map(p=>p.brand))].sort();
const state_ = { brand: new Set(), axle: new Set(), sort: 'relevance' };
function apply(){
let out = list.filter(p =>
(state_.brand.size===0 || state_.brand.has(p.brand)) &&
(state_.axle.size===0 || state_.axle.has(p.tag))
);
if(state_.sort==='price-asc') out = [...out].sort((a,b)=>a.price-b.price);
if(state_.sort==='price-desc') out = [...out].sort((a,b)=>b.price-a.price);
if(state_.sort==='discount') out = [...out].sort((a,b)=>discountPct(b.price,b.mrp)-discountPct(a.price,a.mrp));
qs('#resultCount').textContent = `${out.length} product${out.length!==1?'s':''}`;
qs('#gridTarget').innerHTML = out.length ? out.map(productCardHtml).join('') :
emptyStateHtml(state_.brand.size>0 || state_.axle.size>0);
}
app.innerHTML = `
${opts.subcats && opts.subcats.length ? `
` : ''}
Sort: Relevance
Price: Low to High
Price: High to Low
Best Discount
`;
qsa('input[data-brand]').forEach(cb => cb.onchange = () => {
cb.checked ? state_.brand.add(cb.dataset.brand) : state_.brand.delete(cb.dataset.brand); apply();
});
qsa('input[data-axle]').forEach(cb => cb.onchange = () => {
cb.checked ? state_.axle.add(cb.dataset.axle) : state_.axle.delete(cb.dataset.axle); apply();
});
qs('#sortSelect').onchange = (e) => { state_.sort = e.target.value; apply(); };
qs('#gridTarget').addEventListener('click', (e) => {
if(e.target.id === 'clearFiltersBtn'){
state_.brand.clear(); state_.axle.clear();
qsa('input[data-brand]').forEach(cb => cb.checked = false);
qsa('input[data-axle]').forEach(cb => cb.checked = false);
apply();
}
});
wireSidebarVehiclePicker();
apply();
}
/* ---------------- search ---------------- */
function renderSearch(query){
const qRaw = (query||'').trim();
if(!qRaw){ renderListingPage(`Search results for ""`, [], []); return; }
const qLower = qRaw.toLowerCase();
const STOPWORDS = new Set(['for','and','with','the','of','in','on','at','to','a','an','is','my','your','car','vehicle']);
const qTokens = qLower.split(/[^a-z0-9]+/).filter(t => t.length >= 2 && !STOPWORDS.has(t));
// Generic part/category words should never count as a vehicle-name match (e.g. a Mercedes
// "Shooting Brake" body style shouldn't hijack a search for "brake pads") — they're still kept
// in qTokens so they narrow the product results once a vehicle (or no vehicle) is resolved.
const PART_WORDS = new Set(['brake','brakes','pad','pads','disc','disk','discs','drum','drums','shoe','shoes',
'caliper','calipers','cylinder','cylinders','booster','boosters','kit','kits','fluid','fluids','hose','hoses',
'abs','electronic','repair','vaccum','vacuum','set','front','rear','universal','fit','genuine','spare','part','parts']);
function matchesProductText(p, text){
if(!text) return true;
if(p.name.toLowerCase().includes(text) ||
p.brand.toLowerCase().includes(text) ||
p.part_number.toLowerCase().includes(text) ||
p.id.toLowerCase().includes(text)) return true;
const normText = text.replace(/[\s\-\.\/]/g, '');
return normText.length >= 3 && !!p.part_number && p.part_number.toLowerCase().replace(/[\s\-\.\/]/g, '').includes(normText);
}
function wordsOf(str){
return new Set((str||'').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
}
// Score candidate vehicle names (make / model-line / model) by how many query tokens they match
// as WHOLE WORDS (not raw substrings — otherwise short words like "for" would false-positive match
// inside names like "Forester"), so searches like "dzire brake pads" or "thar 1st generation"
// resolve to compatible parts. Broader matches (make, then line) win by default; a more specific
// match only overrides when it consumes strictly more query tokens.
// Tie-break preference when two vehicle-name levels match the same number of tokens: prefer the
// model LINE (e.g. "Dzire", "Baleno") over both the bare MAKE (too broad) and a specific MODEL/
// generation (too narrow — the user didn't actually ask for a generation, so don't guess one).
const TIE_PRIORITY = { make: 0, model: 1, line: 2 };
let best = null;
function consider(nameLc, modIds, label, level){
const nameWords = wordsOf(nameLc);
const consumed = new Set();
qTokens.forEach(t => { if(!PART_WORDS.has(t) && nameWords.has(t)) consumed.add(t); });
if(!consumed.size) return;
const score = consumed.size;
const tiePriority = TIE_PRIORITY[level];
if(!best || score > best.score || (score === best.score && tiePriority > best.tiePriority)){
best = { score, tiePriority, modIds, label, consumedTokens: consumed };
}
}
state.vehicles.forEach(make => {
const makeLc = make.name.toLowerCase();
const makeModIds = new Set();
make.model_lines.forEach(line => line.models.forEach(m2 => m2.modifications.forEach(md => makeModIds.add(md.id))));
consider(makeLc, makeModIds, properMake(make.name), 'make');
make.model_lines.forEach(line => {
const lineLc = line.name.toLowerCase();
const lineModIds = new Set();
line.models.forEach(m2 => m2.modifications.forEach(md => lineModIds.add(md.id)));
consider(lineLc, lineModIds, `${properMake(make.name)} ${fixOrdinals(line.name)}`, 'line');
line.models.forEach(model => {
const modelLc = model.name.toLowerCase();
const modelModIds = new Set(model.modifications.map(md => md.id));
consider(modelLc, modelModIds, `${properMake(make.name)} ${fixOrdinals(model.name)}`, 'model');
});
});
});
let list, title, metaDescription;
if(best){
const vehicleProductIds = new Set();
best.modIds.forEach(modId => (state.compatByMod[modId]||[]).forEach(pid => vehicleProductIds.add(pid)));
let candidates = [...vehicleProductIds].map(id => state.productsById[id]).filter(Boolean);
const remainderTokens = qTokens.filter(t => !best.consumedTokens.has(t));
if(remainderTokens.length){
const narrowed = candidates.filter(p => remainderTokens.every(t => matchesProductText(p, t)));
if(narrowed.length) candidates = narrowed;
}
list = candidates;
title = `Search results for "${qRaw}" — parts for ${best.label}`;
metaDescription = `${list.length} genuine part${list.length!==1?'s':''} matched to ${best.label} at NoirParts. Verified fitment, fast dispatch, COD available.`;
} else {
list = state.products.filter(p => matchesProductText(p, qLower));
title = `Search results for "${qRaw}"`;
metaDescription = `${list.length} result${list.length!==1?'s':''} for "${qRaw}" at NoirParts — genuine brake parts with verified fitment.`;
}
renderListingPage(title, [], list, { metaDescription });
}
/* ---------------- vehicle results ---------------- */
function renderVehicleResults(modId){
const productIds = state.compatByMod[modId] || [];
const list = productIds.map(id => state.productsById[id]).filter(Boolean);
const v = state.selectedVehicle;
const label = v && v.mod.id === modId ? `${properMake(v.makeName)} ${fixOrdinals(v.modelName)} — ${v.mod.name}` : 'Selected Vehicle';
renderListingPage(`Parts for ${label}`, [], list);
// Insert a helpful banner above the grid
const bar = document.querySelector('.page-title-bar .wrap');
if(bar){
const banner = document.createElement('div');
banner.className = 'fit-banner';
banner.style.marginTop = '10px';
banner.innerHTML = list.length
? `✓ Showing ${list.length} part(s) verified to fit your ${esc(label)}.`
: `No parts found for this exact vehicle yet — try browsing all categories.`;
if(!list.length) banner.classList.add('no');
bar.appendChild(banner);
}
}
/* ---------------- product detail ---------------- */
function displayDescription(p){
const idx = p.description.indexOf('OEM-quality');
if(idx === -1) return p.description;
return `Genuine ${p.brand} ${p.name}. ${p.description.slice(idx)}`;
}
function renderProduct(id){
const p = state.productsById[id];
if(!p){ app.innerHTML = ``; return; }
setPageMeta(`${p.name} — ${p.brand}`, `Buy ${p.name} by ${p.brand} at NoirParts. ${fmtPrice(p.price)}, verified fitment, fast dispatch, COD available.`);
updateProductJsonLd({
"@context": "https://schema.org/",
"@type": "Product",
name: p.name,
sku: p.id,
...(p.part_number ? { mpn: p.part_number } : {}),
brand: { "@type": "Brand", name: p.brand },
description: p.description,
offers: {
"@type": "Offer",
url: location.href,
priceCurrency: "INR",
price: String(p.price),
availability: "https://schema.org/InStock",
itemCondition: "https://schema.org/NewCondition"
}
});
const disc = discountPct(p.price, p.mrp);
const chain = catBreadcrumb(p.category_id);
const compatMods = state.compatByProduct[p.id] || [];
const v = state.selectedVehicle;
const fitsSelected = v && compatMods.includes(v.mod.id);
const related = state.products.filter(x => x.category_id===p.category_id && x.id!==p.id).slice(0,6);
app.innerHTML = `
${p.image ? `
` : productPlaceholder(p)}
${esc(p.brand)}
${esc(p.name)}
Part Number: ${esc(p.part_number || '—')} · Fitment: ${esc(p.tag)}
${v ? `
${fitsSelected ? `✓ Fits your ${esc(properMake(v.makeName))} ${esc(fixOrdinals(v.modelName))} (${esc(v.mod.name)})` : `⚠ Not confirmed to fit your selected ${esc(properMake(v.makeName))} ${esc(fixOrdinals(v.modelName))} — check compatibility list below`}
` : `
`}
${fmtPrice(p.price)}
${p.mrp>p.price ? `${fmtPrice(p.mrp)} ` : ''}
${disc ? `${disc}% off ` : ''}
Inclusive of all taxes
Description
${esc(displayDescription(p))}
${Object.entries(p.specs).filter(([k]) => !['Address of Manufacturer','Marketed by'].includes(k)).length ? `
Specifications
${Object.entries(p.specs).filter(([k]) => !['Address of Manufacturer','Marketed by'].includes(k)).map(([k,val]) => `${esc(k)} ${esc(val)} `).join('')}
` : ''}
Compatible Vehicles (${compatMods.length})
${compatMods.slice(0,60).map(modId => {
const row = findVehicleByModId(modId);
return row ? `
${esc(properMake(row.make))} ${esc(fixOrdinals(row.model))} ${esc(row.mod.name)} · ${row.mod.start||''}-${row.mod.end||''}
` : '';
}).join('') || '
Compatibility data not available.
'}
${compatMods.length>60 ? `
+ ${compatMods.length-60} more vehicles
` : ''}
${related.length ? `
You may also need
${related.map(productCardHtml).join('')}
` : ''}
`;
}
function stepQty(delta){
const el = document.getElementById('pdQty');
el.value = Math.max(1, parseInt(el.value||'1') + delta);
}
function findVehicleByModId(modId){
for(const make of state.vehicles){
for(const line of make.model_lines){
for(const model of line.models){
const mod = model.modifications.find(m => m.id === modId);
if(mod) return { make: make.name, model: model.name, mod };
}
}
}
return null;
}
/* ---------------- cart page ---------------- */
async function goToRealCheckout(btn){
if(btn){ btn.disabled = true; btn.textContent = 'Preparing checkout…'; }
try {
for(const item of state.cart){
const p = state.productsById[item.id];
if(!p || !p.odoo_product_id || !p.odoo_template_id) continue;
const res = await fetch('/shop/cart/add', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({jsonrpc:'2.0', method:'call', params:{
product_id: p.odoo_product_id, product_template_id: p.odoo_template_id, quantity: item.qty
}})
});
const j = await res.json();
if(j.error) throw new Error((j.error.data && j.error.data.message) || 'Could not add ' + p.name + ' to cart');
}
window.location.href = '/shop/cart';
} catch(e){
if(btn){ btn.disabled = false; btn.textContent = 'Proceed to Checkout'; }
alert('Sorry, something went wrong preparing your order. Please try again.\n' + (e.message||''));
}
}
function renderCart(){
setPageMeta('Your Cart', 'Review the items in your NoirParts shopping cart before checkout.');
if(!state.cart.length){
app.innerHTML = `
Your cart is empty
Browse our catalog and add genuine brake parts to your cart.
Start Shopping
`;
return;
}
const rows = state.cart.map(item => {
const p = state.productsById[item.id];
if(!p) return '';
return `
${p.image ? `
` : `
`}
${fmtPrice(p.price)}
−
+
${fmtPrice(p.price*item.qty)}
Remove
`;
}).join('');
const total = cartTotal();
const shipping = total >= 1500 ? 0 : 99;
app.innerHTML = `
Shopping Cart
Product Price Quantity Subtotal
${rows}
Order Summary
Subtotal ${fmtPrice(total)}
Shipping ${shipping===0?'FREE':fmtPrice(shipping)}
Total ${fmtPrice(total+shipping)}
Proceed to Checkout
Continue shopping
`;
}
/* ---------------- checkout (UI only — no real payment is processed) ---------------- */
function renderCheckout(){
if(!state.cart.length){ location.hash = '#/cart'; return; }
setPageMeta('Checkout', 'Secure checkout for your NoirParts order — COD available across India.');
const total = cartTotal();
const shipping = total >= 1500 ? 0 : 99;
app.innerHTML = `
Checkout
Order Summary
${state.cart.map(i => {
const p = state.productsById[i.id]; if(!p) return '';
return `
${esc(p.name)} × ${i.qty} ${fmtPrice(p.price*i.qty)}
`;
}).join('')}
Shipping ${shipping===0?'FREE':fmtPrice(shipping)}
Total ${fmtPrice(total+shipping)}
`;
document.getElementById('checkoutForm').onsubmit = (e) => {
e.preventDefault();
// Demo storefront: no real payment gateway is connected — this simulates order placement only.
state.cart = [];
saveCart();
location.hash = '#/order-success';
};
}
function renderOrderSuccess(){
setPageMeta('Order Confirmed', 'Your NoirParts order has been placed successfully.');
app.innerHTML = `
Order placed successfully!
Thank you for shopping with Noirparts. This is a demo storefront — no payment has actually been charged. A confirmation would normally be sent to your email.
Continue Shopping
`;
}
/* ---------------- search form wiring ---------------- */
document.getElementById('searchForm').addEventListener('submit', (e) => {
e.preventDefault();
const q = document.getElementById('searchInput').value.trim();
if(q) location.hash = `#/search/${encodeURIComponent(q)}`;
});
/* ---------------- search suggestions ---------------- */
(function(){
const searchForm = document.getElementById('searchForm');
if(!searchForm) return;
const searchInput = document.getElementById('searchInput');
const wrap = document.createElement('div');
wrap.className = 'search-wrap';
searchForm.parentNode.insertBefore(wrap, searchForm);
wrap.appendChild(searchForm);
const box = document.createElement('div');
box.id = 'searchSuggest';
box.className = 'search-suggest';
wrap.appendChild(box);
const SS_STOPWORDS = new Set(['for','and','with','the','of','in','on','at','to','a','an','is','my','your','car','vehicle']);
const SS_PART_WORDS = new Set(['brake','brakes','pad','pads','disc','disk','discs','drum','drums','shoe','shoes',
'caliper','calipers','cylinder','cylinders','booster','boosters','kit','kits','fluid','fluids','hose','hoses',
'abs','electronic','repair','vaccum','vacuum','set','front','rear','universal','fit','genuine','spare','part','parts']);
const SS_TIE_PRIORITY = { make: 0, model: 1, line: 2 };
let categoriesWithProducts = null;
function ssCategoriesWithProducts(){
if(categoriesWithProducts) return categoriesWithProducts;
categoriesWithProducts = new Set();
(state.products||[]).forEach(p => { if(p.category_id) categoriesWithProducts.add(p.category_id); });
return categoriesWithProducts;
}
function ssWordsOf(str){ return new Set((str||'').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); }
function ssMatchesProductText(p, text){
if(!text) return true;
if(p.name.toLowerCase().includes(text) || p.brand.toLowerCase().includes(text) ||
p.part_number.toLowerCase().includes(text) || p.id.toLowerCase().includes(text)) return true;
const normText = text.replace(/[\s\-\.\/]/g, '');
return normText.length >= 3 && !!p.part_number && p.part_number.toLowerCase().replace(/[\s\-\.\/]/g, '').includes(normText);
}
function ssFindVehicle(qTokens){
let best = null;
function consider(nameLc, modIds, label, level){
const nameWords = ssWordsOf(nameLc);
const consumed = new Set();
qTokens.forEach(t => { if(!SS_PART_WORDS.has(t) && nameWords.has(t)) consumed.add(t); });
if(!consumed.size) return;
const score = consumed.size;
const tiePriority = SS_TIE_PRIORITY[level];
if(!best || score > best.score || (score === best.score && tiePriority > best.tiePriority)){
best = { score, tiePriority, modIds, label, consumedTokens: consumed };
}
}
state.vehicles.forEach(make => {
const makeLc = make.name.toLowerCase();
const makeModIds = new Set();
make.model_lines.forEach(line => line.models.forEach(m2 => m2.modifications.forEach(md => makeModIds.add(md.id))));
consider(makeLc, makeModIds, properMake(make.name), 'make');
make.model_lines.forEach(line => {
const lineLc = line.name.toLowerCase();
const lineModIds = new Set();
line.models.forEach(m2 => m2.modifications.forEach(md => lineModIds.add(md.id)));
consider(lineLc, lineModIds, `${properMake(make.name)} ${fixOrdinals(line.name)}`, 'line');
line.models.forEach(model => {
const modelLc = model.name.toLowerCase();
const modelModIds = new Set(model.modifications.map(md => md.id));
consider(modelLc, modelModIds, `${properMake(make.name)} ${fixOrdinals(model.name)}`, 'model');
});
});
});
return best;
}
function ssGetResults(qRaw){
const qLower = qRaw.toLowerCase();
const qTokens = qLower.split(/[^a-z0-9]+/).filter(t => t.length >= 2 && !SS_STOPWORDS.has(t));
const vehicle = ssFindVehicle(qTokens);
let candidates;
if(vehicle){
const vehicleProductIds = new Set();
vehicle.modIds.forEach(modId => (state.compatByMod[modId]||[]).forEach(pid => vehicleProductIds.add(pid)));
candidates = [...vehicleProductIds].map(id => state.productsById[id]).filter(Boolean);
const remainderTokens = qTokens.filter(t => !vehicle.consumedTokens.has(t));
if(remainderTokens.length){
const narrowed = candidates.filter(p => remainderTokens.every(t => ssMatchesProductText(p, t)));
if(narrowed.length) candidates = narrowed;
}
} else {
candidates = state.products.filter(p => ssMatchesProductText(p, qLower));
}
const withProducts = ssCategoriesWithProducts();
const categories = Object.values(state.categoriesById || {}).filter(c => {
if(!c.leaf || !withProducts.has(c.id)) return false;
const cWords = ssWordsOf(c.name);
return qTokens.some(t => cWords.has(t));
}).slice(0, 2);
return { vehicle, candidates, categories };
}
function ssProductThumb(p){
const cat = state.categoriesById && state.categoriesById[p.category_id];
const icon = cat ? categoryIcon(cat.name) : CAT_ICONS.default;
return `${icon} `;
}
function ssRender(qRaw){
const { vehicle, candidates, categories } = ssGetResults(qRaw);
let html = '';
if(vehicle){
html += `
Parts for ${esc(vehicle.label)} — ${candidates.length} found
`;
}
if(categories.length){
html += `Categories
`;
categories.forEach(c => {
html += `
${categoryIcon(c.name)}
${esc(c.name)}
`;
});
}
const top = candidates.slice(0, 5);
if(top.length){
html += `Products
`;
top.forEach(p => {
html += `
${ssProductThumb(p)}
${esc(p.name)}
${fmtPrice(p.price)}
`;
});
}
if(!vehicle && !top.length && !categories.length){
html += `No matches yet — press Enter to search all products.
`;
}
if(candidates.length > top.length || vehicle){
html += `View all${candidates.length ? ' '+candidates.length : ''} results for "${esc(qRaw)}" `;
}
box.innerHTML = html;
box.querySelectorAll('[data-hash]').forEach(el => {
el.addEventListener('mousedown', (e) => {
e.preventDefault();
location.hash = el.getAttribute('data-hash');
ssHide();
});
});
}
function ssShow(){ box.classList.add('open'); }
function ssHide(){ box.classList.remove('open'); }
let ssTimer = null;
searchInput.addEventListener('input', () => {
clearTimeout(ssTimer);
const q = searchInput.value.trim();
if(q.length < 2){ ssHide(); return; }
ssTimer = setTimeout(() => { ssRender(q); ssShow(); }, 200);
});
searchInput.addEventListener('focus', () => {
const q = searchInput.value.trim();
if(q.length >= 2){ ssRender(q); ssShow(); }
});
searchInput.addEventListener('blur', () => { setTimeout(ssHide, 150); });
searchInput.addEventListener('keydown', (e) => { if(e.key === 'Escape') ssHide(); });
})();
wireVehiclePicker();
boot();
/* cache-bust-fix */