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
+ +
+ `; + } + 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 + ? `${escapeHtml(product.product_code || product.model || 'Producte')}` + : `
${escapeHtml(product.product_code || 'Sense codi')}
Sense imatge definida a IMAGE_URL.
`; + + const stockState = getStockState(product.stock); + const salesHook = getSalesHook(product); + + card.innerHTML = ` + ${product.top_vendes ? '
Top vendes
' : ''} +
+
+ ${imageHtml} +
+
+
+
+ + + WhatsApp +
+
+
+
+
+
${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) . ' + + +
+
+
+
+ ' . kapvoe_mail_html_escape($brand) . ' +
+
+
Pagament correcte
+
' . kapvoe_mail_html_escape($intro) . '
+
+
+ +
' . kapvoe_mail_html_escape($badge) . '
+ +
+
+
' + . ($imageUrl !== '' + ? '' . $productCode . '' + : '
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
+
Nom
' . $customerName . '
+
Telefon
' . $phone . '
+
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

+
+
+
+
+ Blood Bros Sports +
+
+

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:

- -

Torna al catàleg

+
+
+
+
+ Blood Bros Sports +
+
+

Pagament correcte

+

Hem rebut la teva compra. Aquí tens un resum de la comanda confirmada.

+
+
+ + +
+ No hem pogut carregar el resum de la comanda.

+ +
+ +
✅ Compra confirmada
+ +
+
+
+ + <?= e($productCode) ?> + +
Sense imatge
+ +
+ +
+

+
+ Sessió Stripe:
+ Mètode: +
+
+
+ +
+
+
Resum econòmic
+ +
+ Subtotal producte + +
+ +
+ Enviament + +
+ +
+ Total + +
+
+ +
+
Dades de contacte
+ +
+ Nom + +
+ +
+ Telèfon + +
+ +
+ Correu electrònic + +
+ + +
+ Adreça d'enviament + +
+
+ +
+
+ +
+ 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
+
+
Models visibles
0
+
Top vendes visibles
0
+
Darrera actualització
--:--
+
+ + + + + + +
+
+ + + + + + + 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 @@