1// Product Card Component â Click-through detail view with collection filters 2const ProductCard = (function() { 3 4 let allProducts = []; 5 let allCollections = []; 6 let activeFilter = 'all'; 7 8 // Collection display order â food-drink first so pie appears at the top 9 const COLLECTION_ORDER = ['food-drink', 'merch']; 10 11 // ââ Grid Card (image + title + price only) ââ 12 13 function createCardHTML(product, index) { 14 const price = Cart.formatPrice(product.priceRange.minVariantPrice.amount); 15 const showPriceRange = product.priceRange.minVariantPrice.amount !== product.priceRange.maxVariantPrice.amount; 16 const imageUrl = product.featuredImage?.url || ''; 17 const imageAlt = product.featuredImage?.altText || product.title; 18 19 return ` 20 <div class="product-card" data-product-index="${index}"> 21 <div class="product-card-clickable" data-index="${index}"> 22 <div class="product-card-image"> 23 ${imageUrl 24 ? `<img src="${imageUrl}" alt="${imageAlt}" loading="lazy">` 25 : '<div class="product-card-placeholder"></div>' 26 } 27 </div> 28 <div class="product-card-content"> 29 <h3 class="product-card-title">${product.title}</h3> 30 <p class="product-card-price"> 31 ${showPriceRange ? `From ${price}` : price} 32 </p> 33 </div> 34 </div> 35 </div> 36 `; 37 } 38 39 // ââ Collection Filters ââ 40 41 function getOrderedCollections() { 42 const ordered = []; 43 for (const handle of COLLECTION_ORDER) { 44 const col = allCollections.find(c => c.handle === handle); 45 if (col) ordered.push(col); 46 } 47 // Add any collections not in the order list at the end 48 for (const col of allCollections) { 49 if (!COLLECTION_ORDER.includes(col.handle)) ordered.push(col); 50 } 51 return ordered; 52 } 53 54 function renderFilters() { 55 const container = document.getElementById('shop-filters'); 56 if (!container || allCollections.length === 0) return; 57 58 const ordered = getOrderedCollections(); 59 60 container.innerHTML = ` 61 <button class="shop-filter ${activeFilter === 'all' ? 'shop-filter--active' : ''}" data-filter="all">All</button> 62 ${ordered.map(c => ` 63 <button class="shop-filter ${activeFilter === c.handle ? 'shop-filter--active' : ''}" data-filter="${c.handle}">${c.title}</button> 64 `).join('')} 65 `; 66 67 container.querySelectorAll('.shop-filter').forEach(btn => { 68 btn.addEventListener('click', () => {
69 activeFilter = btn.dataset.filter; 70 renderFilters(); 71 renderGrid(); 72 }); 73 }); 74 } 75 76 function getFilteredProducts() { 77 if (activeFilter !== 'all') { 78 const collection = allCollections.find(c => c.handle === activeFilter); 79 if (!collection) return allProducts; 80 const handles = collection.products.edges.map(e => e.node.handle); 81 return allProducts.filter(p => handles.includes(p.handle)); 82 } 83 84 // "All" view â sort by collection order so food-drink products appear first 85 const sorted = []; 86 const added = new Set(); 87 for (const colHandle of COLLECTION_ORDER) { 88 const col = allCollections.find(c => c.handle === colHandle); 89 if (!col) continue; 90 const handles = col.products.edges.map(e => e.node.handle); 91 for (const handle of handles) { 92 if (added.has(handle)) continue; 93 const product = allProducts.find(p => p.handle === handle); 94 if (product) { 95 sorted.push(product); 96 added.add(handle); 97 } 98 } 99 } 100 // Add any products not in a collection at the end 101 for (const product of allProducts) { 102 if (!added.has(product.handle)) { 103 sorted.push(product); 104 } 105 } 106 return sorted; 107 } 108 109 // ââ Render Grid (from stored products) ââ 110 111 function renderGrid() { 112 const grid = document.getElementById('productGrid'); 113 if (!grid) return; 114 115 const filtered = getFilteredProducts(); 116 117 if (filtered.length === 0) { 118 grid.innerHTML = '<p class="products-empty">No products available.</p>'; 119 return; 120 } 121 122 grid.innerHTML = filtered.map((product) => { 123 const index = allProducts.indexOf(product); 124 return createCardHTML(product, index); 125 }).join(''); 126 127 // Bind clickable cards 128 grid.querySelectorAll('.product-card-clickable').forEach((el) => { 129 el.addEventListener('click', () => { 130 const index = parseInt(el.dataset.index, 10); 131 showProductDetail(index); 132 }); 133 }); 134 } 135 136 // ââ Render Products (initial load) ââ 137 138 async function renderProducts(containerId) { 139 const container = document.getElementById(containerId); 140 if (!container) { 141 console.warn('Product container not found:', containerId); 142 return; 143 } 144 145 container.innerHTML = '<div class="products-loading">Loading products...</div>'; 146 147 try { 148 const result = await ShopifyAPI.getProducts(20); 149 allProducts = result.products; 150 allCollections = result.collections; 151 152 if (allProducts.length === 0) { 153 container.innerHTML = '<p class="products-empty">No products available.</p>'; 154 return; 155 } 156 157 renderFilters(); 158 renderGrid(); 159 } catch (error) { 160 console.error('Error loading products:', error); 161 container.innerHTML = '<p class="products-error">Unable to load products. Please try again later.</p>'; 162 } 163 } 164 165 // ââ Product Detail View ââ 166 167 function showProductDetail(index) { 168 const product = allProducts[index]; 169 if (!product) return; 170 171 const total = allProducts.length; 172 const prevIndex = (index - 1 + total) % total; 173 const nextIndex = (index + 1) % total; 174 175 const variants = product.variants.edges.map(e => e.node); 176 const hasMultipleVariants = variants.length > 1 && variants[0].title !== 'Default Title'; 177 const firstVariant = variants[0]; 178 const anyAvailable = variants.some(v => v.availableForSale); 179 const price = Cart.formatPrice(product.priceRange.minVariantPrice.amount); 180 const images = product.images?.edges?.map(e => e.node) || []; 181 const featuredImage = product.featuredImage; 182 183 let variantSelector = ''; 184 if (hasMultipleVariants) { 185 variantSelector = ` 186 <select class="product-detail__select"> 187 ${variants.map(v => ` 188 <option value="${v.id}" ${!v.availableForSale ? 'disabled' : ''} data-price="${v.price.amount}"> 189 ${v.title}${!v.availableForSale ? ' - Sold Out' : ''} 190 </option> 191 `).join('')} 192 </select>`; 193 } 194 195 const content = document.getElementById('shopContent'); 196 content.innerHTML = ` 197 <nav class="product-nav"> 198 <button class="product-nav__back" id="product-back">← Shop</button> 199 <div class="product-nav__arrows"> 200 <button class="product-nav__arrow" id="product-prev" aria-label="Previous product">←</button>
201 <span class="product-nav__counter">${index + 1} / ${total}</span> 202 <button class="product-nav__arrow" id="product-next" aria-label="Next product">→</button> 203 </div> 204 </nav> 205 <div class="product-detail"> 206 <div class="product-detail__images"> 207 ${images.length > 0 208 ? images.map(img => ` 209 <div class="product-detail__image"> 210 <img src="${img.url}" alt="${img.altText || product.title}" /> 211 </div> 212 `).join('') 213 : featuredImage 214 ? `<div class="product-detail__image"> 215 <img src="${featuredImage.url}" alt="${featuredImage.altText || product.title}" /> 216 </div>` 217 : `<div class="product-detail__image product-detail__image--empty"></div>` 218 } 219 </div> 220 <div class="product-detail__info"> 221 <h2 class="product-detail__title">${product.title}</h2> 222 <span class="product-detail__price">${price}</span> 223 ${product.descriptionHtml ? `<div class="product-detail__desc">${product.descriptionHtml}</div>` : ''} 224 ${variantSelector} 225 ${anyAvailable 226 ? `<button class="btn product-detail__btn" data-variant-id="${firstVariant?.id}">Add to Cart</button>` 227 : `<button class="btn product-detail__btn" disabled>Sold Out</button>` 228 } 229 </div> 230 </div> 231 `; 232 233 // Scroll to top of page content 234 document.querySelector('.page-content').scrollIntoView({ behavior: 'smooth', block: 'start' }); 235 236 // Bind navigation 237 document.getElementById('product-back').addEventListener('click', showShopGrid); 238 document.getElementById('product-prev').addEventListener('click', () => showProductDetail(prevIndex)); 239 document.getElementById('product-next').addEventListener('click', () => showProductDetail(nextIndex)); 240 241 // Bind variant select 242 const select = content.querySelector('.product-detail__select'); 243 if (select) { 244 select.addEventListener('change', () => { 245 const btn = content.querySelector('.product-detail__btn'); 246 const priceEl = content.querySelector('.product-detail__price'); 247 const selectedOption = select.options[select.selectedIndex]; 248 249 if (btn) { 250 btn.dataset.variantId = select.value; 251 } 252 253 if (priceEl && selectedOption.dataset.price) { 254 priceEl.textContent = Cart.formatPrice(selectedOption.dataset.price); 255 } 256 257 if (selectedOption.disabled) { 258 btn.disabled = true; 259 btn.textContent = 'Sold Out'; 260 } else { 261 btn.disabled = false; 262 btn.textContent = 'Add to Cart'; 263 } 264 }); 265 } 266 267 // Bind add to cart 268 const addBtn = content.querySelector('.product-detail__btn[data-variant-id]'); 269 if (addBtn) { 270 addBtn.addEventListener('click', async () => { 271 const vid = addBtn.dataset.variantId; 272 const originalText = addBtn.textContent; 273 addBtn.disabled = true; 274 addBtn.textContent = 'Adding...'; 275 276 try { 277 await Cart.addItem(vid, 1); 278 addBtn.textContent = 'Added!'; 279 280 if (window.CartDrawer) { 281 CartDrawer.open(); 282 } 283 284 setTimeout(() => { 285 addBtn.disabled = false; 286 addBtn.textContent = originalText; 287 }, 1500); 288 } catch (error) { 289 console.error('Error adding to cart:', error); 290 addBtn.textContent = 'Error'; 291 setTimeout(() => { 292 addBtn.disabled = false; 293 addBtn.textContent = originalText; 294 }, 2000); 295 } 296 }); 297 } 298 299 // Keyboard navigation 300 content._keyHandler = (e) => { 301 if (e.key === 'ArrowLeft') showProductDetail(prevIndex); 302 else if (e.key === 'ArrowRight') showProductDetail(nextIndex); 303 else if (e.key === 'Escape') showShopGrid(); 304 }; 305 document.addEventListener('keydown', content._keyHandler); 306 } 307 308 // ââ Restore Shop Grid ââ 309 310 function showShopGrid() { 311 const content = document.getElementById('shopContent'); 312 313 // Clean up keyboard listener 314 if (content._keyHandler) { 315 document.removeEventListener('keydown', content._keyHandler); 316 content._keyHandler = null; 317 } 318 319 // Re-render the shop grid 320 content.innerHTML = ` 321 <div class="page-header-text"> 322 <h1>Gift Vouchers</h1> 323 <p>Give the gift of Winsome.<br>Perfect for birthdays, celebrations, or just because.</p> 324 <a href="https://vouchers.resdiary.com/winsome" target="_blank" class="btn" style="margin-top: 1.5rem;">Buy Gift Vouchers</a> 325 </div> 326 <div id="shop-filters" class="shop-filters"></div> 327 <div class="product-grid" id="productGrid"></div> 328 `; 329 330 renderFilters(); 331 renderGrid(); 332 333 document.querySelector('.page-content').scrollIntoView({ behavior: 'smooth', block: 'start' }); 334 } 335 336 // Public API 337 return { 338 renderProducts, 339 showProductDetail, 340 showShopGrid 341 }; 342})(); 343 344// Make ProductCard globally available 345window.ProductCard = ProductCard;
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.