import {
API_URL,
AUTO_REFRESH_MS,
BLOODBROS_LOGO_URL,
CREATE_CHECKOUT_URL,
TRACK_EVENT_URL
} from './config.js';
import {
fmtPrice,
parseDescription,
getStockState,
getSalesHook,
buildWhatsappLink,
getFilteredProducts
} from './catalog.js';
const SHIPPING_PRICE = 7.99;
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;
let pageViewTracked = false;
function getSessionId() {
const key = 'kapvoe_session_id';
let sessionId = localStorage.getItem(key);
if (!sessionId) {
sessionId = `sess_${Math.random().toString(36).slice(2)}${Date.now()}`;
localStorage.setItem(key, sessionId);
}
return sessionId;
}
function getPageType() {
const path = window.location.pathname.toLowerCase();
if (path.includes('top-vendes')) return 'top-vendes';
if (path.includes('checkout')) return 'checkout';
return 'catalog';
}
function getDeviceType() {
const ua = navigator.userAgent || '';
if (/tablet|ipad/i.test(ua)) return 'tablet';
if (/mobile|android|iphone|ipod/i.test(ua)) return 'mobile';
return 'desktop';
}
function getUtmParams() {
const params = new URLSearchParams(window.location.search);
return {
utm_source: params.get('utm_source') || '',
utm_medium: params.get('utm_medium') || '',
utm_campaign: params.get('utm_campaign') || ''
};
}
function buildAnalyticsPayload(eventType, extra = {}) {
const utm = getUtmParams();
return {
event_type: eventType,
session_id: getSessionId(),
page_url: window.location.href,
referrer: document.referrer || '',
user_agent: navigator.userAgent || '',
device_type: getDeviceType(),
page_type: getPageType(),
...utm,
...extra
};
}
async function trackEvent(eventType, extra = {}) {
try {
await fetch(TRACK_EVENT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildAnalyticsPayload(eventType, extra)),
keepalive: true
});
} catch (_) {
// Ignore tracking failures to avoid affecting the storefront UX.
}
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
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 `
Característiques
${items.map(item => `- ${escapeHtml(item)}
`).join('')}
`;
}
return `
Característiques
Sense descripció disponible.
`;
}
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
? `
`
: ``;
const stockState = getStockState(product.stock);
const salesHook = getSalesHook(product);
card.innerHTML = `
${product.top_vendes ? 'Top vendes
' : ''}
${escapeHtml(product.product_code || '')}
Preu
${escapeHtml(fmtPrice(product))}
${escapeHtml(stockState.label)}
${salesHook ? `
${escapeHtml(salesHook.badge)}
` : ''}
${chips.length ? `
${chips.map(c => `${escapeHtml(c)}`).join('')}
` : ''}
${renderDescription(product)}
`;
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;
const card = node.closest('.card');
const code = card?.querySelector('.product-code')?.textContent?.trim() || '';
trackEvent('view_large', {
product_code: code
});
els.modalImage.src = src;
els.modal.classList.add('open');
els.modal.setAttribute('aria-hidden', 'false');
});
});
document.querySelectorAll('.action-wa').forEach(node => {
node.addEventListener('click', () => {
const card = node.closest('.card');
const code = card?.querySelector('.product-code')?.textContent?.trim() || '';
trackEvent('whatsapp_click', {
product_code: code
});
});
});
}
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();
if (!pageViewTracked) {
pageViewTracked = true;
trackEvent('page_view');
}
} 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'),
summaryImage: document.getElementById('buySummaryImage'),
summaryCode: document.getElementById('buySummaryCode'),
summaryMeta: document.getElementById('buySummaryMeta'),
error: document.getElementById('buyError'),
cancel: document.getElementById('buyCancel'),
shippingToggle: document.getElementById('shippingToggle'),
shippingFields: document.getElementById('shippingFields'),
subtotal: document.getElementById('buySubtotal'),
shippingCost: document.getElementById('buyShippingCost'),
totalAmount: document.getElementById('buyTotalAmount')
};
function formatEuro(value) {
const n = Number(value || 0);
return new Intl.NumberFormat('ca-ES', {
style: 'currency',
currency: 'EUR'
}).format(n);
}
function updateBuyTotals() {
const price = Number(buyEls.form.price.value || 0);
const quantity = Number(buyEls.form.quantity.value || 1);
const shippingRequested = !!buyEls.shippingToggle.checked;
const subtotal = price * quantity;
const shipping = shippingRequested ? SHIPPING_PRICE : 0;
const total = subtotal + shipping;
buyEls.form.shipping_method.value = shippingRequested ? 'shipping' : 'pickup';
buyEls.subtotal.textContent = formatEuro(subtotal);
buyEls.shippingCost.textContent = formatEuro(shipping);
buyEls.totalAmount.textContent = formatEuro(total);
buyEls.shippingFields.style.display = shippingRequested ? 'grid' : 'none';
['address', 'postal_code', 'city', 'province'].forEach(name => {
const field = buyEls.form.elements[name];
if (!field) return;
field.required = shippingRequested;
});
}
function openBuyModal(product) {
trackEvent('checkout_open', {
product_code: product.code || '',
model: product.name || '',
price: String(product.price || ''),
stock: String(product.stock || ''),
quantity: String(product.quantity || 1)
});
buyEls.form.product_code.value = product.code;
buyEls.form.product_name.value = product.name;
buyEls.form.price.value = product.price;
buyEls.form.quantity.value = product.quantity || 1;
buyEls.summaryCode.textContent = product.name || product.code || '-';
buyEls.summaryMeta.innerHTML = `Preu: ${formatEuro(product.price)} · Stock visible: ${escapeHtml(product.stock)}`;
if (product.image) {
buyEls.summaryImage.src = product.image;
buyEls.summaryImage.alt = product.name || product.code || 'Producte';
buyEls.summaryImage.style.display = 'block';
} else {
buyEls.summaryImage.removeAttribute('src');
buyEls.summaryImage.alt = '';
buyEls.summaryImage.style.display = 'none';
}
buyEls.shippingToggle.checked = false;
buyEls.error.style.display = 'none';
buyEls.error.textContent = '';
updateBuyTotals();
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.topFilter.addEventListener('change', () => {
trackEvent('filter_use', {
category: `${els.topFilter.value}|${els.sortFilter.value}|${els.priceFilter.value}|${els.searchInput.value || ''}`
});
});
els.sortFilter.addEventListener('change', () => {
trackEvent('filter_use', {
category: `${els.topFilter.value}|${els.sortFilter.value}|${els.priceFilter.value}|${els.searchInput.value || ''}`
});
});
els.priceFilter.addEventListener('change', () => {
trackEvent('filter_use', {
category: `${els.topFilter.value}|${els.sortFilter.value}|${els.priceFilter.value}|${els.searchInput.value || ''}`
});
});
els.refreshBtn.addEventListener('click', () => {
trackEvent('refresh_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;
trackEvent('buy_click', {
product_code: btn.getAttribute('data-code') || '',
model: btn.getAttribute('data-name') || '',
price: btn.getAttribute('data-price') || '',
stock: btn.getAttribute('data-stock') || '',
quantity: '1'
});
openBuyModal({
code: btn.getAttribute('data-code') || '',
name: btn.getAttribute('data-name') || '',
price: Number(btn.getAttribute('data-price') || 0),
stock: btn.getAttribute('data-stock') || '0',
image: btn.getAttribute('data-image') || '',
quantity: 1
});
});
buyEls.shippingToggle.addEventListener('change', updateBuyTotals);
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());
const analyticsPayload = buildAnalyticsPayload('checkout_submit', {
product_code: payload.product_code || '',
model: payload.product_name || '',
price: payload.price || '',
quantity: payload.quantity || '1',
shipping_method: payload.shipping_method || 'pickup'
});
payload.analytics_session_id = analyticsPayload.session_id;
payload.analytics_page_url = analyticsPayload.page_url;
payload.analytics_referrer = analyticsPayload.referrer;
payload.analytics_user_agent = analyticsPayload.user_agent;
payload.analytics_utm_source = analyticsPayload.utm_source;
payload.analytics_utm_medium = analyticsPayload.utm_medium;
payload.analytics_utm_campaign = analyticsPayload.utm_campaign;
payload.analytics_device_type = analyticsPayload.device_type;
payload.analytics_page_type = analyticsPayload.page_type;
try {
trackEvent('checkout_submit', {
product_code: payload.product_code || '',
model: payload.product_name || '',
price: payload.price || '',
quantity: payload.quantity || '1',
shipping_method: payload.shipping_method || 'pickup'
});
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);