PageSourceSearch

https://winsomemcr.co.uk/js/product-card.js

js winsomemcr.co.uk collected 2026-10-08 07:57:55 UTC 11,995 bytes, 345 lines download raw bytes

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">&larr; Shop</button>
199        <div class="product-nav__arrows">
200          <button class="product-nav__arrow" id="product-prev" aria-label="Previous product">&larr;</button>
201          <span class="product-nav__counter">${index + 1} / ${total}</span>
202          <button class="product-nav__arrow" id="product-next" aria-label="Next product">&rarr;</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.