428 lines
15 KiB
JavaScript
428 lines
15 KiB
JavaScript
import {
|
||
API_URL,
|
||
AUTO_REFRESH_MS,
|
||
BLOODBROS_LOGO_URL,
|
||
WHATSAPP_PHONE,
|
||
CREATE_CHECKOUT_URL
|
||
} from './config.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('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function fmtPrice(product) {
|
||
if (product.europe_price_text) return product.europe_price_text;
|
||
const n = Number(product.europe_price_number);
|
||
if (Number.isFinite(n)) {
|
||
return new Intl.NumberFormat('ca-ES', { style: 'currency', currency: 'EUR' }).format(n);
|
||
}
|
||
return '-';
|
||
}
|
||
|
||
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 normalizeText(v) {
|
||
return String(v ?? '').toLowerCase().trim();
|
||
}
|
||
|
||
function parseDescription(description) {
|
||
const raw = String(description ?? '').trim();
|
||
if (!raw) return [];
|
||
const lines = raw
|
||
.split(/\n|\r|\||•|;/g)
|
||
.map(s => s.trim())
|
||
.filter(Boolean);
|
||
return lines;
|
||
}
|
||
|
||
function getStockState(stock) {
|
||
const n = Number(stock || 0);
|
||
if (n <= 1) {
|
||
return {
|
||
cls: 'stock-last',
|
||
label: '🔥 Última unitat'
|
||
};
|
||
}
|
||
if (n <= 3) {
|
||
return {
|
||
cls: 'stock-low',
|
||
label: `⚠️ Queden poques unitats · ${n} disponibles`
|
||
};
|
||
}
|
||
return {
|
||
cls: 'stock-ok',
|
||
label: `Stock disponible · ${n} unitats`
|
||
};
|
||
}
|
||
|
||
function getSalesHook(product) {
|
||
if (product.top_vendes) {
|
||
return null;
|
||
}
|
||
const stock = Number(product.stock || 0);
|
||
if (stock <= 1) {
|
||
return {
|
||
badge: 'Última oportunitat',
|
||
text: 'Aquest model està a punt d’esgotar-se.'
|
||
};
|
||
}
|
||
if (stock <= 3) {
|
||
return {
|
||
badge: 'Alta demanda',
|
||
text: 'Model amb poques unitats disponibles.'
|
||
};
|
||
}
|
||
return {
|
||
badge: 'Selecció premium',
|
||
text: 'Una aposta segura per estil i rendiment.'
|
||
};
|
||
}
|
||
|
||
function buildWhatsappLink(product) {
|
||
const text = [
|
||
'Hola Blood Bros Sports,',
|
||
'voldria encomanar aquest model:',
|
||
`${product.product_code || ''}`,
|
||
product.model ? `Model: ${product.model}` : '',
|
||
product.category ? `Família: ${product.category}` : '',
|
||
product.colors ? `Colors: ${product.colors}` : '',
|
||
Number(product.europe_price_number) ? `Preu: ${fmtPrice(product)}` : '',
|
||
`Stock visible: ${product.stock ?? ''}`
|
||
].filter(Boolean).join('\n');
|
||
|
||
if (WHATSAPP_PHONE) {
|
||
return `https://wa.me/${encodeURIComponent(WHATSAPP_PHONE)}?text=${encodeURIComponent(text)}`;
|
||
}
|
||
return `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`;
|
||
}
|
||
|
||
function getFilteredProducts() {
|
||
const q = normalizeText(els.searchInput.value);
|
||
const top = els.topFilter.value;
|
||
const sort = els.sortFilter.value;
|
||
const price = els.priceFilter.value;
|
||
|
||
let data = products.filter(p => Number(p.stock) >= 1);
|
||
|
||
if (q) {
|
||
data = data.filter(p => {
|
||
const hay = [p.product_code, p.model, p.category, p.colors, p.description]
|
||
.map(normalizeText).join(' ');
|
||
return hay.includes(q);
|
||
});
|
||
}
|
||
|
||
if (top === 'top') data = data.filter(p => !!p.top_vendes);
|
||
if (top === 'normal') data = data.filter(p => !p.top_vendes);
|
||
|
||
if (price !== 'all') {
|
||
data = data.filter(p => {
|
||
const n = Number(p.europe_price_number);
|
||
if (!Number.isFinite(n)) return false;
|
||
if (price === '0-60') return n <= 60;
|
||
if (price === '60-80') return n > 60 && n <= 80;
|
||
if (price === '80+') return n > 80;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
data.sort((a, b) => {
|
||
const codeA = String(a.product_code || '');
|
||
const codeB = String(b.product_code || '');
|
||
const priceA = Number.isFinite(Number(a.europe_price_number)) ? Number(a.europe_price_number) : -Infinity;
|
||
const priceB = Number.isFinite(Number(b.europe_price_number)) ? Number(b.europe_price_number) : -Infinity;
|
||
const stockA = Number(a.stock || 0);
|
||
const stockB = Number(b.stock || 0);
|
||
|
||
switch (sort) {
|
||
case 'code-desc': return codeB.localeCompare(codeA, 'ca');
|
||
case 'price-asc': return priceA - priceB;
|
||
case 'price-desc': return priceB - priceA;
|
||
case 'stock-desc': return stockB - stockA;
|
||
default: return codeA.localeCompare(codeB, 'ca');
|
||
}
|
||
});
|
||
|
||
return data;
|
||
}
|
||
|
||
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();
|
||
|
||
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); |