diff --git a/analytics/events-header.csv b/analytics/events-header.csv
new file mode 100644
index 0000000..97095eb
--- /dev/null
+++ b/analytics/events-header.csv
@@ -0,0 +1 @@
+timestamp,event_type,product_code,model,category,price,stock,page_url,user_agent,session_id,event_id,order_id,stripe_session_id,payment_status,quantity,shipping_method,total_amount,referrer,utm_source,utm_medium,utm_campaign,device_type,page_type
diff --git a/analytics/google-sheets-formulas.txt b/analytics/google-sheets-formulas.txt
new file mode 100644
index 0000000..fe2523b
--- /dev/null
+++ b/analytics/google-sheets-formulas.txt
@@ -0,0 +1,75 @@
+RESUM
+A1: Metrica
+B1: Valor
+A2: Sessions uniques
+B2: =COUNTUNIQUE(FILTER(EVENTS!J2:J,EVENTS!J2:J<>""))
+A3: Page views
+B3: =COUNTIF(EVENTS!B:B,"page_view")
+A4: Veure gran
+B4: =COUNTIF(EVENTS!B:B,"view_large")
+A5: Clicks WhatsApp
+B5: =COUNTIF(EVENTS!B:B,"whatsapp_click")
+A6: Clicks Comprar
+B6: =COUNTIF(EVENTS!B:B,"buy_click")
+A7: Checkout submit
+B7: =COUNTIF(EVENTS!B:B,"checkout_submit")
+A8: Compres pagades
+B8: =COUNTIF(EVENTS!B:B,"payment_success")
+A9: CTR comprar
+B9: =IF(B3=0,0,B6/B3)
+A10: Checkout rate
+B10: =IF(B6=0,0,B7/B6)
+A11: Conversio final
+B11: =IF(B3=0,0,B8/B3)
+A12: Checkout -> paid
+B12: =IF(B7=0,0,B8/B7)
+A13: Facturacio
+B13: =IFERROR(SUM(FILTER(ORDERS!I2:I,ORDERS!J2:J="paid")),0)
+A14: Ticket mitja
+B14: =IF(B8=0,0,B13/B8)
+
+FUNNEL
+A1: Pas
+B1: Valor
+C1: Ratio vs pas anterior
+A2: page_view
+B2: =COUNTIF(EVENTS!B:B,"page_view")
+C2:
+A3: buy_click
+B3: =COUNTIF(EVENTS!B:B,"buy_click")
+C3: =IF(B2=0,0,B3/B2)
+A4: checkout_submit
+B4: =COUNTIF(EVENTS!B:B,"checkout_submit")
+C4: =IF(B3=0,0,B4/B3)
+A5: payment_success
+B5: =COUNTIF(EVENTS!B:B,"payment_success")
+C5: =IF(B4=0,0,B5/B4)
+
+TOP_MODELS
+A1: product_code
+B1: view_large
+C1: whatsapp_clicks
+D1: buy_clicks
+E1: checkout_submit
+F1: payment_success
+G1: conversion_rate
+H1: revenue
+A2: =SORT(UNIQUE(FILTER(EVENTS!C2:C,EVENTS!C2:C<>"")))
+B2: =ARRAYFORMULA(IF(A2:A="","",COUNTIFS(EVENTS!C:C,A2:A,EVENTS!B:B,"view_large")))
+C2: =ARRAYFORMULA(IF(A2:A="","",COUNTIFS(EVENTS!C:C,A2:A,EVENTS!B:B,"whatsapp_click")))
+D2: =ARRAYFORMULA(IF(A2:A="","",COUNTIFS(EVENTS!C:C,A2:A,EVENTS!B:B,"buy_click")))
+E2: =ARRAYFORMULA(IF(A2:A="","",COUNTIFS(EVENTS!C:C,A2:A,EVENTS!B:B,"checkout_submit")))
+F2: =ARRAYFORMULA(IF(A2:A="","",COUNTIFS(EVENTS!C:C,A2:A,EVENTS!B:B,"payment_success")))
+G2: =ARRAYFORMULA(IF(A2:A="","",IF(D2:D=0,0,F2:F/D2:D)))
+H2: =ARRAYFORMULA(IF(A2:A="","",SUMIF(ORDERS!D:D,A2:A,ORDERS!I:I)))
+
+FILTERS
+A1: filter_key
+B1: uses
+A2: =SORT(UNIQUE(FILTER(EVENTS!C2:C,EVENTS!B2:B="filter_use",EVENTS!C2:C<>"")))
+B2: =ARRAYFORMULA(IF(A2:A="","",COUNTIFS(EVENTS!B:B,"filter_use",EVENTS!C:C,A2:A)))
+
+Notes
+- Si el teu Google Sheets et dona error de sintaxi, substitueix les comes "," per punt i coma ";".
+- Formata B9:B12, C3:C5 i G2:G com a percentatge.
+- Formata B13:B14 i H2:H com a moneda EUR.
diff --git a/analytics/google-sheets-headers.txt b/analytics/google-sheets-headers.txt
new file mode 100644
index 0000000..76c776f
--- /dev/null
+++ b/analytics/google-sheets-headers.txt
@@ -0,0 +1,17 @@
+EVENTS
+timestamp,event_type,product_code,model,category,price,stock,page_url,user_agent,session_id,event_id,order_id,stripe_session_id,payment_status,quantity,shipping_method,total_amount,referrer,utm_source,utm_medium,utm_campaign,device_type,page_type
+
+ORDERS
+timestamp,order_id,stripe_session_id,product_code,model,quantity,price,shipping_method,total_amount,payment_status,customer_name,email,phone,session_id,utm_source,utm_medium,utm_campaign,device_type,page_type
+
+RESUM
+Metrica,Valor
+
+TOP_MODELS
+product_code,views,buy_clicks,checkout_submit,payment_success,conversion_rate
+
+FUNNEL
+step,value
+
+FILTERS
+filter_key,uses
diff --git a/analytics/orders-header.csv b/analytics/orders-header.csv
new file mode 100644
index 0000000..d7e34c6
--- /dev/null
+++ b/analytics/orders-header.csv
@@ -0,0 +1 @@
+timestamp,order_id,stripe_session_id,product_code,model,quantity,price,shipping_method,total_amount,payment_status,customer_name,email,phone,session_id,utm_source,utm_medium,utm_campaign,device_type,page_type
diff --git a/api/track-event.php b/api/track-event.php
new file mode 100644
index 0000000..dbfb03e
--- /dev/null
+++ b/api/track-event.php
@@ -0,0 +1,62 @@
+ false,
+ 'error' => 'event_type invalid',
+ ], 422);
+}
+
+$payload = [
+ 'timestamp' => date('c'),
+ 'event_type' => $eventType,
+ 'product_code' => trim((string)($data['product_code'] ?? '')),
+ 'model' => trim((string)($data['model'] ?? '')),
+ 'category' => trim((string)($data['category'] ?? '')),
+ 'price' => trim((string)($data['price'] ?? '')),
+ 'stock' => trim((string)($data['stock'] ?? '')),
+ 'page_url' => trim((string)($data['page_url'] ?? '')),
+ 'user_agent' => trim((string)($data['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? ''))),
+ 'session_id' => trim((string)($data['session_id'] ?? '')),
+ 'event_id' => trim((string)($data['event_id'] ?? ('evt_' . bin2hex(random_bytes(8))))),
+ 'order_id' => trim((string)($data['order_id'] ?? '')),
+ 'stripe_session_id' => trim((string)($data['stripe_session_id'] ?? '')),
+ 'payment_status' => trim((string)($data['payment_status'] ?? '')),
+ 'quantity' => trim((string)($data['quantity'] ?? '')),
+ 'shipping_method' => trim((string)($data['shipping_method'] ?? '')),
+ 'total_amount' => trim((string)($data['total_amount'] ?? '')),
+ 'referrer' => trim((string)($data['referrer'] ?? ($_SERVER['HTTP_REFERER'] ?? ''))),
+ 'utm_source' => trim((string)($data['utm_source'] ?? '')),
+ 'utm_medium' => trim((string)($data['utm_medium'] ?? '')),
+ 'utm_campaign' => trim((string)($data['utm_campaign'] ?? '')),
+ 'device_type' => trim((string)($data['device_type'] ?? '')),
+ 'page_type' => trim((string)($data['page_type'] ?? 'catalog')),
+];
+
+$ok = kapvoe_track_analytics_event($config, $payload);
+
+kapvoe_json_response([
+ 'ok' => $ok,
+ 'event_type' => $eventType,
+], $ok ? 200 : 202);
diff --git a/archive/js/app.js b/archive/js/app.js
new file mode 100644
index 0000000..dd67f3f
--- /dev/null
+++ b/archive/js/app.js
@@ -0,0 +1,309 @@
+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('<', '<')
+ .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;
+ 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 = `${escapeHtml(product.name)} 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);
\ No newline at end of file
diff --git a/assets/products/Gafas deportivas con diseño camuflaje.png b/assets/products/Gafas deportivas con diseño camuflaje.png
new file mode 100644
index 0000000..3030b9c
Binary files /dev/null and b/assets/products/Gafas deportivas con diseño camuflaje.png differ
diff --git a/assets/products/K123-original.png b/assets/products/K123-original.png
new file mode 100644
index 0000000..08dc40d
Binary files /dev/null and b/assets/products/K123-original.png differ
diff --git a/assets/products/K123.png b/assets/products/K123.png
new file mode 100644
index 0000000..7496724
Binary files /dev/null and b/assets/products/K123.png differ
diff --git a/assets/products/K159-CARBON-FIBER-XR.jpg b/assets/products/K159-CARBON-FIBER-XR.jpg
new file mode 100644
index 0000000..ddda73a
Binary files /dev/null and b/assets/products/K159-CARBON-FIBER-XR.jpg differ
diff --git a/assets/products/K456-original.jpeg b/assets/products/K456-original.jpeg
new file mode 100644
index 0000000..9290fe4
Binary files /dev/null and b/assets/products/K456-original.jpeg differ
diff --git a/assets/products/K456.png b/assets/products/K456.png
new file mode 100644
index 0000000..3c675e1
Binary files /dev/null and b/assets/products/K456.png differ
diff --git a/assets/products/K789-original.jpeg b/assets/products/K789-original.jpeg
new file mode 100644
index 0000000..dac9f8e
Binary files /dev/null and b/assets/products/K789-original.jpeg differ
diff --git a/assets/products/K789.png b/assets/products/K789.png
new file mode 100644
index 0000000..7527379
Binary files /dev/null and b/assets/products/K789.png differ
diff --git a/assets/products/TK159-G-1L-23.jpeg b/assets/products/TK159-G-1L-23.jpeg
new file mode 100644
index 0000000..998e270
Binary files /dev/null and b/assets/products/TK159-G-1L-23.jpeg differ
diff --git a/assets/products/TK159-G-1L-23.png b/assets/products/TK159-G-1L-23.png
new file mode 100644
index 0000000..7fa94d5
Binary files /dev/null and b/assets/products/TK159-G-1L-23.png differ
diff --git a/assets/products/TK204-G-1L-02.jpeg b/assets/products/TK204-G-1L-02.jpeg
new file mode 100644
index 0000000..56f1bc6
Binary files /dev/null and b/assets/products/TK204-G-1L-02.jpeg differ
diff --git a/assets/products/TK204-G-1L-05.jpeg b/assets/products/TK204-G-1L-05.jpeg
new file mode 100644
index 0000000..007e64a
Binary files /dev/null and b/assets/products/TK204-G-1L-05.jpeg differ
diff --git a/assets/products/TK383.jpeg b/assets/products/TK383.jpeg
new file mode 100644
index 0000000..4f62b14
Binary files /dev/null and b/assets/products/TK383.jpeg differ
diff --git a/assets/products/TK410-PH-BL-1L-08.jpeg b/assets/products/TK410-PH-BL-1L-08.jpeg
new file mode 100644
index 0000000..328535c
Binary files /dev/null and b/assets/products/TK410-PH-BL-1L-08.jpeg differ
diff --git a/assets/products/TK410-PH-GO-1L-05.jpg b/assets/products/TK410-PH-GO-1L-05.jpg
new file mode 100644
index 0000000..7c43dd2
Binary files /dev/null and b/assets/products/TK410-PH-GO-1L-05.jpg differ
diff --git a/assets/products/TK428-G-1L-03.jpeg b/assets/products/TK428-G-1L-03.jpeg
new file mode 100644
index 0000000..5755b8a
Binary files /dev/null and b/assets/products/TK428-G-1L-03.jpeg differ
diff --git a/assets/products/TK428-G-1L-14.jpeg b/assets/products/TK428-G-1L-14.jpeg
new file mode 100644
index 0000000..99a8995
Binary files /dev/null and b/assets/products/TK428-G-1L-14.jpeg differ
diff --git a/assets/products/TK428-G-1L-18.jpeg b/assets/products/TK428-G-1L-18.jpeg
new file mode 100644
index 0000000..e548b23
Binary files /dev/null and b/assets/products/TK428-G-1L-18.jpeg differ
diff --git a/assets/products/TK428-G-1L-23.jpeg b/assets/products/TK428-G-1L-23.jpeg
new file mode 100644
index 0000000..d1316f7
Binary files /dev/null and b/assets/products/TK428-G-1L-23.jpeg differ
diff --git a/assets/products/TK428-MPH-1L-21.jpeg b/assets/products/TK428-MPH-1L-21.jpeg
new file mode 100644
index 0000000..c0b5492
Binary files /dev/null and b/assets/products/TK428-MPH-1L-21.jpeg differ
diff --git a/assets/products/TK428-MPH-GO-1L-30.jpeg b/assets/products/TK428-MPH-GO-1L-30.jpeg
new file mode 100644
index 0000000..b0ccddf
Binary files /dev/null and b/assets/products/TK428-MPH-GO-1L-30.jpeg differ
diff --git a/assets/products/TK428-MPH-PI-1L-11.jpeg b/assets/products/TK428-MPH-PI-1L-11.jpeg
new file mode 100644
index 0000000..714826a
Binary files /dev/null and b/assets/products/TK428-MPH-PI-1L-11.jpeg differ
diff --git a/assets/products/TK428-MPH-PI-1L-23.jpeg b/assets/products/TK428-MPH-PI-1L-23.jpeg
new file mode 100644
index 0000000..c873bea
Binary files /dev/null and b/assets/products/TK428-MPH-PI-1L-23.jpeg differ
diff --git a/assets/products/TK428-MPH-RE-1L-03.jpeg b/assets/products/TK428-MPH-RE-1L-03.jpeg
new file mode 100644
index 0000000..c672679
Binary files /dev/null and b/assets/products/TK428-MPH-RE-1L-03.jpeg differ
diff --git a/assets/products/TK428-MPH-SI-1L-28.jpeg b/assets/products/TK428-MPH-SI-1L-28.jpeg
new file mode 100644
index 0000000..ffeb12b
Binary files /dev/null and b/assets/products/TK428-MPH-SI-1L-28.jpeg differ
diff --git a/assets/products/TK434-G-1L-05.jpeg b/assets/products/TK434-G-1L-05.jpeg
new file mode 100644
index 0000000..e4408da
Binary files /dev/null and b/assets/products/TK434-G-1L-05.jpeg differ
diff --git a/assets/products/TK434-G-1L-10.jpg b/assets/products/TK434-G-1L-10.jpg
new file mode 100644
index 0000000..6003182
Binary files /dev/null and b/assets/products/TK434-G-1L-10.jpg differ
diff --git a/assets/products/TK63-PH-BL-1L-09.jpeg b/assets/products/TK63-PH-BL-1L-09.jpeg
new file mode 100644
index 0000000..d0b4131
Binary files /dev/null and b/assets/products/TK63-PH-BL-1L-09.jpeg differ
diff --git a/checkout/common.php b/checkout/common.php
index 1009df0..9cdcb0f 100644
--- a/checkout/common.php
+++ b/checkout/common.php
@@ -60,8 +60,273 @@ function kapvoe_orders_csv_path(array $config): string {
return $dir . DIRECTORY_SEPARATOR . 'orders.csv';
}
+function kapvoe_orders_csv_header(): array {
+ return [
+ 'order_id',
+ 'created_at',
+ 'product_code',
+ 'product_name',
+ 'product_image_url',
+ 'unit_price',
+ 'quantity',
+ 'subtotal',
+ 'shipping_method',
+ 'shipping_cost',
+ 'total_amount',
+ 'customer_name',
+ 'address',
+ 'postal_code',
+ 'city',
+ 'province',
+ 'phone',
+ 'email',
+ 'analytics_session_id',
+ 'analytics_page_url',
+ 'analytics_referrer',
+ 'analytics_user_agent',
+ 'analytics_utm_source',
+ 'analytics_utm_medium',
+ 'analytics_utm_campaign',
+ 'analytics_device_type',
+ 'analytics_page_type',
+ 'payment_status',
+ 'stripe_session_id',
+ 'payment_intent_id',
+ 'stock_updated',
+ 'stock_updated_at',
+ 'webhook_processed_at',
+ 'customer_email_sent',
+ 'admin_email_sent',
+ 'email_notifications_sent_at',
+ ];
+}
+
+function kapvoe_upgrade_orders_csv_schema(array $config): void {
+ $path = kapvoe_orders_csv_path($config);
+ if (!file_exists($path)) {
+ return;
+ }
+
+ $fh = fopen($path, 'rb');
+ if (!$fh) {
+ return;
+ }
+
+ $rows = [];
+ while (($row = fgetcsv($fh, 0, ';')) !== false) {
+ $rows[] = $row;
+ }
+ fclose($fh);
+
+ if (!$rows) {
+ return;
+ }
+
+ $targetHeader = kapvoe_orders_csv_header();
+ $currentHeader = $rows[0];
+
+ if ($currentHeader === $targetHeader) {
+ return;
+ }
+
+ $legacyHeader = [
+ 'order_id',
+ 'created_at',
+ 'product_code',
+ 'product_name',
+ 'unit_price',
+ 'quantity',
+ 'customer_name',
+ 'address',
+ 'postal_code',
+ 'city',
+ 'province',
+ 'phone',
+ 'email',
+ 'payment_status',
+ 'stripe_session_id',
+ 'payment_intent_id',
+ 'stock_updated',
+ 'stock_updated_at',
+ 'webhook_processed_at',
+ ];
+ $headerWithoutImage = [
+ 'order_id',
+ 'created_at',
+ 'product_code',
+ 'product_name',
+ 'unit_price',
+ 'quantity',
+ 'subtotal',
+ 'shipping_method',
+ 'shipping_cost',
+ 'total_amount',
+ 'customer_name',
+ 'address',
+ 'postal_code',
+ 'city',
+ 'province',
+ 'phone',
+ 'email',
+ 'payment_status',
+ 'stripe_session_id',
+ 'payment_intent_id',
+ 'stock_updated',
+ 'stock_updated_at',
+ 'webhook_processed_at',
+ ];
+
+ $newRows = [$targetHeader];
+
+ for ($i = 1; $i < count($rows); $i++) {
+ $row = $rows[$i];
+
+ if ($currentHeader === $legacyHeader) {
+ if (count($row) === count($legacyHeader)) {
+ $normalized = [
+ $row[0] ?? '',
+ $row[1] ?? '',
+ $row[2] ?? '',
+ $row[3] ?? '',
+ '',
+ $row[4] ?? '',
+ $row[5] ?? '',
+ '',
+ '',
+ '',
+ '',
+ $row[6] ?? '',
+ $row[7] ?? '',
+ $row[8] ?? '',
+ $row[9] ?? '',
+ $row[10] ?? '',
+ $row[11] ?? '',
+ $row[12] ?? '',
+ $row[13] ?? '',
+ $row[14] ?? '',
+ $row[15] ?? '',
+ $row[16] ?? '',
+ $row[17] ?? '',
+ $row[18] ?? '',
+ ];
+ } elseif (count($row) === count($legacyHeader) + 1) {
+ $normalized = [
+ $row[0] ?? '',
+ $row[1] ?? '',
+ $row[2] ?? '',
+ $row[3] ?? '',
+ $row[4] ?? '',
+ $row[5] ?? '',
+ $row[6] ?? '',
+ '',
+ '',
+ '',
+ '',
+ $row[7] ?? '',
+ $row[8] ?? '',
+ $row[9] ?? '',
+ $row[10] ?? '',
+ $row[11] ?? '',
+ $row[12] ?? '',
+ $row[13] ?? '',
+ $row[14] ?? '',
+ $row[15] ?? '',
+ $row[16] ?? '',
+ $row[17] ?? '',
+ $row[18] ?? '',
+ $row[19] ?? '',
+ ];
+ } else {
+ $normalized = array_pad(array_slice($row, 0, count($targetHeader)), count($targetHeader), '');
+ }
+ } elseif ($currentHeader === $headerWithoutImage) {
+ if (count($row) === count($headerWithoutImage)) {
+ $normalized = [
+ $row[0] ?? '',
+ $row[1] ?? '',
+ $row[2] ?? '',
+ $row[3] ?? '',
+ '',
+ $row[4] ?? '',
+ $row[5] ?? '',
+ $row[6] ?? '',
+ $row[7] ?? '',
+ $row[8] ?? '',
+ $row[9] ?? '',
+ $row[10] ?? '',
+ $row[11] ?? '',
+ $row[12] ?? '',
+ $row[13] ?? '',
+ $row[14] ?? '',
+ $row[15] ?? '',
+ $row[16] ?? '',
+ $row[17] ?? '',
+ $row[18] ?? '',
+ $row[19] ?? '',
+ $row[20] ?? '',
+ $row[21] ?? '',
+ $row[22] ?? '',
+ ];
+ } elseif (count($row) === count($headerWithoutImage) + 1) {
+ $normalized = [
+ $row[0] ?? '',
+ $row[1] ?? '',
+ $row[2] ?? '',
+ $row[3] ?? '',
+ $row[4] ?? '',
+ $row[5] ?? '',
+ $row[6] ?? '',
+ $row[7] ?? '',
+ $row[8] ?? '',
+ $row[9] ?? '',
+ $row[10] ?? '',
+ $row[11] ?? '',
+ $row[12] ?? '',
+ $row[13] ?? '',
+ $row[14] ?? '',
+ $row[15] ?? '',
+ $row[16] ?? '',
+ $row[17] ?? '',
+ $row[18] ?? '',
+ $row[19] ?? '',
+ $row[20] ?? '',
+ $row[21] ?? '',
+ $row[22] ?? '',
+ $row[23] ?? '',
+ ];
+ } else {
+ $normalized = array_pad(array_slice($row, 0, count($targetHeader)), count($targetHeader), '');
+ }
+ } else {
+ $assoc = [];
+ $limit = min(count($currentHeader), count($row));
+ for ($j = 0; $j < $limit; $j++) {
+ $assoc[$currentHeader[$j]] = $row[$j];
+ }
+
+ $normalized = [];
+ foreach ($targetHeader as $column) {
+ $normalized[] = $assoc[$column] ?? '';
+ }
+ }
+
+ $newRows[] = $normalized;
+ }
+
+ $fh = fopen($path, 'wb');
+ if (!$fh) {
+ return;
+ }
+
+ foreach ($newRows as $row) {
+ fputcsv($fh, $row, ';');
+ }
+ fclose($fh);
+}
+
function kapvoe_append_order(array $config, array $order): void {
$path = kapvoe_orders_csv_path($config);
+ kapvoe_upgrade_orders_csv_schema($config);
$exists = file_exists($path);
$fh = fopen($path, 'ab');
if (!$fh) {
@@ -69,14 +334,20 @@ function kapvoe_append_order(array $config, array $order): void {
}
if (!$exists) {
- fputcsv($fh, array_keys($order), ';');
+ fputcsv($fh, kapvoe_orders_csv_header(), ';');
}
- fputcsv($fh, array_values($order), ';');
+
+ $row = [];
+ foreach (kapvoe_orders_csv_header() as $column) {
+ $row[] = $order[$column] ?? '';
+ }
+ fputcsv($fh, $row, ';');
fclose($fh);
}
function kapvoe_update_order_status(array $config, string $sessionId, string $newStatus, string $paymentIntent = ''): void {
$path = kapvoe_orders_csv_path($config);
+ kapvoe_upgrade_orders_csv_schema($config);
if (!file_exists($path)) {
return;
}
@@ -133,6 +404,7 @@ function kapvoe_update_order_status(array $config, string $sessionId, string $ne
function kapvoe_get_order_by_session_id(array $config, string $sessionId): ?array {
$path = kapvoe_orders_csv_path($config);
+ kapvoe_upgrade_orders_csv_schema($config);
if (!file_exists($path)) {
return null;
}
@@ -165,6 +437,7 @@ function kapvoe_get_order_by_session_id(array $config, string $sessionId): ?arra
function kapvoe_mark_order_stock_updated(array $config, string $sessionId): void {
$path = kapvoe_orders_csv_path($config);
+ kapvoe_upgrade_orders_csv_schema($config);
if (!file_exists($path)) {
return;
}
@@ -245,54 +518,1058 @@ function kapvoe_mark_order_stock_updated(array $config, string $sessionId): void
fclose($fh);
}
-function kapvoe_create_checkout_session(array $config, array $payload): array {
- $secretKey = (string)$config['stripe_secret_key'];
+function kapvoe_format_money($value): string
+{
+ return number_format((float)$value, 2, ',', '.') . ' EUR';
+}
- $fields = [
- 'mode' => 'payment',
- 'success_url' => (string)$config['success_url'] . '?session_id={CHECKOUT_SESSION_ID}',
- 'cancel_url' => (string)$config['cancel_url'],
- 'customer_email' => (string)$payload['email'],
- 'client_reference_id' => (string)$payload['order_id'],
- 'locale' => 'es',
- 'payment_method_types[0]' => 'card',
- 'line_items[0][price_data][currency]' => (string)$config['currency'],
- 'line_items[0][price_data][product_data][name]' => (string)$payload['product_name'],
- 'line_items[0][price_data][unit_amount]' => (string)$payload['unit_amount_cents'],
- 'line_items[0][quantity]' => (string)$payload['quantity'],
- 'metadata[order_id]' => (string)$payload['order_id'],
- 'metadata[product_code]' => (string)$payload['product_code'],
- 'metadata[customer_name]' => (string)$payload['customer_name'],
- 'metadata[phone]' => (string)$payload['phone'],
- 'metadata[postal_code]' => (string)$payload['postal_code'],
- 'metadata[city]' => (string)$payload['city'],
- 'metadata[province]' => (string)$payload['province'],
+function kapvoe_mail_html_escape(?string $value): string
+{
+ return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
+}
+
+function kapvoe_mail_base_url(array $config): string
+{
+ $successUrl = (string)($config['success_url'] ?? '');
+ if ($successUrl === '') {
+ return '';
+ }
+
+ $parts = parse_url($successUrl);
+ if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
+ return '';
+ }
+
+ $base = $parts['scheme'] . '://' . $parts['host'];
+ if (!empty($parts['port'])) {
+ $base .= ':' . $parts['port'];
+ }
+
+ return $base;
+}
+
+function kapvoe_build_order_email_html(array $config, array $order, string $sessionId, bool $forAdmin): string
+{
+ $brand = (string)($config['mail_from_name'] ?? 'Blood Bros Sports');
+ $productCode = kapvoe_mail_html_escape((string)($order['product_code'] ?? ''));
+ $customerName = kapvoe_mail_html_escape((string)($order['customer_name'] ?? ''));
+ $customerEmail = kapvoe_mail_html_escape((string)($order['email'] ?? ''));
+ $phone = kapvoe_mail_html_escape((string)($order['phone'] ?? ''));
+ $subtotal = kapvoe_format_money($order['subtotal'] ?? $order['unit_price'] ?? '0');
+ $shippingCost = kapvoe_format_money($order['shipping_cost'] ?? '0');
+ $totalAmount = kapvoe_format_money($order['total_amount'] ?? $order['unit_price'] ?? '0');
+ $shippingMethod = (string)($order['shipping_method'] ?? 'pickup');
+ $shippingLabel = $shippingMethod === 'shipping' ? 'Enviament' : 'Entrega en persona';
+ $sessionIdSafe = kapvoe_mail_html_escape($sessionId);
+ $imageUrl = trim((string)($order['product_image_url'] ?? ''));
+ $successLink = (string)($config['success_url'] ?? '');
+ if ($successLink !== '' && $sessionId !== '') {
+ $successLink .= '?session_id=' . rawurlencode($sessionId);
+ }
+
+ $deliveryBlock = $shippingMethod === 'shipping'
+ ? ''
+ . '
Adreca d\'enviament
'
+ . '
'
+ . kapvoe_mail_html_escape((string)($order['address'] ?? '')) . ' '
+ . kapvoe_mail_html_escape((string)($order['postal_code'] ?? '')) . ' '
+ . kapvoe_mail_html_escape((string)($order['city'] ?? '')) . ' '
+ . kapvoe_mail_html_escape((string)($order['province'] ?? ''))
+ . '
'
+ : ''
+ . '
Entrega
'
+ . '
Quedarem amb tu per WhatsApp per entregar-te les ulleres en persona a Lleida o Mollerussa.
'
+ . '
';
+
+ $intro = $forAdmin
+ ? 'S\'ha confirmat una nova compra al web. Tens el resum complet de la comanda a continuacio.'
+ : 'Hem rebut la teva compra. Aqui tens un resum de la comanda confirmada.';
+ $badge = $forAdmin ? 'Nova comanda cobrada' : 'Compra confirmada';
+ $cta = $successLink !== ''
+ ? 'Veure resum '
+ : '';
+
+ return '
+
+
+
+
+ ' . kapvoe_mail_html_escape($brand) . '
+
+
+
+
+
+
+
+
+
+
Pagament correcte
+
' . kapvoe_mail_html_escape($intro) . '
+
+
+
+
' . kapvoe_mail_html_escape($badge) . '
+
+
+
+
'
+ . ($imageUrl !== ''
+ ? '
'
+ : '
Sense imatge
')
+ . '
+
+
' . $productCode . '
+
+ Sessio Stripe: ' . $sessionIdSafe . '
+ Metode: ' . kapvoe_mail_html_escape($shippingLabel) . '
+
+
+
+
+
+
+
Resum economic
+
Subtotal producte ' . kapvoe_mail_html_escape($subtotal) . '
+
Enviament ' . kapvoe_mail_html_escape($shippingCost) . '
+
Total ' . kapvoe_mail_html_escape($totalAmount) . '
+
+
+
+
Dades de contacte
+
+
+
Correu electronic
' . $customerEmail . '
+ ' . $deliveryBlock . '
+
+
+
+ ' . ($cta !== '' ? '
' . $cta . '
' : '') . '
+
+
+
+
+';
+}
+
+function kapvoe_mail_log_path(array $config): string
+{
+ $dir = rtrim((string)($config['orders_storage_dir'] ?? (__DIR__ . '/data')), DIRECTORY_SEPARATOR);
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0775, true);
+ }
+
+ return $dir . DIRECTORY_SEPARATOR . 'mail.log';
+}
+
+function kapvoe_log_mail_event(array $config, string $message): void
+{
+ $line = '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL;
+ $path = kapvoe_mail_log_path($config);
+ $written = @file_put_contents($path, $line, FILE_APPEND);
+
+ if ($written === false) {
+ $fallbackPath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'kapvoe-mail.log';
+ @file_put_contents($fallbackPath, $line, FILE_APPEND);
+ error_log('[KAPVOE MAIL] ' . trim($message) . ' | primary_log=' . $path . ' | fallback_log=' . $fallbackPath);
+ return;
+ }
+
+ error_log('[KAPVOE MAIL] ' . trim($message) . ' | log=' . $path);
+}
+
+function kapvoe_analytics_log_path(array $config): string
+{
+ $dir = rtrim((string)($config['orders_storage_dir'] ?? (__DIR__ . '/data')), DIRECTORY_SEPARATOR);
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0775, true);
+ }
+
+ return $dir . DIRECTORY_SEPARATOR . 'analytics.log';
+}
+
+function kapvoe_log_analytics_event(array $config, string $message): void
+{
+ $line = '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL;
+ $path = kapvoe_analytics_log_path($config);
+ @file_put_contents($path, $line, FILE_APPEND);
+ error_log('[KAPVOE ANALYTICS] ' . trim($message) . ' | log=' . $path);
+}
+
+function kapvoe_internal_notification_log_path(array $config): string
+{
+ $configuredPath = trim((string)($config['internal_notification_log_path'] ?? ''));
+ if ($configuredPath !== '') {
+ return $configuredPath;
+ }
+
+ $dir = rtrim((string)($config['orders_storage_dir'] ?? (__DIR__ . '/data')), DIRECTORY_SEPARATOR);
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0775, true);
+ }
+
+ return $dir . DIRECTORY_SEPARATOR . 'internal-notifications.log';
+}
+
+function kapvoe_log_internal_notification(array $config, array $payload): bool
+{
+ $path = kapvoe_internal_notification_log_path($config);
+ $line = '[' . date('Y-m-d H:i:s') . '] ' . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
+ $written = @file_put_contents($path, $line, FILE_APPEND);
+
+ if ($written === false) {
+ error_log('[KAPVOE INTERNAL] No s\'ha pogut escriure el log intern: ' . $path);
+ return false;
+ }
+
+ error_log('[KAPVOE INTERNAL] log=' . $path);
+ return true;
+}
+
+function kapvoe_build_internal_notification_payload(array $order, string $sessionId): array
+{
+ return [
+ 'event' => 'order_paid',
+ 'session_id' => $sessionId,
+ 'order_id' => (string)($order['order_id'] ?? ''),
+ 'product_code' => (string)($order['product_code'] ?? ''),
+ 'product_name' => (string)($order['product_name'] ?? ''),
+ 'customer_name' => (string)($order['customer_name'] ?? ''),
+ 'customer_email' => (string)($order['email'] ?? ''),
+ 'phone' => (string)($order['phone'] ?? ''),
+ 'shipping_method' => (string)($order['shipping_method'] ?? ''),
+ 'total_amount' => (string)($order['total_amount'] ?? ''),
+ 'created_at' => (string)($order['created_at'] ?? ''),
+ 'sent_at' => date('c'),
+ ];
+}
+
+function kapvoe_send_internal_notification(array $config, array $order, string $sessionId): array
+{
+ $enabled = (bool)($config['internal_notification_enabled'] ?? true);
+ if (!$enabled) {
+ return [
+ 'enabled' => false,
+ 'sent' => false,
+ 'channel' => 'disabled',
+ ];
+ }
+
+ $payload = kapvoe_build_internal_notification_payload($order, $sessionId);
+ $webhookUrl = trim((string)($config['internal_notification_webhook_url'] ?? ''));
+ $token = trim((string)($config['internal_notification_webhook_token'] ?? ''));
+ $timeout = max(5, (int)($config['internal_notification_timeout'] ?? 10));
+
+ if ($webhookUrl === '') {
+ $logged = kapvoe_log_internal_notification($config, $payload);
+
+ return [
+ 'enabled' => true,
+ 'sent' => $logged,
+ 'channel' => 'log',
+ ];
+ }
+
+ try {
+ $headers = [];
+ if ($token !== '') {
+ $headers[] = 'Authorization: Bearer ' . $token;
+ }
+
+ $result = kapvoe_post_json($webhookUrl, $headers, $payload, $timeout);
+ $statusCode = (int)($result['status_code'] ?? 0);
+
+ if ($statusCode < 200 || $statusCode >= 300) {
+ throw new RuntimeException('Webhook intern ha respost amb HTTP ' . $statusCode);
+ }
+
+ kapvoe_log_mail_event($config, 'INTERNAL webhook OK session=' . $sessionId . ' url=' . $webhookUrl);
+
+ return [
+ 'enabled' => true,
+ 'sent' => true,
+ 'channel' => 'webhook',
+ ];
+ } catch (Throwable $e) {
+ kapvoe_log_mail_event($config, 'INTERNAL webhook FAIL session=' . $sessionId . ' error=' . $e->getMessage());
+ $logged = kapvoe_log_internal_notification($config, $payload + ['webhook_error' => $e->getMessage()]);
+
+ return [
+ 'enabled' => true,
+ 'sent' => $logged,
+ 'channel' => $logged ? 'log-fallback' : 'failed',
+ 'error' => $e->getMessage(),
+ ];
+ }
+}
+
+function kapvoe_encode_email_header(string $value): string
+{
+ if ($value === '' || !preg_match('/[^\x20-\x7E]/', $value)) {
+ return $value;
+ }
+
+ return '=?UTF-8?B?' . base64_encode($value) . '?=';
+}
+
+function kapvoe_normalize_email_text(string $htmlBody): string
+{
+ $text = str_replace([' ', ' ', ' '], "\n", $htmlBody);
+ $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $text = preg_replace("/\r\n|\r/u", "\n", $text) ?? $text;
+ $text = preg_replace("/\n{3,}/u", "\n\n", $text) ?? $text;
+
+ return trim($text);
+}
+
+function kapvoe_build_email_message(
+ string $fromEmail,
+ string $fromName,
+ string $replyTo,
+ string $toEmail,
+ string $subject,
+ string $htmlBody
+): array {
+ $plainText = kapvoe_normalize_email_text($htmlBody);
+ $boundary = 'kapvoe_' . bin2hex(random_bytes(12));
+ $encodedSubject = kapvoe_encode_email_header($subject);
+ $encodedFromName = kapvoe_encode_email_header($fromName);
+
+ $headers = [
+ 'MIME-Version: 1.0',
+ 'From: ' . $encodedFromName . ' <' . $fromEmail . '>',
+ 'Reply-To: ' . $replyTo,
+ 'To: <' . $toEmail . '>',
+ 'Subject: ' . $encodedSubject,
+ 'Date: ' . date(DATE_RFC2822),
+ 'Message-ID: <' . bin2hex(random_bytes(16)) . '@' . ($_SERVER['SERVER_NAME'] ?? 'localhost') . '>',
+ 'Content-Type: multipart/alternative; boundary="' . $boundary . '"',
];
- $ch = curl_init('https://api.stripe.com/v1/checkout/sessions');
+ $message =
+ "--{$boundary}\r\n" .
+ "Content-Type: text/plain; charset=UTF-8\r\n" .
+ "Content-Transfer-Encoding: 8bit\r\n\r\n" .
+ $plainText . "\r\n\r\n" .
+ "--{$boundary}\r\n" .
+ "Content-Type: text/html; charset=UTF-8\r\n" .
+ "Content-Transfer-Encoding: 8bit\r\n\r\n" .
+ $htmlBody . "\r\n\r\n" .
+ "--{$boundary}--\r\n";
+
+ return [
+ 'headers' => $headers,
+ 'message' => $message,
+ 'subject' => $encodedSubject,
+ ];
+}
+
+function kapvoe_post_json(string $url, array $headers, array $payload, int $timeout = 15): array
+{
+ if (!function_exists('curl_init')) {
+ throw new RuntimeException('cURL no esta disponible al PHP');
+ }
+
+ $ch = curl_init($url);
+ if ($ch === false) {
+ throw new RuntimeException('No s\'ha pogut inicialitzar cURL');
+ }
+
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => http_build_query($fields),
- CURLOPT_HTTPHEADER => [
- 'Authorization: Bearer ' . $secretKey
- ],
+ CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ CURLOPT_HTTPHEADER => array_merge([
+ 'Content-Type: application/json',
+ 'Accept: application/json',
+ ], $headers),
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_TIMEOUT => $timeout,
+ CURLOPT_CONNECTTIMEOUT => min(10, $timeout),
]);
- $raw = curl_exec($ch);
- if ($raw === false) {
- throw new RuntimeException('Error cURL Stripe: ' . curl_error($ch));
+ $response = curl_exec($ch);
+ if ($response === false) {
+ $error = curl_error($ch);
+ curl_close($ch);
+ throw new RuntimeException('Error cURL: ' . $error);
}
- $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
- $decoded = json_decode($raw, true);
- if ($httpCode >= 400 || !is_array($decoded)) {
- throw new RuntimeException('Stripe ha retornat un error: ' . $raw);
+ $decoded = json_decode($response, true);
+
+ return [
+ 'status_code' => $statusCode,
+ 'body' => $response,
+ 'json' => is_array($decoded) ? $decoded : null,
+ ];
+}
+
+function kapvoe_analytics_enabled(array $config): bool
+{
+ return (bool)($config['analytics_enabled'] ?? false)
+ && trim((string)($config['analytics_sync_url'] ?? '')) !== '';
+}
+
+function kapvoe_send_analytics_payload(array $config, array $payload): bool
+{
+ if (!kapvoe_analytics_enabled($config)) {
+ return false;
}
- return $decoded;
+ $url = trim((string)($config['analytics_sync_url'] ?? ''));
+ $token = trim((string)($config['analytics_sync_token'] ?? ''));
+ $timeout = max(5, (int)($config['analytics_timeout'] ?? 10));
+ $headers = [];
+ $requestPayload = $payload;
+
+ if ($token !== '') {
+ $headers[] = 'Authorization: Bearer ' . $token;
+ $requestPayload['token'] = $token;
+ }
+
+ $result = kapvoe_post_json($url, $headers, $requestPayload, $timeout);
+ $statusCode = (int)($result['status_code'] ?? 0);
+ if ($statusCode < 200 || $statusCode >= 300) {
+ throw new RuntimeException('Analytics HTTP ' . $statusCode);
+ }
+
+ return true;
+}
+
+function kapvoe_track_analytics_event(array $config, array $payload): bool
+{
+ if (!kapvoe_analytics_enabled($config)) {
+ return false;
+ }
+
+ try {
+ $sent = kapvoe_send_analytics_payload($config, $payload);
+ kapvoe_log_analytics_event(
+ $config,
+ 'event=' . (string)($payload['event_type'] ?? 'unknown')
+ . ' session=' . (string)($payload['session_id'] ?? '')
+ . ' order=' . (string)($payload['order_id'] ?? '')
+ . ' sent=' . ($sent ? '1' : '0')
+ );
+ return $sent;
+ } catch (Throwable $e) {
+ kapvoe_log_analytics_event(
+ $config,
+ 'ERROR event=' . (string)($payload['event_type'] ?? 'unknown')
+ . ' session=' . (string)($payload['session_id'] ?? '')
+ . ' order=' . (string)($payload['order_id'] ?? '')
+ . ' ' . $e->getMessage()
+ );
+ return false;
+ }
+}
+
+function kapvoe_build_payment_success_event(array $order, string $sessionId): array
+{
+ return [
+ 'timestamp' => date('c'),
+ 'event_type' => 'payment_success',
+ 'product_code' => (string)($order['product_code'] ?? ''),
+ 'model' => (string)($order['product_name'] ?? ''),
+ 'category' => (string)($order['analytics_page_type'] ?? ''),
+ 'price' => (string)($order['unit_price'] ?? ''),
+ 'stock' => '',
+ 'page_url' => (string)($order['analytics_page_url'] ?? ''),
+ 'user_agent' => (string)($order['analytics_user_agent'] ?? ''),
+ 'session_id' => (string)($order['analytics_session_id'] ?? ''),
+ 'event_id' => 'evt_' . bin2hex(random_bytes(8)),
+ 'order_id' => (string)($order['order_id'] ?? ''),
+ 'stripe_session_id' => $sessionId,
+ 'payment_status' => (string)($order['payment_status'] ?? 'paid'),
+ 'quantity' => (string)($order['quantity'] ?? '1'),
+ 'shipping_method' => (string)($order['shipping_method'] ?? ''),
+ 'total_amount' => (string)($order['total_amount'] ?? ''),
+ 'referrer' => (string)($order['analytics_referrer'] ?? ''),
+ 'utm_source' => (string)($order['analytics_utm_source'] ?? ''),
+ 'utm_medium' => (string)($order['analytics_utm_medium'] ?? ''),
+ 'utm_campaign' => (string)($order['analytics_utm_campaign'] ?? ''),
+ 'device_type' => (string)($order['analytics_device_type'] ?? ''),
+ 'page_type' => (string)($order['analytics_page_type'] ?? ''),
+ ];
+}
+
+function kapvoe_send_via_resend(
+ array $config,
+ string $fromEmail,
+ string $fromName,
+ string $replyTo,
+ string $toEmail,
+ string $subject,
+ string $htmlBody
+): bool {
+ $apiKey = trim((string)($config['resend_api_key'] ?? ''));
+ $apiUrl = trim((string)($config['resend_api_url'] ?? 'https://api.resend.com/emails'));
+ $timeout = max(5, (int)($config['smtp_timeout'] ?? 15));
+
+ if ($apiKey === '') {
+ throw new RuntimeException('Falta configurar resend_api_key');
+ }
+
+ $payload = [
+ 'from' => trim($fromName) !== '' ? ($fromName . ' <' . $fromEmail . '>') : $fromEmail,
+ 'to' => [$toEmail],
+ 'subject' => $subject,
+ 'html' => $htmlBody,
+ 'text' => kapvoe_normalize_email_text($htmlBody),
+ 'reply_to' => [$replyTo],
+ ];
+
+ $result = kapvoe_post_json($apiUrl, [
+ 'Authorization: Bearer ' . $apiKey,
+ ], $payload, $timeout);
+
+ if (($result['status_code'] ?? 0) < 200 || ($result['status_code'] ?? 0) >= 300) {
+ $json = $result['json'] ?? [];
+ $message = '';
+
+ if (is_array($json)) {
+ $message = (string)($json['message'] ?? $json['error'] ?? '');
+ }
+ if ($message === '') {
+ $message = (string)($result['body'] ?? 'Resposta invalida de Resend');
+ }
+
+ throw new RuntimeException('Resend API error: ' . $message);
+ }
+
+ return true;
+}
+
+function kapvoe_smtp_expect($socket, array $expectedCodes, string $context): string
+{
+ $response = '';
+
+ while (!feof($socket)) {
+ $line = fgets($socket, 515);
+ if ($line === false) {
+ break;
+ }
+
+ $response .= $line;
+
+ if (preg_match('/^\d{3} /', $line) === 1) {
+ break;
+ }
+ }
+
+ if ($response === '') {
+ throw new RuntimeException('SMTP sense resposta en ' . $context);
+ }
+
+ $code = (int)substr($response, 0, 3);
+ if (!in_array($code, $expectedCodes, true)) {
+ throw new RuntimeException('SMTP error en ' . $context . ': ' . trim($response));
+ }
+
+ return $response;
+}
+
+function kapvoe_smtp_write($socket, string $command): void
+{
+ $written = fwrite($socket, $command);
+ if ($written === false || $written < strlen($command)) {
+ throw new RuntimeException('No s\'ha pogut escriure al servidor SMTP');
+ }
+}
+
+function kapvoe_send_via_smtp_curl(
+ array $config,
+ string $fromEmail,
+ string $fromName,
+ string $replyTo,
+ string $toEmail,
+ string $subject,
+ string $htmlBody
+): bool {
+ if (!function_exists('curl_init')) {
+ throw new RuntimeException('cURL no esta disponible al PHP');
+ }
+
+ $host = trim((string)($config['smtp_host'] ?? ''));
+ if ($host === '') {
+ return false;
+ }
+
+ $port = (int)($config['smtp_port'] ?? 587);
+ $username = trim((string)($config['smtp_username'] ?? ''));
+ $password = (string)($config['smtp_password'] ?? '');
+ $encryption = strtolower(trim((string)($config['smtp_encryption'] ?? 'tls')));
+ $timeout = max(5, (int)($config['smtp_timeout'] ?? 15));
+ $allowInvalidCert = (bool)($config['smtp_allow_invalid_certificates'] ?? false);
+ $built = kapvoe_build_email_message($fromEmail, $fromName, $replyTo, $toEmail, $subject, $htmlBody);
+ $payload = implode("\r\n", $built['headers']) . "\r\n\r\n" . $built['message'];
+ $urlScheme = $encryption === 'ssl' ? 'smtps' : 'smtp';
+ $url = $urlScheme . '://' . $host . ':' . $port;
+ $payloadLength = strlen($payload);
+ $offset = 0;
+
+ $ch = curl_init($url);
+ if ($ch === false) {
+ throw new RuntimeException('No s\'ha pogut inicialitzar cURL SMTP');
+ }
+
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_UPLOAD, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(10, $timeout));
+ curl_setopt($ch, CURLOPT_MAIL_FROM, '<' . $fromEmail . '>');
+ curl_setopt($ch, CURLOPT_MAIL_RCPT, ['<' . $toEmail . '>']);
+ curl_setopt($ch, CURLOPT_INFILESIZE, $payloadLength);
+ curl_setopt($ch, CURLOPT_READFUNCTION, static function ($curl, $fd, $length) use (&$payload, &$offset, $payloadLength) {
+ if ($offset >= $payloadLength) {
+ return '';
+ }
+
+ $chunk = substr($payload, $offset, $length);
+ $offset += strlen($chunk);
+ return $chunk;
+ });
+
+ if ($username !== '') {
+ curl_setopt($ch, CURLOPT_USERNAME, $username);
+ curl_setopt($ch, CURLOPT_PASSWORD, $password);
+ }
+
+ if ($encryption === 'tls') {
+ curl_setopt($ch, CURLOPT_USE_SSL, CURLUSESSL_ALL);
+ } elseif ($encryption === '' || $encryption === 'none') {
+ curl_setopt($ch, CURLOPT_USE_SSL, CURLUSESSL_NONE);
+ } else {
+ curl_setopt($ch, CURLOPT_USE_SSL, CURLUSESSL_ALL);
+ }
+
+ if ($allowInvalidCert) {
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
+ }
+
+ $response = curl_exec($ch);
+ if ($response === false) {
+ $error = curl_error($ch);
+ curl_close($ch);
+ throw new RuntimeException('SMTP cURL error: ' . $error);
+ }
+
+ $statusCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+
+ if ($statusCode >= 400) {
+ throw new RuntimeException('SMTP cURL ha respost amb codi ' . $statusCode);
+ }
+
+ return true;
+}
+
+function kapvoe_send_via_smtp(
+ array $config,
+ string $fromEmail,
+ string $fromName,
+ string $replyTo,
+ string $toEmail,
+ string $subject,
+ string $htmlBody
+): bool {
+ try {
+ return kapvoe_send_via_smtp_curl($config, $fromEmail, $fromName, $replyTo, $toEmail, $subject, $htmlBody);
+ } catch (Throwable $curlError) {
+ kapvoe_log_mail_event($config, 'SMTP cURL FAIL -> ' . $toEmail . ' [' . $subject . '] ' . $curlError->getMessage());
+ }
+
+ $host = trim((string)($config['smtp_host'] ?? ''));
+ if ($host === '') {
+ return false;
+ }
+
+ $port = (int)($config['smtp_port'] ?? 587);
+ $username = trim((string)($config['smtp_username'] ?? ''));
+ $password = (string)($config['smtp_password'] ?? '');
+ $encryption = strtolower(trim((string)($config['smtp_encryption'] ?? 'tls')));
+ $timeout = max(5, (int)($config['smtp_timeout'] ?? 15));
+ $allowInvalidCert = (bool)($config['smtp_allow_invalid_certificates'] ?? false);
+ $secureTransport = $encryption === 'ssl' ? 'ssl://' : '';
+ $remote = $secureTransport . $host . ':' . $port;
+ $errno = 0;
+ $errstr = '';
+ $contextOptions = [];
+
+ if ($allowInvalidCert) {
+ $contextOptions['ssl'] = [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'allow_self_signed' => true,
+ 'SNI_enabled' => true,
+ 'peer_name' => $host,
+ ];
+ }
+
+ $context = !empty($contextOptions) ? stream_context_create($contextOptions) : null;
+
+ $socket = @stream_socket_client(
+ $remote,
+ $errno,
+ $errstr,
+ $timeout,
+ STREAM_CLIENT_CONNECT,
+ $context
+ );
+ if (!$socket) {
+ throw new RuntimeException('No s\'ha pogut connectar a SMTP ' . $host . ':' . $port . ' (' . $errstr . ')');
+ }
+
+ stream_set_timeout($socket, $timeout);
+
+ try {
+ kapvoe_smtp_expect($socket, [220], 'connexio');
+
+ $clientHost = $_SERVER['SERVER_NAME'] ?? 'localhost';
+ kapvoe_smtp_write($socket, 'EHLO ' . $clientHost . "\r\n");
+ kapvoe_smtp_expect($socket, [250], 'EHLO');
+
+ if ($encryption === 'tls') {
+ kapvoe_smtp_write($socket, "STARTTLS\r\n");
+ kapvoe_smtp_expect($socket, [220], 'STARTTLS');
+
+ $cryptoMethod = defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')
+ ? STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
+ : STREAM_CRYPTO_METHOD_TLS_CLIENT;
+ $cryptoEnabled = @stream_socket_enable_crypto($socket, true, $cryptoMethod);
+ if ($cryptoEnabled !== true) {
+ throw new RuntimeException('No s\'ha pogut establir el canal TLS amb SMTP');
+ }
+
+ kapvoe_smtp_write($socket, 'EHLO ' . $clientHost . "\r\n");
+ kapvoe_smtp_expect($socket, [250], 'EHLO post-TLS');
+ }
+
+ if ($username !== '') {
+ kapvoe_smtp_write($socket, "AUTH LOGIN\r\n");
+ kapvoe_smtp_expect($socket, [334], 'AUTH LOGIN');
+ kapvoe_smtp_write($socket, base64_encode($username) . "\r\n");
+ kapvoe_smtp_expect($socket, [334], 'SMTP username');
+ kapvoe_smtp_write($socket, base64_encode($password) . "\r\n");
+ kapvoe_smtp_expect($socket, [235], 'SMTP password');
+ }
+
+ $built = kapvoe_build_email_message($fromEmail, $fromName, $replyTo, $toEmail, $subject, $htmlBody);
+ $smtpPayload = implode("\r\n", $built['headers']) . "\r\n\r\n" . $built['message'];
+ $smtpPayload = preg_replace("/(?m)^\./", '..', $smtpPayload) ?? $smtpPayload;
+
+ kapvoe_smtp_write($socket, 'MAIL FROM:<' . $fromEmail . ">\r\n");
+ kapvoe_smtp_expect($socket, [250], 'MAIL FROM');
+ kapvoe_smtp_write($socket, 'RCPT TO:<' . $toEmail . ">\r\n");
+ kapvoe_smtp_expect($socket, [250, 251], 'RCPT TO');
+ kapvoe_smtp_write($socket, "DATA\r\n");
+ kapvoe_smtp_expect($socket, [354], 'DATA');
+ kapvoe_smtp_write($socket, $smtpPayload . "\r\n.\r\n");
+ kapvoe_smtp_expect($socket, [250], 'envio DATA');
+ kapvoe_smtp_write($socket, "QUIT\r\n");
+
+ return true;
+ } finally {
+ fclose($socket);
+ }
+}
+
+function kapvoe_send_html_email(array $config, string $toEmail, string $subject, string $htmlBody): bool
+{
+ $toEmail = trim($toEmail);
+ if ($toEmail === '') {
+ return false;
+ }
+
+ $fromEmail = trim((string)($config['mail_from_email'] ?? ''));
+ $fromName = trim((string)($config['mail_from_name'] ?? 'Blood Bros Sports'));
+ $replyTo = trim((string)($config['mail_reply_to'] ?? $fromEmail));
+
+ if ($fromEmail === '') {
+ throw new RuntimeException('Falta configurar mail_from_email');
+ }
+
+ $mailTransport = strtolower(trim((string)($config['mail_transport'] ?? 'auto')));
+ $smtpConfigured = (bool)($config['smtp_enabled'] ?? false) || trim((string)($config['smtp_host'] ?? '')) !== '';
+ $resendConfigured = trim((string)($config['resend_api_key'] ?? '')) !== '';
+
+ try {
+ $built = kapvoe_build_email_message($fromEmail, $fromName, $replyTo, $toEmail, $subject, $htmlBody);
+
+ if ($mailTransport === 'resend') {
+ $resendSent = kapvoe_send_via_resend($config, $fromEmail, $fromName, $replyTo, $toEmail, $subject, $htmlBody);
+ kapvoe_log_mail_event($config, 'Resend OK -> ' . $toEmail . ' [' . $subject . ']');
+ return $resendSent;
+ }
+
+ if ($mailTransport === 'smtp') {
+ $smtpSent = kapvoe_send_via_smtp($config, $fromEmail, $fromName, $replyTo, $toEmail, $subject, $htmlBody);
+ kapvoe_log_mail_event($config, 'SMTP OK -> ' . $toEmail . ' [' . $subject . ']');
+ return $smtpSent;
+ }
+
+ if ($mailTransport === 'auto' && $resendConfigured) {
+ $resendSent = kapvoe_send_via_resend($config, $fromEmail, $fromName, $replyTo, $toEmail, $subject, $htmlBody);
+ kapvoe_log_mail_event($config, 'Resend auto OK -> ' . $toEmail . ' [' . $subject . ']');
+ return $resendSent;
+ }
+
+ $mailSent = mail($toEmail, $built['subject'], $built['message'], implode("\r\n", $built['headers']));
+ if ($mailSent) {
+ kapvoe_log_mail_event($config, 'mail() OK -> ' . $toEmail . ' [' . $subject . ']');
+ return true;
+ }
+
+ $lastPhpError = error_get_last();
+ $lastPhpErrorMessage = is_array($lastPhpError) ? (string)($lastPhpError['message'] ?? '') : '';
+ kapvoe_log_mail_event(
+ $config,
+ 'mail() FAIL -> ' . $toEmail . ' [' . $subject . ']'
+ . ($lastPhpErrorMessage !== '' ? ' ' . $lastPhpErrorMessage : '')
+ );
+
+ if ($smtpConfigured && ($mailTransport === 'mail' || $mailTransport === 'auto')) {
+ $smtpSent = kapvoe_send_via_smtp($config, $fromEmail, $fromName, $replyTo, $toEmail, $subject, $htmlBody);
+ kapvoe_log_mail_event($config, 'SMTP fallback OK -> ' . $toEmail . ' [' . $subject . ']');
+ return $smtpSent;
+ }
+
+ if ($resendConfigured && ($mailTransport === 'mail' || $mailTransport === 'auto')) {
+ $resendSent = kapvoe_send_via_resend($config, $fromEmail, $fromName, $replyTo, $toEmail, $subject, $htmlBody);
+ kapvoe_log_mail_event($config, 'Resend fallback OK -> ' . $toEmail . ' [' . $subject . ']');
+ return $resendSent;
+ }
+
+ return false;
+ } catch (Throwable $e) {
+ kapvoe_log_mail_event($config, 'ERROR -> ' . $toEmail . ' [' . $subject . '] ' . $e->getMessage());
+ return false;
+ }
+}
+
+function kapvoe_mark_order_email_notifications(array $config, string $sessionId, bool $customerSent, bool $adminSent): void
+{
+ $path = kapvoe_orders_csv_path($config);
+ kapvoe_upgrade_orders_csv_schema($config);
+ if (!file_exists($path)) {
+ return;
+ }
+
+ $rows = [];
+ $fh = fopen($path, 'rb');
+ if (!$fh) {
+ return;
+ }
+
+ while (($row = fgetcsv($fh, 0, ';')) !== false) {
+ $rows[] = $row;
+ }
+ fclose($fh);
+
+ if (count($rows) < 2) {
+ return;
+ }
+
+ $header = $rows[0];
+ $sessionIdx = array_search('stripe_session_id', $header, true);
+ $customerIdx = array_search('customer_email_sent', $header, true);
+ $adminIdx = array_search('admin_email_sent', $header, true);
+ $sentAtIdx = array_search('email_notifications_sent_at', $header, true);
+
+ if ($sessionIdx === false || $customerIdx === false || $adminIdx === false || $sentAtIdx === false) {
+ return;
+ }
+
+ $now = date('Y-m-d H:i:s');
+
+ for ($i = 1; $i < count($rows); $i++) {
+ while (count($rows[$i]) < count($header)) {
+ $rows[$i][] = '';
+ }
+
+ if (($rows[$i][$sessionIdx] ?? '') === $sessionId) {
+ if ($customerSent) {
+ $rows[$i][$customerIdx] = '1';
+ }
+ if ($adminSent) {
+ $rows[$i][$adminIdx] = '1';
+ }
+ if ($customerSent || $adminSent) {
+ $rows[$i][$sentAtIdx] = $now;
+ }
+ break;
+ }
+ }
+
+ $fh = fopen($path, 'wb');
+ if (!$fh) {
+ return;
+ }
+
+ foreach ($rows as $row) {
+ while (count($row) < count($header)) {
+ $row[] = '';
+ }
+ fputcsv($fh, $row, ';');
+ }
+ fclose($fh);
+}
+
+function kapvoe_send_order_notifications(array $config, array $order, string $sessionId): array
+{
+ $customerAlreadySent = (string)($order['customer_email_sent'] ?? '') === '1';
+ $adminAlreadySent = (string)($order['admin_email_sent'] ?? '') === '1';
+
+ $customerSent = false;
+ $adminSent = false;
+ $internalResult = [
+ 'enabled' => false,
+ 'sent' => false,
+ 'channel' => 'disabled',
+ ];
+ $customerEmail = trim((string)($order['email'] ?? ''));
+ $adminEmail = trim((string)($config['admin_notification_email'] ?? ''));
+
+ kapvoe_log_mail_event(
+ $config,
+ 'notify session=' . $sessionId
+ . ' customer_already=' . ($customerAlreadySent ? '1' : '0')
+ . ' admin_already=' . ($adminAlreadySent ? '1' : '0')
+ . ' customer_email=' . ($customerEmail !== '' ? $customerEmail : '(empty)')
+ . ' admin_email=' . ($adminEmail !== '' ? $adminEmail : '(empty)')
+ );
+
+ if (!$customerAlreadySent && $customerEmail !== '') {
+ $customerSubject = 'Compra confirmada - ' . (string)($order['product_code'] ?? 'Blood Bros Sports');
+ $customerHtml = kapvoe_build_order_email_html($config, $order, $sessionId, false);
+ $customerSent = kapvoe_send_html_email($config, $customerEmail, $customerSubject, $customerHtml);
+ }
+
+ if (!$adminAlreadySent && $adminEmail !== '') {
+ $adminSubject = 'Nova comanda - ' . (string)($order['product_code'] ?? 'Blood Bros Sports');
+ $adminHtml = kapvoe_build_order_email_html($config, $order, $sessionId, true);
+ $adminSent = kapvoe_send_html_email($config, $adminEmail, $adminSubject, $adminHtml);
+ }
+
+ $internalResult = kapvoe_send_internal_notification($config, $order, $sessionId);
+
+ if ($customerSent || $adminSent) {
+ kapvoe_mark_order_email_notifications($config, $sessionId, $customerSent, $adminSent);
+ }
+
+ $result = [
+ 'customer_already_sent' => $customerAlreadySent,
+ 'admin_already_sent' => $adminAlreadySent,
+ 'customer_sent' => $customerSent,
+ 'admin_sent' => $adminSent,
+ 'internal_notification' => $internalResult,
+ ];
+
+ kapvoe_log_mail_event(
+ $config,
+ 'notify_result session=' . $sessionId
+ . ' customer_sent=' . ($customerSent ? '1' : '0')
+ . ' admin_sent=' . ($adminSent ? '1' : '0')
+ . ' internal_channel=' . (string)($internalResult['channel'] ?? 'unknown')
+ . ' internal_sent=' . (!empty($internalResult['sent']) ? '1' : '0')
+ );
+
+ return $result;
+}
+
+function kapvoe_create_checkout_session(array $config, array $payload): array
+{
+ $secretKey = (string)($config['stripe_secret_key'] ?? '');
+ $currency = (string)($config['currency'] ?? 'eur');
+ $successUrl = (string)($config['success_url'] ?? '');
+ $cancelUrl = (string)($config['cancel_url'] ?? '');
+
+ if ($secretKey === '') {
+ throw new RuntimeException('Falta stripe_secret_key');
+ }
+
+ if ($successUrl === '' || $cancelUrl === '') {
+ throw new RuntimeException('Falten success_url o cancel_url');
+ }
+
+ $lineItems = [
+ [
+ 'name' => (string)($payload['product_name'] ?? 'Producte'),
+ 'unit_amount' => (int)($payload['unit_amount_cents'] ?? 0),
+ 'quantity' => max(1, (int)($payload['quantity'] ?? 1)),
+ ],
+ ];
+
+ $shippingMethod = (string)($payload['shipping_method'] ?? 'pickup');
+ $shippingCostCents = (int)($payload['shipping_cost_cents'] ?? 0);
+
+ if ($shippingMethod === 'shipping' && $shippingCostCents > 0) {
+ $lineItems[] = [
+ 'name' => 'Enviament',
+ 'unit_amount' => $shippingCostCents,
+ 'quantity' => 1,
+ ];
+ }
+
+ $postFields = [
+ 'mode' => 'payment',
+ 'success_url' => $successUrl . '?session_id={CHECKOUT_SESSION_ID}',
+ 'cancel_url' => $cancelUrl,
+ 'customer_email' => (string)($payload['email'] ?? ''),
+ 'metadata[order_id]' => (string)($payload['order_id'] ?? ''),
+ 'metadata[product_code]' => (string)($payload['product_code'] ?? ''),
+ 'metadata[shipping_method]' => $shippingMethod,
+ ];
+
+ foreach ($lineItems as $i => $item) {
+ $postFields["line_items[$i][price_data][currency]"] = $currency;
+ $postFields["line_items[$i][price_data][product_data][name]"] = $item['name'];
+ $postFields["line_items[$i][price_data][unit_amount]"] = (string)$item['unit_amount'];
+ $postFields["line_items[$i][quantity]"] = (string)$item['quantity'];
+ }
+
+ $ch = curl_init('https://api.stripe.com/v1/checkout/sessions');
+
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => http_build_query($postFields),
+ CURLOPT_HTTPHEADER => [
+ 'Authorization: Bearer ' . $secretKey,
+ 'Content-Type: application/x-www-form-urlencoded',
+ ],
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ ]);
+
+ $response = curl_exec($ch);
+
+ if ($response === false) {
+ $error = curl_error($ch);
+ curl_close($ch);
+ throw new RuntimeException('Error cURL Stripe: ' . $error);
+ }
+
+ $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ $json = json_decode($response, true);
+
+ if ($statusCode < 200 || $statusCode >= 300) {
+ $message = is_array($json) ? ($json['error']['message'] ?? 'Error Stripe desconegut') : $response;
+ throw new RuntimeException('Stripe error: ' . $message);
+ }
+
+ if (!is_array($json) || empty($json['url'])) {
+ throw new RuntimeException('Resposta invàlida de Stripe');
+ }
+
+ return $json;
}
function kapvoe_decrement_sheet_stock(array $config, array $order, string $sessionId): array
@@ -337,6 +1614,8 @@ function kapvoe_decrement_sheet_stock(array $config, array $order, string $sessi
],
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 10,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 5,
]);
$response = curl_exec($ch);
@@ -367,6 +1646,74 @@ function kapvoe_decrement_sheet_stock(array $config, array $order, string $sessi
return $json;
}
+function kapvoe_fetch_catalog_products(array $config): array
+{
+ $url = (string)($config['catalog_api_url'] ?? '');
+
+ if ($url === '') {
+ throw new RuntimeException('Falta configurar catalog_api_url');
+ }
+
+ if (!function_exists('curl_init')) {
+ throw new RuntimeException('cURL no està disponible al PHP');
+ }
+
+ $ch = curl_init($url);
+
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPGET => true,
+ CURLOPT_HTTPHEADER => [
+ 'Accept: application/json',
+ ],
+ CURLOPT_TIMEOUT => 20,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ ]);
+
+ $response = curl_exec($ch);
+
+ if ($response === false) {
+ $error = curl_error($ch);
+ curl_close($ch);
+ throw new RuntimeException("Error cURL catàleg: {$error}");
+ }
+
+ $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($statusCode < 200 || $statusCode >= 300) {
+ throw new RuntimeException("Catàleg HTTP {$statusCode}: {$response}");
+ }
+
+ $json = json_decode($response, true);
+
+ if (!is_array($json) || !($json['ok'] ?? false) || !isset($json['products']) || !is_array($json['products'])) {
+ throw new RuntimeException('Resposta invàlida del catàleg');
+ }
+
+ return $json['products'];
+}
+
+function kapvoe_get_catalog_product_by_code(array $config, string $productCode): array
+{
+ $productCode = trim($productCode);
+
+ if ($productCode === '') {
+ throw new RuntimeException('Falta product_code');
+ }
+
+ $products = kapvoe_fetch_catalog_products($config);
+
+ foreach ($products as $product) {
+ $code = trim((string)($product['product_code'] ?? ''));
+ if ($code === $productCode) {
+ return $product;
+ }
+ }
+
+ throw new RuntimeException("No s'ha trobat el producte {$productCode} al catàleg");
+}
+
function kapvoe_verify_stripe_signature(string $payload, string $header, string $secret, int $tolerance = 300): bool {
if (!$header || !$secret) {
return false;
diff --git a/checkout/config.php b/checkout/config.php
index 18345b0..7cd36c1 100644
--- a/checkout/config.php
+++ b/checkout/config.php
@@ -11,8 +11,8 @@ return [
// Moneda
'currency' => 'eur',
- // On guardar les comandes provisionals
- 'orders_storage_dir' => __DIR__ . '/data',
+ // On guardar les comandes i logs
+ 'orders_storage_dir' => dirname(__DIR__) . '/data',
// URLs públiques
'success_url' => 'https://kapvoe-portfoli.treblarella.org/checkout/payment-success.php',
@@ -20,4 +20,58 @@ return [
'stock_sync_url' => 'https://script.google.com/macros/s/AKfycbyH6PuQPR342mwSiKCkYZ1lOnn1VccqzFO-ScbhwjvJoABU3LqVNJcS3gtPE5ZuT0JyvQ/exec',
'stock_sync_token' => 'kapvoe_stock_2026_x7F9mQpL2vN8rT4sZ1kW',
+
+ 'catalog_api_url' => 'https://kapvoe-portfoli.treblarella.org/api/products.php',
+
+ // Tracking i analytics per Google Sheets / Looker Studio
+ 'analytics_enabled' => true,
+ 'analytics_sync_url' => 'https://script.google.com/macros/s/AKfycbyyK8fAytZcPHuJKn-LVItMC8BAyTz0jzHml47vvfOHsTwhetCITND_-_yNd7HqickQ/exec',
+ 'analytics_sync_token' => 'kapvoe_analitycs_2026_x7F9mQpL2vN8rT4sZ1kW',
+ 'analytics_timeout' => 10,
+
+ // Correu sortint
+ // Si vols dependre nomes de la Synology, fes servir 'smtp'.
+ // Opcions: auto | mail | smtp | resend
+ 'mail_transport' => 'smtp',
+ 'mail_from_email' => 'pedidos@bloodbros.store',
+ 'mail_from_name' => 'Blood Bros Sports',
+ 'mail_reply_to' => 'pedidos@bloodbros.store',
+
+ // API HTTP de correu. Recomanat si el hosting falla amb mail() o SMTP.
+ // Amb Resend nomes cal una API key i el domini verificat.
+ 'resend_api_key' => '',
+ 'resend_api_url' => 'https://api.resend.com/emails',
+
+ // AvÃs intern de noves comandes
+ // SMTP sortint. Activa-ho si el servidor no envia be amb mail().
+ // SMTP sortint. Idealment apunta al servidor SMTP de la teva Synology.
+ // Exemples habituals:
+ // - NAS mateixa maquina: 127.0.0.1
+ // - NAS a la LAN: 192.168.x.x
+ 'smtp_enabled' => true,
+ 'smtp_host' => 'mail.bloodbros.store',
+ 'smtp_port' => 587,
+ 'smtp_encryption' => 'tls',
+ 'smtp_username' => 'pedidos@bloodbros.store',
+ 'smtp_password' => '2%0Jx5zv',
+ 'smtp_timeout' => 15,
+ // Prova de compatibilitat TLS per hostings/PHPs restrictius.
+ // Activa-ho nomes per provar si el problema es la validacio del certificat.
+ 'smtp_allow_invalid_certificates' => true,
+
+ // Avis intern per correu
+ 'admin_notification_email' => 'pedidos@bloodbros.store',
+
+ // Avis intern independent del correu.
+ // Si poses un webhook intern de la Synology, es cridara per cada compra pagada.
+ // Si ho deixes buit, es guardara a data/internal-notifications.log
+ 'internal_notification_enabled' => true,
+ 'internal_notification_webhook_url' => '',
+ 'internal_notification_webhook_token' => '',
+ 'internal_notification_timeout' => 10,
+ 'internal_notification_log_path' => dirname(__DIR__) . '/data/internal-notifications.log',
+
+ // Token per eines internes com el test de correu.
+ // Posa-hi un valor llarg i privat abans d'usar test-mail.php
+ 'admin_tools_token' => 'kapvoe-test-2026-9xF2mL7qP4sT8vN1',
];
diff --git a/checkout/create-checkout-session.php b/checkout/create-checkout-session.php
index cb81456..15207f7 100644
--- a/checkout/create-checkout-session.php
+++ b/checkout/create-checkout-session.php
@@ -3,65 +3,108 @@ declare(strict_types=1);
require __DIR__ . '/common.php';
+const KAPVOE_SHIPPING_PRICE = 7.99;
+
kapvoe_require_post();
$config = kapvoe_load_config();
$data = kapvoe_json_input();
$required = [
'product_code',
- 'product_name',
- 'price',
'quantity',
'customer_name',
- 'address',
- 'postal_code',
- 'city',
- 'province',
'phone',
'email',
];
+
$errors = kapvoe_validate_required($data, $required);
if (!filter_var((string)($data['email'] ?? ''), FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Correu electrònic invàlid';
}
-if (!preg_match('/^\d{5}$/', (string)($data['postal_code'] ?? ''))) {
- $errors['postal_code'] = 'Codi postal invàlid';
-}
+
if (!preg_match('/^[0-9+\s]{8,20}$/', (string)($data['phone'] ?? ''))) {
$errors['phone'] = 'Telèfon invàlid';
}
-$price = (float)str_replace(',', '.', (string)$data['price']);
-$quantity = max(1, (int)$data['quantity']);
+$quantity = max(1, (int)($data['quantity'] ?? 1));
-if ($price <= 0) {
- $errors['price'] = 'Preu invàlid';
+$productCode = trim((string)($data['product_code'] ?? ''));
+$shippingMethod = ((string)($data['shipping_method'] ?? 'pickup') === 'shipping') ? 'shipping' : 'pickup';
+$shippingCost = $shippingMethod === 'shipping' ? KAPVOE_SHIPPING_PRICE : 0.0;
+
+$address = trim((string)($data['address'] ?? ''));
+$postalCode = trim((string)($data['postal_code'] ?? ''));
+$city = trim((string)($data['city'] ?? ''));
+$province = trim((string)($data['province'] ?? ''));
+
+if ($shippingMethod === 'shipping') {
+ if ($address === '') {
+ $errors['address'] = 'Adreça obligatòria si hi ha enviament';
+ }
+ if (!preg_match('/^\d{5}$/', $postalCode)) {
+ $errors['postal_code'] = 'Codi postal invàlid';
+ }
+ if ($city === '') {
+ $errors['city'] = 'Ciutat obligatòria si hi ha enviament';
+ }
+ if ($province === '') {
+ $errors['province'] = 'Província obligatòria si hi ha enviament';
+ }
}
if ($errors) {
kapvoe_json_response(['ok' => false, 'errors' => $errors], 422);
}
-$orderId = 'ORD-' . date('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
-$unitAmountCents = (int)round($price * 100);
-
-$payload = [
- 'order_id' => $orderId,
- 'product_code' => trim((string)$data['product_code']),
- 'product_name' => trim((string)$data['product_name']),
- 'unit_amount_cents' => $unitAmountCents,
- 'quantity' => $quantity,
- 'customer_name' => trim((string)$data['customer_name']),
- 'address' => trim((string)$data['address']),
- 'postal_code' => trim((string)$data['postal_code']),
- 'city' => trim((string)$data['city']),
- 'province' => trim((string)$data['province']),
- 'phone' => trim((string)$data['phone']),
- 'email' => trim((string)$data['email']),
-];
-
try {
+ $catalogProduct = kapvoe_get_catalog_product_by_code($config, $productCode);
+
+ $realPrice = (float)($catalogProduct['europe_price_number'] ?? 0);
+ if ($realPrice <= 0) {
+ throw new RuntimeException("El producte {$productCode} no té un preu vàlid");
+ }
+
+ $productName = trim((string)($catalogProduct['product_code'] ?? $productCode));
+ $productImageUrl = trim((string)($catalogProduct['image_url'] ?? ''));
+
+ $orderId = 'ORD-' . date('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
+
+ $unitAmountCents = (int)round($realPrice * 100);
+ $subtotalCents = $unitAmountCents * $quantity;
+ $shippingCostCents = (int)round($shippingCost * 100);
+ $totalAmountCents = $subtotalCents + $shippingCostCents;
+
+ $payload = [
+ 'order_id' => $orderId,
+ 'product_code' => $productCode,
+ 'product_name' => $productName,
+ 'unit_amount_cents' => $unitAmountCents,
+ 'quantity' => $quantity,
+ 'customer_name' => trim((string)$data['customer_name']),
+ 'phone' => trim((string)$data['phone']),
+ 'email' => trim((string)$data['email']),
+ 'analytics_session_id' => trim((string)($data['analytics_session_id'] ?? '')),
+ 'analytics_page_url' => trim((string)($data['analytics_page_url'] ?? '')),
+ 'analytics_referrer' => trim((string)($data['analytics_referrer'] ?? '')),
+ 'analytics_user_agent' => trim((string)($data['analytics_user_agent'] ?? '')),
+ 'analytics_utm_source' => trim((string)($data['analytics_utm_source'] ?? '')),
+ 'analytics_utm_medium' => trim((string)($data['analytics_utm_medium'] ?? '')),
+ 'analytics_utm_campaign' => trim((string)($data['analytics_utm_campaign'] ?? '')),
+ 'analytics_device_type' => trim((string)($data['analytics_device_type'] ?? '')),
+ 'analytics_page_type' => trim((string)($data['analytics_page_type'] ?? 'catalog')),
+
+ 'shipping_method' => $shippingMethod,
+ 'shipping_cost_cents' => $shippingCostCents,
+ 'subtotal_cents' => $subtotalCents,
+ 'total_amount_cents' => $totalAmountCents,
+
+ 'address' => $address,
+ 'postal_code' => $postalCode,
+ 'city' => $city,
+ 'province' => $province,
+ ];
+
$session = kapvoe_create_checkout_session($config, $payload);
kapvoe_append_order($config, [
@@ -69,8 +112,13 @@ try {
'created_at' => date('Y-m-d H:i:s'),
'product_code' => $payload['product_code'],
'product_name' => $payload['product_name'],
- 'unit_price' => $price,
+ 'product_image_url' => $productImageUrl,
+ 'unit_price' => number_format($realPrice, 2, '.', ''),
'quantity' => $quantity,
+ 'subtotal' => number_format($subtotalCents / 100, 2, '.', ''),
+ 'shipping_method' => $shippingMethod,
+ 'shipping_cost' => number_format($shippingCostCents / 100, 2, '.', ''),
+ 'total_amount' => number_format($totalAmountCents / 100, 2, '.', ''),
'customer_name' => $payload['customer_name'],
'address' => $payload['address'],
'postal_code' => $payload['postal_code'],
@@ -78,9 +126,24 @@ try {
'province' => $payload['province'],
'phone' => $payload['phone'],
'email' => $payload['email'],
+ 'analytics_session_id' => $payload['analytics_session_id'],
+ 'analytics_page_url' => $payload['analytics_page_url'],
+ 'analytics_referrer' => $payload['analytics_referrer'],
+ 'analytics_user_agent' => $payload['analytics_user_agent'],
+ 'analytics_utm_source' => $payload['analytics_utm_source'],
+ 'analytics_utm_medium' => $payload['analytics_utm_medium'],
+ 'analytics_utm_campaign' => $payload['analytics_utm_campaign'],
+ 'analytics_device_type' => $payload['analytics_device_type'],
+ 'analytics_page_type' => $payload['analytics_page_type'],
'payment_status' => 'pending',
'stripe_session_id' => $session['id'] ?? '',
'payment_intent_id' => '',
+ 'stock_updated' => '0',
+ 'stock_updated_at' => '',
+ 'webhook_processed_at' => '',
+ 'customer_email_sent' => '0',
+ 'admin_email_sent' => '0',
+ 'email_notifications_sent_at' => '',
]);
kapvoe_json_response([
diff --git a/checkout/payment-cancel.php b/checkout/payment-cancel.php
index 4c0c694..296ecee 100644
--- a/checkout/payment-cancel.php
+++ b/checkout/payment-cancel.php
@@ -1,20 +1,163 @@
-
+
+
-
-
- Pagament cancel·lat
+
+
+ Pagament cancel·lat · Blood Bros Sports
+
-
-
Pagament cancel·lat
-
No s'ha completat el pagament. Pots tornar al catàleg i provar-ho de nou.
-
Torna al catàleg
+
+
+
+
+
+
+
+
Pagament cancel·lat
+
No s'ha completat la compra. Si vols, pots tornar al catàleg i reprendre la comanda quan et vagi bé.
+
+
+
+
+
+
diff --git a/checkout/payment-success.php b/checkout/payment-success.php
index df054ba..e8729d8 100644
--- a/checkout/payment-success.php
+++ b/checkout/payment-success.php
@@ -1,26 +1,469 @@
+
+require_once __DIR__ . '/common.php';
+
+$config = kapvoe_load_config();
+$sessionId = trim((string)($_GET['session_id'] ?? ''));
+$order = null;
+$product = null;
+$error = '';
+$catalogProductMissing = false;
+$mailRetryResult = null;
+
+if ($sessionId === '') {
+ $error = 'Falta el codi de sessió de Stripe.';
+} else {
+ $order = kapvoe_get_order_by_session_id($config, $sessionId);
+
+ if (!$order) {
+ $error = 'No s\'ha trobat la comanda associada a aquesta sessió.';
+ } else {
+ try {
+ $product = kapvoe_get_catalog_product_by_code(
+ $config,
+ (string)($order['product_code'] ?? '')
+ );
+ } catch (Throwable $e) {
+ $product = null;
+ $catalogProductMissing = true;
+ }
+
+ if ((string)($order['payment_status'] ?? '') === 'paid') {
+ try {
+ $mailRetryResult = kapvoe_send_order_notifications($config, $order, $sessionId);
+ $order = kapvoe_get_order_by_session_id($config, $sessionId) ?? $order;
+ } catch (Throwable $e) {
+ $mailRetryResult = [
+ 'error' => $e->getMessage(),
+ ];
+ }
+ }
+ }
+}
+
+function kapvoe_find_local_product_image_url(string $productCode): string {
+ $productCode = trim($productCode);
+ if ($productCode === '') {
+ return '';
+ }
+
+ foreach (['png', 'jpg', 'jpeg', 'webp'] as $ext) {
+ $absolutePath = dirname(__DIR__) . "/assets/products/{$productCode}.{$ext}";
+ if (is_file($absolutePath)) {
+ return "/assets/products/{$productCode}.{$ext}";
+ }
+ }
+
+ return '';
+}
+
+function e(?string $value): string {
+ return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
+}
+
+function fmt_eur($value): string {
+ $n = (float)$value;
+ return number_format($n, 2, ',', '.') . ' €';
+}
+
+$productCode = (string)($order['product_code'] ?? '');
+$imageUrl = trim((string)($order['product_image_url'] ?? ''));
+if ($imageUrl === '') {
+ $imageUrl = (string)($product['image_url'] ?? '');
+}
+if ($imageUrl === '') {
+ $imageUrl = kapvoe_find_local_product_image_url($productCode);
+}
+$shippingMethod = (string)($order['shipping_method'] ?? 'pickup');
+$subtotal = (string)($order['subtotal'] ?? $order['unit_price'] ?? '0.00');
+$shippingCost = (string)($order['shipping_cost'] ?? '0.00');
+$totalAmount = (string)($order['total_amount'] ?? $order['unit_price'] ?? '0.00');
+?>
+
-
-
-
Pagament correcte
+
+
+
Compra confirmada · Blood Bros Sports
+
-
-
Pagament correcte
-
Hem rebut la teva compra. En breu revisarem la comanda i t'avisarem.
-
-
Stripe session: = $sessionId ?>
-
-
Torna al catàleg
+
+
+
+
+
+
+
+
Pagament correcte
+
Hem rebut la teva compra. Aquí tens un resum de la comanda confirmada.
+
+
+
+
+
+ No hem pogut carregar el resum de la comanda.
+ = e($error) ?>
+
+
+
✅ Compra confirmada
+
+
+
+
+
+
+
= e($productCode) ?>
+
+ Sessió Stripe: = e($sessionId) ?>
+ Mètode: = e($shippingMethod === 'shipping' ? 'Enviament' : 'Entrega en persona') ?>
+
+
+
+
+
+
+
Resum econòmic
+
+
+ Subtotal producte
+ = fmt_eur($subtotal) ?>
+
+
+
+ Enviament
+ = fmt_eur($shippingCost) ?>
+
+
+
+ Total
+ = fmt_eur($totalAmount) ?>
+
+
+
+
+
Dades de contacte
+
+
+ Nom
+ = e((string)($order['customer_name'] ?? '')) ?>
+
+
+
+ Telèfon
+ = e((string)($order['phone'] ?? '')) ?>
+
+
+
+ Correu electrònic
+ = e((string)($order['email'] ?? '')) ?>
+
+
+
+
+ Adreça d'enviament
+
+ = e((string)($order['address'] ?? '')) ?>
+ = e((string)($order['postal_code'] ?? '')) ?> = e((string)($order['city'] ?? '')) ?>
+ = e((string)($order['province'] ?? '')) ?>
+
+
+
+
+ Entrega
+ Quedarem amb tu per WhatsApp per entregar-te les ulleres en persona a Lleida o Mollerussa.
+
+
+
+
+
+
+
+
+
diff --git a/checkout/stripe-webhook.php b/checkout/stripe-webhook.php
index b4791fd..87ad2d7 100644
--- a/checkout/stripe-webhook.php
+++ b/checkout/stripe-webhook.php
@@ -34,6 +34,8 @@ if (!is_array($event)) {
$type = (string)($event['type'] ?? '');
$object = $event['data']['object'] ?? null;
+error_log('[KAPVOE WEBHOOK] event_type=' . $type);
+
if (!is_array($object)) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
@@ -51,6 +53,8 @@ try {
$paymentIntentId = (string)($object['payment_intent'] ?? '');
$paymentStatus = (string)($object['payment_status'] ?? '');
+ error_log('[KAPVOE WEBHOOK] checkout.session.completed session_id=' . $sessionId . ' payment_status=' . $paymentStatus);
+
if ($sessionId === '') {
throw new RuntimeException('Falta session id');
}
@@ -70,6 +74,8 @@ try {
$alreadyUpdated = (string)($order['stock_updated'] ?? '') === '1';
$stockResult = null;
+ $mailResult = null;
+ $analyticsResult = null;
if ($paymentStatus === 'paid' && !$alreadyUpdated) {
$stockResult = kapvoe_decrement_sheet_stock($config, $order, $sessionId);
@@ -82,6 +88,15 @@ try {
$alreadyUpdated = true;
}
+ if ($paymentStatus === 'paid') {
+ $order = kapvoe_get_order_by_session_id($config, $sessionId) ?? $order;
+ error_log('[KAPVOE WEBHOOK] sending_order_notifications session_id=' . $sessionId . ' customer_email=' . (string)($order['email'] ?? ''));
+ $mailResult = kapvoe_send_order_notifications($config, $order, $sessionId);
+ error_log('[KAPVOE WEBHOOK] mail_result session_id=' . $sessionId . ' result=' . json_encode($mailResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+ $analyticsResult = kapvoe_track_analytics_event($config, kapvoe_build_payment_success_event($order, $sessionId));
+ error_log('[KAPVOE WEBHOOK] analytics_result session_id=' . $sessionId . ' result=' . ($analyticsResult ? '1' : '0'));
+ }
+
http_response_code(200);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
@@ -90,7 +105,9 @@ try {
'session_id' => $sessionId,
'payment_intent_id' => $paymentIntentId,
'already_updated' => $alreadyUpdated,
- 'stock_result' => $stockResult
+ 'stock_result' => $stockResult,
+ 'mail_result' => $mailResult,
+ 'analytics_result' => $analyticsResult,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
@@ -104,6 +121,7 @@ try {
exit;
}
} catch (Throwable $e) {
+ error_log('[KAPVOE WEBHOOK] ERROR ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
@@ -111,4 +129,4 @@ try {
'error' => $e->getMessage()
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
-}
\ No newline at end of file
+}
diff --git a/checkout/test-mail.php b/checkout/test-mail.php
new file mode 100644
index 0000000..5dc8b74
--- /dev/null
+++ b/checkout/test-mail.php
@@ -0,0 +1,81 @@
+ false,
+ 'error' => 'Falta configurar admin_tools_token a config.php',
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+if (!hash_equals($configuredToken, $providedToken)) {
+ http_response_code(403);
+ echo json_encode([
+ 'ok' => false,
+ 'error' => 'Token invalid',
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+$defaultRecipient = trim((string)($config['admin_notification_email'] ?? ''));
+$toEmail = trim((string)($_GET['to'] ?? $defaultRecipient));
+
+if ($toEmail === '' || !filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
+ http_response_code(400);
+ echo json_encode([
+ 'ok' => false,
+ 'error' => 'Cal una adreca de correu valida',
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+$transport = strtolower(trim((string)($config['mail_transport'] ?? 'auto')));
+$smtpHost = trim((string)($config['smtp_host'] ?? ''));
+$smtpPort = (int)($config['smtp_port'] ?? 0);
+
+$subject = 'Prova SMTP Blood Bros Sports';
+$htmlBody = '
+
+
+
+
+
Prova SMTP
+
+
+
+
Prova d\'enviament correcta
+
Aquest correu s\'ha enviat des de test-mail.php per comprovar la configuracio SMTP del projecte.
+
+
Data: ' . htmlspecialchars(date('Y-m-d H:i:s'), ENT_QUOTES, 'UTF-8') . '
+
Destinatari: ' . htmlspecialchars($toEmail, ENT_QUOTES, 'UTF-8') . '
+
Transport: ' . htmlspecialchars($transport, ENT_QUOTES, 'UTF-8') . '
+
SMTP host: ' . htmlspecialchars($smtpHost !== '' ? $smtpHost : '(buit)', ENT_QUOTES, 'UTF-8') . '
+
SMTP port: ' . htmlspecialchars((string)$smtpPort, ENT_QUOTES, 'UTF-8') . '
+
Servidor: ' . htmlspecialchars((string)($_SERVER['HTTP_HOST'] ?? 'localhost'), ENT_QUOTES, 'UTF-8') . '
+
+
+
+';
+
+$sent = kapvoe_send_html_email($config, $toEmail, $subject, $htmlBody);
+
+echo json_encode([
+ 'ok' => $sent,
+ 'to' => $toEmail,
+ 'subject' => $subject,
+ 'transport' => $transport,
+ 'smtp_host' => $smtpHost,
+ 'smtp_port' => $smtpPort,
+ 'mail_log' => kapvoe_mail_log_path($config),
+], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/descarregat/index_modal_updated.html b/descarregat/index_modal_updated.html
new file mode 100644
index 0000000..6eadd01
--- /dev/null
+++ b/descarregat/index_modal_updated.html
@@ -0,0 +1,725 @@
+
+
+
+
+
+
KAPVOE Portfolio · Blood Bros Sports
+
+
+
+
+
+
+
+
+
+
BB
+
+
+
KAPVOE Portfolio
+
Catàleg de les ulleres que estan marcant tendència. Explora cada model, descobreix-ne les característiques, amplia la imatge per veure-les en detall i compra-les per WhatsApp en segons.
+
+
+
+
+
Explora el catàleg
+
+
+
Resum en viu
+
+
+
+ Darrera actualització
--:--
+
+
+
+
+
No s'han pogut carregar les dades
+
Revisa l'endpoint JSON i la publicació del Web App.
+
+
+
+
No hi ha cap model visible
+
No hi ha cap producte que compleixi els filtres actuals o amb stock suficient.
+
+
+
+
+
+
+
+
×
+
+
+
+
+
+
+
+
+
+
+
Comprar ara
+
Omple les teves dades i et redirigirem al pagament segur amb Stripe.
+
+
+
+
+
+
+
-
+
Preu: - · Stock visible: -
+
+
+
+
+
+
+
+
diff --git a/index.html b/index.html
index 2fb3295..73af663 100644
--- a/index.html
+++ b/index.html
@@ -205,9 +205,9 @@
padding:26px 26px 0;overflow:hidden;
}
.card-actions{
- display:flex;gap:12px;
padding:0 26px 6px;
margin-top:2px;
+ width:100%;
}
.media-stage{
width:100%;height:100%;border-radius:26px;
@@ -223,24 +223,33 @@
.media-placeholder strong{display:block;color:var(--text);font-size:18px;margin-bottom:8px}
.media-actions{
- display:flex;gap:12px;justify-content:stretch;z-index:3;
+ display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1.18fr);gap:10px;justify-content:stretch;z-index:3;
pointer-events:none;width:100%;
}
.action-btn{
pointer-events:auto;
display:inline-flex;align-items:center;justify-content:center;gap:10px;
- min-height:48px;padding:0 18px;border-radius:999px;border:1px solid rgba(255,255,255,.12);
- text-decoration:none;color:#fff;font-weight:900;font-size:14px;backdrop-filter:blur(10px);
+ min-height:48px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,.12);
+ text-decoration:none;color:#fff;font-weight:900;font-size:13px;backdrop-filter:blur(10px);
box-shadow:0 8px 18px rgba(0,0,0,.22);
transition:transform .16s ease, filter .16s ease, border-color .16s ease;
- cursor:pointer;flex:1;
+ cursor:pointer;min-width:0;white-space:nowrap;line-height:1;
}
.action-btn:hover{transform:translateY(-1px);filter:brightness(1.04)}
- .action-wa:hover{animation-play-state:paused}
+ .action-buy:hover{animation-play-state:paused}
.action-view{background:rgba(8,15,29,.66)}
.action-wa{
background:linear-gradient(180deg, rgba(37,211,102,.92), rgba(26,171,79,.92));
border-color:rgba(255,255,255,.18);
+ }
+ .action-view,
+ .action-buy,
+ .action-wa{
+ min-width:0;
+ }
+ .action-buy{
+ background:linear-gradient(180deg, rgba(93,154,255,.34), rgba(52,115,214,.28));
+ border-color:rgba(100,196,255,.24);
animation:waPulse 2.2s ease-in-out infinite;
}
@keyframes waPulse{
@@ -274,14 +283,16 @@
gap:16px;
align-items:flex-end;
padding:4px 0 2px;
+ flex-wrap:wrap;
}
.price-box{display:flex;flex-direction:column;gap:6px}
.price-label{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.16em}
.price{font-size:38px;font-weight:1000;line-height:1}
.stock-pill{
- padding:11px 16px;border-radius:999px;background:rgba(40,210,103,.12);
- border:1px solid rgba(40,210,103,.24);color:#b9ffd2;font-weight:900;white-space:nowrap;
- box-shadow:0 8px 18px rgba(0,0,0,.16);
+ padding:9px 14px;border-radius:999px;background:rgba(40,210,103,.12);
+ border:1px solid rgba(40,210,103,.24);color:#b9ffd2;font-weight:900;white-space:normal;
+ box-shadow:0 8px 18px rgba(0,0,0,.16);font-size:11px;line-height:1.2;max-width:100%;
+ overflow-wrap:anywhere;
}
.sales-hook{
margin-top:10px;
@@ -363,10 +374,6 @@
}
- .action-buy{
- background:linear-gradient(180deg, rgba(93,154,255,.34), rgba(52,115,214,.28));
- border-color:rgba(100,196,255,.24);
- }
.buy-modal{
position:fixed;inset:0;display:none;align-items:center;justify-content:center;
background:rgba(0,0,0,.72);backdrop-filter:blur(8px);z-index:1200;padding:20px;
@@ -415,8 +422,11 @@
.family-pill{white-space:normal}
.price-stock{flex-direction:column;align-items:flex-start}
.card-actions{padding:0 18px 6px}
- .media-actions{justify-content:stretch}
- .action-btn{flex:1}
+ .media-actions{justify-content:stretch;grid-template-columns:1fr}
+ .action-btn{width:100%;font-size:13px;padding:0 12px;gap:8px}
+ .price-stock{gap:12px;align-items:flex-start;flex-direction:column}
+ .price-box{width:100%}
+ .stock-pill{width:100%;font-size:11px;padding:10px 12px;border-radius:18px}
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
@@ -429,7 +439,153 @@
box-shadow: 0 0 0px 1000px rgba(255,255,255,.05) inset !important;
border: 1px solid rgba(255,255,255,.10) !important;
}
+.buy-delivery{
+ border:1px solid rgba(255,255,255,.10);
+ background:rgba(255,255,255,.04);
+ border-radius:18px;
+ padding:14px 16px;
+}
+.buy-check{
+ display:flex;
+ align-items:center;
+ gap:12px;
+ font-weight:800;
+ color:#eef6ff;
+ cursor:pointer;
+}
+
+.buy-check input{
+ width:18px;
+ height:18px;
+ accent-color:#2563eb;
+ cursor:pointer;
+}
+
+.buy-delivery-note{
+ margin-top:8px;
+ color:var(--muted);
+ font-size:13px;
+ line-height:1.4;
+}
+
+.buy-totals{
+ margin-top:4px;
+ border:1px solid rgba(255,255,255,.10);
+ background:rgba(255,255,255,.04);
+ border-radius:18px;
+ padding:14px 16px;
+ display:grid;
+ gap:10px;
+}
+
+.buy-total-row{
+ display:flex;
+ justify-content:space-between;
+ align-items:center;
+ gap:12px;
+ color:#eef4ff;
+ font-size:15px;
+}
+
+.buy-total-final{
+ padding-top:10px;
+ border-top:1px solid rgba(255,255,255,.08);
+ font-size:17px;
+ font-weight:1000;
+}
+.buy-head{
+ margin-bottom:18px;
+}
+
+.buy-head-brand{
+ display:flex;
+ align-items:center;
+ gap:14px;
+}
+
+.buy-head-logo{
+ width:64px;
+ height:64px;
+ object-fit:contain;
+ border-radius:18px;
+ padding:6px;
+ background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03));
+ border:1px solid rgba(255,255,255,.08);
+ box-shadow:0 16px 32px rgba(0,0,0,.28);
+ flex:0 0 auto;
+}
+
+.buy-head h3{
+ margin:0 0 6px;
+}
+
+.buy-head p{
+ margin:0;
+}
+
+.buy-summary-card{
+ display:grid;
+ grid-template-columns:84px 1fr;
+ gap:16px;
+ align-items:center;
+ border:1px solid rgba(255,255,255,.10);
+ background:rgba(255,255,255,.04);
+ border-radius:18px;
+ padding:14px;
+}
+
+.buy-summary-media{
+ width:84px;
+ height:84px;
+ border-radius:16px;
+ background:rgba(255,255,255,.92);
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ overflow:hidden;
+}
+
+.buy-summary-media img{
+ width:100%;
+ height:100%;
+ object-fit:contain;
+ display:block;
+}
+
+.buy-summary-info strong{
+ display:block;
+ font-size:28px;
+ line-height:1.05;
+ margin-bottom:6px;
+ color:#fff;
+}
+
+.buy-summary-info div{
+ color:var(--muted);
+ font-size:15px;
+ line-height:1.45;
+}
+
+@media (max-width:640px){
+ .buy-summary-card{
+ grid-template-columns:1fr;
+ }
+
+ .buy-summary-media{
+ width:100%;
+ height:180px;
+ }
+
+ .buy-head-brand{
+ align-items:flex-start;
+ }
+
+ .buy-head-logo{
+ width:56px;
+ height:56px;
+ }
+}
@@ -501,29 +657,77 @@
-
Comprar ara
-
Omple les teves dades i et redirigirem al pagament segur amb Stripe.
-
-