1/* Shop preview controls. Cart and saved products stay in this browser. */ 2(() => { 3 "use strict"; 4 const $ = (s) => document.querySelector(s), 5 $$ = (s) => [...document.querySelectorAll(s)], 6 core = globalThis.TEPShopCore; 7 const data = JSON.parse($("#shop-data").textContent); 8 const serverDataNode = $("#shop-server-data"); 9 let hasServerShopData = false; 10 let cartData = data; 11 if (serverDataNode?.textContent.trim()) { 12 try { 13 const serverData = JSON.parse(serverDataNode.textContent); 14 cartData = globalThis.TEPShopCatalog.merge(data, serverData.catalog); 15 if (Array.isArray(serverData?.products)) { 16 const templates = [...data.products]; 17 const templateFor = (id) => { 18 const sum = Array.from(String(id || "")).reduce( 19 (total, character) => total + character.codePointAt(0), 20 0, 21 ); 22 return templates.find(product => product.id === id) || templates[sum % templates.length] || templates[0] || {}; 23 }; 24 const defaultCategories = [...data.categories]; 25 const categoryStyles = [ 26 ["chip", "cyan"], 27 ["sensor", "green"], 28 ["box", "amber"], 29 ["book", "violet"], 30 ]; 31 const liveCategories = new Map(); 32 (serverData.categories || []).forEach((incoming) => { 33 if (incoming?.id) liveCategories.set(incoming.id, incoming.name || incoming.id); 34 }); 35 serverData.products.forEach((incoming) => { 36 if (incoming?.category) 37 liveCategories.set( 38 incoming.category, 39 incoming.categoryName || incoming.category, 40 ); 41 }); 42 data.categories = [...liveCategories].map(([id, name], index) => { 43 const existing = defaultCategories.find((category) => category.id === id); 44 const [icon, tone] = categoryStyles[index % categoryStyles.length]; 45 return { id, name, icon: existing?.icon || icon, tone: existing?.tone || tone }; 46 }); 47 data.products = serverData.products.map((incoming, index) => { 48 const template = templateFor(incoming.id); 49 const categoryId = incoming.category || template.category || "uncategorized"; 50 if (!data.categories.some((category) => category.id === categoryId)) { 51 const [icon, tone] = categoryStyles[index % categoryStyles.length]; 52 data.categories.push({ 53 id: categoryId, 54 name: incoming.categoryName || categoryId, 55 icon, 56 tone, 57 }); 58 } 59 return { 60 ...template, 61 ...incoming, 62 ...incoming.commerce, 63 category: categoryId, 64 description: incoming.description || template.description, 65 tags: 66 Array.isArray(incoming.tags) && incoming.tags.length 67 ? incoming.tags 68 : template.tags, 69 }; 70 }); 71 hasServerShopData = true; 72 } 73 } catch { 74 // Keep the approved preview catalog if a server payload cannot be read. 75 } 76 } 77 const keyCart = "tep.shop.cart.v1", 78 keySaved = "tep.shop.saved.v1"; 79 const read = (key) => { 80 try { 81 return JSON.parse(localStorage.getItem(key)); 82 } catch { 83 return null; 84 } 85 }; 86 let cart = core.cartFromLocation(cartData, read(keyCart)), 87 saved = read(keySaved), 88 timer, 89 returnFocus = null, 90 dialogMode = ""; 91 saved = Array.isArray(saved) 92 ? [ 93 ...new Set( 94 saved.filter((id) => typeof id === "string" && cartData.products.some((p) => p.id === id)), 95 ), 96 ] 97 : []; 98 const state = { 99 query: "", 100 category: "all", 101 sort: "featured", 102 price: "", 103 type: "all", 104 stock: false, 105 saved: false, 106 page: 1, 107 }; 108 if (hasServerShopData) { 109 $("#shop-category").innerHTML = 110 '<option value="all">All categories</option>' + 111 data.categories 112 .map((category) => `<option value="${core.esc(category.id)}">${core.esc(category.name)}</option>`) 113 .join(""); 114 $(".shop-categories").innerHTML = 115 `<button type="button" data-category="all" aria-pressed="true">${core.icon("grid")}All products <small>${data.products.length}</small></button>` + 116 data.categories 117 .map( 118 (category) =>
119 `<button type="button" data-category="${core.esc(category.id)}" aria-pressed="false">${core.icon(category.icon, category.tone)}${core.esc(category.name)}<small>${data.products.filter((product) => product.category === category.id).length}</small></button>`, 120 ) 121 .join(""); 122 } 123 const dialog = $("#shop-dialog"); 124 function toast(message) { 125 clearTimeout(timer); 126 $("#shop-toast").textContent = message; 127 $("#shop-toast").hidden = false; 128 timer = setTimeout(() => { 129 $("#shop-toast").hidden = true; 130 }, 4500); 131 } 132 function save(key, value) { 133 try { 134 localStorage.setItem(key, JSON.stringify(value)); 135 return true; 136 } catch { 137 return false; 138 } 139 } 140 function counts() { 141 $("#shop-cart-count").textContent = cart.reduce((n, x) => n + x.qty, 0); 142 $("#shop-saved-count").textContent = saved.length; 143 } 144 function render() { 145 const all = core.select(data, state, saved), 146 pages = Math.ceil(all.length / data.pageSize); 147 state.page = Math.max(1, Math.min(state.page, pages || 1)); 148 const from = (state.page - 1) * data.pageSize; 149 $("#shop-grid").innerHTML = core.cards(data, all.slice(from, from + data.pageSize), saved); 150 $("#shop-grid").hidden = !all.length; 151 $("#shop-empty").hidden = Boolean(all.length); 152 $("#shop-result-count").textContent = all.length 153 ? `Showing ${from + 1}â${Math.min(from + data.pageSize, all.length)} of ${all.length} ${state.saved ? "saved " : ""}products` 154 : "No matching products"; 155 $("#shop-saved").setAttribute("aria-pressed", String(state.saved)); 156 $$("[data-category]").forEach((button) => 157 button.setAttribute("aria-pressed", String(button.dataset.category === state.category)), 158 ); 159 $("#shop-reset").hidden = !( 160 state.query || 161 state.category !== "all" || 162 state.price || 163 state.type !== "all" || 164 state.stock || 165 state.saved || 166 state.sort !== "featured" 167 ); 168 $("#shop-pagination").innerHTML = 169 pages > 1 170 ? `<button type="button" data-page="${state.page - 1}" aria-label="Previous product page"${state.page === 1 ? " disabled" : ""}>${core.svg("left")}</button>` + 171 Array.from( 172 { length: pages }, 173 (_, i) => 174 `<button type="button" data-page="${i + 1}" aria-label="Product page ${i + 1}"${state.page === i + 1 ? ' aria-current="page"' : ""}>${i + 1}</button>`, 175 ).join("") + 176 `<button type="button" data-page="${state.page + 1}" aria-label="Next product page"${state.page === pages ? " disabled" : ""}>${core.svg("arrow")}</button>` 177 : ""; 178 counts(); 179 window.TEPShopSelects?.sync(); 180 } 181 function reset() { 182 Object.assign(state, { 183 query: "", 184 category: "all", 185 sort: "featured", 186 price: "", 187 type: "all", 188 stock: false, 189 saved: false, 190 page: 1, 191 }); 192 for (const name of ["search", "category", "sort", "price", "type"]) 193 $("#shop-" + name).value = { 194 search: "", 195 category: "all", 196 sort: "featured", 197 price: "", 198 type: "all", 199 }[name]; 200 $("#shop-stock").checked = false; 201 render(); 202 } 203 function advanced(open) { 204 $("#shop-advanced").hidden = !open; 205 $("#shop-advanced-toggle").setAttribute("aria-expanded", String(open)); 206 } 207 $("#shop-filters").addEventListener("submit", (event) => event.preventDefault()); 208 for (const [control, property, event] of [ 209 ["search", "query", "input"], 210 ["category", "category", "change"], 211 ["sort", "sort", "change"], 212 ["price", "price", "change"], 213 ["type", "type", "change"], 214 ["stock", "stock", "change"], 215 ]) 216 $("#shop-" + control).addEventListener(event, () => { 217 state[property] = 218 control === "stock" ? $("#shop-stock").checked : $("#shop-" + control).value; 219 state.page = 1; 220 render(); 221 }); 222 $("#shop-advanced-toggle").addEventListener("click", () => advanced($("#shop-advanced").hidden)); 223 $("#shop-advanced-close").addEventListener("click", () => { 224 advanced(false); 225 $("#shop-advanced-toggle").focus(); 226 }); 227 $("#shop-reset").addEventListener("click", reset); 228 $("#shop-empty-reset").addEventListener("click", reset); 229 $("#shop-saved").addEventListener("click", () => { 230 state.saved = !state.saved; 231 state.page = 1; 232 render(); 233 }); 234 function show(mode, product) { 235 dialogMode = mode; 236 returnFocus = document.activeElement; 237 $("#shop-dialog-title").textContent = mode === "cart" ? "Your cart" : "Product details"; 238 $("#shop-dialog-content").innerHTML = 239 mode === "cart" ? core.cartHtml(cartData, cart) : core.detailHtml(data, product); 240 dialog.classList.toggle("shop-dialog--cart", mode === "cart"); 241 dialog.showModal(); 242 $("#shop-dialog-close").focus(); 243 } 244 function close() { 245 dialog.close(); 246 } 247 dialog.addEventListener("close", () => { 248 dialogMode = ""; 249 if (returnFocus?.isConnected) returnFocus.focus(); 250 else $("#shop-cart-open").focus(); 251 }); 252 dialog.addEventListener("click", (event) => { 253 if (event.target === dialog) close(); 254 }); 255 $("#shop-dialog-close").addEventListener("click", close); 256 $("#shop-cart-open").addEventListener("click", () => show("cart")); 257 function updateCart(focusSelector) { 258 const stored = save(keyCart, cart); 259 counts(); 260 if (dialogMode === "cart") { 261 $("#shop-dialog-content").innerHTML = core.cartHtml(cartData, cart); 262 ((focusSelector && $(focusSelector)) || $("#shop-dialog-close")).focus(); 263 } 264 if (!stored) toast("Cart updated for this visit. Browser saving is unavailable."); 265 return stored; 266 } 267 $("#shop-main").addEventListener("click", (event) => { 268 const button = event.target.closest("button"); 269 if (!button || button.disabled) return; 270 if (button.hasAttribute("data-category")) { 271 state.category = button.dataset.category; 272 $("#shop-category").value = state.category; 273 state.page = 1; 274 render(); 275 } 276 if (button.hasAttribute("data-page")) { 277 state.page = Number(button.dataset.page); 278 render(); 279 $("#shop-grid").setAttribute("tabindex", "-1"); 280 $("#shop-grid").focus({ preventScroll: true }); 281 $("#shop-grid").scrollIntoView({ 282 block: "start", 283 behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", 284 }); 285 } 286 if (button.hasAttribute("data-view")) { 287 const p = data.products.find((p) =>
287 p.id === button.dataset.view); 288 if (p) show("product", p); 289 } 290 if (button.hasAttribute("data-save")) { 291 const id = button.dataset.save, 292 p = data.products.find((p) => p.id === id); 293 if (!p) return; 294 const was = saved.includes(id); 295 saved = was ? saved.filter((x) => x !== id) : [...saved, id]; 296 const stored = save(keySaved, saved); 297 render(); 298 ($(`[data-save="${id}"]`) || $("#shop-saved")).focus(); 299 toast( 300 (was ? "Removed from saved products." : "Product saved.") + 301 (stored ? "" : " Saved for this visit only."), 302 ); 303 } 304 if (button.hasAttribute("data-add")) { 305 const p = data.products.find((p) => p.id === button.dataset.add); 306 if (!p?.stock) return; 307 const item = cart.find((x) => x.id === p.id); 308 if (item?.qty >= core.maxQuantity(p)) { 309 toast("You can add up to the available quantity of each product."); 310 return; 311 } 312 if (item) item.qty++; 313 else cart.push({ id: p.id, qty: 1 }); 314 const stored = updateCart(); 315 toast(p.title + " added to cart." + (stored ? "" : " Saved for this visit only.")); 316 } 317 if (button.hasAttribute("data-qty")) { 318 const item = cart.find((x) => x.id === button.dataset.qty); 319 if (!item) return; 320 item.qty = Math.min(core.maxQuantity(cartData.products.find(p => p.id === item.id)), Math.max(1, item.qty + Number(button.dataset.delta))); 321 updateCart(`[data-qty="${item.id}"][data-delta="${button.dataset.delta}"]:not(:disabled)`); 322 } 323 if (button.hasAttribute("data-remove")) { 324 cart = cart.filter((x) => x.id !== button.dataset.remove); 325 updateCart("[data-qty]"); 326 } 327 if (button.hasAttribute("data-close")) close(); 328 }); 329 const routeCategory = location.pathname.match(/^\/shop\/category\/([^/]+)\/?$/i)?.[1]; 330 const requestedCategory = 331 routeCategory || new URLSearchParams(location.search).get("category"); 332 if (data.categories.some((category) => category.id === requestedCategory)) { 333 state.category = requestedCategory; 334 $("#shop-category").value = requestedCategory; 335 } 336 render(); 337 core.bindCartLinks(() => cart); 338})();
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.