Files
portfoli-ulleres/js/app.js
T

309 lines
11 KiB
JavaScript

import {
API_URL,
AUTO_REFRESH_MS,
BLOODBROS_LOGO_URL,
CREATE_CHECKOUT_URL
} from './config.js';
import {
fmtPrice,
parseDescription,
getStockState,
getSalesHook,
buildWhatsappLink,
getFilteredProducts
} from './catalog.js';
const els = {
cards: document.getElementById('cards'),
errorBox: document.getElementById('errorBox'),
errorText: document.getElementById('errorText'),
emptyBox: document.getElementById('emptyBox'),
statVisible: document.getElementById('statVisible'),
statTop: document.getElementById('statTop'),
statUpdated: document.getElementById('statUpdated'),
searchInput: document.getElementById('searchInput'),
topFilter: document.getElementById('topFilter'),
sortFilter: document.getElementById('sortFilter'),
priceFilter: document.getElementById('priceFilter'),
refreshBtn: document.getElementById('refreshBtn'),
modal: document.getElementById('imageModal'),
modalImage: document.getElementById('modalImage'),
modalClose: document.getElementById('modalClose'),
brandLogo: document.getElementById('brandLogo'),
brandFallback: document.getElementById('brandFallback')
};
let products = [];
let lastFetchAt = null;
let lastUpdatedAt = null;
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function fmtTime(value) {
if (!value) return '--:--';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '--:--';
return d.toLocaleTimeString('ca-ES', { hour: '2-digit', minute: '2-digit' });
}
function renderDescription(product) {
const items = parseDescription(product.description);
if (items.length) {
return `
<div class="desc-box">
<div class="desc-title">Característiques</div>
<ul class="desc-list">${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>
</div>
`;
}
return `
<div class="desc-box">
<div class="desc-title">Característiques</div>
<p class="desc-plain">Sense descripció disponible.</p>
</div>
`;
}
function render() {
const data = getFilteredProducts(products, {
search: els.searchInput.value,
top: els.topFilter.value,
sort: els.sortFilter.value,
price: els.priceFilter.value
});
els.cards.innerHTML = '';
els.errorBox.style.display = 'none';
els.emptyBox.style.display = data.length ? 'none' : 'block'; const totalTop = data.filter(p => !!p.top_vendes).length;
els.statVisible.textContent = data.length;
els.statTop.textContent = totalTop; els.statUpdated.textContent = fmtTime(lastUpdatedAt || lastFetchAt);
if (!data.length) return;
const frag = document.createDocumentFragment();
data.forEach(product => {
const card = document.createElement('article');
card.className = 'card';
const chips = [];
if (product.colors) chips.push(product.colors);
const imageHtml = product.image_url
? `<img src="${escapeHtml(product.image_url)}" alt="${escapeHtml(product.product_code || product.model || 'Producte')}" loading="lazy" referrerpolicy="no-referrer">`
: `<div class="media-placeholder"><strong>${escapeHtml(product.product_code || 'Sense codi')}</strong><div>Sense imatge definida a IMAGE_URL.</div></div>`;
const stockState = getStockState(product.stock);
const salesHook = getSalesHook(product);
card.innerHTML = `
${product.top_vendes ? '<div class="top-badge">Top vendes</div>' : ''}
<div class="card-media">
<div class="media-stage" data-image="${escapeHtml(product.image_url || '')}">
${imageHtml}
</div>
</div>
<div class="card-actions">
<div class="media-actions">
<button class="action-btn action-view js-view" type="button" data-image="${escapeHtml(product.image_url || '')}">🔎 Veure gran</button>
<button class="action-btn action-buy js-buy" type="button"
data-code="${escapeHtml(product.product_code || '')}"
data-name="${escapeHtml(product.product_code || '')}"
data-price="${escapeHtml(product.europe_price_number || 0)}"
data-stock="${escapeHtml(product.stock || 0)}">💳 Comprar ara</button>
<a class="action-btn action-wa" href="${escapeHtml(buildWhatsappLink(product))}" target="_blank" rel="noopener noreferrer"><span class="wa-icon" aria-hidden="true"></span>WhatsApp</a>
</div>
</div>
<div class="body">
<div class="title-row">
<div class="title-stack">
<div class="product-code">${escapeHtml(product.product_code || '')}</div>
</div>
</div>
<div class="price-stock">
<div class="price-box">
<div class="price-label">Preu</div>
<div class="price">${escapeHtml(fmtPrice(product))}</div>
</div>
<div class="stock-pill ${stockState.cls}">${escapeHtml(stockState.label)}</div>
</div>
${salesHook ? `
<div class="sales-hook">
<span class="sales-hook-badge">${escapeHtml(salesHook.badge)}</span>
</div>` : ''}
${chips.length ? `<div class="chips">${chips.map(c => `<span class="chip">${escapeHtml(c)}</span>`).join('')}</div>` : ''}
${renderDescription(product)}
<div class="footer-row">
<div class="footer-box"><strong>Família</strong><span>${escapeHtml(product.category || 'No indicada')}</span></div>
<div class="footer-box"><strong>Vidre</strong><span>${escapeHtml(product.colors || 'No indicats')}</span></div>
</div>
</div>
`;
frag.appendChild(card);
});
els.cards.appendChild(frag);
document.querySelectorAll('.js-view, .media-stage').forEach(node => {
node.addEventListener('click', () => {
const src = node.getAttribute('data-image');
if (!src) return;
els.modalImage.src = src;
els.modal.classList.add('open');
els.modal.setAttribute('aria-hidden', 'false');
});
});
}
async function loadData() { els.errorBox.style.display = 'none';
try {
const response = await fetch(API_URL, { method: 'GET', cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
if (!json || json.ok !== true || !Array.isArray(json.products)) {
throw new Error('Resposta JSON invàlida');
}
products = json.products;
lastFetchAt = new Date().toISOString();
lastUpdatedAt = json.updated_at || lastFetchAt; render();
} catch (err) {
products = [];
lastFetchAt = new Date().toISOString();
lastUpdatedAt = null; els.errorText.textContent = `No s'ha pogut llegir l'endpoint JSON. Detall: ${err.message}`;
els.errorBox.style.display = 'block';
render();
}
}
const buyEls = {
modal: document.getElementById('buyModal'),
form: document.getElementById('buyForm'),
summary: document.getElementById('buySummary'),
error: document.getElementById('buyError'),
cancel: document.getElementById('buyCancel'),
};
function openBuyModal(product) {
buyEls.form.product_code.value = product.code;
buyEls.form.product_name.value = product.name;
buyEls.form.price.value = product.price;
buyEls.summary.innerHTML = `<strong>${escapeHtml(product.name)}</strong><br>Preu: ${escapeHtml(String(product.price).replace('.', ',') + ' €')} · Stock visible: ${escapeHtml(product.stock)}`;
buyEls.error.style.display = 'none';
buyEls.error.textContent = '';
buyEls.modal.classList.add('open');
buyEls.modal.setAttribute('aria-hidden', 'false');
}
function closeBuyModal() {
buyEls.modal.classList.remove('open');
buyEls.modal.setAttribute('aria-hidden', 'true');
}
function initLogo() {
if (!BLOODBROS_LOGO_URL) return;
els.brandLogo.onload = () => {
els.brandLogo.style.display = 'block';
els.brandFallback.style.display = 'none';
};
els.brandLogo.onerror = () => {
els.brandLogo.style.display = 'none';
els.brandFallback.style.display = 'flex';
};
els.brandLogo.src = BLOODBROS_LOGO_URL;
}
[els.searchInput, els.topFilter, els.sortFilter, els.priceFilter].forEach(el => {
el.addEventListener('input', render);
el.addEventListener('change', render);
});
els.refreshBtn.addEventListener('click', loadData);
els.modalClose.addEventListener('click', () => {
els.modal.classList.remove('open');
els.modal.setAttribute('aria-hidden', 'true');
els.modalImage.src = '';
});
els.modal.addEventListener('click', (e) => {
if (e.target === els.modal) {
els.modal.classList.remove('open');
els.modal.setAttribute('aria-hidden', 'true');
els.modalImage.src = '';
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
els.modal.classList.remove('open');
els.modal.setAttribute('aria-hidden', 'true');
els.modalImage.src = '';
closeBuyModal();
}
});
document.addEventListener('click', (e) => {
const btn = e.target.closest('.js-buy');
if (!btn) return;
openBuyModal({
code: btn.getAttribute('data-code') || '',
name: btn.getAttribute('data-name') || '',
price: btn.getAttribute('data-price') || '0',
stock: btn.getAttribute('data-stock') || '0'
});
});
buyEls.cancel.addEventListener('click', closeBuyModal);
buyEls.modal.addEventListener('click', (e) => {
if (e.target === buyEls.modal) closeBuyModal();
});
buyEls.form.addEventListener('submit', async (e) => {
e.preventDefault();
buyEls.error.style.display = 'none';
const payload = Object.fromEntries(new FormData(buyEls.form).entries());
try {
const response = await fetch(CREATE_CHECKOUT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await response.json();
if (!response.ok || !json.ok || !json.checkout_url) {
throw new Error(json.error || 'No s\'ha pogut crear el checkout');
}
window.location.href = json.checkout_url;
} catch (err) {
buyEls.error.textContent = err.message || 'Error inesperat';
buyEls.error.style.display = 'block';
}
});
initLogo();
loadData();
setInterval(loadData, AUTO_REFRESH_MS);