commit 618efcd05f51a5b9d38042eb6814ba2b6fbdd182 Author: Albert Date: Tue Apr 7 23:30:33 2026 +0200 Primer commit diff --git a/assets/logo/bloodbros-sports-logo.png b/assets/logo/bloodbros-sports-logo.png new file mode 100644 index 0000000..40faad6 Binary files /dev/null and b/assets/logo/bloodbros-sports-logo.png differ diff --git a/assets/products/K383.png b/assets/products/K383.png new file mode 100644 index 0000000..5152820 Binary files /dev/null and b/assets/products/K383.png differ diff --git a/assets/products/TK159-G-1L-21.png b/assets/products/TK159-G-1L-21.png new file mode 100644 index 0000000..ad73e56 Binary files /dev/null and b/assets/products/TK159-G-1L-21.png differ diff --git a/assets/products/TK204-G-1L-01.png b/assets/products/TK204-G-1L-01.png new file mode 100644 index 0000000..8360a3e Binary files /dev/null and b/assets/products/TK204-G-1L-01.png differ diff --git a/assets/products/TK204-G-1L-03.png b/assets/products/TK204-G-1L-03.png new file mode 100644 index 0000000..d33e95d Binary files /dev/null and b/assets/products/TK204-G-1L-03.png differ diff --git a/assets/products/TK284-G-1L-21.png b/assets/products/TK284-G-1L-21.png new file mode 100644 index 0000000..fee85da Binary files /dev/null and b/assets/products/TK284-G-1L-21.png differ diff --git a/assets/products/TK404-G-2L-04.png b/assets/products/TK404-G-2L-04.png new file mode 100644 index 0000000..8bbb7c8 Binary files /dev/null and b/assets/products/TK404-G-2L-04.png differ diff --git a/assets/products/TK410-PH-BL-1L-08.png b/assets/products/TK410-PH-BL-1L-08.png new file mode 100644 index 0000000..4530167 Binary files /dev/null and b/assets/products/TK410-PH-BL-1L-08.png differ diff --git a/assets/products/TK410-PH-GO-1L-04.png b/assets/products/TK410-PH-GO-1L-04.png new file mode 100644 index 0000000..2642124 Binary files /dev/null and b/assets/products/TK410-PH-GO-1L-04.png differ diff --git a/assets/products/TK410-PH-PU-1L-02.png b/assets/products/TK410-PH-PU-1L-02.png new file mode 100644 index 0000000..a9c1a67 Binary files /dev/null and b/assets/products/TK410-PH-PU-1L-02.png differ diff --git a/assets/products/TK410-PH-RE-1L-03.png b/assets/products/TK410-PH-RE-1L-03.png new file mode 100644 index 0000000..fdef2ca Binary files /dev/null and b/assets/products/TK410-PH-RE-1L-03.png differ diff --git a/assets/products/TK410-Q-1L-01.png b/assets/products/TK410-Q-1L-01.png new file mode 100644 index 0000000..f785076 Binary files /dev/null and b/assets/products/TK410-Q-1L-01.png differ diff --git a/assets/products/TK410-Q-1L-03.png b/assets/products/TK410-Q-1L-03.png new file mode 100644 index 0000000..a682385 Binary files /dev/null and b/assets/products/TK410-Q-1L-03.png differ diff --git a/assets/products/TK410-Q-1L-04.png b/assets/products/TK410-Q-1L-04.png new file mode 100644 index 0000000..0cbb028 Binary files /dev/null and b/assets/products/TK410-Q-1L-04.png differ diff --git a/assets/products/TK410-Q-1L-07.png b/assets/products/TK410-Q-1L-07.png new file mode 100644 index 0000000..cd7b69b Binary files /dev/null and b/assets/products/TK410-Q-1L-07.png differ diff --git a/assets/products/TK410-Q-1L-08.png b/assets/products/TK410-Q-1L-08.png new file mode 100644 index 0000000..cedbb94 Binary files /dev/null and b/assets/products/TK410-Q-1L-08.png differ diff --git a/assets/products/TK410-Q-1L-09.png b/assets/products/TK410-Q-1L-09.png new file mode 100644 index 0000000..bb8a17e Binary files /dev/null and b/assets/products/TK410-Q-1L-09.png differ diff --git a/assets/products/TK428-MPH-SI-1L-01.png b/assets/products/TK428-MPH-SI-1L-01.png new file mode 100644 index 0000000..a299a06 Binary files /dev/null and b/assets/products/TK428-MPH-SI-1L-01.png differ diff --git a/assets/products/TK434-G-2L-07.png b/assets/products/TK434-G-2L-07.png new file mode 100644 index 0000000..f879ea3 Binary files /dev/null and b/assets/products/TK434-G-2L-07.png differ diff --git a/assets/products/TK434-MPH-RE-1L-07.png b/assets/products/TK434-MPH-RE-1L-07.png new file mode 100644 index 0000000..2c5363d Binary files /dev/null and b/assets/products/TK434-MPH-RE-1L-07.png differ diff --git a/assets/products/TK454B-G-1L-01.png b/assets/products/TK454B-G-1L-01.png new file mode 100644 index 0000000..259c34c Binary files /dev/null and b/assets/products/TK454B-G-1L-01.png differ diff --git a/assets/products/TK454B-MPH-BL-1L-05.png b/assets/products/TK454B-MPH-BL-1L-05.png new file mode 100644 index 0000000..259c34c Binary files /dev/null and b/assets/products/TK454B-MPH-BL-1L-05.png differ diff --git a/assets/products/TK454B-MPH-SI-1L-11.png b/assets/products/TK454B-MPH-SI-1L-11.png new file mode 100644 index 0000000..259c34c Binary files /dev/null and b/assets/products/TK454B-MPH-SI-1L-11.png differ diff --git a/assets/products/TK63-PH-1L-15.png b/assets/products/TK63-PH-1L-15.png new file mode 100644 index 0000000..019185b Binary files /dev/null and b/assets/products/TK63-PH-1L-15.png differ diff --git a/assets/products/TK63-PH-BL-1L-02.png b/assets/products/TK63-PH-BL-1L-02.png new file mode 100644 index 0000000..835755d Binary files /dev/null and b/assets/products/TK63-PH-BL-1L-02.png differ diff --git a/assets/products/TK63-PH-BL-1L-09.png b/assets/products/TK63-PH-BL-1L-09.png new file mode 100644 index 0000000..12fb8d3 Binary files /dev/null and b/assets/products/TK63-PH-BL-1L-09.png differ diff --git a/assets/products/TK63-PH-RE-1L-01.png b/assets/products/TK63-PH-RE-1L-01.png new file mode 100644 index 0000000..dbd283b Binary files /dev/null and b/assets/products/TK63-PH-RE-1L-01.png differ diff --git a/assets/products/TK63-Q-3L-01.png b/assets/products/TK63-Q-3L-01.png new file mode 100644 index 0000000..d2e5c2f Binary files /dev/null and b/assets/products/TK63-Q-3L-01.png differ diff --git a/assets/products/TK63-Q-3L-09.png b/assets/products/TK63-Q-3L-09.png new file mode 100644 index 0000000..7ab59e0 Binary files /dev/null and b/assets/products/TK63-Q-3L-09.png differ diff --git a/checkout/common.php b/checkout/common.php new file mode 100644 index 0000000..8a64281 --- /dev/null +++ b/checkout/common.php @@ -0,0 +1,329 @@ + false, + 'error' => 'Falta el fitxer config.php' + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + /** @var array $config */ + $config = require $configFile; + return $config; +} + +function kapvoe_json_input(): array { + $raw = file_get_contents('php://input') ?: '{}'; + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : []; +} + +function kapvoe_json_response(array $payload, int $status = 200): never { + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function kapvoe_require_post(): void { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + kapvoe_json_response(['ok' => false, 'error' => 'Mètode no permès'], 405); + } +} + +function kapvoe_validate_required(array $data, array $fields): array { + $errors = []; + foreach ($fields as $field) { + if (!isset($data[$field]) || trim((string)$data[$field]) === '') { + $errors[$field] = 'Camp obligatori'; + } + } + return $errors; +} + +function kapvoe_orders_csv_path(array $config): string { + $dir = rtrim((string)$config['orders_storage_dir'], DIRECTORY_SEPARATOR); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + return $dir . DIRECTORY_SEPARATOR . 'orders.csv'; +} + +function kapvoe_append_order(array $config, array $order): void { + $path = kapvoe_orders_csv_path($config); + $exists = file_exists($path); + $fh = fopen($path, 'ab'); + if (!$fh) { + throw new RuntimeException('No s\'ha pogut obrir orders.csv'); + } + + if (!$exists) { + fputcsv($fh, array_keys($order), ';'); + } + fputcsv($fh, array_values($order), ';'); + fclose($fh); +} + +function kapvoe_update_order_status(array $config, string $sessionId, string $newStatus, string $paymentIntent = ''): void { + $path = kapvoe_orders_csv_path($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); + $statusIdx = array_search('payment_status', $header, true); + $intentIdx = array_search('payment_intent_id', $header, true); + + if ($sessionIdx === false || $statusIdx === false) { + return; + } + + for ($i = 1; $i < count($rows); $i++) { + while (count($rows[$i]) < count($header)) { + $rows[$i][] = ''; + } + + if (($rows[$i][$sessionIdx] ?? '') === $sessionId) { + $rows[$i][$statusIdx] = $newStatus; + if ($intentIdx !== false) { + $rows[$i][$intentIdx] = $paymentIntent; + } + 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_get_order_by_session_id(array $config, string $sessionId): ?array { + $path = kapvoe_orders_csv_path($config); + if (!file_exists($path)) { + return null; + } + + $fh = fopen($path, 'rb'); + if (!$fh) { + return null; + } + + $header = fgetcsv($fh, 0, ';'); + if (!$header) { + fclose($fh); + return null; + } + + while (($row = fgetcsv($fh, 0, ';')) !== false) { + while (count($row) < count($header)) { + $row[] = ''; + } + $assoc = array_combine($header, $row); + if (($assoc['stripe_session_id'] ?? '') === $sessionId) { + fclose($fh); + return $assoc ?: null; + } + } + + fclose($fh); + return null; +} + +function kapvoe_mark_order_stock_updated(array $config, string $sessionId): void { + $path = kapvoe_orders_csv_path($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) < 1) { + return; + } + + $header = $rows[0]; + + $sessionIdx = array_search('stripe_session_id', $header, true); + $stockUpdatedIdx = array_search('stock_updated', $header, true); + $stockUpdatedAtIdx = array_search('stock_updated_at', $header, true); + $webhookProcessedIdx = array_search('webhook_processed_at', $header, true); + + if ($sessionIdx === false) { + return; + } + + if ($stockUpdatedIdx === false) { + $header[] = 'stock_updated'; + $stockUpdatedIdx = count($header) - 1; + } + + if ($stockUpdatedAtIdx === false) { + $header[] = 'stock_updated_at'; + $stockUpdatedAtIdx = count($header) - 1; + } + + if ($webhookProcessedIdx === false) { + $header[] = 'webhook_processed_at'; + $webhookProcessedIdx = count($header) - 1; + } + + $rows[0] = $header; + $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 (($rows[$i][$stockUpdatedIdx] ?? '') === '1') { + return; + } + + $rows[$i][$stockUpdatedIdx] = '1'; + $rows[$i][$stockUpdatedAtIdx] = $now; + $rows[$i][$webhookProcessedIdx] = $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_create_checkout_session(array $config, array $payload): array { + $secretKey = (string)$config['stripe_secret_key']; + + $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'], + ]; + + $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($fields), + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $secretKey + ], + ]); + + $raw = curl_exec($ch); + if ($raw === false) { + throw new RuntimeException('Error cURL Stripe: ' . curl_error($ch)); + } + + $httpCode = (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); + } + + return $decoded; +} + +function kapvoe_verify_stripe_signature(string $payload, string $header, string $secret, int $tolerance = 300): bool { + if (!$header || !$secret) { + return false; + } + + $parts = explode(',', $header); + $timestamp = null; + $signature = null; + + foreach ($parts as $part) { + [$k, $v] = array_pad(explode('=', trim($part), 2), 2, null); + if ($k === 't') { + $timestamp = $v; + } + if ($k === 'v1') { + $signature = $v; + } + } + + if (!$timestamp || !$signature) { + return false; + } + + if (abs(time() - (int)$timestamp) > $tolerance) { + return false; + } + + $signedPayload = $timestamp . '.' . $payload; + $expected = hash_hmac('sha256', $signedPayload, $secret); + + return hash_equals($expected, $signature); +} diff --git a/checkout/config.php b/checkout/config.php new file mode 100644 index 0000000..42a1567 --- /dev/null +++ b/checkout/config.php @@ -0,0 +1,20 @@ + 'sk_test_51IvGaFHRV8rA2XwsDiGII0rVfot2HabnvF862L8G6rYIzUscZDMkM4WlVAV6GbYSMZsFEDF44PEppdJaLdGo0idB00UyabGh0u', + + // Secret del webhook de Stripe (Signing secret) + 'stripe_webhook_secret' => 'whsec_wBJZjwPIhPclM1i4bKUtnQqol26caq5L', + + // Moneda + 'currency' => 'eur', + + // On guardar les comandes provisionals + 'orders_storage_dir' => __DIR__ . '/data', + + // URLs públiques + 'success_url' => 'https://kapvoe-portfoli.treblarella.org/checkout/payment-success.php', + 'cancel_url' => 'https://kapvoe-portfoli.treblarella.org/checkout/payment-cancel.php', +]; diff --git a/checkout/create-checkout-session.php b/checkout/create-checkout-session.php new file mode 100644 index 0000000..cb81456 --- /dev/null +++ b/checkout/create-checkout-session.php @@ -0,0 +1,96 @@ + 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 { + $session = kapvoe_create_checkout_session($config, $payload); + + kapvoe_append_order($config, [ + 'order_id' => $orderId, + 'created_at' => date('Y-m-d H:i:s'), + 'product_code' => $payload['product_code'], + 'product_name' => $payload['product_name'], + 'unit_price' => $price, + 'quantity' => $quantity, + 'customer_name' => $payload['customer_name'], + 'address' => $payload['address'], + 'postal_code' => $payload['postal_code'], + 'city' => $payload['city'], + 'province' => $payload['province'], + 'phone' => $payload['phone'], + 'email' => $payload['email'], + 'payment_status' => 'pending', + 'stripe_session_id' => $session['id'] ?? '', + 'payment_intent_id' => '', + ]); + + kapvoe_json_response([ + 'ok' => true, + 'checkout_url' => $session['url'] ?? null, + 'order_id' => $orderId, + ]); +} catch (Throwable $e) { + kapvoe_json_response([ + 'ok' => false, + 'error' => $e->getMessage(), + ], 500); +} diff --git a/checkout/payment-cancel.php b/checkout/payment-cancel.php new file mode 100644 index 0000000..4c0c694 --- /dev/null +++ b/checkout/payment-cancel.php @@ -0,0 +1,20 @@ + + + + + + Pagament cancel·lat + + + +
+

Pagament cancel·lat

+

No s'ha completat el pagament. Pots tornar al catàleg i provar-ho de nou.

+

Torna al catàleg

+
+ + diff --git a/checkout/payment-success.php b/checkout/payment-success.php new file mode 100644 index 0000000..df054ba --- /dev/null +++ b/checkout/payment-success.php @@ -0,0 +1,26 @@ + + + + + + Pagament correcte + + + +
+

Pagament correcte

+

Hem rebut la teva compra. En breu revisarem la comanda i t'avisarem.

+ +

Stripe session:

+ +

Torna al catàleg

+
+ + diff --git a/checkout/stripe-webhook.php b/checkout/stripe-webhook.php new file mode 100644 index 0000000..79254e9 --- /dev/null +++ b/checkout/stripe-webhook.php @@ -0,0 +1,106 @@ + false, + 'error' => 'Signatura Stripe invàlida' + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +$event = json_decode($payload, true); + +if (!is_array($event)) { + http_response_code(400); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => false, + 'error' => 'Payload JSON invàlid' + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +$type = (string)($event['type'] ?? ''); +$object = $event['data']['object'] ?? null; + +if (!is_array($object)) { + http_response_code(400); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => false, + 'error' => 'Event sense objecte de dades' + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +try { + switch ($type) { + case 'checkout.session.completed': + $sessionId = (string)($object['id'] ?? ''); + $paymentIntentId = (string)($object['payment_intent'] ?? ''); + $paymentStatus = (string)($object['payment_status'] ?? ''); + + if ($sessionId === '') { + throw new RuntimeException('Falta session id'); + } + + kapvoe_update_order_status( + $config, + $sessionId, + $paymentStatus === 'paid' ? 'paid' : 'pending', + $paymentIntentId + ); + + $order = kapvoe_get_order_by_session_id($config, $sessionId); + + if (!$order) { + throw new RuntimeException('No s\'ha trobat la comanda al CSV'); + } + + $alreadyUpdated = (string)($order['stock_updated'] ?? '') === '1'; + + if ($paymentStatus === 'paid' && !$alreadyUpdated) { + kapvoe_mark_order_stock_updated($config, $sessionId); + $alreadyUpdated = true; + } + + http_response_code(200); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => true, + 'handled' => $type, + 'session_id' => $sessionId, + 'payment_intent_id' => $paymentIntentId, + 'already_updated' => $alreadyUpdated + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + + default: + http_response_code(200); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => true, + 'ignored' => $type + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } +} catch (Throwable $e) { + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => false, + 'error' => $e->getMessage() + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} diff --git a/data/orders.csv b/data/orders.csv new file mode 100644 index 0000000..c1b34d1 --- /dev/null +++ b/data/orders.csv @@ -0,0 +1,2 @@ +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 +ORD-20260403-114000-45c216;"2026-04-03 11:40:02";K383;K383;60;1;"Albert Gadea Llevot";"Carrer del Progrés 5";25244;Fondarella;Lleida;686255350;albert@bloodbros.store;paid;cs_test_a1OGoVroq2P9ljWEHeyNewPwku0zZ1aJG3d2uDyDwIzl1q70bCZPRbrWT8;pi_3TI4CsHRV8rA2Xws1vaNX2My;1;"2026-04-03 11:40:24";"2026-04-03 11:40:24" diff --git a/index.html b/index.html new file mode 100644 index 0000000..a422945 --- /dev/null +++ b/index.html @@ -0,0 +1,510 @@ + + + + + + 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/js/app.js b/js/app.js new file mode 100644 index 0000000..b29dd51 --- /dev/null +++ b/js/app.js @@ -0,0 +1,428 @@ +import { + API_URL, + AUTO_REFRESH_MS, + BLOODBROS_LOGO_URL, + WHATSAPP_PHONE, + CREATE_CHECKOUT_URL +} from './config.js'; + + const els = { + cards: document.getElementById('cards'), + errorBox: document.getElementById('errorBox'), + errorText: document.getElementById('errorText'), + emptyBox: document.getElementById('emptyBox'), + statVisible: document.getElementById('statVisible'), + statTop: document.getElementById('statTop'), + statUpdated: document.getElementById('statUpdated'), + searchInput: document.getElementById('searchInput'), + topFilter: document.getElementById('topFilter'), + sortFilter: document.getElementById('sortFilter'), + priceFilter: document.getElementById('priceFilter'), + refreshBtn: document.getElementById('refreshBtn'), + modal: document.getElementById('imageModal'), + modalImage: document.getElementById('modalImage'), + modalClose: document.getElementById('modalClose'), + brandLogo: document.getElementById('brandLogo'), + brandFallback: document.getElementById('brandFallback') + }; + + let products = []; + let lastFetchAt = null; + let lastUpdatedAt = null; + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function fmtPrice(product) { + if (product.europe_price_text) return product.europe_price_text; + const n = Number(product.europe_price_number); + if (Number.isFinite(n)) { + return new Intl.NumberFormat('ca-ES', { style: 'currency', currency: 'EUR' }).format(n); + } + return '-'; + } + + function fmtTime(value) { + if (!value) return '--:--'; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return '--:--'; + return d.toLocaleTimeString('ca-ES', { hour: '2-digit', minute: '2-digit' }); + } + + function normalizeText(v) { + return String(v ?? '').toLowerCase().trim(); + } + + function parseDescription(description) { + const raw = String(description ?? '').trim(); + if (!raw) return []; + const lines = raw + .split(/\n|\r|\||•|;/g) + .map(s => s.trim()) + .filter(Boolean); + return lines; + } + + function getStockState(stock) { + const n = Number(stock || 0); + if (n <= 1) { + return { + cls: 'stock-last', + label: '🔥 Última unitat' + }; + } + if (n <= 3) { + return { + cls: 'stock-low', + label: `⚠️ Queden poques unitats · ${n} disponibles` + }; + } + return { + cls: 'stock-ok', + label: `Stock disponible · ${n} unitats` + }; + } + + function getSalesHook(product) { + if (product.top_vendes) { + return null; + } + const stock = Number(product.stock || 0); + if (stock <= 1) { + return { + badge: 'Última oportunitat', + text: 'Aquest model està a punt d’esgotar-se.' + }; + } + if (stock <= 3) { + return { + badge: 'Alta demanda', + text: 'Model amb poques unitats disponibles.' + }; + } + return { + badge: 'Selecció premium', + text: 'Una aposta segura per estil i rendiment.' + }; + } + + function buildWhatsappLink(product) { + const text = [ + 'Hola Blood Bros Sports,', + 'voldria encomanar aquest model:', + `${product.product_code || ''}`, + product.model ? `Model: ${product.model}` : '', + product.category ? `Família: ${product.category}` : '', + product.colors ? `Colors: ${product.colors}` : '', + Number(product.europe_price_number) ? `Preu: ${fmtPrice(product)}` : '', + `Stock visible: ${product.stock ?? ''}` + ].filter(Boolean).join('\n'); + + if (WHATSAPP_PHONE) { + return `https://wa.me/${encodeURIComponent(WHATSAPP_PHONE)}?text=${encodeURIComponent(text)}`; + } + return `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`; + } + + function getFilteredProducts() { + const q = normalizeText(els.searchInput.value); + const top = els.topFilter.value; + const sort = els.sortFilter.value; + const price = els.priceFilter.value; + + let data = products.filter(p => Number(p.stock) >= 1); + + if (q) { + data = data.filter(p => { + const hay = [p.product_code, p.model, p.category, p.colors, p.description] + .map(normalizeText).join(' '); + return hay.includes(q); + }); + } + + if (top === 'top') data = data.filter(p => !!p.top_vendes); + if (top === 'normal') data = data.filter(p => !p.top_vendes); + + if (price !== 'all') { + data = data.filter(p => { + const n = Number(p.europe_price_number); + if (!Number.isFinite(n)) return false; + if (price === '0-60') return n <= 60; + if (price === '60-80') return n > 60 && n <= 80; + if (price === '80+') return n > 80; + return true; + }); + } + + data.sort((a, b) => { + const codeA = String(a.product_code || ''); + const codeB = String(b.product_code || ''); + const priceA = Number.isFinite(Number(a.europe_price_number)) ? Number(a.europe_price_number) : -Infinity; + const priceB = Number.isFinite(Number(b.europe_price_number)) ? Number(b.europe_price_number) : -Infinity; + const stockA = Number(a.stock || 0); + const stockB = Number(b.stock || 0); + + switch (sort) { + case 'code-desc': return codeB.localeCompare(codeA, 'ca'); + case 'price-asc': return priceA - priceB; + case 'price-desc': return priceB - priceA; + case 'stock-desc': return stockB - stockA; + default: return codeA.localeCompare(codeB, 'ca'); + } + }); + + return data; + } + + function renderDescription(product) { + const items = parseDescription(product.description); + if (items.length) { + return ` +
+
Característiques
+ +
+ `; + } + return ` +
+
Característiques
+

Sense descripció disponible.

+
+ `; + } + + function render() { + const data = getFilteredProducts(); + + els.cards.innerHTML = ''; + els.errorBox.style.display = 'none'; + els.emptyBox.style.display = data.length ? 'none' : 'block'; const totalTop = data.filter(p => !!p.top_vendes).length; + + els.statVisible.textContent = data.length; + els.statTop.textContent = totalTop; els.statUpdated.textContent = fmtTime(lastUpdatedAt || lastFetchAt); + if (!data.length) return; + + const frag = document.createDocumentFragment(); + + data.forEach(product => { + const card = document.createElement('article'); + card.className = 'card'; + + const chips = []; + if (product.colors) chips.push(product.colors); + + + + const imageHtml = product.image_url + ? `${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/js/catalog.js b/js/catalog.js new file mode 100644 index 0000000..e69de29 diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..be3bc33 --- /dev/null +++ b/js/config.js @@ -0,0 +1,10 @@ + export const API_URL = 'https://script.googleusercontent.com/macros/echo?user_content_key=AWDtjMVe9JQMYFUTitGGiHSzQsuEyr7VsarNnNpOluFcWHXa-CGnuKhrinmwBYVLw1otHQoBg5rnYNpGlIvCg_I8u1QhCKr-FQwCC2bG9LdttpST6nS_k8TxRcaT5LmmDjeZENcy8A0ujTU1yJwFLoxudMFW-OGjkYtywE5YT_CUJpKQWmqPN8IRV1drNysBtQFfQH1tXUS1JrOODghrxxjAA3T77kqRnz-aaMZJ23YfZntVm4C1KpaBBloFs4OO2wYIcD7Sf1iAYX1OMjIHXsCypvIgFRfIdhMqADWRmluv&lib=MLri0H8XjzrQY2XIy3BzwJ1YbTZr7i_Wa'; + export const AUTO_REFRESH_MS = 120000; + + // Si vols el logo real, puja'l a assets i canvia aquesta URL. + export const BLOODBROS_LOGO_URL = 'assets/logo/bloodbros-sports-logo.png'; + + // Si vols que l'acció vagi al teu número real, posa'l aquí en format internacional sense + ni espais. + // Exemple: '34600111222' + export const WHATSAPP_PHONE = '34644695160'; + export const CREATE_CHECKOUT_URL = '/checkout/create-checkout-session.php'; \ No newline at end of file