1/* Product interactions share the locked Shop's browser cart and saved products. */ 2(() => { 3 "use strict"; 4 const $ = (selector) => document.querySelector(selector); 5 const $$ = (selector) => [...document.querySelectorAll(selector)]; 6 const core = globalThis.TEPProductCore, 7 shop = globalThis.TEPShopCore; 8 const data = JSON.parse($("#product-data").textContent); 9 const previewProducts = [...data.catalog.products]; 10 const catalogNode = $("#shop-catalog-server-data"); 11 if (catalogNode?.textContent.trim()) { 12 try { data.catalog = globalThis.TEPShopCatalog.merge(data.catalog, JSON.parse(catalogNode.textContent)); } 13 catch { /* Retain the design catalog if server data is unavailable. */ } 14 } 15 const serverDataNode = $("#product-server-data"); 16 if (serverDataNode?.textContent.trim()) { 17 try { 18 const serverData = JSON.parse(serverDataNode.textContent); 19 const live = serverData?.product; 20 if (live?.id) { 21 const templates = previewProducts; 22 const sum = Array.from(String(live.id)).reduce( 23 (total, character) => total + character.codePointAt(0), 24 0, 25 ); 26 const template = 27 templates.find(item => item.id === live.id) || 28 templates[sum % templates.length] || 29 templates.find((item) => item.id === data.defaultProduct) || 30 templates[0]; 31 const templateDetails = data.details[template.id] || {}; 32 const category = { 33 id: live.category || template.category, 34 name: live.categoryName || live.category || "Uncategorized", 35 icon: "box", 36 tone: "cyan", 37 }; 38 const existingCategory = data.catalog.categories.find( 39 (item) => item.id === category.id, 40 ); 41 if (existingCategory) existingCategory.name = category.name; 42 else data.catalog.categories.push(category); 43 44 const liveProduct = { 45 ...template, 46 ...live, 47 ...live.commerce, 48 category: category.id, 49 description: live.description || template.description, 50 tags: 51 Array.isArray(live.tags) && live.tags.length ? live.tags : template.tags, 52 }; 53 const serverDetails = serverData.details || {}; 54 data.catalog.products = [ 55 liveProduct, 56 ...data.catalog.products.filter((item) => item.id !== liveProduct.id), 57 ]; 58 data.defaultProduct = liveProduct.id; 59 data.details[liveProduct.id] = { 60 ...templateDetails, 61 ...serverDetails, 62 intro: serverDetails.intro || templateDetails.intro, 63 subtitle: serverDetails.subtitle || templateDetails.subtitle, 64 sku: serverDetails.sku ?? templateDetails.sku, 65 specs: 66 Array.isArray(serverDetails.specs) 67 ? serverDetails.specs 68 : templateDetails.specs, 69 included: 70 Array.isArray(serverDetails.included) && serverDetails.included.length 71 ? serverDetails.included 72 : templateDetails.included, 73 gallery: 74 serverDetails.gallery?.length > 0 75 ? serverDetails.gallery 76 : templateDetails.gallery, 77 }; 78 } 79 } catch { 80 // Keep the approved preview data if a server payload is unavailable. 81 } 82 } 83 const pageUrl = new URL(location.href), 84 params = pageUrl.searchParams, 85 pathProduct = pageUrl.pathname.match(/^\/shop\/product\/([^/]+)\/?$/i)?.[1]; 86 const product = core.get( 87 data, 88 pathProduct 89 ? decodeURIComponent(pathProduct) 90 : params.has("id") 91 ? params.get("id") 92 : data.defaultProduct, 93 ); 94 const cartKey = "tep.shop.cart.v1", 95 savedKey = "tep.shop.saved.v1"; 96 function read(key) { 97 try { 98 return JSON.parse(localStorage.getItem(key)); 99 } catch { 100 return null; 101 } 102 } 103 function store(key, value) { 104 try { 105 localStorage.setItem(key, JSON.stringify(value)); 106 return true; 107 } catch { 108 return false; 109 } 110 } 111 let cart = shop.cartFromLocation(data.catalog, read(cartKey)); 112 let saved = read(savedKey); 113 saved = Array.isArray(saved) ? [...new Set(saved.filter((id) => core.get(data, id)))] : []; 114 $("#product-page").innerHTML = core.pageHtml(data, product, saved); 115 if (!product) return; 116 if (!serverDataNode) document.title = `${product.title} â TEP Shop`; 117 118 const images = core.gallery(product, data.details[product.id]); 119 const quantity = $("#product-quantity"), 120 dialog = $("#product-dialog"); 121 let imageIndex = 0, 122 mode = "", 123 returnFocus = null, 124 timer; 125 function toast(message) { 126 clearTimeout(timer); 127 $("#product-toast").textContent = message; 128 $("#product-toast").hidden = false;
129 timer = setTimeout(() => { 130 $("#product-toast").hidden = true; 131 }, 4500); 132 } 133 function status(message) { 134 $("#product-buy-status").textContent = message; 135 $("#product-buy-status").hidden = !message; 136 } 137 function validQuantity() { 138 const value = Number(quantity.value); 139 return Number.isInteger(value) && value >= 1 && value <= shop.maxQuantity(product) ? value : null; 140 } 141 function updateQuantity() { 142 if (!product.stock) { 143 quantity.setAttribute("aria-invalid", "false"); 144 status("This product is out of stock."); 145 return; 146 } 147 const value = validQuantity(); 148 quantity.setAttribute("aria-invalid", String(value === null)); 149 $("#product-total").textContent = 150 value === null ? "â" : shop.money((Math.round(product.price * 100) * value) / 100); 151 $("#product-decrease").disabled = !product.stock || value === null || value <= 1; 152 $("#product-increase").disabled = !product.stock || value === null || value >= shop.maxQuantity(product); 153 status(value === null ? `Enter a whole quantity from 1 to ${shop.maxQuantity(product)}.` : ""); 154 } 155 function counts() { 156 $("#product-cart-count").textContent = cart.reduce((n, item) => n + item.qty, 0); 157 } 158 function open(modeName, title, content) { 159 mode = modeName; 160 returnFocus = document.activeElement; 161 $("#product-dialog-title").textContent = title; 162 $("#product-dialog-content").innerHTML = content; 163 dialog.classList.toggle("shop-dialog--cart", mode === "cart"); 164 dialog.classList.toggle("product-dialog--image", mode === "image"); 165 dialog.showModal(); 166 $("#product-dialog-close").focus(); 167 } 168 function close() { 169 dialog.close(); 170 } 171 dialog.addEventListener("close", () => { 172 mode = ""; 173 if (returnFocus?.isConnected) returnFocus.focus(); 174 else $("#product-cart-open").focus(); 175 }); 176 dialog.addEventListener("click", (event) => { 177 if (event.target === dialog) close(); 178 }); 179 $("#product-dialog-close").addEventListener("click", close); 180 $("#product-cart-open").addEventListener("click", () => 181 open("cart", "Your cart", shop.cartHtml(data.catalog, cart)), 182 ); 183 function persistCart(focusSelector) { 184 const stored = store(cartKey, cart); 185 counts(); 186 if (mode === "cart") { 187 $("#product-dialog-content").innerHTML = shop.cartHtml(data.catalog, cart); 188 ($(focusSelector || "#product-dialog-close") || $("#product-dialog-close")).focus(); 189 } 190 if (!stored) toast("Cart updated for this visit. Browser saving is unavailable."); 191 return stored; 192 } 193 function add(id, amount) { 194 const itemProduct = core.get(data, id); 195 if (!itemProduct?.stock) return; 196 const existing = cart.find((item) => item.id === id), 197 previous = existing?.qty || 0; 198 if (previous + amount > shop.maxQuantity(itemProduct)) { 199 const message = `Your cart already has ${previous}. The available limit is ${shop.maxQuantity(itemProduct)}.`; 200 if (id === product.id) status(message); 201 toast(message); 202 return; 203 } 204 if (existing) existing.qty += amount; 205 else cart.push({ id, qty: amount }); 206 const stored = persistCart(); 207 const message = 208 `${amount} à ${itemProduct.title} added to cart.` + 209 (stored ? "" : " Saved for this visit only."); 210 if (id === product.id) status(message); 211 toast(message); 212 } 213 quantity.addEventListener("input", updateQuantity); 214 $("#product-decrease").addEventListener("click", () => { 215 const value = validQuantity(); 216 if (value !== null) { 217 quantity.value = Math.max(1, value - 1); 218 updateQuantity(); 219 } 220 }); 221 $("#product-increase").addEventListener("click", () => { 222 const value = validQuantity(); 223 if (value !== null) { 224 quantity.value = Math.min(shop.maxQuantity(product), value + 1); 225 updateQuantity(); 226 } 227 }); 228 $("#product-buy-form").addEventListener("submit", (event) => { 229 event.preventDefault(); 230 if (!product.stock) return; 231 const value = validQuantity(); 232 if (value === null) { 233 updateQuantity(); 234 quantity.focus(); 235 return; 236 } 237 add(product.id, value); 238 }); 239 function selectImage(index) { 240 if (!Number.isInteger(index) || index < 0 || index >= images.length) return; 241 imageIndex = index; 242 const selected = images[index]; 243 $("#product-image").closest("picture").outerHTML = core.picture( 244 data, 245 selected.link ? selected : selected.image, 246 product.title + " â " + selected.label, 247 ); 248 $("#product-image-caption").textContent = selected.label + " · Product illustration"; 249 $$("[data-gallery]").forEach((button) => 250 button.setAttribute("aria-pressed", String(Number(button.dataset.gallery) === index)), 251 ); 252 } 253 $("#product-zoom").addEventListener("click", () => { 254 const selected = images[imageIndex]; 255 open( 256 "image", 257 product.title, 258 `<figure class="product-zoom-view">${core.picture(data, selected.link ? selected : selected.image, product.title + " â " + selected.label, "zoom")}<figcaption>${core.esc(selected.label)} · Product illustration</figcaption></figure>`, 259 ); 260 }); 261 function toggleSaved(id) { 262 if (!core.get(data, id)) return; 263 const wasSaved = saved.includes(id); 264 saved = wasSaved ? saved.filter((value) => value !== id) : [...saved, id]; 265 const stored = store(savedKey, saved); 266 $("#product-save").setAttribute("aria-pressed", String(saved.includes(product.id))); 267 $("#product-save span").textContent = saved.includes(product.id) ? "Saved" : "Save product"; 268 $$("[data-save]").forEach((button) => { 269 const active = saved.includes(button.dataset.save); 270 button.setAttribute("aria-pressed", String(active)); 271 button.setAttribute( 272 "aria-label", 273 (active ? "Unsave " : "Save ") + core.get(data, button.dataset.save).title, 274 ); 275 }); 276 toast( 277 (wasSaved ? "Removed from saved products." : "Product saved.") + 278 (stored ? "" : " Saved for this visit only."), 279 ); 280 } 281 $("#product-save").addEventListener("click", () => toggleSaved(product.id)); 282 function selectTab(name, focus = false) { 283 const selected = $(`[data-tab="${name}"]`); 284 if (!selected) return; 285 $$("[data-tab]").forEach((tab) => { 286 const active = tab === selected; 287 tab.setAttribute("aria-selected", String(active)); 288 tab.tabIndex = active ? 0 : -1; 289 $("#" + tab.getAttribute("aria-controls")).hidden = !active; 290 }); 291 if (focus) selected.focus(); 292 } 293 $(".product-tabs").addEventListener("keydown", (event) => { 294 const tabs = $$("[data-tab]"), 295 current = tabs.indexOf(event.target); 296 if (current < 0) return; 297 let index; 298 if (event.key === "ArrowRight") index = (current + 1) % tabs.length; 299 else if (event.key === "ArrowLeft") index = (current + tabs.length - 1) % tabs.length; 300 else if (event.key === "Home") index = 0; 301 else if (event.key === "End") index = tabs.length - 1; 302 else return; 303 event.preventDefault(); 304 selectTab(tabs[index].dataset.tab, true); 305 }); 306 $("#product-share").addEventListener("click", async () => { 307 const link = new URL(location.href); 308 link.searchParams.set("id", product.id); 309 link.hash = ""; 310 if (link.protocol === "https:" || link.protocol === "http:") {
311 try { 312 await navigator.clipboard.writeText(link.href); 313 toast("Product link copied."); 314 return; 315 } catch { 316 /* Show a manual copy field. */ 317 } 318 } 319 const local = link.protocol === "file:"; 320 open( 321 "share", 322 "Share this product", 323 `<div class="product-share-panel"><p>${local ? "This preview is a local file. Share the preview ZIP so someone else can open the product." : "Copy the product link below."}</p><label for="product-share-link">${local ? "Local preview address" : "Product link"}</label><input id="product-share-link" type="text" readonly value="${core.esc(link.href)}"></div>`, 324 ); 325 $("#product-share-link").focus(); 326 $("#product-share-link").select(); 327 }); 328 $("#product-main").addEventListener("click", (event) => { 329 const reviewLink = event.target.closest("[data-select-tab]"); 330 if (reviewLink) { 331 event.preventDefault(); 332 selectTab(reviewLink.dataset.selectTab, true); 333 $("#product-information").scrollIntoView({ 334 block: "start", 335 behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", 336 }); 337 return; 338 } 339 const button = event.target.closest("button"); 340 if (!button || button.disabled) return; 341 if (button.hasAttribute("data-gallery")) selectImage(Number(button.dataset.gallery)); 342 if (button.hasAttribute("data-tab")) selectTab(button.dataset.tab); 343 if (button.hasAttribute("data-save")) toggleSaved(button.dataset.save); 344 if (button.hasAttribute("data-add")) add(button.dataset.add, 1); 345 if (button.hasAttribute("data-qty")) { 346 const item = cart.find((value) => value.id === button.dataset.qty); 347 if (!item) return; 348 item.qty = Math.max(1, Math.min(shop.maxQuantity(core.get(data, item.id)), item.qty + Number(button.dataset.delta))); 349 persistCart(`[data-qty="${item.id}"][data-delta="${button.dataset.delta}"]:not(:disabled)`); 350 } 351 if (button.hasAttribute("data-remove")) { 352 cart = cart.filter((item) => item.id !== button.dataset.remove); 353 persistCart("[data-qty]"); 354 } 355 if (button.hasAttribute("data-close")) close(); 356 }); 357 shop.bindCartLinks(() => cart); 358 updateQuantity(); 359 counts(); 360})();
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.