1/* ===== Eastman â page-specific behaviour ===== */ 2/* cache-bust: h46 â PDP gallery restore + shops + Bicycles nav */ 3(function () { 4 "use strict"; 5 const $ = (s, c = document) => (c || document).querySelector(s); 6 const $$ = (s, c = document) => Array.from((c || document).querySelectorAll(s)); 7 const PRODUCTS = window.EASTMAN_PRODUCTS || []; 8 const CATEGORIES = window.EASTMAN_CATEGORIES || []; 9 const BRANDS = window.EASTMAN_BRANDS || []; 10 const INFO = window.EASTMAN_INFO || {}; 11 const STATS = window.EASTMAN_STATS || {}; 12 const byId = (id) => PRODUCTS.find((p) => p.id === id); 13 const params = new URLSearchParams(location.search); 14 const pathParts = (location.pathname || "").replace(/\/+$/, "").split("/").filter(Boolean); 15 const TAG_LABELS = window.EASTMAN_TAG_LABELS || { new: "New", featured: "Featured", demand: "In Demand" }; 16 17 function slugifyName(name) { 18 return String(name || "") 19 .toLowerCase() 20 .replace(/&/g, " and ") 21 .replace(/[^a-z0-9]+/g, "-") 22 .replace(/-+/g, "-") 23 .replace(/^-|-$/g, "") || "item"; 24 } 25 26 function productUrl(p) { 27 if (!p) return "/bicycle-parts"; 28 if (p.href) return p.href; 29 const id = p.id || ""; 30 if (!/^p[0-9]+$/i.test(id)) return "/bicycle-parts"; 31 const cat = p.catSlug || "parts"; 32 const nameSlug = slugifyName(p.name || id); 33 return "/bicycle-parts/" + encodeURIComponent(cat) + "/" + encodeURIComponent(nameSlug + "-" + id); 34 } 35 36 function bikeUrl(slug) { 37 return "/bicycles/" + encodeURIComponent(slug); 38 } 39 40 function categoryUrl(group, catSlug) { 41 const g = group || "india"; 42 if (catSlug) return "/bicycle-parts/" + encodeURIComponent(g) + "/" + encodeURIComponent(catSlug); 43 return "/bicycle-parts/" + encodeURIComponent(g); 44 } 45 window.productUrl = productUrl; 46 window.bikeUrl = bikeUrl; 47 window.categoryUrl = categoryUrl; 48 window.slugifyName = slugifyName; 49 50 function resolvePdpIds() { 51 let bikeSlug = params.get("bike"); 52 let partId = params.get("id"); 53 // Pretty: /bicycles/{slug} 54 if (!bikeSlug && pathParts[0] === "bicycles" && pathParts[1]) bikeSlug = pathParts[1]; 55 // Legacy: /bike/{slug} 56 if (!bikeSlug && pathParts[0] === "bike" && pathParts[1]) bikeSlug = pathParts[1]; 57 // Pretty: /bicycle-parts/{catSlug}/{name-slug}-{id} 58 if (!partId && pathParts[0] === "bicycle-parts" && pathParts.length >= 3) { 59 const last = pathParts[pathParts.length - 1] || ""; 60 const m = last.match(/-(p[0-9]+)$/i); 61 if (m) partId = m[1]; 62 } 63 // Legacy: /product/p#### 64 if (!partId && pathParts[0] === "product" && pathParts[1] && /^p[0-9]+$/i.test(pathParts[1])) { 65 partId = pathParts[1]; 66 } 67 return { bikeSlug: bikeSlug || "", partId: partId || "" }; 68 } 69 70 71 function setCanonical(path) { 72 if (!path) return; 73 const href = path.charAt(0) === "/" ? ("https://eastmanbicycles.com" + path) : path; 74 let link = document.querySelector('link[rel="canonical"]'); 75 if (!link) { 76 link = document.createElement("link"); 77 link.rel = "canonical"; 78 document.head.appendChild(link); 79 } 80 link.href = href; 81 try { 82 const ou = document.querySelector('meta[property="og:url"]'); 83 if (ou) ou.setAttribute("content", href); 84 } catch (_) {} 85 } 86 87 function resolveCategoryPath() { 88 let group = params.get("group") || ""; 89 let catSlug = params.get("cat") || ""; 90 // Pretty: /bicycle-parts/{india|china}/{catSlug} OR /bicycle-parts/{india|china} 91 if (pathParts[0] === "bicycle-parts" && pathParts[1]) { 92 if (pathParts[1] === "india" || pathParts[1] === "china") { 93 group = pathParts[1]; 94 // If third segment looks like a product (ends with -p####), not a category 95 if (pathParts[2] && !/-(p[0-9]+)$/i.test(pathParts[2])) { 96 catSlug = pathParts[2]; 97 } else if (!pathParts[2]) { 98 catSlug = catSlug || ""; 99 } 100 } 101 } 102 return { group: group || "india", catSlug: catSlug || "" }; 103 } 104 const tagPills = (tags, max) => (tags || []).slice(0, max || 3).map((t) => `<span class="cat-tag cat-tag--${t}">${TAG_LABELS[t] || t}</span>`).join(""); 105 const PAGE_SIZE = document.body.dataset.page === "home" ? 24 : 24; 106 const isHomeShop = document.body.dataset.page === "home" && $("#sideQuickFilters"); 107 const HOME_RENDER_CAP = 120; 108 const MOBILE_MQ = window.matchMedia("(max-width: 960px)"); 109 const MOBILE_PRODUCT_CAP = 4; 110 111 const isMobileShop = () => MOBILE_MQ.matches; 112 const shopRenderCap = (desktopCap) => (isMobileShop() ? MOBILE_PRODUCT_CAP : desktopCap); 113 114 function ensureMobileTrack(root) { 115 if (!root) return null; 116 let viewport = $(".mobile-cat-nav__viewport", root); 117 if (!viewport) { 118 viewport = document.createElement("div"); 119 viewport.className = "mobile-cat-nav__viewport"; 120 root.appendChild(viewport); 121 } 122 let track = $(".mobile-cat-nav__track", viewport); 123 if (!track) { 124 track = document.createElement("div"); 125 track.className = "mobile-cat-nav__track"; 126 viewport.appendChild(track); 127 } 128 return track; 129 } 130 131 function categoryAllLink(group, slug, name, total) { 132 return `<a href="${categoryUrl(group, slug)}" class="btn btn-primary btn-block prod-more__all">All ${total} products in ${name} →</a>`; 133 } 134 135 function rangeAllLink(group, label) { 136 return `<a href="${categoryUrl(group)}" class="btn btn-outline btn-block">${label} →</a>`; 137 } 138 139 function mobileCatPill(c, active) { 140 return `<button type="button" class="mobile-cat-pill${active ? " is-active" : ""}" data-cat-group="${c.group}" data-cat-slug="${c.slug}" aria-pressed="${active ? "true" : "false"}"><span>${c.name}</span><span class="mobile-cat-count">${c.count}</span></button>`; 141 } 142 143 function scrollMobilePill(track, slug) { 144 if (!track || !slug) return; 145 const pills = $$(".mobile-cat-pill", track); 146 const btn = pills.find((b) => b.dataset.catSlug === slug); 147 if (!btn) return; 148 const idx = pills.indexOf(btn);
149 btn.scrollIntoView({ inline: idx <= 1 ? "start" : "center", block: "nearest", behavior: "smooth" }); 150 } 151 152 function bindMobileCatPills(track, onPick) { 153 if (!track) return; 154 $$(".mobile-cat-pill", track).forEach((btn) => { 155 btn.addEventListener("click", () => onPick(btn.dataset.catGroup, btn.dataset.catSlug)); 156 }); 157 } 158 159 function assetUrl(src) { 160 if (src == null || src === "") return ""; 161 const s = String(src).trim(); 162 if (!s) return ""; 163 if (/^https?:\/\//i.test(s) || s.startsWith("//") || s.startsWith("data:") || s.startsWith("blob:")) return s; 164 if (s.startsWith("/")) return s; 165 return "/" + s.replace(/^\.\//, ""); 166 } 167 168 const imgTag = (p, cls, opts) => { 169 opts = opts || {}; 170 const localRaw = (p && p.img) || ""; 171 let remoteRaw = String((p && p.imgRemote) || "").trim(); 172 if (remoteRaw && /\/wp-content\/uploads\//i.test(remoteRaw) && remoteRaw.indexOf("/blog/wp-content/") === -1) { 173 remoteRaw = ""; 174 } 175 const src = assetUrl(localRaw || remoteRaw); 176 const loading = opts.eager ? "eager" : "lazy"; 177 const prio = opts.eager ? ' fetchpriority="high"' : ""; 178 const onerr = src ? ` onerror="if(!this.dataset.ebR){this.dataset.ebR=1;var u=this.src.split('?')[0];this.src=u+(u.indexOf('?')>=0?'&':'?')+'r='+Date.now();}"` : ""; 179 return `<img class="${cls || ""}" src="${escAttr(src)}" alt="${escAttr((p && p.name) || "")}" width="400" height="300" decoding="async" loading="${loading}"${prio}${onerr} />`; 180 }; 181 182 function brandCardHTML(b, className, withName) { 183 const label = escHtml(b.name); 184 const name = withName ? `<span>${label}</span>` : ""; 185 return `<div class="${className}"><img src="${escAttr(assetUrl(b.img))}" alt="${label}" loading="lazy" />${name}</div>`; 186 } 187 188 function escHtml(s) { 189 return String(s || "") 190 .replace(/&/g, "&") 191 .replace(/</g, "<") 192 .replace(/>/g, ">") 193 .replace(/"/g, """); 194 } 195 196 function escAttr(s) { 197 return escHtml(s); 198 } 199 200 function modelFromDesc(p) { 201 const text = String((p && (p.desc || p.short)) || "").replace(/\\n
201/g, "\n").trim(); 202 const first = text.split(/\n/)[0].trim(); 203 if (/^[A-Z0-9][A-Z0-9._-]{1,24}$/i.test(first) && first.length >= 3) return first; 204 return ""; 205 } 206 207 function displayProductName(p) { 208 if (window.EastmanProductSEO && window.EastmanProductSEO.displayName) { 209 return window.EastmanProductSEO.displayName(p); 210 } 211 const name = String((p && p.name) || "").trim(); 212 const model = modelFromDesc(p); 213 if (model && name && name.toUpperCase().indexOf(model.toUpperCase()) === -1) { 214 return name + " " + model; 215 } 216 return name; 217 } 218 219 function applyPdpSeo(seo, item) { 220 if (!seo) return; 221 document.title = seo.title; 222 let meta = document.querySelector('meta[name="description"]'); 223 if (!meta) { 224 meta = document.createElement("meta"); 225 meta.setAttribute("name", "description"); 226 document.head.appendChild(meta); 227 } 228 meta.setAttribute("content", seo.description); 229 const imgRaw = (item && (item.img || item.imgRemote)) || seo.image || ""; 230 let img = imgRaw; 231 if (img && img.indexOf("http") !== 0) { 232 img = "https://eastmanbicycles.com/" + String(img).replace(/^\//, ""); 233 } 234 if (window.EastmanMeta && window.EastmanMeta.applySocial) { 235 window.EastmanMeta.applySocial({ 236 title: seo.title, 237 description: seo.description, 238 image: img || undefined, 239 imageAlt: (seo.h1 || seo.title) + " â Eastman Bicycles", 240 type: "product", 241 }); 242 } 243 } 244 245 function specsFromDesc(text) { 246 const rows = [];
247 const lines = String(text || "").replace(/\\n/g, "\n").split(/\n+/).map((l) => l.trim()).filter(Boolean); 248 if (lines[0] && /^[A-Z0-9][A-Z0-9._-]{1,24}$/i.test(lines[0])) { 249 rows.push(["Model", lines[0]]); 250 } 251 lines.forEach((line) => { 252 const m = line.match(/^([A-Za-z][A-Za-z0-9 /&-]{1,40})\s*:\s*(.+)$/) || line.match(/^(Size|Net Weight|Weight|Material|Colour|Color)\s*[:\-]\s*(.+)$/i); 253 if (m) rows.push([m[1], m[2]]); 254 }); 255 return rows; 256 } 257 258 function productIntroFromDesc(text) { 259 if (!text) return ""; 260 let s = String(text) 261 .replace(/\\n/g, "\n") 262 .replace(/\r/g, "") 263 .trim(); 264 const cut = s.search( 265 /\n\s*(Product Details|Key Features|Applications|Benefits|Installation Process|Specifications)\s*:/i 266 ); 267 if (cut > 0) s = s.slice(0, cut); 268 return s.replace(/\n{3,}/g, "\n\n").trim(); 269 } 270 271 function productOverview(p) { 272 const intro = productIntroFromDesc(p.desc || p.short || ""); 273 const snippet = 274 intro 275 .split(/\n\n+/) 276 .map((l) => l.replace(/\s+/g, " ").trim()) 277 .filter(Boolean) 278 .join(" ") 279 .slice(0, 520) || p.name; 280 const range = p.group === "china" ? "Eastman China" : "Eastman India"; 281 const originLine = p.group === "china" 282 ? "sourced from our China range and exported for B2B programmes" 283 : "manufactured and exported from India"; 284 return `${p.name} is part of our ${range} ${p.catName || "bicycle parts"} range â ${originLine}. ${snippet} Request a B2B quote for MOQ, pricing and export lead times.`; 285 } 286 287 function pdpCheckList(items) { 288 return `<ul class="pdp-checklist">${items.map((item) => `<li>${escHtml(item)}</li>`).join("")}</ul>`; 289 } 290 291 function quoteChecklistHTML(opts) { 292 opts = opts || {}; 293 const origin = opts.origin || "India"; 294 const moq = (window.EASTMAN_MOQ && window.EASTMAN_MOQ.policyText) || (window.EASTMAN_FACTS && window.EASTMAN_FACTS.moqPolicyText) || "Minimum Order: 1 Ã 20 ft container. Mixed products are allowed. Standard MOQ starts from 200 pcs per product; selected/custom products may require higher quantities. Final MOQ depends on specification, packing and destination."; 295 return `<aside class="pdp-quote-check" aria-label="Quote checklist"> 296 <p class="pdp-quote-check__title">Quote checklist</p> 297 <ul> 298 <li><strong>Origin</strong> â ${escHtml(origin)}</li> 299 <li><strong>Ask FOB/CIF</strong></li> 300 <li><strong>Share destination</strong></li> 301 <li><strong>Container / volume</strong></li> 302 <li><strong>Optional</strong> â sample ask</li> 303 </ul> 304 <div class="moq-module moq-module--pdp" data-moq-module> 305 <p class="moq-module__title">MOQ</p> 306 <p class="moq-module__text">${escHtml(moq)}</p> 307 </div> 308 </aside>`; 309 } 310 311 function specsTableHTML(rows) { 312 const list = rows || []; 313 if (!list.length) return ""; 314 const top = list.slice(0, 5); 315 const rest = list.slice(5); 316 const rowHtml = (pairs) => pairs.map(([k, v]) => `<tr><th>${escHtml(k)}</th><td>${escHtml(v)}</td></tr>`).join(""); 317 let html = `<div class="pdp-specs-card"><table class="pdp-attrs"><tbody>${rowHtml(top)}</tbody></table>`; 318 if (rest.length) { 319 html += `<details class="pdp-specs-more"><summary>Full specifications (${rest.length} more)</summary><table class="pdp-attrs"><tbody>${rowHtml(rest)}</tbody></table></details>`; 320 } 321 html += `</div>`; 322 return html; 323 } 324 325 function pdpSpecChipsHTML(pairs, limit) { 326 const list = (pairs || []).slice(0, limit || 6); 327 if (!list.length) return ""; 328 return `<div class="pdp-spec-chips" aria-label="Key specifications">${list.map(([k, v]) => 329 `<span class="pdp-spec-chip"><strong>${escHtml(k)}</strong> ${escHtml(v)}</span>` 330 ).join("")}</div>`; 331 } 332 333 function pdpOemBlockHTML(opts) { 334 opts = opts || {}; 335 const enquiry = opts.enquiry || "OEM / Private Label Quote"; 336 const sku = opts.sku || ""; 337 const origin = opts.origin || "India"; 338 const pillar = opts.pillar || ""; 339 return `<section class="pdp-oem-block" id="pdpOem"> 340 <div class="pdp-oem-block__inner"> 341 <div>
342 <span class="eyebrow">OEM / Private Label</span> 343 <h2>Customise this product for your brand</h2> 344 <p>Private-label branding, colourways, packing and documentation for importers and distributors. Share your brand brief for a factory quote.</p> 345 </div> 346 <button type="button" class="btn btn-primary" data-enquiry="${escAttr(enquiry)}" data-enquiry-origin="${escAttr(origin)}" data-enquiry-sku="${escAttr(sku)}" data-enquiry-pillar="${escAttr(pillar)}">Request a Quote</button> 347 </div> 348 </section>`; 349 } 350 351 function pdpExportPackHTML(opts) { 352 opts = opts || {}; 353 const moq = (window.EASTMAN_MOQ && window.EASTMAN_MOQ.policyText) || (window.EASTMAN_FACTS && window.EASTMAN_FACTS.moqPolicyText) || "Minimum Order: 1 Ã 20 ft container. Mixed products are allowed."; 354 return `<section class="pdp-export-pack" id="pdpExportPack"> 355 <div class="pdp-block-card"> 356 <div class="pdp-block-head"><span class="eyebrow">Export</span><h2>Export & Packing</h2> 357 <p class="pdp-block-lead">Container programmes for B2B buyers â discuss Incoterms with our export desk.</p> 358 </div> 359 <ul class="pdp-checklist"> 360 <li><strong>MOQ</strong> â ${escHtml(moq)}</li> 361 <li><strong>Mixed container</strong> â combine SKUs / models in one load</li> 362 <li><strong>Incoterms</strong> â FOB / CIF discussion by destination</li> 363 <li><strong>Docs</strong> â commercial invoice, packing list, B/L, COO if required</li> 364 </ul> 365 </div> 366 </section>`; 367 } 368 369 function pdpCertStripHTML() { 370 return `<div class="pdp-cert-strip" aria-label="Certifications"> 371 <span>ISO 9001</span><span>BSCI</span><span>AEO</span><span>ZED</span> 372 </div>`; 373 } 374 375 function pdpFinalCtaHTML(opts) { 376 opts = opts || {}; 377 return `<section class="pdp-final-cta"> 378 <h2>Ready to quote this product?</h2> 379 <p class="cta-reassurance">For importers, distributors, wholesalers and OEM/private-label buyers.</p> 380 <div class="pdp-final-cta__actions"> 381 ${opts.addQuoteBtn || ""} 382 ${opts.requestBtn || ""} 383 <a class="btn btn-text" href="https://wa.me/919115110415" target="_blank" rel="noopener">Request a Quote on WhatsApp</a> 384 </div> 385 </section>`; 386 } 387 388 function pdpPrimaryActionsHTML(opts) { 389 opts = opts || {}; 390 const requestBtn = opts.requestBtn || ""; 391 return `<div class="pdp-actions pdp-actions--lean">${requestBtn}</div>`; 392 } 393 394 function pdpBlockCard(eyebrow, title, intro, body, alt) { 395 const lead = intro ? `<p class="pdp-block-lead">${escHtml(intro)}</p>` : ""; 396 return ` 397 <section class="pdp-block${alt ? " pdp-block--alt" : ""}"> 398 <div class="pdp-block-card"> 399 <div class="pdp-block-head"> 400 <span class="eyebrow">${escHtml(eyebrow)}</span> 401 <h2>${escHtml(title)}</h2> 402 ${lead} 403 </div> 404 <div class="pdp-block-body">${body}</div> 405 </div> 406 </section>`; 407 } 408 409 function pdpFaqItems(p) { 410 const cat = p.catName || "bicycle parts"; 411 const model = p.name || "this product"; 412 const origin = p.group === "china" ? "China range" : "India manufacturing"; 413 return [ 414 { 415 q: `What is the MOQ for ${model}?`, 416 a: "Standard export MOQ is 1 Ã 20 ft container. Mixed products are allowed. Typical MOQ starts from 200 pcs per product; custom/OEM may be higher. Final MOQ depends on specification, packing and destination.", 417 }, 418 { 419 q: `Can I order ${model} (${cat}) with other SKUs in one container?`, 420 a: "Yes â mixed container loads are available. Share preferred SKUs and destination so we can confirm packing and volume.", 421 }, 422 { 423 q: `Do you offer OEM / private label for ${cat}?`, 424 a: "Yes. Branding, colourways and packing can be discussed for OEM/private-label programmes. Use Request a Quote on this page.", 425 }, 426 { 427 q: `Where is ${model} from?`, 428 a: `${model} is part of our ${origin}. Origin is shown on the product page; China-range items are not manufactured in India.`, 429 }, 430 { 431 q: "Which Incoterms and documents can you support?", 432 a: "FOB and CIF can be discussed by destination. We support commercial invoice, packing list, bill of lading and certificate of origin where required.", 433 }, 434 ]; 435 } 436 437 function pdpExtraSections(p, opts) { 438 opts = opts || {}; 439 const faqs = pdpFaqItems(p) 440 .slice(0, 5) 441 .map( 442 (item) => 443 `<details class="pdp-faq-item"><summary>${escHtml(item.q)}</summary><p>${escHtml(item.a)}</p></details>` 444 ) 445 .join(""); 446 return ` 447 <div class="pdp-sections pdp-sections--sourcing"> 448 ${pdpBlockCard("FAQ", "Product FAQ", "", `<div class="pdp-faq">${faqs}</div>`, true)} 449 </div>`; 450 } 451 452 453 function pdpGalleryHTML(p, images) { 454 const list = (images && images.length ? images : [{ src: p.img || p.imgRemote, thumb: p.img || p.imgRemote }]) 455 .filter((img) => img && img.src) 456 .map((img) => ({ src: assetUrl(img.src), thumb: assetUrl(img.thumb || img.src) })); 457 const main = list[0] || { src: assetUrl(p.img || p.imgRemote || ""), thumb: assetUrl(p.img || p.imgRemote || "") }; 458 const mainSrc = main.src || assetUrl(p.img || p.imgRemote || "") || ""; 459 let remote = p.imgRemote && /^https?:\/\//i.test(String(p.imgRemote).trim()) ? String(p.imgRemote).trim() : ""; 460 if (remote && /\/wp-content\/uploads\//i.test(remote) && remote.indexOf("/blog/wp-content/") === -1) remote = ""; 461 const onerr = mainSrc ? ` onerror="if(!this.dataset.ebR){this.dataset.ebR=1;var u=this.src.split('?')[0];this.src=u+'?r='+Date.now();}"` : ""; 462 const mainImg = `<img id="pdpMainImg" class="pdp-main-img" src="${escAttr(mainSrc)}" alt="${escHtml(p.name || "")}" loading="eager"${onerr} />`; 463 if (list.length <= 1) return `<div class="pdp-gallery">${mainImg}</div>`; 464 const thumbs = list 465 .map( 466 (img, idx) => 467 `<button type="button" class="pdp-thumb${idx === 0 ? " is-active" : ""}" data-src="${escAttr(img.src)}" aria-label="View image ${idx + 1}"><img src="${escAttr(img.thumb || img.src)}" alt="" loading="lazy" /></button>` 468 ) 469 .join(""); 470 return `<div class="pdp-gallery">${mainImg}<div class="pdp-thumbs">${thumbs}</div></div>`; 471 } 472 473 function bindPdpGallery(root) { 474 const main = $("#pdpMainImg", root); 475 if (!main) return; 476 $$(".pdp-thumb", root).forEach((btn) => { 477 btn.addEventListener("click", () => { 478 main.src = btn.dataset.src; 479 $$(".pdp-thumb", root).forEach((b) => b.classList.toggle("is-active", b === btn)); 480 }); 481 }); 482 } 483 484 function loadPdpGallery(p, root) { 485 const imgs = (p.images || []).map((src) => ({ src, thumb: src })); 486 if (imgs.length > 1) { 487 const media = $(".pdp-media", root); 488 if (media) {
489 media.innerHTML = (p.tags && p.tags[0] ? `<span class="pdp-badge pcard-badge--${p.tags[0]}">${TAG_LABELS[p.tags[0]] || p.tags[0]}</span>` : "") + pdpGalleryHTML(p, imgs); 490 bindPdpGallery(root); 491 } 492 return; 493 } 494 if (!p.wcId) return; 495 fetch(`https://eastmanbicycles.com/wp-json/wc/store/v1/products/${p.wcId}`) 496 .then((r) => (r.ok ? r.json() : null)) 497 .then((data) => { 498 if (!data || !data.images || data.images.length <= 1) return; 499 const gallery = data.images.map((img) => ({ src: img.src, thumb: img.thumbnail || img.src })); 500 const media = $(".pdp-media", root); 501 if (!media) return; 502 media.innerHTML = (p.tags && p.tags[0] ? `<span class="pdp-badge pcard-badge--${p.tags[0]}">${TAG_LABELS[p.tags[0]] || p.tags[0]}</span>` : "") + pdpGalleryHTML(p, gallery); 503 bindPdpGallery(root); 504 }) 505 .catch(() => {}); 506 } 507 508 const BANNER_CATS = [ 509 { group: "india", slug: "saddle-saddle-parts", title: "Premium", grad: "Saddles & Parts", desc: "72+ SKUs â export-ready comfort seats and accessories for every bicycle segment." }, 510 { group: "india", slug: "pedals", title: "Heavy-Duty", grad: "Pedals", desc: "71+ models â alloy, steel and composite pedals built for daily riding and bulk orders." }, 511 { group: "india", slug: "hub-parts", title: "Precision", grad: "Hub Parts", desc: "Durable hubs, axles and fittings for OEM and aftermarket supply." }, 512 { group: "india", slug: "brakes-brake-parts", title: "Reliable", grad: "Brakes & Parts", desc: "69+ SKUs â calipers, levers, cables and brake sets for safe, consistent performance." }, 513 { group: "india", slug: "tyres-tubes", title: "Export-Ready", grad: "Tyres & Tubes", desc: "67+ sizes â road, city and MTB tyres with matching tubes for global distributors." }, 514 { group: "india", slug: "complete-bicycles", title: "Ready-to-Ship", grad: "Complete Bicycles", desc: "29+ bicycle models â city, MTB and kids ranges for importers and fleet buyers." }, 515 { group: "china", slug: "chainwheel-cranks", title: "High-Precision", grad: "Chainwheel & Cranks", desc: "39+ alloy chainwheels and cranks â lightweight performance for modern bicycles." }, 516 { group: "china", slug: "hubs-hubs-parts", title: "Engineered", grad: "Hubs & Hub Parts", desc: "39+ precision hubs â sealed bearings and alloy construction for premium builds." }, 517 ]; 518 519 function productScore(p) { 520 let s = 0; 521 if (p.tags && p.tags.includes("demand")) s += 2; 522 if (p.tags && p.tags.includes("featured")) s += 1; 523 return s; 524 } 525 526 function pickBannerProducts({ group, catSlug, limit = 6, excludeImgs } = {}) { 527 const seen = new Set(excludeImgs || []); 528 const pool = PRODUCTS.filter((p) => { 529 if (group && p.group !== group) return false; 530 if (catSlug && p.catSlug !== catSlug) return false; 531 return p.img || p.imgRemote; 532 }).sort((a, b) => productScore(b) - productScore(a)); 533 534 const out = []; 535 for (const p of pool) { 536 const key = p.img || p.imgRemote; 537 if (seen.has(key)) continue; 538 seen.add(key); 539 out.push(p); 540 if (out.length >= limit) break; 541 } 542 return out; 543 } 544 545 546 /* ================= home hero banners (fixed 3 conversion slides) ================= */ 547 548 function bikesBySlugs(slugs) { 549 const bikes = (window.getAllIndiaBikes ? window.getAllIndiaBikes() : []).filter(Boolean); 550 const bySlug = new Map(bikes.map((b) => [b.slug, b])); 551 const out = []; 552 slugs.forEach((slug) => { 553 const b = bySlug.get(slug); 554 if (!b) return; 555 out.push({ 556 id: "bike-" + b.slug, 557 name: b.name, 558 img: b.img, 559 href: bikeUrl(b.slug), 560 }); 561 }); 562 return out; 563 } 564 565 function pickIntroBikeProducts() { 566 /* Banner 1 â complete bicycles */ 567 return bikesBySlugs(["cornado-26", "brooklyn-29-ms", "tesler-26-ms", "u-bike-20"]).slice(0, 4); 568 } 569 570 function pickBanner2BikeProducts() { 571 /* Banner 2 â complete bicycles */ 572 return bikesBySlugs(["cloudia-1620", "hotride-1620", "jessica-20", "highlander-29-ms"]).slice(0, 4); 573 } 574 575 function pickPartsBannerProducts() { 576 const specs = [ 577 { group: "india", catSlug: "saddle-saddle-parts", label: "Saddle" }, 578 { group: "india", catSlug: "pedals", label: "Pedal" }, 579 { group: "india", catSlug: "handle-bar", label: "Handlebars" }, 580 { group: "india", catSlug: "chains", label: "Chain" }, 581 { group: "india", catSlug: "tyres-tubes", label: "Rim / Tire" }, 582 ]; 583 const out = []; 584 const seen = new Set(); 585 specs.forEach((spec) => { 586 const picked = pickBannerProducts({ group: spec.group, catSlug: spec.catSlug, limit: 1, excludeImgs: [...seen] }); 587 if (!picked.length) return; 588 const p = { ...picked[0] }; 589 if (spec.label) p.name = spec.label + (p.name ? " â " + p.name : "");
590 const key = p.img || p.imgRemote; 591 if (key) seen.add(key); 592 out.push(p); 593 }); 594 /* Rim fallback from rims category if tyre pick failed */ 595 if (out.length < 5) { 596 const rim = pickBannerProducts({ group: "india", catSlug: "rims", limit: 1, excludeImgs: [...seen] }); 597 if (rim.length) { 598 const p = { ...rim[0], name: "Rim / Tire" + (rim[0].name ? " â " + rim[0].name : "") }; 599 out.push(p); 600 } 601 } 602 return out.slice(0, 4); 603 } 604 605 function bannerShortName(name) { 606 if (!name) return ""; 607 const base = String(name).split(" â ")[0].split(" - ")[0].trim(); 608 return base.length > 24 ? base.slice(0, 22) + "â¦" : base; 609 } 610 611 /** One large featured product + slim model chips (replaces cramped collage boxes). */ 612 function bannerShowcaseHTML(products, fallback) { 613 const items = (products && products.length ? products : fallback ? [fallback] : []).slice(0, 5); 614 if (!items.length) return ""; 615 const hero = items[0]; 616 const rest = items.slice(1); 617 const heroHref = productUrl(hero); 618 const chips = rest 619 .map((p) => { 620 const href = productUrl(p); 621 return `<a class="banner-chip" href="${href}" title="${escAttr(p.name)}"> 622 <span class="banner-chip__img">${imgTag(p, "", { eager: false })}</span> 623 </a>`; 624 }) 625 .join(""); 626 return `<div class="banner-showcase"> 627 <a class="banner-hero" href="${heroHref}" title="${escAttr(hero.name)}"> 628 ${imgTag(hero, "", { eager: true })} 629 </a> 630 ${chips ? `<div class="banner-chips">${chips}</div>` : ""} 631 </div>`; 632 } 633 634 function bannerCollageHTML(products, fallback) { 635 return bannerShowcaseHTML(products, fallback); 636 } 637 638 function buildHomeBannerSlides() { 639 const banner = $("#banner"); 640 if (!banner || document.body.dataset.page !== "home") return; 641 const controls = $(".banner-controls", banner); 642 const hasStaticIntro = !!$('[data-home-intro="1"]', banner); 643 644 if (hasStaticIntro) { 645 const art = $("#homeIntroArt") || $(".banner-art", $('[data-home-intro="1"]', banner)); 646 if (art) art.innerHTML = bannerCollageHTML(pickIntroBikeProducts()); 647 } 648 649 const slide2 = ` 650 <div class="banner-slide" data-banner="bikes-2"> 651 <div class="banner-text"> 652 <img class="banner-logo" src="assets/eastman-logo-white.png" alt="Eastman" width="180" height="54" /> 653 <span class="pill"><span class="dot"></span> Kids · MTB · Wholesale</span> 654 <p class="banner-heading">Mix that <span class="grad">moves containers</span></p> 655 <p>Cloudia, Hotride, Jessica and Highlander â volume programmes built for distributors.</p> 656 <div class="banner-trust" aria-label="Trust markers"> 657 <span class="banner-trust__chip">Factory MOQ / FOB</span> 658 <span class="banner-trust__chip">65+ countries</span> 659 <span class="banner-trust__chip">Three Star Export House</span> 660 </div> 661 </div> 662 <div class="banner-art">${bannerCollageHTML(pickBanner2BikeProducts())}</div> 663 </div>`; 664 665 const slide3 = ` 666 <div class="banner-slide" data-banner="parts"> 667 <div class="banner-text"> 668 <img class="banner-logo" src="assets/eastman-logo-white.png" alt="Eastman" width="180" height="54" /> 669 <span class="pill"><span class="dot"></span> Parts · India & China</span> 670 <p class="banner-heading">Fast-moving parts, <span class="grad">bulk ready</span></p> 671 <p>Saddle, pedal, handlebars, chain and rim / tire â QC-backed SKUs for mixed containers.</p> 672 <div class="banner-trust" aria-label="Trust markers"> 673 <span class="banner-trust__chip">ZED / AEO</span> 674 <span class="banner-trust__chip">ISO 9001</span> 675 <span class="banner-trust__chip">Factory MOQ / FOB</span> 676 </div> 677 </div> 678 <div class="banner-art">${bannerCollageHTML(pickPartsBannerProducts())}</div> 679 </div>`; 680 681 const extra = slide2 + slide3; 682 if (hasStaticIntro) { 683 if (controls) controls.insertAdjacentHTML("beforebegin", extra); 684 else banner.insertAdjacentHTML("beforeend", extra); 685 } else { 686 const intro = ` 687 <div class="banner-slide is-active" data-banner="bikes-1"> 688 <div class="banner-text"> 689 <img class="banner-logo" src="assets/eastman-logo-white.png" alt="Eastman" width="180" height="54" />
690 <span class="pill"><span class="dot"></span> For Importers · Distributors · Wholesalers</span> 691 <h1 class="banner-heading">Container-ready <span class="grad">complete bicycles</span> from India</h1> 692 <p>Stock <strong>Cornado</strong>, <strong>Brooklyn</strong>, <strong>Tesler Fat Bike</strong> and <strong>U Bike</strong> for your next container programme.</p> 693 <div class="banner-trust" aria-label="Trust markers"> 694 <span class="banner-trust__chip">ISO 9001</span> 695 <span class="banner-trust__chip">Three Star Export House</span> 696 <span class="banner-trust__chip">Since 1970</span> 697 <span class="banner-trust__chip">65+ countries</span> 698 </div> 699 </div> 700 <div class="banner-art">${bannerCollageHTML(pickIntroBikeProducts())}</div> 701 </div>`; 702 banner.insertAdjacentHTML("afterbegin", intro + extra); 703 } 704 if (window.fixSiteLinks) window.fixSiteLinks(banner); 705 } 706 707 buildHomeBannerSlides(); /* BANNER_BUILD_H11 */ 708 709 /* ---------- scroll reveal ---------- */ 710 const io = "IntersectionObserver" in window 711 ? new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } }), { threshold: 0.1 }) 712 : null; 713 const reveal = (el) => { 714 if (!io) { 715 el.classList.add("in"); 716 return; 717 } 718 el.classList.add("reveal"); 719 io.observe(el); 720 /* Cards inside overflow scroll panels can miss the first IO pass â reveal if already on-screen */ 721 requestAnimationFrame(() => { 722 const r = el.getBoundingClientRect(); 723 if (r.width && r.top < window.innerHeight + 40 && r.bottom > -40) { 724 el.classList.add("in"); 725 io.unobserve(el); 726 } 727 }); 728 }; 729 const revealAll = () => $$(".section-head, .usp-card, .cert-card, .award-card, .testi-card, .catsplit-card, .infra-item, .cap, .trust, .brand-item, .pcard, .bikes-adv-card, .product-adv-card, .value-card, .cat-card, .pdp-block").forEach(reveal); 730 731 /* ---------- product card ---------- */ 732 function originBadge(p) { 733 const g = p && p.group; 734 if (g === "china") return `<span class="pcard-badge pcard-badge--china">China</span>`; 735 if (g === "india") return `<span class="pcard-badge pcard-badge--india">India</span>`; 736 return ""; 737 } 738 739 function addQuoteBtnHTML(opts) { 740 opts = opts || {}; 741 const id = opts.id || ""; 742 if (!id) return ""; 743 return `<button type="button" class="btn-add-quote" data-add-quote="${escAttr(id)}" data-quote-name="${escAttr(opts.name || "")}" data-quote-sku="${escAttr(opts.sku || "")}" data-quote-origin="${escAttr(opts.origin || "")}" data-quote-type="${escAttr(opts.type || "part")}" data-quote-url="${escAttr(opts.url || "")}">Add to Quote</button>`; 744 } 745 746 function productCard(p) { 747 const href = productUrl(p); 748 const origin = p.group === "china" ? "China" : "India"; 749 const sku = p.sku || (p.attrs && p.attrs.SKU) || p.id || ""; 750 const badges = []; 751 badges.push(originBadge(p)); 752 const tag = (p.tags || []).find((t) => t === "new" || t === "featured" || t === "demand"); 753 if (tag && badges.filter(Boolean).length < 2) { 754 badges.push(`<span class="pcard-badge pcard-badge--${tag}">${TAG_LABELS[tag] || tag}</span>`); 755 } 756 const badgeHtml = badges.filter(Boolean).slice(0, 2).join(""); 757 return `<a class="bikes-adv-card trend-card product-adv-card product-adv-card--lean trend-card--view" href="${href}"> 758 <span class="bikes-adv-card__media">${imgTag(p)}${badgeHtml ? `<span class="pcard-badges">${badgeHtml}</span>` : ""}</span> 759 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 760 <span class="bikes-adv-card__body"> 761 <span> 762 <span class="bikes-adv-card__title">${escHtml(displayProductName(p))}</span> 763 <span class="bikes-adv-card__sub">${escHtml(sku ? "SKU " + sku : "")}${sku ? " · " : ""}${escHtml(origin)}</span> 764 </span> 765 <span class="bikes-adv-card__cta">View Details</span> 766 </span> 767 </a>`; 768 } 769 770 function renderGrid(el, list) { 771 if (!el) return; 772 el.innerHTML = list.length ? list.map(productCard).join("") : `<p class="empty">No products found.</p>`; 773 $$(".bikes-adv-card, .product-adv-card, .pcard", el).forEach(reveal); 774 } 775 776 function scrollToProducts() { 777 const target = $("#products") || $("#prodGridScroll") || $("#prodGrid"); 778 if (target) window.scrollTo({ top: target.offsetTop - 100, behavior: "smooth" }); 779 } 780 781 function renderPagination(el, total, page, onPage) { 782 if (!el) return; 783 const pages = Math.ceil(total / PAGE_SIZE); 784 if (pages <= 1) { el.innerHTML = ""; return; } 785 let html = ""; 786 if (page > 1) html += `<button class="page-btn" data-p="${page - 1}">â Prev</button>`; 787 html += `<span class="page-info" aria-current="page">Page ${page} of ${pages}</span>`;
788 if (page < pages) html += `<button class="page-btn" data-p="${page + 1}">Next â</button>`; 789 el.innerHTML = `<nav class="pagination-nav" aria-label="Pagination">${html}</nav>`; 790 $$(".page-btn", el).forEach((b) => b.addEventListener("click", () => onPage(+b.dataset.p))); 791 } 792 793 function setPressed(btn, on) { 794 if (btn) btn.setAttribute("aria-pressed", on ? "true" : "false"); 795 } 796 797 function paginate(list, page) { 798 const start = (page - 1) * PAGE_SIZE; 799 return list.slice(start, start + PAGE_SIZE); 800 } 801 802 const filterByGroup = (g) => { 803 if (!g || g === "all") return PRODUCTS; 804 if (g === "india" || g === "china") return PRODUCTS.filter((p) => p.group === g); 805 return PRODUCTS.filter((p) => (p.tags || []).includes(g)); 806 }; 807 808 const GROUP_CAT_ORDER = { 809 india: [ 810 "saddle-saddle-parts", 811 "pedals", 812 "hub-parts", 813 "brakes-brake-parts", 814 "tyres-tubes", 815 "handle-parts", 816 "single-chainwheel-cranks", 817 "frame-frame-parts", 818 "forks-fork-parts", 819 "stands", 820 "carriers", 821 "tools", 822 "mudguards", 823 "baskets", 824 "new-items", 825 ], 826 china: [], 827 }; 828 829 function sortCatsByGroupOrder(list, group) { 830 const order = GROUP_CAT_ORDER[group]; 831 if (!order || !order.length) return list; 832 const rank = new Map(order.map((slug, i) => [slug, i])); 833 return list.slice().sort((a, b) => { 834 const ai = rank.has(a.slug) ? rank.get(a.slug) : 9999; 835 const bi = rank.has(b.slug) ? rank.get(b.slug) : 9999; 836 if (ai !== bi) return ai - bi; 837 return (b.count || 0) - (a.count || 0); 838 }); 839 } 840 841 const PARTS_EXCLUDE_CAT = { india: ["complete-bicycles"] }; 842 843 /* P1 â functional parts grouping (slug â group) */ 844 const PART_GROUP_ORDER = [ 845 "Wheels", 846 "Drivetrain", 847 "Braking", 848 "Cockpit", 849 "Seating", 850 "Frames & Forks", 851 "Tyres", 852 "Accessories", 853 ]; 854 const PART_GROUP_MAP = { 855 "hub-parts": "Wheels", 856 "hub-step": "Wheels", 857 "hubs-hubs-parts": "Wheels", 858 "steel-hubs": "Wheels", 859 "rims": "Wheels", 860 "spoke": "Wheels", 861 "spokes-nipples": "Wheels", 862 "free-wheels": "Wheels", 863 "freewheels": "Wheels", 864 "eva-wheels": "Wheels", 865 "wheels-rims": "Wheels", 866 "side-support-wheels": "Wheels", 867 "pedals": "Drivetrain", 868 "pedal-pedal-parts": "Drivetrain", 869 "chains": "Drivetrain", 870 "chainwheel-cranks": "Drivetrain", 871 "single-chainwheel-cranks": "Drivetrain", 872 "chain-wheel-for-opc": "Drivetrain", 873 "deraileur": "Drivetrain", 874 "bb-fittings": "Drivetrain", 875 "bottom-bracket-cup": "Drivetrain", 876 "grip-shifters": "Drivetrain", 877 "brakes-brake-parts": "Braking", 878 "brake-set": "Braking", 879 "brake-levers": "Braking", 880 "brake-shoes": "Braking", 881 "brake-accessories": "Braking", 882 "disc-brake": "Braking", 883 "handle-parts": "Cockpit", 884 "handle-bar": "Cockpit", 885 "handle-stem-extensions": "Cockpit", 886 "handle-grip-tape": "Cockpit", 887 "head-sets": "Cockpit", 888 "bells": "Cockpit", 889 "bell-horn": "Cockpit", 890 "air-horns-bells": "Cockpit", 891 "mirrors": "Cockpit", 892 "saddle-saddle-parts": "Seating", 893 "seat-post-accessories": "Seating", 894 "seat-clamp": "Seating", 895 "frame-frame-parts": "Frames & Forks", 896 "forks-fork-parts": "Frames & Forks", 897 "fork-fittings": "Frames & Forks", 898 "frames-and-forks": "Frames & Forks", 899 "frames": "Frames & Forks", 900 "forks": "Frames & Forks", 901 "tyres-tubes": "Tyres", 902 "tube-valve-cap": "Tyres", 903 "stands": "Accessories", 904 "side-stand": "Accessories", 905 "carriers": "Accessories", 906 "carrier": "Accessories", 907 "mudguards": "Accessories", 908 "mudguards-and-fenders": "Accessories", 909 "baskets": "Accessories", 910 "tools": "Accessories", 911 "repair-tools": "Accessories", 912 "pumps": "Accessories", 913 "pump-parts": "Accessories", 914 "locks": "Accessories", 915 "reflectors": "Accessories", 916 "chain-covers": "Accessories", 917 "bottle-cage-bottle": "Accessories", 918 "light-computer": "Accessories", 919 "helmet-parts": "Accessories", 920 "leg-guard-spokes": "Accessories", 921 "ball-retainers": "Accessories", 922 "inner-cable": "Accessories", 923 "inner-cable-casing": "Accessories", 924 "dynamo-sets": "Accessories", 925 "steamer": "Accessories", 926 "new-items": "Accessories", 927 }; 928 function partGroupForSlug(slug) { 929 return PART_GROUP_MAP[slug] || "Other"; 930 } 931 function groupCatsByFunction(cats) { 932 const buckets = {};
933 (cats || []).forEach((c) => { 934 const g = partGroupForSlug(c.slug); 935 if (!buckets[g]) buckets[g] = []; 936 buckets[g].push(c); 937 }); 938 const ordered = []; 939 PART_GROUP_ORDER.forEach((name) => { 940 if (buckets[name] && buckets[name].length) ordered.push({ name, cats: buckets[name] }); 941 }); 942 Object.keys(buckets).forEach((name) => { 943 if (name === "Other" || !PART_GROUP_ORDER.includes(name)) { 944 if (buckets[name] && buckets[name].length && !ordered.find((o) => o.name === name)) { 945 ordered.push({ name, cats: buckets[name] }); 946 } 947 } 948 }); 949 return ordered; 950 } 951 952 953 const catsForGroup = (g) => sortCatsByGroupOrder(CATEGORIES.filter((c) => c.group === g), g); 954 955 const catsForPartsGroup = (g) => { 956 const exclude = PARTS_EXCLUDE_CAT[g] || []; 957 return catsForGroup(g).filter((c) => !exclude.includes(c.slug)); 958 }; 959 960 function normCatName(name) { 961 return String(name || "") 962 .toLowerCase() 963 .replace(/&/g, "and") 964 .replace(/[^a-z0-9]+/g, " ") 965 .trim(); 966 } 967 968 /** Unified parts categories across India + China (matched by slug, then name). */ 969 function unifiedPartsCategories() { 970 const india = catsForPartsGroup("india"); 971 const china = catsForPartsGroup("china"); 972 const byKey = new Map(); 973 974 function upsert(c) { 975 const keySlug = c.slug; 976 const keyName = "name:" + normCatName(c.name); 977 let row = byKey.get(keySlug) || byKey.get(keyName); 978 if (!row) { 979 row = { 980 key: keySlug, 981 name: c.name, 982 indiaSlug: null, 983 chinaSlug: null, 984 indiaCount: 0, 985 chinaCount: 0, 986 img: c.img, 987 imgRemote: c.imgRemote, 988 }; 989 byKey.set(keySlug, row); 990 byKey.set(keyName, row); 991 } 992 if (c.group === "india") { 993 row.indiaSlug = c.slug; 994 row.indiaCount = c.count || 0; 995 if (!row.name || row.name === row.name.toUpperCase()) row.name = c.name; 996 } else if (c.group === "china") { 997 row.chinaSlug = c.slug; 998 row.chinaCount = c.count || 0; 999 } 1000 if (!row.img && c.img) { 1001 row.img = c.img; 1002 row.imgRemote = c.imgRemote; 1003 } 1004 row.total = (row.indiaCount || 0) + (row.chinaCount || 0); 1005 } 1006 1007 india.forEach(upsert);
1008 china.forEach(upsert); 1009 1010 const rows = []; 1011 const seen = new Set(); 1012 byKey.forEach((row) => { 1013 if (seen.has(row)) return; 1014 seen.add(row); 1015 rows.push(row); 1016 }); 1017 1018 /* Prefer India catalogue order, then leftover China-only */ 1019 const order = GROUP_CAT_ORDER.india || []; 1020 rows.sort((a, b) => { 1021 const ai = a.indiaSlug ? order.indexOf(a.indiaSlug) : 999; 1022 const bi = b.indiaSlug ? order.indexOf(b.indiaSlug) : 999; 1023 const aa = ai < 0 ? 500 : ai; 1024 const bb = bi < 0 ? 500 : bi; 1025 if (aa !== bb) return aa - bb; 1026 return a.name.localeCompare(b.name); 1027 }); 1028 return rows; 1029 } 1030 1031 function trendingProducts(limit) { 1032 const scored = PRODUCTS.map((p) => { 1033 let s = 0; 1034 const tags = p.tags || []; 1035 if (tags.includes("featured")) s += 8; 1036 if (tags.includes("new")) s += 5; 1037 if (tags.includes("demand")) s += 2; 1038 return { p, s }; 1039 }) 1040 .filter((x) => x.s > 0) 1041 .sort((a, b) => b.s - a.s || a.p.name.localeCompare(b.p.name)); 1042 const out = []; 1043 const seen = new Set(); 1044 for (const { p } of scored) { 1045 if (seen.has(p.id)) continue; 1046 seen.add(p.id); 1047 out.push(p); 1048 if (limit && out.length >= limit) break; 1049 } 1050 return out; 1051 } 1052 1053 function categoryCard(c) { 1054 const p = { name: c.name, img: c.img, imgRemote: c.imgRemote }; 1055 const countLabel = (c.count == null ? "" : String(c.count)) + (c.count == 1 ? " product" : " products"); 1056 return `<a href="${categoryUrl(c.group, c.slug)}" class="bikes-adv-card trend-card product-adv-card cat-adv-card"> 1057 <span class="bikes-adv-card__media">${imgTag(p)}</span> 1058 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 1059 <span class="bikes-adv-card__body"> 1060 <span> 1061 <span class="bikes-adv-card__title">${escHtml(c.name)}</span> 1062 <span class="bikes-adv-card__sub">${escHtml(countLabel.trim() || "Category")}</span> 1063 </span> 1064 <span class="bikes-adv-card__go" aria-hidden="true">â</span> 1065 </span> 1066 </a>`; 1067 } 1068 1069 function renderCategoryGrid(el, group) { 1070 if (!el) return; 1071 const list = catsForGroup(group); 1072 el.innerHTML = list.length ? list.map(categoryCard).join("") : `<p class="empty">No categories found.</p>`; 1073 $$(".bikes-adv-card, .cat-adv-card, .cat-card", el).forEach(reveal); 1074 } 1075 1076 /* ================= banner slider ================= */ 1077 const banner = $("#banner"); 1078 if (banner) { 1079 const slides = $$(".banner-slide", banner); 1080 const dotsWrap = $("#bDots"); 1081 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; 1082 let i = 0, timer = null; 1083 slides.forEach((_, idx) => { 1084 const d = document.createElement("button"); 1085 d.className = "banner-dot" + (idx === 0 ? " is-active" : ""); 1086 d.setAttribute("aria-label", "Slide " + (idx + 1)); 1087 d.setAttribute("aria-current", idx === 0 ? "true" : "false"); 1088 d.addEventListener("click", () => go(idx, true)); 1089 dotsWrap.appendChild(d); 1090 }); 1091 const dots = $$(".banner-dot", dotsWrap); 1092 function go(n, manual) { 1093 slides[i].classList.remove("is-active"); dots[i].classList.remove("is-active"); dots[i].setAttribute("aria-current", "false"); 1094 i = (n + slides.length) % slides.length; 1095 slides[i].classList.add("is-active"); dots[i].classList.add("is-active"); dots[i].setAttribute("aria-current", "true"); 1096 if (manual) restart(); 1097 } 1098 function restart() { clearInterval(timer); if (!reduceMotion) timer = setInterval(() => go(i + 1), 5500); } 1099 $("#bNext").addEventListener("click", () => go(i + 1, true)); 1100 $("#bPrev").addEventListener("click", () => go(i - 1, true)); 1101 if (!reduceMotion) { 1102 banner.addEventListener("mouseenter", () => clearInterval(timer)); 1103 banner.addEventListener("mouseleave", restart); 1104 restart(); 1105 } 1106 1107 let touchStartX = 0; 1108 let touchStartY = 0; 1109 banner.addEventListener( 1110 "touchstart", 1111 (e) => { 1112 if (!e.changedTouches[0]) return; 1113 touchStartX = e.changedTouches[0].screenX; 1114 touchStartY = e.changedTouches[0].screenY; 1115 }, 1116 { passive: true } 1117 ); 1118 banner.addEventListener( 1119 "touchend", 1120 (e) => { 1121 if (!e.changedTouches[0]) return; 1122 const dx = e.changedTouches[0].screenX - touchStartX; 1123 const dy = e.changedTouches[0].screenY - touchStartY; 1124 if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy)) return; 1125 go(dx < 0 ? i + 1 : i - 1, true); 1126 banner.dataset.swiped = "1"; 1127 window.setTimeout(() => delete banner.dataset.swiped, 350); 1128 }, 1129 { passive: true } 1130 ); 1131 banner.addEventListener( 1132 "click", 1133 (e) => { 1134 if (!banner.dataset.swiped) return; 1135 e.preventDefault(); 1136 e.stopPropagation(); 1137 }, 1138 true 1139 ); 1140 } 1141 1142 /* ================= category page ================= */ 1143 function renderPartsOriginShop() { 1144 const shop = $("#partsOriginShop") || $("#catIndex"); 1145 if (!shop && !$("#catProdGrid")) return false;
1146 const catPath = resolveCategoryPath(); 1147 let group = catPath.group || "india"; 1148 if (group !== "india" && group !== "china") group = "india"; 1149 let catSlug = catPath.catSlug || ""; 1150 const originLabel = group === "china" ? "Eastman China" : "Eastman India"; 1151 const cats = catsForPartsGroup(group); 1152 1153 $$(".parts-origin-tab, .cat-tab", $("#catGroupTabs")).forEach((t) => { 1154 t.classList.toggle("is-active", t.dataset.group === group); 1155 }); 1156 1157 if (!catSlug && cats.length) catSlug = cats[0].slug; 1158 const catMeta = cats.find((c) => c.slug === catSlug) || CATEGORIES.find((c) => c.group === group && c.slug === catSlug); 1159 1160 const titleEl = $("#catPageTitle"); 1161 const descEl = $("#catPageDesc"); 1162 const crumbEl = $("#catCrumbs"); 1163 const crumbOrigin = $("#catCrumbOrigin"); 1164 const listTitle = $("#catListTitle"); 1165 const countEl = $("#catResultCount"); 1166 const grid = $("#catProdGrid"); 1167 const side = $("#partsOriginCats"); 1168 1169 if (titleEl) titleEl.textContent = originLabel; 1170 if (descEl) descEl.textContent = catMeta 1171 ? `${catMeta.name} â ${group === "china" ? "China" : "India"} range for B2B export.` 1172 : `Browse ${originLabel} categories and products.`; 1173 if (crumbOrigin) crumbOrigin.textContent = originLabel; 1174 if (crumbEl) { 1175 crumbEl.innerHTML = catMeta 1176 ? `<a href="/">Home</a> <span>/</span> <a href="/bicycle-parts">Parts</a> <span>/</span> <a href="${categoryUrl(group)}">${originLabel}</a> <span>/</span> <span>${escHtml(catMeta.name)}</span>` 1177 : `<a href="/">Home</a> <span>/</span> <a href="/bicycle-parts">Parts</a> <span>/</span> <span>${originLabel}</span>`; 1178 } 1179 document.title = (catMeta ? catMeta.name + " â " : "") + originLabel + " | Eastman Bicycles"; 1180 1181 if (side) { 1182 const catLinks = cats.length 1183 ? cats.map((c) => { 1184 const active = c.slug === catSlug ? " is-active" : ""; 1185 return `<a class="parts-origin-cat${active}" href="${categoryUrl(group, c.slug)}"><span>${escHtml(c.name)}</span><span class="side-count">${c.count || 0}</span></a>`; 1186 }).join("") 1187 : `<p class="empty">No categories found.</p>`; 1188 const activeName = (catMeta && catMeta.name) || "Categories"; 1189 side.innerHTML = `<button type="button" class="parts-origin-cats-toggle" id="partsCatsToggle" aria-expanded="false"><span>Categories</span><strong>${escHtml(activeName)}</strong><span class="parts-origin-cats-toggle__chev" aria-hidden="true">â¾</span></button> 1190 <div class="parts-origin-cats-panel" id="partsCatsPanel">${catLinks}</div> 1191 <div class="parts-origin-pills" id="partsCatsPills" aria-label="Category shortcuts">${cats.slice(0, 12).map((c) => { 1192 const active = c.slug === catSlug ? " is-active" : ""; 1193 return `<a class="parts-origin-pill${active}" href="${categoryUrl(group, c.slug)}">${escHtml(c.name)}</a>`; 1194 }).join("")}</div>`; 1195 if (window.fixSiteLinks) window.fixSiteLinks(side); 1196 const tog = $("#partsCatsToggle"); 1197 const panel = $("#partsCatsPanel"); 1198 if (tog && panel) { 1199 tog.addEventListener("click", () => { 1200 const open = panel.classList.toggle("is-open"); 1201 tog.setAttribute("aria-expanded", open ? "true" : "false"); 1202 tog.classList.toggle("is-open", open); 1203 }); 1204 } 1205 } 1206 1207 const products = catSlug 1208 ? PRODUCTS.filter((p) => p.group === group && p.catSlug === catSlug) 1209 : []; 1210 products.sort((a, b) => productScore(b) - productScore(a) || (a.name || "").localeCompare(b.name || "")); 1211 1212 if (listTitle) listTitle.textContent = catMeta ? catMeta.name : "Products"; 1213 if (countEl) countEl.textContent = `${products.length} product${products.length === 1 ? "" : "s"}`; 1214 1215 let page = Math.max(1, parseInt(params.get("page") || "1", 10)); 1216 function showPage(p) { 1217 page = p; 1218 renderGrid(grid, paginate(products, page)); 1219 renderPagination($("#catPagination"), products.length, page, (np) => { 1220 showPage(np); 1221 window.scrollTo({ top: (shop || grid).offsetTop - 100, behavior: "smooth" }); 1222 }); 1223 } 1224 if (grid) { 1225 if (!products.length) grid.innerHTML = `<p class="empty">No products in this category.</p>`; 1226 else showPage(page); 1227 } 1228 1229 const catIndex = $("#catIndex"); 1230 if (catIndex) catIndex.hidden = true; 1231 const catProducts = $("#catProducts"); 1232 if (catProducts) catProducts.hidden = true; 1233 return true; 1234 } 1235 1236 if ($("#partsOriginShop") || ($("#catIndex") && (resolveCategoryPath().group === "india" || resolveCategoryPath().group === "china" || document.body.dataset.page === "products"))) { 1237 try { renderPartsOriginShop(); } catch (err) { console.error("parts origin shop", err); } 1238 } 1239 1240 /* ================= shop layout (home + products page) ================= */ 1241 const sideQuick = $("#sideQuickFilters"); 1242 if (sideQuick) { 1243 const grid = $("#prodGrid"); 1244 const titles = { all: "All Products", india: "Eastman India", china: "Eastman China", new: "New Arrivals", featured: "Featured", demand: "In High Demand" };
1245 let current = params.get("group") || "all"; 1246 let currentCat = params.get("cat") ? { group: params.get("group") || "india", slug: params.get("cat") } : null; 1247 let query = (params.get("q") || "").toLowerCase(); 1248 let page = Math.max(1, parseInt(params.get("page") || "1", 10)); 1249 if (!titles[current] && !currentCat) current = "all"; 1250 if (currentCat && !params.get("group")) current = currentCat.group; 1251 1252 const countFor = (g) => filterByGroup(g).length; 1253 $$("button", sideQuick).forEach((b) => { 1254 const g = b.dataset.group; 1255 const label = titles[g] || b.textContent.trim(); 1256 const tagBadge = ["new", "featured", "demand"].includes(g) ? tagPills([g], 1) : ""; 1257 b.innerHTML = `<span class="side-label">${label}</span><span class="side-meta">${tagBadge}<span class="side-count">${countFor(g)}</span></span>`; 1258 }); 1259 1260 const browseList = $("#sideBrowseList"); 1261 const sideRange = $("#sideRangeFilters"); 1262 const mobileShopRoot = $("#mobileCatShop"); 1263 let mobileShopRange = null; 1264 let mobileShopTrack = null; 1265 if (mobileShopRoot) { 1266 mobileShopRange = $(".mobile-cat-nav__range", mobileShopRoot); 1267 if (!mobileShopRange) { 1268 mobileShopRange = document.createElement("div"); 1269 mobileShopRange.className = "mobile-cat-nav__range"; 1270 mobileShopRange.innerHTML = `<button type="button" data-browse="india">Eastman India</button><button type="button" data-browse="china">Eastman China</button>`; 1271 mobileShopRoot.insertBefore(mobileShopRange, mobileShopRoot.firstChild); 1272 } 1273 mobileShopTrack = ensureMobileTrack(mobileShopRoot); 1274 } 1275 1276 let browseGroup = currentCat ? currentCat.group : (params.get("group") === "china" ? "china" : "india"); 1277 1278 function syncMobileRange() { 1279 if (!mobileShopRange) return; 1280 $$("button", mobileShopRange).forEach((t) => { 1281 t.classList.toggle("is-active", t.dataset.browse === browseGroup); 1282 }); 1283 } 1284 1285 function pickCategory(group, slug) { 1286 currentCat = { group, slug }; 1287 current = group; 1288 browseGroup = group; 1289 query = ""; 1290 page = 1; 1291 if (sideRange) $$("button", sideRange).forEach((t) => t.classList.toggle("is-active", t.dataset.browse === group)); 1292 syncMobileRange(); 1293 renderBrowse(group); 1294 apply(); 1295 if (!isMobileShop()) scrollToProducts(); 1296 else scrollMobilePill(mobileShopTrack, slug); 1297 } 1298 1299 function switchBrowseGroup(group) { 1300 browseGroup = group; 1301 current = group; 1302 query = ""; 1303 page = 1; 1304 if (isMobileShop()) { 1305 const cats = catsForGroup(group); 1306 currentCat = cats.length ? { group, slug: cats[0].slug } : null; 1307 } else { 1308 currentCat = null; 1309 } 1310 if (sideRange) $$("button", sideRange).forEach((t) => t.classList.toggle("is-active", t.dataset.browse === group)); 1311 syncMobileRange(); 1312 renderBrowse(group); 1313 apply(); 1314 if (!isMobileShop()) scrollToProducts(); 1315 } 1316 1317 function renderBrowse(group) { 1318 if (!browseList) return; 1319 browseGroup = group; 1320 const list = catsForGroup(group); 1321 browseList.innerHTML = list.map((c) => 1322 `<button type="button" class="side-browse-item${currentCat && currentCat.group === c.group && currentCat.slug === c.slug ? " is-active" : ""}" data-cat-group="${c.group}" data-cat-slug="${c.slug}"><span class="side-browse-name">${c.name}</span><span class="side-browse-tags"><span class="side-count">${c.count}</span></span></button>` 1323 ).join(""); 1324 $$(".side-browse-item", browseList).forEach((btn) => btn.addEventListener("click", () => { 1325 pickCategory(btn.dataset.catGroup, btn.dataset.catSlug); 1326 })); 1327 1328 if (mobileShopTrack) { 1329 mobileShopTrack.innerHTML = list.map((c) => mobileCatPill(c, currentCat && currentCat.group === c.group && currentCat.slug === c.slug)).join(""); 1330 bindMobileCatPills(mobileShopTrack, (g, slug) => pickCategory(g, slug)); 1331 if (currentCat) scrollMobilePill(mobileShopTrack, currentCat.slug); 1332 } 1333 syncMobileRange(); 1334 } 1335 1336 const browseLabels = { india: "Eastman India", china: "Eastman China" }; 1337 if (sideRange) { 1338 $$("button", sideRange).forEach((tab) => { 1339 const g = tab.dataset.browse;
1340 tab.innerHTML = `<span class="side-label">${browseLabels[g]}</span><span class="side-count">${countFor(g)}</span>`; 1341 tab.addEventListener("click", () => switchBrowseGroup(tab.dataset.browse)); 1342 }); 1343 const activeRange = ["india", "china"].includes(current) ? current : browseGroup; 1344 $$("button", sideRange).forEach((t) => t.classList.toggle("is-active", t.dataset.browse === activeRange)); 1345 renderBrowse(browseGroup); 1346 } 1347 1348 if (mobileShopRange) { 1349 $$("button", mobileShopRange).forEach((tab) => { 1350 tab.addEventListener("click", () => switchBrowseGroup(tab.dataset.browse)); 1351 }); 1352 } 1353 1354 function filteredList() { 1355 let list = PRODUCTS; 1356 if (currentCat) { 1357 list = list.filter((p) => p.group === currentCat.group && p.catSlug === currentCat.slug); 1358 } else { 1359 list = filterByGroup(current); 1360 } 1361 if (query) list = list.filter((p) => (p.name + " " + p.sku + " " + p.catName + " " + p.desc).toLowerCase().includes(query)); 1362 return list; 1363 } 1364 1365 function listTitle() { 1366 if (query) return `Results for â${query}â`; 1367 if (currentCat) { 1368 const meta = CATEGORIES.find((c) => c.group === currentCat.group && c.slug === currentCat.slug); 1369 return meta ? meta.name : currentCat.slug; 1370 } 1371 return titles[current] || "All Products"; 1372 } 1373 1374 function apply() { 1375 const list = filteredList(); 1376 const showAll = !!isHomeShop; 1377 const pagEl = $("#prodPagination"); 1378 let visible; 1379 if (showAll) { 1380 visible = list.slice(0, HOME_RENDER_CAP); 1381 if (pagEl) pagEl.innerHTML = ""; 1382 } else if (isMobileShop()) { 1383 visible = list.slice(0, MOBILE_PRODUCT_CAP); 1384 if (pagEl) pagEl.innerHTML = ""; 1385 } else { 1386 visible = paginate(list, page); 1387 renderPagination(pagEl, list.length, page, (np) => { page = np; apply(); window.scrollTo({ top: grid.offsetTop - 100, behavior: "smooth" }); }); 1388 } 1389 renderGrid(grid, visible); 1390 const scrollWrap = $("#prodGridScroll"); 1391 if (scrollWrap) scrollWrap.scrollTop = 0; 1392 const t = listTitle(); 1393 const set = (id, val) => { const el = $("#" + id); if (el) el.textContent = val; }; 1394 set("listTitle", t); 1395 set("pageTitle", t); 1396 set("crumbCat", t); 1397 let countText; 1398 if (isMobileShop() && !showAll) { 1399 const shown = Math.min(list.length, MOBILE_PRODUCT_CAP); 1400 countText = currentCat ? `${t} · showing ${shown} of ${list.length}` : `${shown} of ${list.length} products`; 1401 } else { 1402 countText = list.length + " product" + (list.length === 1 ? "" : "s") + (query ? " found" : " available"); 1403 if (showAll && list.length > HOME_RENDER_CAP) countText += ` · scroll to browse ${HOME_RENDER_CAP} shown`; 1404 } 1405 set("resultCount", countText); 1406 $$("button", sideQuick).forEach((b) => b.classList.toggle("is-active", !currentCat && ["all", "new", "featured", "demand"].includes(current) && b.dataset.group === current)); 1407 if (sideRange) $$("button", sideRange).forEach((t) => { 1408 t.classList.toggle("is-active", !["all", "new", "featured", "demand"].includes(current) && (current === t.dataset.browse || (currentCat && currentCat.group === t.dataset.browse))); 1409 }); 1410 if (browseList) $$(".side-browse-item", browseList).forEach((btn) => { 1411 btn.classList.toggle("is-active", currentCat && btn.dataset.catGroup === currentCat.group && btn.dataset.catSlug === currentCat.slug); 1412 }); 1413 if (mobileShopRoot) mobileShopRoot.hidden = !isMobileShop(); 1414 const moreShop = $("#prodMoreShop"); 1415 if (moreShop) { 1416 if (currentCat) { 1417 const meta = CATEGORIES.find((c) => c.group === currentCat.group && c.slug === currentCat.slug); 1418 const total = PRODUCTS.filter((p) => p.group === currentCat.group && p.catSlug === currentCat.slug).length; 1419 const name = meta ? meta.name : currentCat.slug; 1420 const rangeLabels = { india: "Indian", china: "Chinese" }; 1421 moreShop.innerHTML = 1422 categoryAllLink(currentCat.group, currentCat.slug, name, total) + 1423 `<div class="prod-more--secondary"><a href="${categoryUrl(currentCat.group)}" class="btn btn-outline">All ${rangeLabels[currentCat.group]} categories →</a></div>`; 1424 moreShop.hidden = false;
1425 } else { 1426 moreShop.hidden = true; 1427 moreShop.innerHTML = ""; 1428 } 1429 } 1430 } 1431 1432 function bootProductsMobile() { 1433 if (!isMobileShop() || currentCat) return; 1434 if (["all", "new", "featured", "demand"].includes(current)) { 1435 const group = browseGroup || "india"; 1436 current = group; 1437 browseGroup = group; 1438 const cats = catsForGroup(group); 1439 if (cats.length) currentCat = { group, slug: cats[0].slug }; 1440 } else if (["india", "china"].includes(current)) { 1441 const cats = catsForGroup(current); 1442 if (cats.length) currentCat = { group: current, slug: cats[0].slug }; 1443 } 1444 } 1445 1446 bootProductsMobile(); 1447 if (sideRange) renderBrowse(browseGroup); 1448 $$("button", sideQuick).forEach((b) => b.addEventListener("click", () => { 1449 current = b.dataset.group; 1450 currentCat = null; 1451 query = ""; 1452 page = 1; 1453 apply(); 1454 scrollToProducts(); 1455 })); 1456 apply(); 1457 MOBILE_MQ.addEventListener("change", () => { 1458 bootProductsMobile(); 1459 renderBrowse(browseGroup); 1460 apply(); 1461 }); 1462 } 1463 1464 /* ================= Complete Bicycles India (tabbed catalogue) ================= */ 1465 1466 /* ================= Complete Bicycles adventure carousel (homepage) ================= */ 1467 function initBikesAdventure() { 1468 const track = $("#bikesAdvTrack"); 1469 if (!track) return; 1470 1471 const CATS = [ 1472 { 1473 id: "mtb", 1474 title: "MTB & Hybrid", 1475 sub: "Highlander · Brooklyn", 1476 slug: "highlander-29-ms", 1477 href: "/bicycles?tab=mtb", 1478 }, 1479 { 1480 id: "city", 1481 title: "City Cycles", 1482 sub: "Cornado · Dinamic", 1483 slug: "cornado-26", 1484 href: "/bicycles?tab=city26", 1485 }, 1486 { 1487 id: "fat", 1488 title: "Fat Bike", 1489 sub: "Tesler Fat Bike", 1490 slug: "tesler-26-ms", 1491 href: "/bicycles?tab=city26", 1492 }, 1493 { 1494 id: "kids", 1495 title: "Kids Cycles", 1496 sub: "Cloudia · Jessica · Hotride", 1497 slug: "cloudia-1620", 1498 href: "/bicycles?tab=kids", 1499 }, 1500 { 1501 id: "compact", 1502 title: "Compact 24\"", 1503 sub: "Cameron · Cornado 24", 1504 slug: "cameron-24", 1505 href: "/bicycles?tab=compact24", 1506 }, 1507 { 1508 id: "trending", 1509 title: "Trending Models", 1510 sub: "U Bike · Dynamite", 1511 slug: "u-bike-20", 1512 href: "/bicycles?tab=trending", 1513 }, 1514 ]; 1515 1516 const bySlug = new Map((window.getAllIndiaBikes ? window.getAllIndiaBikes() : []).map((b) => [b.slug, b])); 1517 1518 track.innerHTML = CATS.map((c) => { 1519 const bike = bySlug.get(c.slug); 1520 const img = bike 1521 ? imgTag({ name: bike.name, img: bike.img }, "", { eager: false }) 1522 : `<img src="assets/bikes-india/${c.slug}.jpg" alt="${escAttr(c.title)}" loading="lazy" />`; 1523 return `<a class="bikes-adv-card trend-card trend-card--view" href="${c.href}"> 1524 <span class="bikes-adv-card__media">${img}</span> 1525 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 1526 <span class="bikes-adv-card__body"> 1527 <span> 1528 <span class="bikes-adv-card__title">${escHtml(c.title)}</span> 1529 <span class="bikes-adv-card__sub">${escHtml(c.sub)}</span> 1530 </span> 1531 <span class="bikes-adv-card__cta">View Details</span> 1532 </span> 1533 </a>`; 1534 }).join(""); 1535 1536 if (window.fixSiteLinks) window.fixSiteLinks(track); 1537 1538 const step = () => { 1539 const card = track.querySelector(".bikes-adv-card"); 1540 if (!card) return Math.floor(track.clientWidth * 0.22); 1541 return Math.round(card.getBoundingClientRect().width + 18); 1542 }; 1543 const prev = $("#bikesAdvPrev"); 1544 const next = $("#bikesAdvNext"); 1545 if (prev) prev.addEventListener("click", () => track.scrollBy({ left: -step(), behavior: "smooth" })); 1546 if (next) next.addEventListener("click", () => track.scrollBy({ left: step(), behavior: "smooth" })); 1547 } 1548 1549 function initIndiaBikesTabs() { 1550 /* Homepage uses adventure carousel instead of tabbed grid */ 1551 if ($("#bikesAdvTrack")) return; 1552 const tabsEl = $("#indiaBikesTabs"); 1553 const panelsEl = $("#indiaBikesPanels"); 1554 const data = window.EASTMAN_INDIA_BIKES; 1555 if (!tabsEl || !panelsEl || !data || !data.tabs || !data.tabs.length) return; 1556 1557 const titleEl = $("#indiaBikesTitle"); 1558 const subEl = $("#indiaBikesSubtitle"); 1559 if (titleEl && data.title) titleEl.textContent = data.title; 1560 if (subEl && data.subtitle) subEl.textContent = data.subtitle; 1561 1562 let activeId = data.tabs[0].id; 1563 const tabParam = params.get("tab"); 1564 if (tabParam && data.tabs.some((t) => t.id === tabParam)) activeId = tabParam; 1565 const isFullPage = document.body.dataset.page === "bicycles" || document.body.dataset.page === "bikes-india"; 1566 const scrollClass = isFullPage ? "bikes-india-scroll bikes-india-scroll--full" : "prod-grid-scroll bikes-india-scroll"; 1567 1568 function bikeCard(item) { 1569 const catLabel = item.tag || item.category || "Complete Bicycle"; 1570 const detailUrl = bikeUrl(item.slug); 1571 return `<a class="bikes-adv-card trend-card product-adv-card bike-pcard trend-card--view" href="${detailUrl}">
1572 <span class="bikes-adv-card__media"><img src="${escAttr(assetUrl(item.img))}" alt="${escAttr(item.name)}" loading="lazy" width="720" height="540" /></span> 1573 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 1574 <span class="bikes-adv-card__body"> 1575 <span> 1576 <span class="bikes-adv-card__title">${escHtml(item.name)}</span> 1577 <span class="bikes-adv-card__sub">${escHtml(catLabel)}</span> 1578 </span> 1579 <span class="bikes-adv-card__cta">View Details</span> 1580 </span> 1581 </a>`; 1582 } 1583 1584 function renderPanels() { 1585 panelsEl.innerHTML = data.tabs 1586 .map( 1587 (tab) => 1588 `<div class="bikes-india-panel${tab.id === activeId ? " is-active" : ""}" id="bikes-panel-${tab.id}" role="tabpanel" aria-labelledby="bikes-tab-${tab.id}"> 1589 <div class="${scrollClass}"${isFullPage ? "" : ' tabindex="0"'}> 1590 <div class="prod-grid bikes-india-grid bikes-india-grid--row">${tab.items.map(bikeCard).join("")}</div> 1591 </div> 1592 </div>` 1593 ) 1594 .join(""); 1595 $$(".bikes-adv-card, .bike-pcard", panelsEl).forEach(reveal); 1596 } 1597 1598 function setTab(id) { 1599 activeId = id; 1600 $$(".bikes-india-tab", tabsEl).forEach((btn) => { 1601 const on = btn.dataset.tabId === id; 1602 btn.classList.toggle("is-active", on); 1603 btn.setAttribute("aria-selected", on ? "true" : "false"); 1604 }); 1605 renderPanels(); 1606 } 1607 1608 tabsEl.innerHTML = data.tabs 1609 .map( 1610 (tab, i) => 1611 `<button type="button" class="bikes-india-tab${i === 0 ? " is-active" : ""}" id="bikes-tab-${tab.id}" role="tab" data-tab-id="${tab.id}" aria-selected="${i === 0 ? "true" : "false"}" aria-controls="bikes-panel-${tab.id}">${tab.label}</button>` 1612 ) 1613 .join(""); 1614 1615 renderPanels(); 1616 $$(".bikes-india-tab", tabsEl).forEach((btn) => { 1617 btn.addEventListener("click", () => setTab(btn.dataset.tabId)); 1618 }); 1619 } 1620 1621 /* ================= Trending products (homepage) ================= */ 1622 function trendingBikeCard(b) { 1623 const href = bikeUrl(b.slug); 1624 return `<a class="bikes-adv-card trend-card trend-card--view" href="${href}"> 1625 <span class="bikes-adv-card__media">${imgTag({ name: b.name, img: b.img }, "", { eager: false })}</span> 1626 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 1627 <span class="bikes-adv-card__body"> 1628 <span> 1629 <span class="bikes-adv-card__title">${escHtml(b.name)}</span> 1630 <span class="bikes-adv-card__sub">Trending</span> 1631 </span> 1632 <span class="bikes-adv-card__cta">View Details</span> 1633 </span> 1634 </a>`; 1635 } 1636 1637 function initTrendingHome() { 1638 const track = $("#prodGridTrending"); 1639 if (!track) return; 1640 1641 const TRENDING_BIKE_SLUGS = [ 1642 "cloudia-1620", 1643 "hotride-1620", 1644 "jessica-20", 1645 "highlander-29-ms", 1646 "cornado-26", 1647 "brooklyn-29-ms", 1648 "dinamic-26-ms", 1649 "dynamite-20", 1650 "tesler-26-ms", 1651 "u-bike-20", 1652 ]; 1653 const bySlug = new Map((window.getAllIndiaBikes ? window.getAllIndiaBikes() : []).map((b) => [b.slug, b])); 1654 const bikes = TRENDING_BIKE_SLUGS.map((s) => bySlug.get(s)).filter(Boolean); 1655 1656 if (bikes.length) { 1657 track.innerHTML = bikes.map(trendingBikeCard).join(""); 1658 /* no reveal â horizontal track cards stay opacity 0 if IO/width miss */ 1659 /* trending count line removed */ 1660 const moreEl = $("#prodMoreTrending"); 1661 if (moreEl) { 1662 moreEl.innerHTML = `<a href="/bicycles" class="btn btn-outline">Browse complete bicycle range â</a>`; 1663 } 1664 } else { 1665 const list = trendingProducts(12); 1666 track.innerHTML = list.map(productCard).join(""); 1667 /* carousel: skip reveal so cards never stick at opacity 0 */ 1668 } 1669 1670 const step = () => { 1671 const card = track.querySelector(".bikes-adv-card, .trend-card, .pcard"); 1672 if (!card) return Math.floor(track.clientWidth * 0.22); 1673 const gap = 18; 1674 return Math.round(card.getBoundingClientRect().width + gap); 1675 }; 1676 const prev = $("#trendingPrev"); 1677 const next = $("#trendingNext"); 1678 if (prev) prev.addEventListener("click", () => track.scrollBy({ left: -step(), behavior: "smooth" })); 1679 if (next) next.addEventListener("click", () => track.scrollBy({ left: step(), behavior: "smooth" })); 1680 } 1681 1682 /* ================= Bicycle Parts â separate India + China homepage sections ================= */
1683 function initPartsShop() { 1684 function catImgForGroup(cat, group) { 1685 const probe = 1686 PRODUCTS.find((p) => p.group === group && p.catSlug === cat.slug && (p.img || p.imgRemote)) || 1687 (cat.img || cat.imgRemote ? cat : null); 1688 if (!probe) return ""; 1689 return imgTag( 1690 { name: cat.name, img: probe.img, imgRemote: probe.imgRemote }, 1691 "", 1692 { eager: false } 1693 ); 1694 } 1695 1696 function catCardHTML(c, group) { 1697 const count = c.count || 0; 1698 const href = categoryUrl(group, c.slug); 1699 return `<a class="bikes-adv-card trend-card product-adv-card parts-adv-card" href="${href}" data-group="${escAttr(group)}" data-cat="${escAttr(c.slug)}"> 1700 <span class="bikes-adv-card__media">${catImgForGroup(c, group)}</span> 1701 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 1702 <span class="bikes-adv-card__body"> 1703 <span> 1704 <span class="bikes-adv-card__title">${escHtml(c.name)}</span> 1705 <span class="bikes-adv-card__sub">${count} products</span> 1706 </span> 1707 <span class="bikes-adv-card__go" aria-hidden="true">â</span> 1708 </span> 1709 </a>`; 1710 } 1711 1712 function bindPartsCarousel(trackId, prevId, nextId) { 1713 const track = $(trackId); 1714 if (!track) return; 1715 const step = () => Math.min(320, Math.floor(track.clientWidth * 0.85)); 1716 const prev = $(prevId); 1717 const next = $(nextId); 1718 if (prev) prev.addEventListener("click", () => track.scrollBy({ left: -step(), behavior: "smooth" })); 1719 if (next) next.addEventListener("click", () => track.scrollBy({ left: step(), behavior: "smooth" })); 1720 } 1721 1722 function renderPartsGrid(gridId, group, opts) { 1723 const grid = $(gridId); 1724 if (!grid) return; 1725 opts = opts || {}; 1726 let cats = catsForPartsGroup(group); 1727 if (opts.popularFirst && opts.popularFirst.length) { 1728 const byKey = new Map(); 1729 CATEGORIES.forEach((c) => byKey.set(c.group + "::" + c.slug, c)); 1730 const pinned = []; 1731 const seen = new Set(); 1732 opts.popularFirst.forEach((item) => { 1733 const g = item.group || group; 1734 const key = g + "::" + item.slug; 1735 const cat = byKey.get(key); 1736 if (!cat || seen.has(key)) return; 1737 seen.add(key); 1738 pinned.push({ cat, group: g }); 1739 }); 1740 const rest = cats 1741 .filter((c) => !seen.has(group + "::" + c.slug)) 1742 .map((c) => ({ cat: c, group: group })); 1743 grid.innerHTML = pinned.concat(rest).map((x) => catCardHTML(x.cat, x.group)).join(""); 1744 } else { 1745 grid.innerHTML = cats.map((c) => catCardHTML(c, group)).join(""); 1746 } 1747 if (window.fixSiteLinks) window.fixSiteLinks(grid); 1748 } 1749 1750 renderPartsGrid("#partsCatGridIndia", "india"); 1751 renderPartsGrid("#partsCatGridChina", "china"); 1752 } 1753 1754 try { initBikesAdventure(); } catch (err) { console.error("bikes adventure", err); } 1755 try { initIndiaBikesTabs(); } catch (err) { console.error("bikes tabs", err); } 1756 1757 1758 function bikeShopCategories() { 1759 const data = window.EASTMAN_INDIA_BIKES || {}; 1760 const tabs = (data.tabs || []).filter((t) => t && t.id && t.id !== "trending"); 1761 const all = window.getAllIndiaBikes ? window.getAllIndiaBikes() : []; 1762 const uniq = new Map(); 1763 all.forEach((b) => { if (b && b.slug && !uniq.has(b.slug)) uniq.set(b.slug, b); }); 1764 return { 1765 all: Array.from(uniq.values()), 1766 tabs: tabs.length 1767 ? tabs 1768 : [ 1769 { id: "mtb", label: "Mountain / MTB", items: all.filter((b) => /mountain|mtb|hybrid/i.test(`${b.category || ""} ${b.tag || ""} ${b.name || ""}`)) }, 1770 { id: "city", label: "City", items: all.filter((b) => /city|road|urban/i.test(`${b.category || ""} ${b.tag || ""} ${b.name || ""}`)) }, 1771 { id: "kids", label: "Kids", items: all.filter((b) => /kid|junior|balance|child/i.test(`${b.category || ""} ${b.tag || ""} ${b.name || ""}`)) }, 1772 { id: "bmx", label: "BMX", items: all.filter((b) => /bmx|dynamite|impulse|shoorveer/i.test(`${b.category || ""} ${b.tag || ""} ${b.name || ""}`)) }, 1773 ], 1774 }; 1775 } 1776 1777 function bikeCardHTML(b) { 1778 const url = bikeUrl(b.slug); 1779 const sub = b.tag || b.category || "Bicycle"; 1780 return `<a class="bikes-adv-card trend-card product-adv-card bike-pcard trend-card--view" href="${url}">
1781 <span class="bikes-adv-card__media"><img src="${escAttr(assetUrl(b.img))}" alt="${escAttr(b.name)}" loading="lazy" width="720" height="540" /></span> 1782 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 1783 <span class="bikes-adv-card__body"> 1784 <span> 1785 <span class="bikes-adv-card__title">${escHtml(b.name)}</span> 1786 <span class="bikes-adv-card__sub">${escHtml(sub)}</span> 1787 </span> 1788 <span class="bikes-adv-card__cta">View Details</span> 1789 </span> 1790 </a>`; 1791 } 1792 1793 function renderBikesOriginShop() { 1794 const shop = $("#bikesOriginShop"); 1795 if (!shop) return false; 1796 const { all, tabs } = bikeShopCategories(); 1797 const rawCat = (params.get("cat") || "").toLowerCase(); 1798 let catId = rawCat; 1799 if (!catId && tabs.length) catId = tabs[0].id; // default first category on hub 1800 if (catId === "all") catId = ""; 1801 const activeTab = catId ? tabs.find((t) => t.id === catId) : null; 1802 const products = activeTab 1803 ? (() => { 1804 const seen = new Set(); 1805 const out = []; 1806 (activeTab.items || []).forEach((b) => { 1807 if (!b || !b.slug || seen.has(b.slug)) return; 1808 seen.add(b.slug); 1809 out.push(b); 1810 }); 1811 return out; 1812 })() 1813 : all; 1814 1815 const titleEl = $("#bikesPageTitle"); 1816 const descEl = $("#bikesPageDesc"); 1817 const crumbEl = $("#bikesCrumbs"); 1818 const listTitle = $("#bikesListTitle"); 1819 const countEl = $("#bikesResultCount"); 1820 const grid = $("#bikesProdGrid"); 1821 const side = $("#bikesOriginCats"); 1822 1823 if (titleEl) titleEl.textContent = "Bicycles"; 1824 if (descEl) { 1825 descEl.textContent = activeTab 1826 ? `${activeTab.label} â Eastman India complete bicycles for B2B export.` 1827 : "Browse Eastman India complete bicycles for B2B export."; 1828 } 1829 if (crumbEl) { 1830 crumbEl.innerHTML = activeTab 1831 ? `<a href="/">Home</a> <span>/</span> <a href="/bicycles">Bicycles</a> <span>/</span> <span>${escHtml(activeTab.label)}</span>` 1832 : `<a href="/">Home</a> <span>/</span> <span>Bicycles</span>`; 1833 } 1834 document.title = (activeTab ? activeTab.label + " â " : "") + "Bicycles | Eastman Bicycles"; 1835 1836 if (side) { 1837 const allCount = all.length; 1838 const rows = [`<a class="parts-origin-cat${activeTab ? "" : " is-active"}" href="/bicycles?cat=all"><span>All bikes</span><span class="side-count">${allCount}</span></a>`];
1839 tabs.forEach((t) => { 1840 const seen = new Set(); 1841 (t.items || []).forEach((b) => { if (b && b.slug) seen.add(b.slug); }); 1842 const active = t.id === catId ? " is-active" : ""; 1843 rows.push(`<a class="parts-origin-cat${active}" href="/bicycles?cat=${encodeURIComponent(t.id)}"><span>${escHtml(t.label)}</span><span class="side-count">${seen.size}</span></a>`); 1844 }); 1845 side.innerHTML = rows.join(""); 1846 if (window.fixSiteLinks) window.fixSiteLinks(side); 1847 } 1848 1849 if (listTitle) listTitle.textContent = activeTab ? activeTab.label : "All bikes"; 1850 if (countEl) countEl.textContent = `${products.length} model${products.length === 1 ? "" : "s"}`; 1851 if (grid) { 1852 grid.innerHTML = products.length 1853 ? products.map(bikeCardHTML).join("") 1854 : `<p class="empty">No bicycles in this category.</p>`; 1855 $$(".bikes-adv-card, .product-adv-card", grid).forEach(reveal); 1856 if (window.fixSiteLinks) window.fixSiteLinks(grid); 1857 } 1858 return true; 1859 } 1860 1861 1862 /* ================= Clean hub pages: image category cards (no text landing cards) ================= */ 1863 function initHubCategoryPages() { 1864 const page = document.body.getAttribute("data-page") || ""; 1865 const grid = $("#hubCatGrid"); 1866 if (!grid) return; 1867 1868 if (page === "bicycle-parts") { 1869 const seen = new Set(); 1870 const list = []; 1871 const pushCat = (c, group) => { 1872 if (!c || !c.slug || seen.has(c.slug)) return; 1873 seen.add(c.slug); 1874 list.push(Object.assign({}, c, { group: group || c.group || "india" })); 1875 }; 1876 (typeof catsForPartsGroup === "function" ? catsForPartsGroup("india") : []).forEach((c) => pushCat(c, "india")); 1877 (typeof catsForPartsGroup === "function" ? catsForPartsGroup("china") : []).forEach((c) => pushCat(c, "china")); 1878 1879 const grouped = groupCatsByFunction(list); 1880 const MAX_PER = 8; 1881 let html = ""; 1882 grouped.forEach((bucket) => { 1883 const show = bucket.cats.slice(0, MAX_PER); 1884 const rest = bucket.cats.length - show.length; 1885 html += `<div class="parts-group"> 1886 <h2 class="parts-group__title">${escHtml(bucket.name)}</h2> 1887 <div class="prod-grid hub-adv-grid parts-group__grid">`; 1888 show.forEach((c) => { 1889 const href = categoryUrl(c.group, c.slug); 1890 const probe = 1891 (window.EASTMAN_PRODUCTS || []).find((p) => p.catSlug === c.slug && (p.img || p.imgRemote)) || 1892 (c.img || c.imgRemote ? c : null); 1893 const media = probe 1894 ? imgTag({ name: c.name, img: probe.img || probe.imgRemote, imgRemote: probe.imgRemote }, "", { eager: false }) 1895 : ""; 1896 html += `<a class="bikes-adv-card trend-card product-adv-card hub-adv-card" href="${href}"> 1897 <span class="bikes-adv-card__media">${media}</span> 1898 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 1899 <span class="bikes-adv-card__body"> 1900 <span> 1901 <span class="bikes-adv-card__title">${escHtml(c.name)}</span> 1902 <span class="bikes-adv-card__sub">${c.group === "china" ? "China" : "India"} · Parts</span> 1903 </span> 1904 <span class="bikes-adv-card__go" aria-hidden="true">â</span> 1905 </span> 1906 </a>`; 1907 }); 1908 html += `</div>`; 1909 if (rest > 0) { 1910 html += `<p class="parts-group__more"><a href="/bicycle-parts/india">View all in ${escHtml(bucket.name)} (+${rest}) â</a></p>`; 1911 } 1912 html += `</div>`; 1913 }); 1914 grid.classList.add("hub-grouped"); 1915 grid.innerHTML = html || `<p class="empty">No categories found.</p>`; 1916 if (window.fixSiteLinks) window.fixSiteLinks(grid); 1917 return; 1918 } 1919 1920 if (page === "bicycles") { 1921 const bikes = window.getAllIndiaBikes ? window.getAllIndiaBikes() : []; 1922 const cats = [ 1923 { title: "Mountain bikes", href: "/mountain-bikes", match: /mountain|mtb|hybrid/i, fallback: ["cornado-26", "highlander-29-ms"] }, 1924 { title: "City bicycles", href: "/city-bicycles", match: /city|road|urban/i, fallback: ["brooklyn-29-ms"] },
1925 { title: "Kids bicycles", href: "/kids-bicycles", match: /kid|junior|balance|child/i, fallback: ["cloudia-1620", "hotride-1620", "jessica-20"] }, 1926 { title: "BMX-style", href: "/bmx-bicycles", match: /bmx|dynamite|impulse|shoorveer/i, fallback: ["dynamite-20"] }, 1927 ]; 1928 const bySlug = new Map(bikes.map((b) => [b.slug, b])); 1929 grid.innerHTML = cats.map((c) => { 1930 let sample = bikes.find((b) => c.match.test(`${b.name} ${b.category || ""} ${b.tag || ""}`)); 1931 if (!sample) { 1932 for (const s of c.fallback) { if (bySlug.get(s)) { sample = bySlug.get(s); break; } } 1933 } 1934 if (!sample) sample = bikes[0]; 1935 const media = sample 1936 ? imgTag({ name: sample.name, img: sample.img || sample.imgRemote, imgRemote: sample.imgRemote }, "", { eager: false }) 1937 : ""; 1938 return `<a class="bikes-adv-card trend-card product-adv-card hub-adv-card" href="${c.href}"> 1939 <span class="bikes-adv-card__media">${media}</span> 1940 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 1941 <span class="bikes-adv-card__body"> 1942 <span> 1943 <span class="bikes-adv-card__title">${escHtml(c.title)}</span> 1944 <span class="bikes-adv-card__sub">Complete bicycles</span> 1945 </span> 1946 <span class="bikes-adv-card__go" aria-hidden="true">â</span> 1947 </span> 1948 </a>`; 1949 }).join(""); 1950 if (window.fixSiteLinks) window.fixSiteLinks(grid); 1951 } 1952 } 1953 1954 1955 try { initTrendingHome(); } catch (err) { console.error("trending", err); } 1956 try { if (!renderBikesOriginShop()) initHubCategoryPages(); } catch (err) { console.error("hub pages", err); } 1957 try { initPartsShop(); } catch (err) { console.error("parts shop", err); } 1958 1959 /* ================= Homepage blog (WP REST) ================= */ 1960 function stripHtml(html) { 1961 const d = document.createElement("div"); 1962 d.innerHTML = html || ""; 1963 return (d.textContent || d.innerText || "").replace(/\s+/g, " ").trim(); 1964 } 1965 1966 function blogCardFromPost(p) { 1967 const title = stripHtml(p.title && p.title.rendered ? p.title.rendered : "Article"); 1968 const excerpt = stripHtml(p.excerpt && p.excerpt.rendered ? p.excerpt.rendered : "").slice(0, 140); 1969 const link = p.link || "/blog/"; 1970 let img = ""; 1971 try { 1972 img = p._embedded && p._embedded["wp:featuredmedia"] && p._embedded["wp:featuredmedia"][0] 1973 ? p._embedded["wp:featuredmedia"][0].source_url 1974 : ""; 1975 } catch (_) { img = ""; } 1976 if (!img) img = "assets/infra/infra-2.webp"; 1977 const excerptHtml = excerpt ? `<p class="blog-card-excerpt">${escHtml(excerpt)}${excerpt.length >= 140 ? "â¦" : ""}</p>` : ""; 1978 return `<article class="blog-card blog-card--slide"> 1979 <a class="blog-card-img" href="${escAttr(link)}"><img src="${escAttr(img)}" alt="" loading="lazy" onerror="this.src='assets/infra/infra-2.webp'" /></a> 1980 <div class="blog-card-body"> 1981 <span class="blog-tag">Blog</span> 1982 <h3><a href="${escAttr(link)}">${escHtml(title)}</a></h3> 1983 ${excerptHtml} 1984 <a href="${escAttr(link)}" class="blog-link">Read article â</a> 1985 </div> 1986 </article>`; 1987 } 1988 1989 function initHomeBlog() { 1990 const track = $("#homeBlogGrid"); 1991 if (!track) return; 1992 const api = "https://eastmanbicycles.com/wp-json/wp/v2/posts?per_page=8&_embed=1"; 1993 const showEmpty = () => { 1994 track.innerHTML = `<div class="blog-empty"><p>Could not load posts right now.</p><a class="btn btn-outline" href="/blog/">Visit the blog â</a></div>`; 1995 }; 1996 fetch(api) 1997 .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) 1998 .then((posts) => { 1999 if (!Array.isArray(posts) || !posts.length) { showEmpty(); return; } 2000 track.innerHTML = posts.map(blogCardFromPost).join(""); 2001 const step = () => { 2002 const card = track.querySelector(".blog-card"); 2003 if (!card) return Math.floor(track.clientWidth * 0.34); 2004 return Math.round(card.getBoundingClientRect().width + 18); 2005 }; 2006 const prev = $("#blogPrev"); 2007 const next = $("#blogNext"); 2008 if (prev) prev.addEventListener("click", () => track.scrollBy({ left: -step(), behavior: "smooth" })); 2009 if (next) next.addEventListener("click", () => track.scrollBy({ left: step(), behavior: "smooth" })); 2010 }) 2011 .catch(() => showEmpty()); 2012 } 2013 2014 function initInfraGallery() { 2015 const track = $("#infraTrack"); 2016 if (!track) return; 2017 const step = () => { 2018 const tile = track.querySelector(".infra-tile"); 2019 if (!tile) return Math.floor(track.clientWidth * 0.4); 2020 return Math.round(tile.getBoundingClientRect().width + 16); 2021 }; 2022 const prev = $("#infraPrev"); 2023 const next = $("#infraNext"); 2024 if (prev) prev.addEventListener("click", () => track.scrollBy({ left: -step(), behavior: "smooth" })); 2025 if (next) next.addEventListener("click", () => track.scrollBy({ left: step(), behavior: "smooth" })); 2026 } 2027 2028 try { initHomeBlog(); } catch (err) { console.error("home blog", err); } 2029 try { initInfraGallery(); } catch (err) { console.error("infra gallery", err); } 2030 2031 2032 /* ================= global export map + chips (homepage) ================= */ 2033 const MAP_PIN_POS = {
2034 MX: [18, 42], BR: [32, 68], AR: [30, 82], CL: [26, 78], CO: [24, 52], PE: [24, 62], 2035 BO: [28, 66], PY: [30, 72], UY: [32, 80], EC: [22, 56], CR: [20, 48], PA: [22, 50], 2036 GT: [17, 44], HN: [18, 46], NI: [19, 48], SV: [17, 46], DO: [26, 40], 2037 DE: [50, 28], FR: [46, 30], IT: [51, 34], ES: [44, 34], NL: [48, 26], AT: [51, 30], 2038 BE: [47, 28], PT: [42, 36], PL: [53, 26], CZ: [51, 28], GB: [45, 24], 2039 SE: [52, 18], NO: [50, 16], DK: [49, 22], FI: [55, 16], 2040 MY: [78, 55], TH: [76, 48] 2041 }; 2042 2043 function initGlobalMap() { 2044 const flagsEl = $("#globalFlags"); 2045 const chipsEl = $("#globalRegionChips"); 2046 const markets = window.EASTMAN_MARKETS; 2047 if (!markets || !markets.regions) return; 2048 2049 const preferred = ["Latin America", "Europe", "Asia"]; 2050 const regions = preferred 2051 .map((name) => markets.regions.find((r) => (r.name || "").toLowerCase() === name.toLowerCase())) 2052 .filter(Boolean) 2053 .concat(markets.regions.filter((r) => !preferred.some((p) => p.toLowerCase() === (r.name || "").toLowerCase()))); 2054 2055 const countries = regions.flatMap((r) => 2056 (r.countries || []).map((c) => ({ ...c, region: c.region || r.name })) 2057 ); 2058 2059 function renderFlags(list) { 2060 if (!flagsEl) return; 2061 flagsEl.innerHTML = list 2062 .map((c) => { 2063 const code = (c.code || "").toLowerCase(); 2064 const img = code 2065 ? `<img class="country-flag-img" src="https://flagcdn.com/w40/${code}.png" alt="" width="18" height="12" loading="lazy" />` 2066 : `<span class="country-flag">${c.flag || ""}</span>`; 2067 return `<a href="${c.href}" class="market-pill">${img} ${escHtml(c.name)}</a>`; 2068 }) 2069 .join(""); 2070 } 2071 2072 if (chipsEl) { 2073 chipsEl.innerHTML = regions 2074 .map((r) => `<button type="button" class="region-chip" data-region="${escAttr(r.name)}">${escHtml(r.name)}</button>`) 2075 .join(""); 2076 chipsEl.addEventListener("click", (e) => { 2077 const btn = e.target.closest(".region-chip"); 2078 if (!btn) return; 2079 const region = btn.getAttribute("data-region"); 2080 $$(".region-chip", chipsEl).forEach((b) => b.classList.toggle("is-active", b === btn)); 2081 renderFlags(countries.filter((c) => (c.region || "") === region)); 2082 }); 2083 const first = chipsEl.querySelector(".region-chip"); 2084 if (first) first.click(); 2085 } else { 2086 renderFlags(countries); 2087 } 2088 2089 const cta = document.querySelector(".global-export-cta"); 2090 if (cta && !cta.querySelector("[data-enquiry]")) { 2091 const btn = document.createElement("button"); 2092 btn.type = "button"; 2093 btn.className = "btn btn-outline btn-lg"; 2094 btn.setAttribute("data-enquiry", "Global Export Markets"); 2095 btn.textContent = "Request a Quote"; 2096 cta.appendChild(btn); 2097 } 2098 } 2099 2100 function initExportFaqExpand() { 2101 const btn = $("#faqExpandBtn"); 2102 const list = $("#exportFaqList") || document.querySelector("#export-faq .faq-list"); 2103 if (!btn || !list) return; 2104 const collapsed = () => $$(".faq-item--collapsed", list); 2105 btn.addEventListener("click", () => { 2106 const open = btn.getAttribute("aria-expanded") === "true"; 2107 const next = !open; 2108 collapsed().forEach((el) => { 2109 if (next) el.removeAttribute("hidden"); 2110 else el.setAttribute("hidden", ""); 2111 }); 2112 btn.setAttribute("aria-expanded", next ? "true" : "false"); 2113 btn.textContent = next ? "Show less" : "Show all FAQs"; 2114 }); 2115 } 2116 2117 function initTestimonialsCarousel() { 2118 const track = $("#testiTrack"); 2119 if (!track) return; 2120 const prev = $("#testiPrev"); 2121 const next = $("#testiNext"); 2122 const step = () => { 2123 const card = track.querySelector(".testi-card"); 2124 return card ? card.getBoundingClientRect().width + 18 : 320; 2125 }; 2126 const scrollByDir = (dir) => track.scrollBy({ left: dir * step(), behavior: "smooth" }); 2127 if (prev) prev.addEventListener("click", () => scrollByDir(-1)); 2128 if (next) next.addEventListener("click", () => scrollByDir(1)); 2129 /* h39: no aggressive autoplay â manual nav only */ 2130 let timer = null; 2131 const stop = () => { if (timer) clearInterval(timer); timer = null; }; 2132 stop(); 2133 } 2134 2135 try { initGlobalMap(); } catch (err) { console.error("global markets", err); } 2136 try { initExportFaqExpand(); } catch (err) { console.error("faq expand", err); }
2137 try { initTestimonialsCarousel(); } catch (err) { console.error("testimonials", err); } 2138 2139 function bikeOverview(bike) { 2140 return ( 2141 (bike.desc || bike.name) + 2142 " Part of Eastman India complete bicycle export range. Send an enquiry for FOB pricing, MOQ, colour options and container loading plans." 2143 ); 2144 } 2145 2146 function bikePdpExtra(bike, related) { 2147 const faqs = [ 2148 { 2149 q: `What is the MOQ for ${bike.name}?`, 2150 a: "Standard export MOQ is 1 Ã 20 ft container. Mixed models/products are allowed. Final MOQ depends on colourway, packing and destination.", 2151 }, 2152 { 2153 q: `Can I mix ${bike.name} with other bicycles or parts?`, 2154 a: "Yes â mixed container programmes are common. Share your preferred mix and destination for loading guidance.", 2155 }, 2156 { 2157 q: "Do you offer OEM / private label on complete bicycles?", 2158 a: "Yes. Branding, colourways and packing can be quoted for OEM/private-label buyers.", 2159 }, 2160 { 2161 q: "Which Incoterms apply?", 2162 a: "FOB and CIF can be discussed based on order size and destination. Our export team confirms the best option.", 2163 }, 2164 { 2165 q: "What export documents do you provide?", 2166 a: "Commercial invoice, packing list, bill of lading and certificate of origin where required.", 2167 }, 2168 ] 2169 .map( 2170 (item) => 2171 `<details class="pdp-faq-item"><summary>${escHtml(item.q)}</summary><p>${escHtml(item.a)}</p></details>` 2172 ) 2173 .join(""); 2174 return ` 2175 <div class="pdp-sections pdp-sections--sourcing"> 2176 ${pdpBlockCard("FAQ", "Product FAQ", "", `<div class="pdp-faq">${faqs}</div>`, true)} 2177 </div>`; 2178 } 2179 2180 function pdpShareHTML() { 2181 const icon = { 2182 facebook: '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M14 8h3V4h-3c-2.2 0-4 1.8-4 4v2H7v4h3v8h4v-8h3.2L18 10h-4V8c0-.6.4-1 1-1z"/></svg>', 2183 linkedin: '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M6.5 9.5H3.7V21h2.8V9.5zM5.1 3.5A1.7 1.7 0 1 0 5.1 6.9 1.7 1.7 0 0 0 5.1 3.5zM20.3 21h-2.8v-5.6c0-1.5-.5-2.5-1.9-2.5-1 0-1.6.7-1.9 1.4-.1.2-.1.6-.1.9V21h-2.8s0-9.4 0-10.4h2.8v1.5c.4-.6 1.1-1.7 2.8-1.7 2 0 3.6 1.3 3.6 4.2V21z"/></svg>', 2184 instagram: '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 7.2A4.8 4.8 0 1 0 12 16.8 4.8 4.8 0 0 0 12 7.2zm0 7.9a3.1 3.1 0 1 1 0-6.2 3.1 3.1 0 0 1 0 6.2z"/><circle fill="currentColor" cx="17.5" cy="6.6" r="1.1"/><path fill="currentColor" d="M16.7 2H7.3A5.3 5.3 0 0 0 2 7.3v9.4A5.3 5.3 0 0 0 7.3 22h9.4A5.3 5.3 0 0 0 22 16.7V7.3A5.3 5.3 0 0 0 16.7 2zm3.5 14.7a3.5 3.5 0 0 1-3.5 3.5H7.3a3.5 3.5 0 0 1-3.5-3.5V7.3a3.5 3.5 0 0 1 3.5-3.5h9.4a3.5 3.5 0 0 1 3.5 3.5v9.4z"/></svg>', 2185 x: '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M17.5 3h2.8l-6.1 7 7.2 11h-5.6l-4.4-6.5L6.1 21H3.3l6.5-7.5L3 3h5.8l4 5.9L17.5 3zm-1 16.2h1.6L7.6 4.7H6L16.5 19.2z"/></svg>', 2186 pinterest: '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2C6.5 2 2 6.5 2 12c0 4.1 2.5 7.6 6.1 9.1-.1-.8-.2-2 0-2.9.2-.8 1.3-5.4 1.3-5.4s-.3-.7-.3-1.6c0-1.5.9-2.7 2-2.7.9 0 1.4.7 1.4 1.5 0 .9-.6 2.3-.9 3.5-.3 1.1.5 1.9 1.6 1.9 1.9 0 3.2-2.4 3.2-5.3 0-2.2-1.5-3.8-4.2-3.8-3.1 0-5 2.3-5 4.8 0 .9.3 1.5.7 2 .1.1.1.2.1.3l-.3 1c0 .1-.2.2-.3.1-1.3-.6-2-2.1-2-3.8 0-2.8 2.4-6.2 7.1-6.2 3.8 0 6.3 2.7 6.3 5.7 0 3.9-2.2 6.8-5.4 6.8-1.1 0-2.1-.6-2.4-1.3l-.7 2.5c-.2.9-.9 2-1.3 2.7 1 .3 2 .5 3.1.5 5.5 0 10-4.5 10-10S17.5 2 12 2z"/></svg>', 2187 link: '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10.6 13.4a4 4 0 0 1 0-5.7l2.1-2.1a4 4 0 1 1 5.7 5.7l-1.1 1.1a.8.8 0 0 1-1.1-1.1l1.1-1.1a2.4 2.4 0 1 0-3.4-3.4l-2.1 2.1a2.4 2.4 0 0 0 0 3.4.8.8 0 0 1-1.2 1.1zm2.8-2.8a4 4 0 0 1 0 5.7l-2.1 2.1a4 4 0 1 1-5.7-5.7l1.1-1.1a.8.8 0 1 1 1.1 1.1l-1.1 1.1a2.4 2.4 0 1 0 3.4 3.4l2.1-2.1a2.4 2.4 0 0 0 0-3.4.8.8 0 0 1 1.2-1.1z"/></svg>', 2188 }; 2189 return `<div class="pdp-share" data-pdp-share> 2190 <span class="pdp-share-label">Share Product</span> 2191 <div class="pdp-share-icons" role="group" aria-label="Share product"> 2192 <button type="button" class="pdp-share-btn" data-share="facebook" aria-label="Share on Facebook" title="Facebook">${icon.facebook}</button> 2193 <button type="button" class="pdp-share-btn" data-share="linkedin" aria-label="Share on LinkedIn" title="LinkedIn">${icon.linkedin}</button> 2194 <button type="button" class="pdp-share-btn" data-share="instagram" aria-label="Copy link for Instagram" title="Instagram">${icon.instagram}</button> 2195 <button type="button" class="pdp-share-btn" data-share="x" aria-label="Share on X" title="X">${icon.x}</button> 2196 <button type="button" class="pdp-share-btn" data-share="pinterest" aria-label="Share on Pinterest" title="Pinterest">${icon.pinterest}</button> 2197 <button type="button" class="pdp-share-btn pdp-share-btn--copy" data-share="copy" aria-label="Copy product link" title="Copy link">${icon.link}<span class="pdp-share-copied" hidden>Copied</span></button> 2198 </div> 2199 </div>`; 2200 } 2201 2202 function bindPdpShare(root) { 2203 const wrap = root && $(".pdp-share", root); 2204 if (!wrap || wrap.dataset.bound === "1") return; 2205 wrap.dataset.bound = "1"; 2206 const pageUrl = () => location.href.split("#")[0]; 2207 const pageTitle = () => document.title || "Eastman Bicycles"; 2208 const openShare = (url) => window.open(url, "_blank", "noopener,noreferrer,width=640,height=560"); 2209 2210 async function copyLink(msg) { 2211 const url = pageUrl();
2212 try { 2213 if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(url); 2214 else { 2215 const ta = document.createElement("textarea"); 2216 ta.value = url; 2217 ta.setAttribute("readonly", ""); 2218 ta.style.position = "fixed"; 2219 ta.style.left = "-9999px"; 2220 document.body.appendChild(ta); 2221 ta.select(); 2222 document.execCommand("copy"); 2223 document.body.removeChild(ta); 2224 } 2225 } catch (_) {} 2226 const tip = $(".pdp-share-copied", wrap); 2227 if (tip) { 2228 tip.textContent = msg || "Copied"; 2229 tip.hidden = false; 2230 window.setTimeout(() => { tip.hidden = true; }, 1800); 2231 } 2232 } 2233 2234 wrap.addEventListener("click", (e) => { 2235 const btn = e.target.closest("[data-share]"); 2236 if (!btn) return; 2237 e.preventDefault(); 2238 const type = btn.getAttribute("data-share"); 2239 const url = encodeURIComponent(pageUrl()); 2240 const title = encodeURIComponent(pageTitle()); 2241 if (type === "facebook") openShare("https://www.facebook.com/sharer/sharer.php?u=" + url); 2242 else if (type === "linkedin") openShare("https://www.linkedin.com/sharing/share-offsite/?url=" + url); 2243 else if (type === "x") openShare("https://twitter.com/intent/tweet?url=" + url + "&text=" + title); 2244 else if (type === "pinterest") openShare("https://pinterest.com/pin/create/button/?url=" + url + "&description=" + title); 2245 else if (type === "instagram") copyLink("Copied â paste in Instagram"); 2246 else if (type === "copy") copyLink("Copied"); 2247 }); 2248 } 2249 2250 function renderBikePdp(bike) { 2251 const specPairs = Object.entries(bike.specs || {}); 2252 const enquiry = "Complete Bicycle: " + bike.name; 2253 const galleryImgs = (bike.images || []).map((src) => ({ src, thumb: src })); 2254 if (!galleryImgs.length && bike.img) galleryImgs.push({ src: bike.img, thumb: bike.img }); 2255 const img = pdpGalleryHTML({ name: bike.name, img: bike.img }, galleryImgs); 2256 const related = (window.getAllIndiaBikes ? window.getAllIndiaBikes() : []) 2257 .filter((b) => b.category === bike.category && b.slug !== bike.slug) 2258 .slice(0, 6); 2259 const together = (window.getAllIndiaBikes ? window.getAllIndiaBikes() : []) 2260 .filter((b) => b.slug !== bike.slug && b.category !== bike.category) 2261 .slice(0, 4); 2262 const catLabel = bike.category || bike.tag || "Complete Bicycles"; 2263 const catHref = /mountain|mtb|hybrid/i.test(catLabel) ? "/mountain-bikes" 2264 : /city|road|urban/i.test(catLabel) ? "/city-bicycles" 2265 : /kid|junior|child|balance/i.test(catLabel) ? "/kids-bicycles" 2266 : /bmx/i.test(catLabel) ? "/bmx-bicycles" : "/bicycles"; 2267 const requestBtn = `<button class="btn btn-primary btn-lg" data-enquiry="${escAttr(enquiry)}" data-enquiry-origin="India" data-enquiry-sku="${escAttr(bike.slug || "")}" data-enquiry-pillar="complete-bicycles">Request a Quote</button>`; 2268 pdpBody.classList.add("pdp--bike"); 2269 pdpBody.innerHTML = ` 2270 <div class="pdp-main"> 2271 <div class="pdp-media">${img}</div> 2272 <div class="pdp-info"> 2273 <div class="pdp-meta-row"> 2274 <span class="pdp-origin-chip pdp-origin-chip--india">India</span> 2275 <span class="pdp-sku">SKU: ${escHtml(bike.slug || "â")}</span> 2276 <span class="pdp-cat"><a href="${catHref}">${escHtml(catLabel)}</a></span> 2277 </div> 2278 <h1>${escHtml((window.EastmanProductSEO && window.EastmanProductSEO.forBike(bike) || {}).h1 || displayProductName(bike))}</h1> 2279 <p class="pdp-lead">${escHtml((window.EastmanProductSEO && window.EastmanProductSEO.forBike(bike) || {}).lead || "Complete bicycle for B2B export from India.")}</p> 2280 ${pdpPrimaryActionsHTML({ requestBtn: requestBtn })} 2281 ${pdpShareHTML()} 2282 </div> 2283 </div> 2284 ${bikePdpExtra(bike, related)}`; 2285 bindPdpShare(pdpBody); 2286 const bikeSeo = window.EastmanProductSEO ? window.EastmanProductSEO.forBike(bike) : null; 2287 if (window.EastmanSchema && window.EastmanSchema.applyProduct) { 2288 window.EastmanSchema.applyProduct({ 2289 name: bike.name, 2290 short: (bikeSeo && bikeSeo.description) || "Complete bicycle for B2B export from India.", 2291 img: bike.img, 2292 sku: bike.slug, 2293 id: bike.slug, 2294 catName: "Complete Bicycles India", 2295 slug: bike.slug, 2296 }); 2297 } 2298 applyPdpSeo(bikeSeo, bike); 2299 const rg = $("#relatedGrid"); 2300 if (related.length && rg) { 2301 $("#relatedWrap").hidden = false;
2302 const relHead = $("#relatedWrap .section-head h2") || $("#relatedWrap h2"); 2303 if (relHead) relHead.textContent = "Related Products"; 2304 rg.className = "prod-grid pdp-related-grid"; 2305 const togetherHtml = together.length ? `<div class="pdp-together"><h3>Buyers Often Source Together</h3><div class="prod-grid pdp-related-grid">${together.map((b) => { 2306 const url = bikeUrl(b.slug); 2307 const sub = b.tag || b.category || "Complete Bicycle"; 2308 return `<a class="bikes-adv-card trend-card product-adv-card bike-pcard trend-card--view" href="${url}"> 2309 <span class="bikes-adv-card__media"><img src="${escAttr(assetUrl(b.img))}" alt="${escAttr(b.name)}" loading="lazy" /></span> 2310 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 2311 <span class="bikes-adv-card__body"><span><span class="bikes-adv-card__title">${escHtml(b.name)}</span><span class="bikes-adv-card__sub">${escHtml(sub)}</span></span><span class="bikes-adv-card__cta">View Details</span></span></a>`; 2312 }).join("")}</div></div>` : ""; 2313 rg.innerHTML = related 2314 .map((b) => { 2315 const url = bikeUrl(b.slug); 2316 const sub = b.tag || b.category || "Complete Bicycle"; 2317 return `<a class="bikes-adv-card trend-card product-adv-card bike-pcard trend-card--view" href="${url}"> 2318 <span class="bikes-adv-card__media"><img src="${escAttr(assetUrl(b.img))}" alt="${escAttr(b.name)}" loading="lazy" /></span> 2319 <span class="bikes-adv-card__shade" aria-hidden="true"></span> 2320 <span class="bikes-adv-card__body"> 2321 <span> 2322 <span class="bikes-adv-card__title">${escHtml(b.name)}</span> 2323 <span class="bikes-adv-card__sub">${escHtml(sub)}</span> 2324 </span> 2325 <span class="bikes-adv-card__cta">View Details</span> 2326 </span> 2327 </a>`; 2328 }) 2329 .join(""); 2330 $$(".bikes-adv-card, .product-adv-card, .pcard", rg).forEach(reveal); 2331 enhanceRelatedCarousel(rg); 2332 if (togetherHtml) { 2333 const wrap = $("#relatedWrap"); 2334 if (wrap && !wrap.querySelector(".pdp-together")) wrap.insertAdjacentHTML("beforeend", '<div class="container">' + togetherHtml + "</div>"); 2335 const tg = wrap && wrap.querySelector(".pdp-together .pdp-related-grid"); 2336 if (tg) enhanceRelatedCarousel(tg); 2337 } 2338 } 2339 } 2340 2341 2342 function enhanceRelatedCarousel(gridEl) { 2343 if (!gridEl) return; 2344 gridEl.classList.remove("pdp-related-track", "bikes-adventure-track"); 2345 gridEl.classList.add("prod-grid", "pdp-related-grid"); 2346 } 2347 2348 /* ================= product detail ================= */ 2349 const pdpTestimonials = $("#pdpTestimonials"); 2350 if (pdpTestimonials) pdpTestimonials.hidden = true; 2351 const pdpBody = $("#pdpBody"); 2352 if (pdpBody) try { 2353 const resolved = resolvePdpIds(); 2354 const bikeSlug = resolved.bikeSlug; 2355 const bikeItem = bikeSlug && window.getIndiaBikeBySlug ? window.getIndiaBikeBySlug(bikeSlug) : null; 2356 if (bikeItem) { 2357 setCanonical(bikeUrl(bikeItem.slug)); 2358 const crumbParent = $("#crumbParent"); 2359 const catLabel = bikeItem.category || bikeItem.tag || "Complete Bicycles"; 2360 const catHref = /mountain|mtb|hybrid/i.test(catLabel) ? "/mountain-bikes" 2361 : /city|road|urban/i.test(catLabel) ? "/city-bicycles" 2362 : /kid|junior|child|balance/i.test(catLabel) ? "/kids-bicycles" 2363 : /bmx/i.test(catLabel) ? "/bmx-bicycles" : "/bicycles"; 2364 if (crumbParent) { 2365 crumbParent.href = catHref; 2366 crumbParent.textContent = catLabel; 2367 } 2368 const crumbsNav = crumbParent && crumbParent.closest(".crumbs"); 2369 if (crumbsNav && !crumbsNav.querySelector("[data-bike-hub]")) { 2370 const hub = document.createElement("a"); 2371 hub.href = "/bicycles"; 2372 hub.textContent = "Bicycles"; 2373 hub.setAttribute("data-bike-hub", "1"); 2374 const sep = document.createElement("span"); 2375 sep.textContent = " / "; 2376 crumbParent.parentNode.insertBefore(hub, crumbParent); 2377 crumbParent.parentNode.insertBefore(sep, crumbParent); 2378 } 2379 $("#crumbName").textContent = bikeItem.name; 2380 renderBikePdp(bikeItem); 2381 } else { 2382 const p = byId(resolved.partId); 2383 if (p) { 2384 setCanonical(productUrl(p)); 2385 $("#crumbName").textContent = p.name; 2386 const crumbParent = $("#crumbParent"); 2387 const crumbsNav = crumbParent && crumbParent.closest(".crumbs"); 2388 if (crumbsNav) { 2389 const pillarHref = "/bicycle-parts"; 2390 const pillarLabel = "Bicycle Parts"; 2391 let pillar = crumbsNav.querySelector("[data-parts-hub]"); 2392 if (!pillar && crumbParent) { 2393 pillar = document.createElement("a"); 2394 pillar.href = pillarHref; 2395 pillar.textContent = pillarLabel; 2396 pillar.setAttribute("data-parts-hub", "1"); 2397 const sep = document.createElement("span"); 2398 sep.textContent = " / "; 2399 crumbParent.parentNode.insertBefore(pillar, crumbParent); 2400 crumbParent.parentNode.insertBefore(sep, crumbParent); 2401 } 2402 if (crumbParent) { 2403 crumbParent.href = categoryUrl(p.group, p.catSlug); 2404 crumbParent.textContent = p.catName || p.cat || "Category"; 2405 } 2406 } 2407 const partSeo = window.EastmanProductSEO ? window.EastmanProductSEO.forPart(p) : null; 2408 const allTags = p.tags && p.tags.length ? `<div class="pdp-tags">${tagPills(p.tags)}</div>` : "";
2409 const badge = p.tags && p.tags[0] ? `<span class="pdp-badge pcard-badge--${p.tags[0]}">${TAG_LABELS[p.tags[0]] || p.tags[0]}</span>` : ""; 2410 const specRows = specsFromDesc(p.desc || p.short || ""); 2411 const seen = {}; 2412 const attrPairs = Object.entries(p.attrs || {}) 2413 .concat(specRows) 2414 .filter(([k, v]) => { 2415 const key = String(k).toLowerCase(); 2416 if (seen[key]) return false; 2417 seen[key] = 1; 2418 if (/^sku$/i.test(k) && /^on request$/i.test(String(v))) return false; 2419 return true; 2420 }); 2421 const catUrl = categoryUrl(p.group, p.catSlug); 2422 const shownName = displayProductName(p); 2423 const originLabel = p.group === "china" ? "China" : "India"; 2424 const skuVal = p.sku || (p.attrs && p.attrs.SKU) || p.id || ""; 2425 const requestBtn = `<button class="btn btn-primary btn-lg" data-enquiry-id="${p.id}" data-enquiry-country="" data-enquiry-origin="${originLabel}">Request a Quote</button>`; 2426 pdpBody.innerHTML = ` 2427 <div class="pdp-main"> 2428 <div class="pdp-media">${badge}${pdpGalleryHTML(p)}</div> 2429 <div class="pdp-info"> 2430 <div class="pdp-meta-row"> 2431 <span class="pdp-origin-chip pdp-origin-chip--${p.group === "china" ? "china" : "india"}">${originLabel}</span> 2432 <span class="pdp-sku">SKU: ${escHtml(skuVal || "â")}</span> 2433 <span class="pdp-cat"><a href="${catUrl}">${p.catName || p.cat}</a></span> 2434 </div> 2435 ${allTags} 2436 <h1>${escHtml((partSeo && partSeo.h1) || shownName)}</h1> 2437 <p class="pdp-lead">${escHtml((partSeo && partSeo.lead) || "")}</p> 2438 ${pdpPrimaryActionsHTML({ requestBtn: requestBtn })} 2439 ${pdpShareHTML()} 2440 </div> 2441 </div> 2442 ${pdpExtraSections(p, { shownName, originLabel, skuVal, requestBtn })}`; 2443 bindPdpGallery(pdpBody); 2444 bindPdpShare(pdpBody); 2445 loadPdpGallery(p, pdpBody); 2446 if (window.EastmanSchema && window.EastmanSchema.applyProduct) { 2447 window.EastmanSchema.applyProduct( 2448 Object.assign({}, p, { short: (partSeo && partSeo.description) || p.short }) 2449 ); 2450 } 2451 applyPdpSeo(partSeo, p); 2452 const related = PRODUCTS.filter((x) => x.catSlug === p.catSlug && x.id !== p.id).slice(0, 6); 2453 const together = PRODUCTS.filter((x) => x.group === p.group && x.catSlug !== p.catSlug && x.id !== p.id).slice(0, 4); 2454 const rg = $("#relatedGrid"); 2455 if (related.length && rg) { 2456 $("#relatedWrap").hidden = false; 2457 const relHead = $("#relatedWrap .section-head h2") || $("#relatedWrap h2"); 2458 if (relHead) relHead.textContent = "Related Products"; 2459 rg.className = "prod-grid pdp-related-grid"; 2460 renderGrid(rg, related.slice(0, 6)); 2461 enhanceRelatedCarousel(rg); 2462 if (together.length) { 2463 const wrap = $("#relatedWrap"); 2464 if (wrap && !wrap.querySelector(".pdp-together")) { 2465 const box = document.createElement("div"); 2466 box.className = "container pdp-together"; 2467 box.innerHTML = "<h3>Buyers Often Source Together</h3><div class=\"prod-grid pdp-related-grid\" id=\"togetherGrid\"></div>"; 2468 wrap.appendChild(box); 2469 renderGrid($("#togetherGrid"), together); 2470 enhanceRelatedCarousel($("#togetherGrid")); 2471 } 2472 } 2473 } 2474 } else { 2475 pdpBody.innerHTML = `<div class="pdp-not-found"><h1>Product not found</h1><p>We could not find this product. It may have been moved or the link is incomplete.</p><p><a class="btn btn-primary" href="/bicycle-parts">Browse bicycle parts</a> <a class="btn btn-outline" href="/bicycles">Browse complete bicycles</a></p></div>`; 2476 document.title = "Product not found | Eastman Bicycles"; 2477 let robots = document.querySelector('meta[name="robots"]'); 2478 if (!robots) { 2479 robots = document.createElement("meta"); 2480 robots.setAttribute("name", "robots"); 2481 document.head.appendChild(robots); 2482 } 2483 robots.setAttribute("content", "noindex, follow"); 2484 const crumb = $("#crumbName"); 2485 if (crumb) crumb.textContent = "Not found"; 2486 } 2487 } 2488 } catch (err) { 2489 console.error("PDP render", err);
2490 pdpBody.innerHTML = `<div class="pdp-not-found"><h1>Couldnât load this product</h1><p>Something went wrong while loading product details. Please try again or browse the catalogue.</p><p><a class="btn btn-primary" href="/bicycle-parts">Browse bicycle parts</a> <a class="btn btn-outline" href="/bicycles">Browse bicycles</a></p></div>`; 2491 } 2492 2493 /* ================= brands ================= */ 2494 const brandRow = $("#brandRow"); 2495 if (brandRow) brandRow.innerHTML = BRANDS.map((b) => brandCardHTML(b, "brand-item", false)).join(""); 2496 2497 /* ================= careers (HR jobs from WordPress) ================= */ 2498 function stripHtml(html) { 2499 const el = document.createElement("div"); 2500 el.innerHTML = html || ""; 2501 return (el.textContent || el.innerText || "").replace(/\s+/g, " ").trim(); 2502 } 2503 2504 function jobSlug(title) { 2505 return String(title || "") 2506 .toLowerCase() 2507 .replace(/&/g, "and") 2508 .replace(/[^a-z0-9]+/g, "-") 2509 .replace(/^-+|-+$/g, ""); 2510 } 2511 2512 function jobApplyUrl(job) { 2513 var path = ""; 2514 if (job.applyUrl) { 2515 path = job.applyUrl; 2516 } else if (job.link) { 2517 try { 2518 var u = new URL(job.link, "https://eastmanbicycles.com"); 2519 path = u.pathname || ""; 2520 } catch (err) { /* slug fallback */ } 2521 } 2522 if (!path && job.slug) path = "/jobs/" + job.slug + "/"; 2523 if (!path && job.title) { 2524 var slug = jobSlug(job.title); 2525 path = slug ? "/jobs/" + slug + "/" : "/career"; 2526 } 2527 if (!path) path = "/career"; 2528 // Keep trailing slash for WP CPT; add source=career so browsers that cached the 2529 // old /jobs/... redirect-loop still open the apply form reliably. 2530 try { 2531 var abs = new URL(path, "https://eastmanbicycles.com"); 2532 if (!abs.pathname.endsWith("/")) abs.pathname += "/"; 2533 if (!abs.searchParams.has("source")) abs.searchParams.set("source", "career"); 2534 return abs.pathname + abs.search; 2535 } catch (err2) { 2536 return path.indexOf("source=") >= 0 ? path : (path + (path.indexOf("?") >= 0 ? "&" : "?") + "source=career"); 2537 } 2538 } 2539 2540 function jobCardHTML(job) { 2541 const title = job.title || "Job Opening"; 2542 const dept = job.job_department || "Staff"; 2543 const location = job.job_location || "Ludhiana"; 2544 const type = job.job_type || "Full Time"; 2545 const desc = (job.description || "").slice(0, 280); 2546 const applyUrl = jobApplyUrl(job); 2547 return `<article class="job-card"> 2548 <h3>${escHtml(title)}</h3> 2549 <p class="job-meta"><span>${escHtml(dept)}</span><span>${escHtml(location)}</span><span>${escHtml(type)}</span></p> 2550 <p>${escHtml(desc)}</p> 2551 <a class="btn btn-outline btn-sm" href="${escAttr(applyUrl)}" rel="noopener">Apply Now</a> 2552 </article>`; 2553 } 2554 2555 function mapWpJob(post) { 2556 const meta = post.meta || {}; 2557 const classes = Array.isArray(post.class_list) ? post.class_list.join(" ") : ""; 2558 function classVal(prefix) { 2559 const m = classes.match(new RegExp("(?:^|\\s)" + prefix + "([a-z0-9-]+)", "i")); 2560 if (!m) return ""; 2561 return m[1].replace(/-/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); }); 2562 } 2563 return { 2564 title: stripHtml(post.title?.rendered || ""), 2565 description: stripHtml(post.excerpt?.rendered || post.content?.rendered || ""), 2566 job_location: meta.job_location || meta._job_location || classVal("job-location-") || "Ludhiana", 2567 job_type: meta.job_type || meta._job_type || classVal("job-type-") || "Full Time", 2568 job_department: meta.job_department || meta._job_category || classVal("job-category-") || "Staff", 2569 link: post.link || "", 2570 slug: post.slug || "", 2571 id: post.id || "", 2572 datePosted: (post.date || "").slice(0, 10), 2573 applyUrl: jobApplyUrl({ link: post.link || "", slug: post.slug || "", title: stripHtml(post.title?.rendered || "") }), 2574 }; 2575 } 2576 2577 async function fetchJobPosts(api, perPage) { 2578 const res = await fetch(`${api}?per_page=${perPage}&status=publish&orderby=date&order=desc`, { 2579 headers: { Accept: "application/json" }, 2580 }); 2581 if (!res.ok) throw new Error(String(res.status)); 2582 return res.json(); 2583 } 2584 2585 async function loadCareerJobs(el) { 2586 const cfg = window.EASTMAN_JOBS || {}; 2587 const perPage = cfg.perPage || 50; 2588 // Root /wp-json works on live Hostinger; /blog/wp-json is 404. 2589 const apis = [ 2590 cfg.apiUrl, 2591 "/wp-json/wp/v2/awsm_job_openings", 2592 "/blog/wp-json/wp/v2/awsm_job_openings", 2593 ].filter((url, i, arr) => url && arr.indexOf(url) === i); 2594 el.innerHTML = '<p class="loading-note">Loading current openingsâ¦</p>'; 2595 2596 try { 2597 let posts = []; 2598 for (const api of apis) { 2599 try { 2600 posts = await fetchJobPosts(api, perPage); 2601 if (Array.isArray(posts) && posts.length) break; 2602 } catch (err) { /* try next REST path */ } 2603 } 2604 if (!Array.isArray(posts) || !posts.length) throw new Error("empty"); 2605 const mapped = posts.map((p) => mapWpJob(p)); 2606 el.innerHTML = mapped.map((j) => jobCardHTML(j)).join(""); 2607 if (window.EastmanSchema && window.EastmanSchema.applyJobs) window.EastmanSchema.applyJobs(mapped); 2608 } catch { 2609 const list = cfg.fallback || []; 2610 el.innerHTML = list.length 2611 ? list.map((j) => jobCardHTML(j)).join("") 2612 : '<p class="empty">Unable to load jobs. Please try again later or contact us.</p>'; 2613 if (list.length && window.EastmanSchema && window.EastmanSchema.applyJobs) window.EastmanSchema.applyJobs(list); 2614 } 2615 $$(".job-card", el).forEach(reveal); 2616 } 2617 2618 const jobGrid = $("#jobGrid"); 2619 if (jobGrid && document.body.dataset.page === "career") loadCareerJobs(jobGrid); 2620 2621 revealAll(); 2622 if (window.fixSiteLinks) window.fixSiteLinks(document); 2623 2624 /* ================= h49: PDP sticky bottom CTA bar ================= */ 2625 function initPdpStickyBar() { 2626 const pdp = $("#pdpBody"); 2627 if (!pdp) return; 2628 const actions = pdp.querySelector(".pdp-actions"); 2629 if (!actions) return; 2630 let bar = $("#pdpStickyBar"); 2631 if (!bar) { 2632 bar = document.createElement("div"); 2633 bar.id = "pdpStickyBar"; 2634 bar.className = "pdp-sticky-bar"; 2635 bar.hidden = true; 2636 const title = (document.querySelector("#pdpBody h1") || {}).textContent || "this product"; 2637 const waText = encodeURIComponent("Hi Eastman, I want a quote for " + title.trim()); 2638 const waSvg = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M20.5 3.5A11 11 0 0 0 2.1 17.8L1 23l5.4-1.1A11 11 0 1 0 20.5 3.5zm-8.5 17a9 9 0 0 1-4.6-1.3l-.3-.2-3.2.7.7-3.1-.2-.3a9 9 0 1 1 7.6 4.2zm5-6.7c-.3-.1-1.6-.8-1.8-.9s-.4-.1-.6.2-.7.9-.8 1-.3.2-.6.1a7.4 7.4 0 0 1-2.2-1.4 8.2 8.2 0 0 1-1.5-1.9c-.2-.3 0-.4.1-.6l.4-.5.2-.3a.5.5 0 0 0 0-.5l-.6-1.5c-.2-.4-.3-.4-.6-.4h-.5a1 1 0 0 0-.7.3 2.9 2.9 0 0 0-.9 2.2 5 5 0 0 0 1.1 2.7 11.5 11.5 0 0 0 4.4 3.9 5 5 0 0 0 3 .9 2.5 2.5 0 0 0 1.7-.7 2 2 0 0 0 .4-1.4c0-.2-.2-.3-.5-.4z"/></svg>'; 2639 bar.innerHTML = `<button type="button" class="pdp-sticky-bar__quote btn btn-primary" data-enquiry="">Request a Quote</button> 2640 <a class="pdp-sticky-bar__wa" href="https://wa.me/919115110415?text=${waText}" target="_blank" rel="noopener" aria-label="WhatsApp">${waSvg}</a>`; 2641 const fab = document.getElementById("whatsappFabBtn"); 2642 const waLink = bar.querySelector(".pdp-sticky-bar__wa"); 2643 if (fab && waLink && fab.innerHTML.trim()) waLink.innerHTML = fab.innerHTML; 2644 document.body.appendChild(bar); 2645 document.body.classList.add("has-pdp-sticky"); 2646 // Hide generic mobile bar on PDP to avoid double stack 2647 const mob = document.getElementById("mobileStickyBar"); 2648 if (mob) mob.classList.add("mobile-bar--pdp-hidden"); 2649 } 2650 const quoteBtn = bar.querySelector("[data-enquiry]"); 2651 if (quoteBtn) { 2652 // copy enquiry attrs from in-page CTA if any 2653 const src = actions.querySelector("[data-enquiry], [data-enquiry-id]"); 2654 if (src) { 2655 ["data-enquiry", "data-enquiry-id", "data-enquiry-origin", "data-enquiry-sku", "data-enquiry-pillar", "data-enquiry-country"].forEach((a) => { 2656 if (src.hasAttribute(a)) quoteBtn.setAttribute(a, src.getAttribute(a)); 2657 }); 2658 } 2659 } 2660 let past = false;
2661 const update = () => { 2662 const rect = actions.getBoundingClientRect(); 2663 const shouldShow = rect.bottom < 8; 2664 if (shouldShow !== past) { 2665 past = shouldShow; 2666 bar.hidden = !shouldShow; 2667 bar.classList.toggle("is-visible", shouldShow); 2668 document.body.classList.toggle("pdp-sticky-visible", shouldShow); 2669 } 2670 }; 2671 window.addEventListener("scroll", update, { passive: true }); 2672 window.addEventListener("resize", update); 2673 update(); 2674 } 2675 try { initPdpStickyBar(); } catch (err) { console.error("pdp sticky", err); } 2676 2677})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.