Files
portfoli-ulleres/js/app.js
T
2026-04-07 23:30:33 +02:00

428 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 desgotar-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);