Procházet zdrojové kódy

Wielojęzykowość poprawna - system gotowy do głębszych testów i edge case

Miłosz Semenov před 3 týdny
rodič
revize
59a6e501b4

+ 5 - 0
.env.example

@@ -24,6 +24,11 @@ WOOCOMMERCE_CONSUMER_SECRET=cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
 # === Relay Auth ===
 RELAY_SHARED_SECRET=change-me-to-a-random-string
 
+# === Wielowalutowość ===
+# Waluty aktywne w pluginie multicurrency WooCommerce (ISO 4217, oddzielone przecinkiem).
+# Musi odpowiadać rzeczywistej konfiguracji pluginu — inne waluty → fallback EUR.
+STORE_CURRENCIES=PLN,EUR,AED,CZK,HUF,DKK,SEK,NOK,RON,BGN,GBP
+
 # === Logging ===
 # Poziom logowania: debug | info | warn | error  (domyślnie: info)
 # Ustaw debug tymczasowo podczas diagnozowania problemów.

+ 0 - 0
ID


+ 69 - 0
fix_compat.py

@@ -0,0 +1,69 @@
+import json, re
+
+path = r'c:/Users/aiac_dev/eks_relay/flowise-tools/get_product_compatibility.json'
+
+with open(path, 'r', encoding='utf-8') as f:
+    data = json.load(f)
+
+# ── 1. Schema: insert product_id before language ────────────────────
+schema = json.loads(data['schema'])
+for s in schema:
+    if s['property'] == 'language':
+        s['id'] = 6
+schema.insert(5, {
+    'id': 5,
+    'property': 'product_id',
+    'description': 'ID produktu — podaj zamiast product_name gdy uzytkownik wybral z listy (uzyj mapy Number->ID)',
+    'type': 'number',
+    'required': False,
+})
+data['schema'] = json.dumps(schema, ensure_ascii=False)
+
+# ── 2. Func: work on the actual decoded JS string ───────────────────
+func = data['func']
+
+# 2a. Add product_id variable
+OLD_A = "const language = typeof $language !== 'undefined' && $language ? String($language) : ''\n\nif (!car_brand"
+NEW_A = "const language = typeof $language !== 'undefined' && $language ? String($language) : ''\nconst product_id = typeof $product_id !== 'undefined' && $product_id ? Number($product_id) : 0\n\nif (!car_brand"
+assert OLD_A in func, 'pattern 2a not found'
+func = func.replace(OLD_A, NEW_A)
+
+# 2b. Body construction: product_id takes priority over product_name
+OLD_B = '  if (car_engine) body.car_engine = car_engine\n  if (product_name) body.product_name = product_name\n  if (language) body.language = language'
+NEW_B = '  if (car_engine) body.car_engine = car_engine\n  if (product_id > 0) body.product_id = product_id\n  else if (product_name) body.product_name = product_name\n  if (language) body.language = language'
+assert OLD_B in func, 'pattern 2b not found'
+func = func.replace(OLD_B, NEW_B)
+
+# 2c. Replace options handler block
+match = re.search(r'    // Opcje do wyboru \(np\. silnik.*?    \}', func, re.DOTALL)
+assert match, 'options handler not found'
+old_c = match.group(0)
+
+# In the actual JS string (after JSON.load), \n inside template literals
+# is the two-char sequence \n. Python represents this as \\n in string literals.
+new_c = (
+    "    // Opcje do wyboru\n"
+    "    if (result && result.error === 'options') {\n"
+    "      const fieldName = result.field || 'pole'\n"
+    "      const rawOptions = result.options || []\n"
+    "\n"
+    "      // Product options with IDs — use ID map like get_product_data\n"
+    "      if (fieldName === 'product_name' && rawOptions.length > 0 && typeof rawOptions[0] === 'object') {\n"
+    "        const idMap = rawOptions.map((o, i) => `${i + 1}=${o.id}`).join(', ')\n"
+    "        const list = rawOptions.map((o, i) => `${i + 1}. ${o.title}`).join('\\n')\n"
+    "        return `Found ${rawOptions.length} products:\\n${list}\\n\\n[INSTRUCTION: Show the numbered list to the user. When user picks a number N, use the Number->ID map to get the product_id, then call this tool again with product_id=<that id> and the same car parameters. NEVER search by name again.\\nNumber->ID: ${idMap}]`\n"
+    "      }\n"
+    "\n"
+    "      // Generic options (brand, model, engine)\n"
+    "      const options = rawOptions.map(o => typeof o === 'object' ? o.title : String(o).trim()).filter(Boolean)\n"
+    "      return `Selection required for field\"${fieldName}\". Available options:\\n${options.map((o, i) => `${i + 1}. ${o}`).join('\\n')}\\nAsk the user to choose, then call the tool again with the selected value as parameter\"${fieldName}\".`\n"
+    "    }"
+)
+
+func = func.replace(old_c, new_c)
+data['func'] = func
+
+with open(path, 'w', encoding='utf-8') as f:
+    json.dump(data, f, ensure_ascii=False, indent=2)
+
+print('OK')

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
flowise-tools/get_car_data.json


+ 3 - 3
flowise-tools/get_order_data.json

@@ -1,8 +1,8 @@
 {
   "name": "get_order_data",
-  "description": "Pobierz dane zamówienia z WooCommerce. Zawsze podaj email klienta (używany do weryfikacji tożsamości). Opcjonalnie podaj numer zamówienia. Email pobierz z danych konwersacji — NIE pytaj użytkownika o email jeśli piszą z maila.",
+  "description": "IMPORTANT: Always reply to the customer in their own language — the exact language they write in. Never switch to Polish unless the customer writes in Polish.\n\nPobierz dane zamówienia z WooCommerce. Zawsze podaj email klienta (używany do weryfikacji tożsamości). Opcjonalnie podaj numer zamówienia. Email pobierz z danych konwersacji — NIE pytaj użytkownika o email jeśli piszą z maila. Zawsze przekazuj parametr language z wykrytym językiem klienta, aby wartości były w odpowiedniej walucie.",
   "color": "linear-gradient(rgb(100,150,200), rgb(50,100,170))",
   "iconSrc": "",
-  "schema": "[{\"id\":0,\"property\":\"orderNumber\",\"description\":\"Numer zamówienia WooCommerce (opcjonalny)\",\"type\":\"string\",\"required\":false},{\"id\":1,\"property\":\"email\",\"description\":\"Adres e-mail klienta — wymagany do weryfikacji. Jeśli nieznany, zostanie pobrany automatycznie z danych konwersacji.\",\"type\":\"string\",\"required\":false}]",
-  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nconst orderNumber = typeof $orderNumber !== 'undefined' && $orderNumber ? String($orderNumber) : ''\n// Email: explicit parameter OR auto-filled from conversation sender\nconst email = (typeof $email !== 'undefined' && $email)\n  ? String($email)\n  : String(($vars && $vars.sender_email) || '')\n\nif (!email) return 'Błąd: nie można ustalić adresu e-mail klienta potrzebnego do weryfikacji tożsamości.'\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const body = { email }\n  if (orderNumber) body.orderNumber = orderNumber\n\n  const res = await fetch(`${base}/tools/get_order_data`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify(body)\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return JSON.stringify(data.data, null, 2)\n  }\n  if (data.code === 'UNAUTHORIZED') {\n    return 'Brak autoryzacji: podany adres e-mail nie pasuje do zamówienia.'\n  }\n  return `Błąd: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
+  "schema": "[{\"id\":0,\"property\":\"orderNumber\",\"description\":\"Numer zamówienia WooCommerce (opcjonalny)\",\"type\":\"string\",\"required\":false},{\"id\":1,\"property\":\"email\",\"description\":\"Adres e-mail klienta — wymagany do weryfikacji. Jeśli nieznany, zostanie pobrany automatycznie z danych konwersacji.\",\"type\":\"string\",\"required\":false},{\"id\":2,\"property\":\"language\",\"description\":\"Kod języka WPML wykryty z wiadomości klienta (np. pl, de, en, fr, cs, tr). Wymagany do podania wartości zamówienia w odpowiedniej walucie.\",\"type\":\"string\",\"required\":false},{\"id\":3,\"property\":\"currency\",\"description\":\"Nadpisanie waluty — podaj kod ISO 4217 (np. EUR, CZK, PLN) gdy użytkownik wyraźnie prosi o wartości zamówienia w konkretnej walucie. Opcjonalne.\",\"type\":\"string\",\"required\":false}]",
+  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nconst orderNumber = typeof $orderNumber !== 'undefined' && $orderNumber ? String($orderNumber) : ''\n// Email: explicit parameter OR auto-filled from conversation sender\nconst email = (typeof $email !== 'undefined' && $email)\n  ? String($email)\n  : String(($vars && $vars.sender_email) || '')\nconst language = typeof $language !== 'undefined' && $language ? String($language) : ''\nconst currency = typeof $currency !== 'undefined' && $currency ? String($currency).toUpperCase().trim() : ''\n\nif (!email) return 'Error: unable to determine customer email address required for identity verification.'\nif (!secret) return 'Error: missing $vars.relay_shared_secret'\n\ntry {\n  const body = { email }\n  if (orderNumber) body.orderNumber = orderNumber\n  if (language) body.language = language\n  if (currency) body.currency = currency\n\n  const res = await fetch(`${base}/tools/get_order_data`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify(body)\n  })\n  const data = await res.json()\n  if (data.ok) {\n    const currencyNote = data.currency_fallback && data.currency_note ? '\\n[NOTE: ' + data.currency_note + ']' : ''\n    return JSON.stringify(data.data, null, 2) + currencyNote\n  }\n  if (data.code === 'UNAUTHORIZED') {\n    return 'Unauthorized: the provided email address does not match the order.'\n  }\n  return `Error: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Connection error: ${error?.message || String(error)}`\n}\n"
 }

+ 2 - 2
flowise-tools/get_payment_methods.json

@@ -1,8 +1,8 @@
 {
   "name": "get_payment_methods",
-  "description": "Pobierz dostępne metody płatności ze sklepu WooCommerce.",
+  "description": "IMPORTANT: Always reply to the customer in their own language — the exact language they write in. Never switch to Polish unless the customer writes in Polish.\n\nPobierz dostępne metody płatności ze sklepu WooCommerce. Wywołaj gdy użytkownik pyta o metody płatności, sposoby zapłaty, czy można płacić kartą, przelewem, BLIK-iem, gotówką itp. Nigdy nie zgaduj dostępnych metod — zawsze wywołaj to narzędzie.",
   "color": "linear-gradient(rgb(200,180,100), rgb(180,140,50))",
   "iconSrc": "",
   "schema": "[]",
-  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/get_payment_methods`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({})\n  })\n  const data = await res.json()\n  if (data.ok) {\n    const gateways = data.data\n    if (!Array.isArray(gateways) || !gateways.length) return 'Brak dostępnych metod płatności.'\n    const lines = gateways.map(g => '- ' + g.title).join('\\n')\n    return 'Dostępne metody płatności:\\n' + lines\n  }\n  return `Info: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
+  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nif (!secret) return 'Error: missing $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/get_payment_methods`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({})\n  })\n  const data = await res.json()\n  if (data.ok) {\n    const gateways = data.data\n    if (!Array.isArray(gateways) || !gateways.length) return 'No payment methods available.'\n    const lines = gateways.map(g => '- ' + g.title).join('\\n')\n    return 'Available payment methods:\\n' + lines\n  }\n  return `Info: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Connection error: ${error?.message || String(error)}`\n}\n"
 }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 2
flowise-tools/get_product_compatibility.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2 - 3
flowise-tools/get_product_data.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 2
flowise-tools/get_shipping_data.json


+ 2 - 2
flowise-tools/new_ticket.json

@@ -1,8 +1,8 @@
 {
   "name": "new_ticket",
-  "description": "Utwórz ticket supportowy i przekieruj rozmowę do zespołu wsparcia. Używaj gdy klient wyraźnie prosi o kontakt z człowiekiem lub gdy nie jesteś w stanie rozwiązać problemu.",
+  "description": "IMPORTANT: Always reply to the customer in their own language — the exact language they write in. Never switch to Polish unless the customer writes in Polish.\n\nUtwórz ticket supportowy i przekieruj rozmowę do obsługi sklepu. Używaj gdy:\n- klient wyraźnie prosi o kontakt z człowiekiem\n- nie jesteś w stanie rozwiązać problemu samodzielnie\n- baza wiedzy wskazuje że sprawa wymaga kontaktu z supportem\n- minęło dużo czasu od realizacji zamówienia, a paczka nie dotarła\n\nWAŻNE: gdy get_order_data zwróci 'Unauthorized' — NIE zakładaj od razu ticketu. Najpierw poproś klienta o weryfikację numeru zamówienia. Ticket zakładaj dopiero gdy klient potwierdzi dane i nadal nie możesz pomóc, lub wyraźnie poprosi o kontakt z obsługą.\n\nZa każdym razem gdy tworzysz ticket, poinformuj klienta i poproś o cierpliwość — osoba z obsługi skontaktuje się w najbliższym czasie. Przekaż klientowi numer otrzymanego zgłoszenia.",
   "color": "linear-gradient(rgb(189,154,166), rgb(167,81,91))",
   "iconSrc": "",
   "schema": "[{\"id\":0,\"property\":\"conversationId\",\"description\":\"ID konwersacji Chatwoot (z overrideConfig.conversationId)\",\"type\":\"number\",\"required\":true},{\"id\":1,\"property\":\"summary\",\"description\":\"Krótkie podsumowanie problemu klienta\",\"type\":\"string\",\"required\":false}]",
-  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nconst conversationId = typeof $conversationId !== 'undefined' ? Number($conversationId) : Number(($flow && $flow.sessionId || '').replace('chatwoot:',''))\nconst summary = typeof $summary !== 'undefined' ? String($summary) : ''\n\nif (!conversationId || conversationId <= 0) return 'Błąd: brak conversationId'\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/new_ticket`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({ conversationId, summary })\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return `Ticket utworzony: ${data.ticketNumber} (status: ${data.status})`\n  }\n  return `Błąd tworzenia ticketu: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
+  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nconst conversationId = typeof $conversationId !== 'undefined' ? Number($conversationId) : Number(($flow && $flow.sessionId || '').replace('chatwoot:',''))\nconst summary = typeof $summary !== 'undefined' ? String($summary) : ''\n\nif (!conversationId || conversationId <= 0) return 'Error: missing conversationId'\nif (!secret) return 'Error: missing $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/new_ticket`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({ conversationId, summary })\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return `Ticket created: ${data.ticketNumber} (status: ${data.status})`\n  }\n  return `Error creating ticket: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Connection error: ${error?.message || String(error)}`\n}\n"
 }

+ 123 - 13
src/Clients/WooCommerceClient.php

@@ -66,17 +66,19 @@ final class WooCommerceClient
      * Find order by order number or by customer email.
      * Priority: orderNumber > email.
      */
-    public function getOrder(?string $orderNumber = null, ?string $email = null): array
+    public function getOrder(?string $orderNumber = null, ?string $email = null, string $currency = ''): array
     {
+        $currencyParam = $currency !== '' ? ['currency' => $currency] : [];
+
         if ($orderNumber !== null && $orderNumber !== '') {
             // WooCommerce stores order number as the post ID by default.
             // Try direct fetch first.
             try {
-                return $this->get("orders/{$orderNumber}");
+                return $this->get("orders/{$orderNumber}", $currencyParam);
             } catch (HttpException) {
                 // If direct fetch fails (e.g. custom order numbers plugin),
                 // fall back to search.
-                $results = $this->get('orders', ['search' => $orderNumber, 'per_page' => 1]);
+                $results = $this->get('orders', array_merge(['search' => $orderNumber, 'per_page' => 1], $currencyParam));
                 if (!empty($results)) {
                     return $results[0];
                 }
@@ -85,7 +87,7 @@ final class WooCommerceClient
         }
 
         if ($email !== null && $email !== '') {
-            $results = $this->get('orders', ['search' => $email, 'per_page' => 5, 'orderby' => 'date', 'order' => 'desc']);
+            $results = $this->get('orders', array_merge(['search' => $email, 'per_page' => 5, 'orderby' => 'date', 'order' => 'desc'], $currencyParam));
             if (empty($results)) {
                 throw new HttpException(404, 'ORDER_NOT_FOUND', "No orders found for email {$email}.");
             }
@@ -98,9 +100,13 @@ final class WooCommerceClient
     /**
      * Get multiple orders by email (for listing).
      */
-    public function getOrdersByEmail(string $email, int $limit = 5): array
+    public function getOrdersByEmail(string $email, int $limit = 5, string $currency = ''): array
     {
-        return $this->get('orders', ['search' => $email, 'per_page' => $limit, 'orderby' => 'date', 'order' => 'desc']);
+        $params = ['search' => $email, 'per_page' => $limit, 'orderby' => 'date', 'order' => 'desc'];
+        if ($currency !== '') {
+            $params['currency'] = $currency;
+        }
+        return $this->get('orders', $params);
     }
 
     // ---------------------------------------------------------------
@@ -109,15 +115,24 @@ final class WooCommerceClient
 
     /**
      * Get product by ID or by SKU.
+     * $lang — WPML language code (e.g. 'de', 'pl'), passed as ?lang= to get translated content.
      */
-    public function getProduct(?int $productId = null, ?string $sku = null): array
+    public function getProduct(?int $productId = null, ?string $sku = null, string $currency = '', string $lang = ''): array
     {
+        $params = [];
+        if ($currency !== '') $params['currency'] = $currency;
+        if ($lang !== '')     $params['lang']     = $lang;
+
         if ($productId !== null) {
-            return $this->get("products/{$productId}");
+            $product = $this->get("products/{$productId}", $params);
+            if (($product['status'] ?? '') !== 'publish' || ($product['catalog_visibility'] ?? '') === 'hidden') {
+                throw new HttpException(404, 'PRODUCT_NOT_FOUND', "Product #{$productId} is not available.");
+            }
+            return $product;
         }
 
         if ($sku !== null && $sku !== '') {
-            $results = $this->get('products', ['sku' => $sku, 'per_page' => 1]);
+            $results = $this->get('products', array_merge(['sku' => $sku, 'per_page' => 1, 'status' => 'publish'], $params));
             if (empty($results)) {
                 throw new HttpException(404, 'PRODUCT_NOT_FOUND', "Product with SKU {$sku} not found.");
             }
@@ -129,10 +144,72 @@ final class WooCommerceClient
 
     /**
      * Search products by name/keyword.
+     * $lang — WPML language code (e.g. 'de', 'pl'), passed as ?lang= to search within the
+     * translated product index and return translated content.
      */
-    public function searchProducts(string $query, int $limit = 5): array
+    public function searchProducts(string $query, int $limit = 5, string $currency = '', string $lang = ''): array
     {
-        return $this->get('products', ['search' => $query, 'per_page' => $limit]);
+        $params = ['search' => $query, 'per_page' => $limit, 'status' => 'publish'];
+        if ($currency !== '') $params['currency'] = $currency;
+        if ($lang !== '')     $params['lang']     = $lang;
+        $results = $this->get('products', $params);
+
+        // WPML may return empty results when the default language is explicitly
+        // requested via ?lang=XX. Retry without lang so WPML uses the default.
+        if (empty($results) && $lang !== '') {
+            $fallback = ['search' => $query, 'per_page' => $limit, 'status' => 'publish'];
+            if ($currency !== '') $fallback['currency'] = $currency;
+            $results = $this->get('products', $fallback);
+        }
+
+        // Cross-language fallback: the search term may be in a different language than the
+        // store default (e.g. user saw product names in German, now chats in Polish).
+        // Use WPML's lang=all to search across ALL language versions simultaneously —
+        // this is language-agnostic and works regardless of how many languages the store has.
+        // After finding matching products, re-fetch each one by SKU in the target language
+        // (SKUs are identical across all translations in WooCommerce + WPML).
+        if (empty($results) && $lang !== '') {
+            $allFound = [];
+            try {
+                $allParams = ['search' => $query, 'per_page' => $limit, 'status' => 'publish', 'lang' => 'all'];
+                if ($currency !== '') $allParams['currency'] = $currency;
+                $allFound = $this->get('products', $allParams);
+            } catch (\Throwable) { /* lang=all not supported by this WPML version — skip */ }
+
+            if (!empty($allFound)) {
+                // Re-fetch each found product in the target language using its SKU.
+                // Deduplicate by SKU to avoid returning multiple language versions of the same product.
+                $translated = [];
+                $seenSkus   = [];
+                foreach ($allFound as $foundProduct) {
+                    $sku = trim((string)($foundProduct['sku'] ?? ''));
+                    if ($sku === '') {
+                        // No SKU — keep the found version as-is (can't re-fetch reliably)
+                        $translated[] = $foundProduct;
+                        continue;
+                    }
+                    if (isset($seenSkus[$sku])) continue; // already processed this product
+                    $seenSkus[$sku] = true;
+                    try {
+                        $skuParams = ['sku' => $sku, 'per_page' => 1, 'status' => 'publish'];
+                        if ($lang !== '')     $skuParams['lang']     = $lang;
+                        if ($currency !== '') $skuParams['currency'] = $currency;
+                        $skuResult  = $this->get('products', $skuParams);
+                        $translated[] = !empty($skuResult) ? $skuResult[0] : $foundProduct;
+                    } catch (\Throwable) {
+                        $translated[] = $foundProduct; // re-fetch failed — keep found version
+                    }
+                }
+                if (!empty($translated)) {
+                    $results = $translated;
+                }
+            }
+        }
+
+        // Exclude hidden products (catalog_visibility=hidden).
+        $results = array_values(array_filter($results, fn($p) => ($p['catalog_visibility'] ?? '') !== 'hidden'));
+
+        return $results;
     }
 
     // ---------------------------------------------------------------
@@ -149,9 +226,42 @@ final class WooCommerceClient
         return $this->get('shipping/zones');
     }
 
-    public function getShippingMethods(int $zoneId): array
+    public function getShippingMethods(int $zoneId, string $currency = ''): array
+    {
+        $params = $currency !== '' ? ['currency' => $currency] : [];
+        return $this->get("shipping/zones/{$zoneId}/methods", $params);
+    }
+
+    /**
+     * Get locations (country codes) for a shipping zone.
+     * Returns array of objects with 'code' (e.g. 'PL', 'DE') and 'type' ('country'|'state'|'continent').
+     * Zone 0 (Rest of the World) typically returns an empty array.
+     */
+    public function getShippingZoneLocations(int $zoneId): array
+    {
+        try {
+            return $this->get("shipping/zones/{$zoneId}/locations");
+        } catch (HttpException) {
+            return [];
+        }
+    }
+
+    /**
+     * Batch-fetch products by IDs.
+     * Used to retrieve translated titles/prices after finding product IDs in a different language context.
+     * $lang — WPML language code, returned titles will be in that language (falls back to default if no translation).
+     */
+    public function getProductsByIds(array $ids, string $lang = '', string $currency = ''): array
     {
-        return $this->get("shipping/zones/{$zoneId}/methods");
+        if (empty($ids)) return [];
+        $params = [
+            'include'  => implode(',', array_map('intval', $ids)),
+            'per_page' => min(count($ids), 100),
+            'status'   => 'publish',
+        ];
+        if ($lang !== '')     $params['lang']     = $lang;
+        if ($currency !== '') $params['currency'] = $currency;
+        return $this->get('products', $params);
     }
 
     /**

+ 121 - 0
src/Core/StoreLocales.php

@@ -0,0 +1,121 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Core;
+
+/**
+ * Maps WPML language codes to ISO 4217 currency codes.
+ *
+ * Used to resolve the correct shop currency based on the detected language
+ * of the customer's message. Handles fallback to EUR for currencies that are
+ * not configured in the shop's multicurrency plugin.
+ *
+ * Supported currencies are read from the STORE_CURRENCIES env variable
+ * (comma-separated ISO 4217 codes). Default matches the shop configuration.
+ */
+final class StoreLocales
+{
+    /**
+     * WPML language code → natural ISO 4217 currency code.
+     * Based on the official currency of the country/region for each language.
+     */
+    private const CURRENCY_MAP = [
+        'aed'   => 'AED',  // Arabic (UAE) → UAE Dirham
+        'be'    => 'BYN',  // Belarusian → Belarusian ruble (not in shop → fallback)
+        'bg'    => 'BGN',  // Bulgarian → Bulgarian lev
+        'hr'    => 'EUR',  // Croatian → Euro (adopted 2023)
+        'cs'    => 'CZK',  // Czech → Czech koruna
+        'da'    => 'DKK',  // Danish → Danish krone
+        'nl'    => 'EUR',  // Dutch → Euro
+        'en'    => 'EUR',  // English → Euro (default for EU context)
+        'et'    => 'EUR',  // Estonian → Euro
+        'fi'    => 'EUR',  // Finnish → Euro
+        'fr'    => 'EUR',  // French → Euro
+        'de'    => 'EUR',  // German → Euro
+        'el'    => 'EUR',  // Greek → Euro
+        'hu'    => 'HUF',  // Hungarian → Hungarian forint
+        'it'    => 'EUR',  // Italian → Euro
+        'lv'    => 'EUR',  // Latvian → Euro
+        'lt'    => 'EUR',  // Lithuanian → Euro
+        'no'    => 'NOK',  // Norwegian → Norwegian krone
+        'pl'    => 'PLN',  // Polish → Polish złoty
+        'pt-pt' => 'EUR',  // Portuguese (Portugal) → Euro
+        'ro'    => 'RON',  // Romanian → Romanian leu
+        'sk'    => 'EUR',  // Slovak → Euro
+        'sl'    => 'EUR',  // Slovenian → Euro
+        'es'    => 'EUR',  // Spanish → Euro
+        'sv'    => 'SEK',  // Swedish → Swedish krona
+        'tr'    => 'TRY',  // Turkish → Turkish lira (not in shop → fallback)
+        'uk'    => 'UAH',  // Ukrainian → Ukrainian hryvnia (not in shop → fallback)
+    ];
+
+    /** Fallback currency used when the natural currency is not supported. */
+    public const FALLBACK_CURRENCY = 'EUR';
+
+    /**
+     * Default list of currencies configured in the shop's multicurrency plugin.
+     * Used when STORE_CURRENCIES env variable is not set.
+     * Keep in sync with WooCommerce multicurrency configuration.
+     */
+    private const DEFAULT_SUPPORTED = 'PLN,EUR,AED,CZK,HUF,DKK,SEK,NOK,RON,BGN,GBP';
+
+    /**
+     * Resolve a WPML language code to a shop-supported ISO 4217 currency.
+     *
+     * Returns an array with:
+     *   currency         — ISO 4217 code to pass to the WooCommerce API
+     *   fallback         — true if we had to fall back from the natural currency
+     *   natural_currency — (only when fallback=true) the natural currency for the language
+     *   reason           — (only when fallback=true) 'unknown_language' | 'unsupported_currency'
+     *
+     * @param string|null $language WPML language code (e.g. 'de', 'pl', 'pt-pt')
+     */
+    public static function resolve(?string $language): array
+    {
+        if ($language === null || $language === '') {
+            return ['currency' => self::FALLBACK_CURRENCY, 'fallback' => false];
+        }
+
+        $lang    = strtolower(trim($language));
+        $natural = self::CURRENCY_MAP[$lang] ?? null;
+
+        if ($natural === null) {
+            return [
+                'currency' => self::FALLBACK_CURRENCY,
+                'fallback' => true,
+                'reason'   => 'unknown_language',
+            ];
+        }
+
+        if (!in_array($natural, self::supportedCurrencies(), true)) {
+            return [
+                'currency'         => self::FALLBACK_CURRENCY,
+                'fallback'         => true,
+                'reason'           => 'unsupported_currency',
+                'natural_currency' => $natural,
+            ];
+        }
+
+        return ['currency' => $natural, 'fallback' => false];
+    }
+
+    /**
+     * Check whether a currency code is supported by the shop's multicurrency configuration.
+     *
+     * @param string $currency ISO 4217 code (comparison is case-insensitive)
+     */
+    public static function isSupported(string $currency): bool
+    {
+        return in_array(strtoupper($currency), self::supportedCurrencies(), true);
+    }
+
+    /**
+     * @return string[]
+     */
+    private static function supportedCurrencies(): array
+    {
+        $raw = Env::get('STORE_CURRENCIES', self::DEFAULT_SUPPORTED);
+        return array_values(array_filter(array_map('trim', explode(',', strtoupper($raw)))));
+    }
+}

+ 179 - 33
src/Handlers/WooToolsHandler.php

@@ -37,24 +37,37 @@ final class WooToolsHandler
             throw new HttpException(400, 'MISSING_PARAMS', 'email is required to verify order ownership.');
         }
 
+        $language         = $body['language'] ?? null;
+        $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
+        $locale           = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
+            ? ['currency' => $currencyOverride, 'fallback' => false]
+            : StoreLocales::resolve($language);
+
         $woo = new WooCommerceClient();
 
         if ($orderNumber !== null && $orderNumber !== '') {
-            $order = $woo->getOrder(orderNumber: $orderNumber);
+            $order = $woo->getOrder(orderNumber: $orderNumber, currency: $locale['currency']);
             // Security: verify that the caller's email matches the billing email on the order.
             $billingEmail = strtolower(trim($order['billing']['email'] ?? ''));
             if ($billingEmail === '' || $billingEmail !== strtolower(trim($email))) {
                 throw new HttpException(403, 'UNAUTHORIZED', 'Provided email does not match the order.');
             }
         } else {
-            $order = $woo->getOrder(email: $email);
+            $order = $woo->getOrder(email: $email, currency: $locale['currency']);
+        }
+
+        $response = [
+            'ok'       => true,
+            'data'     => self::formatOrder($order),
+            'currency' => $locale['currency'],
+        ];
+        if ($locale['fallback']) {
+            $response['currency_fallback'] = true;
+            $response['currency_note']     = self::buildCurrencyNote($locale, $language);
         }
 
         // Return a curated subset useful for the LLM agent
-        Router::sendJson(200, [
-            'ok'   => true,
-            'data' => self::formatOrder($order),
-        ]);
+        Router::sendJson(200, $response);
     }
 
     // =================================================================
@@ -68,25 +81,38 @@ final class WooToolsHandler
         $productId = isset($body['productId']) ? (int)$body['productId'] : (isset($body['product_id']) ? (int)$body['product_id'] : null);
         $sku       = $body['sku'] ?? null;
         $search    = $body['search'] ?? null;
+        $language         = $body['language'] ?? null;
+        $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
+        $locale           = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
+            ? ['currency' => $currencyOverride, 'fallback' => false]
+            : StoreLocales::resolve($language);
 
         $woo = new WooCommerceClient();
 
+        $currencyMeta = ['currency' => $locale['currency']];
+        if ($locale['fallback']) {
+            $currencyMeta['currency_fallback'] = true;
+            $currencyMeta['currency_note']     = self::buildCurrencyNote($locale, $language);
+        }
+
+        $lang = $language ?? '';
+
         if ($productId !== null && $productId > 0) {
-            $product = $woo->getProduct(productId: $productId);
-            Router::sendJson(200, ['ok' => true, 'data' => self::formatProduct($product)]);
+            $product = $woo->getProduct(productId: $productId, currency: $locale['currency'], lang: $lang);
+            Router::sendJson(200, array_merge(['ok' => true, 'data' => self::formatProduct($product)], $currencyMeta));
             return;
         }
 
         if ($sku !== null && $sku !== '') {
-            $product = $woo->getProduct(sku: $sku);
-            Router::sendJson(200, ['ok' => true, 'data' => self::formatProduct($product)]);
+            $product = $woo->getProduct(sku: $sku, currency: $locale['currency'], lang: $lang);
+            Router::sendJson(200, array_merge(['ok' => true, 'data' => self::formatProduct($product)], $currencyMeta));
             return;
         }
 
         if ($search !== null && $search !== '') {
-            $products = $woo->searchProducts($search);
+            $products  = $woo->searchProducts($search, currency: $locale['currency'], lang: $lang);
             $formatted = array_map(fn($p) => self::formatProduct($p), $products);
-            Router::sendJson(200, ['ok' => true, 'data' => $formatted]);
+            Router::sendJson(200, array_merge(['ok' => true, 'data' => $formatted], $currencyMeta));
             return;
         }
 
@@ -101,6 +127,12 @@ final class WooToolsHandler
         Auth::requireBearer();
         $body = Router::jsonBody();
 
+        $language         = $body['language'] ?? null;
+        $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
+        $locale           = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
+            ? ['currency' => $currencyOverride, 'fallback' => false]
+            : StoreLocales::resolve($language);
+
         $woo = new WooCommerceClient();
 
         // If zoneId provided — return methods for that specific zone
@@ -108,17 +140,42 @@ final class WooToolsHandler
 
         try {
             if ($zoneId !== null) {
-                $methods = $woo->getShippingMethods((int)$zoneId);
-                Router::sendJson(200, ['ok' => true, 'data' => ['zone_id' => (int)$zoneId, 'methods' => $methods]]);
+                // Use custom WP endpoint to get per-currency shipping costs
+                // (WC REST API only returns PLN costs; WCML costs are in wp_options)
+                $wpBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
+                $url    = $wpBase . '/shipping-costs?' . http_build_query([
+                    'zone_id'  => (int)$zoneId,
+                    'currency' => $locale['currency'],
+                ]);
+                $res = HttpClient::request('GET', $url);
+
+                $response = ['ok' => true, 'data' => $res['json'] ?? [], 'currency' => $locale['currency']];
+                if ($locale['fallback']) {
+                    $response['currency_fallback'] = true;
+                    $response['currency_note']     = self::buildCurrencyNote($locale, $language);
+                }
+                Router::sendJson(200, $response);
                 return;
             }
 
-            // Default: return list of zones (without methods to avoid 29+ API calls)
-            $zones = $woo->getShippingZones();
-            $formatted = array_map(fn($z) => [
-                'id'   => $z['id'] ?? 0,
-                'name' => $z['name'] ?? '',
-            ], $zones);
+            // Default: return list of zones with ISO country codes
+            // Zone 0 (Rest of the World) has no locations — it's the fallback zone.
+            $zones     = $woo->getShippingZones();
+            $formatted = [];
+            foreach ($zones as $z) {
+                $zid       = (int)($z['id'] ?? 0);
+                $locations = $woo->getShippingZoneLocations($zid);
+                // Extract country-level ISO codes only (skip state/continent entries)
+                $countries = array_values(array_filter(array_map(
+                    fn($l) => ($l['type'] ?? '') === 'country' ? strtoupper((string)($l['code'] ?? '')) : null,
+                    $locations
+                )));
+                $formatted[] = [
+                    'id'        => $zid,
+                    'name'      => $z['name'] ?? '',
+                    'countries' => $countries, // ISO 3166-1 alpha-2 codes, e.g. ["PL"] or ["GB","IE"]
+                ];
+            }
 
             Router::sendJson(200, [
                 'ok'   => true,
@@ -186,27 +243,107 @@ final class WooToolsHandler
 
         $restBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
 
-        $productName = $body['product_name'] ?? ($body['productName'] ?? null);
-        $carBrand    = $body['car_brand']    ?? ($body['carBrand']   ?? null);
-        $carModel    = $body['car_model']    ?? ($body['carModel']   ?? null);
-        $carYear     = $body['car_year']     ?? ($body['carYear']    ?? null);
-        $carEngine   = $body['car_engine']   ?? ($body['carEngine']  ?? null);
+        $productName      = $body['product_name'] ?? ($body['productName'] ?? null);
+        $productId        = isset($body['productId']) ? (int)$body['productId'] : (isset($body['product_id']) ? (int)$body['product_id'] : null);
+        $carBrand         = $body['car_brand']    ?? ($body['carBrand']   ?? null);
+        $carModel         = $body['car_model']    ?? ($body['carModel']   ?? null);
+        $carYear          = $body['car_year']     ?? ($body['carYear']    ?? null);
+        $carEngine        = $body['car_engine']        ?? ($body['carEngine']       ?? null);
+        $carEngineIndex   = isset($body['car_engine_index']) ? (int)$body['car_engine_index'] : -1;
+        $language         = $body['language']     ?? null;
+        $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
+        $locale           = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
+            ? ['currency' => $currencyOverride, 'fallback' => false]
+            : StoreLocales::resolve($language);
+
+        // When a non-Polish product name is given, try to pre-resolve it to a product ID
+        // via the WooCommerce REST API (which has a PL language fallback). This prevents
+        // the WP endpoint from failing to find products with non-Polish search terms.
+        if ($productName !== null && $productName !== '' && ($productId === null || $productId === 0)) {
+            $woo   = new WooCommerceClient();
+            $found = $woo->searchProducts($productName, 5, '', $language ?? '');
+            if (empty($found) && $language && $language !== 'pl') {
+                // Fallback: search in PL (default) database without language filter
+                $found = $woo->searchProducts($productName, 5, '');
+            }
+            if (count($found) === 1) {
+                // Unambiguous match — use product ID so WP endpoint skips name-based search
+                $productId   = (int)$found[0]['id'];
+                $productName = null;
+            }
+            // Multiple matches or no match: pass product_name to WP endpoint as usual
+        }
 
         $payload = [];
+        if ($productId !== null && $productId > 0)       $payload['product_id']   = $productId;
         if ($productName !== null && $productName !== '') $payload['product_name'] = $productName;
         if ($carBrand    !== null && $carBrand    !== '') $payload['car_brand']    = $carBrand;
         if ($carModel    !== null && $carModel    !== '') $payload['car_model']    = $carModel;
         if ($carYear     !== null && $carYear     !== '') $payload['car_year']     = $carYear;
-        if ($carEngine   !== null && $carEngine   !== '') $payload['car_engine']   = $carEngine;
+        if ($carEngine      !== null && $carEngine      !== '') $payload['car_engine']       = $carEngine;
+        if ($carEngineIndex >= 0)                              $payload['car_engine_index'] = $carEngineIndex;
+        if ($language       !== null && $language       !== '') $payload['lang']             = $language;
+        $payload['currency'] = $locale['currency'];
+
+        $res  = HttpClient::request('POST', $restBase . '/product-compatibility', [], $payload);
+        $data = $res['json'] ?? $res['body'];
+
+        // Post-process product titles: the WP endpoint does not switch WPML for the
+        // selected_product branch, so titles come back in Polish. Re-fetch from WC REST
+        // API with the correct language to get the translated title.
+        if (is_array($data) && $language !== null && $language !== '' && $language !== 'pl') {
+            $woo = new WooCommerceClient();
+
+            // Case A: specific product compatibility check — replace title in selected_product
+            if (isset($data['selected_product']['id'])) {
+                try {
+                    $translated = $woo->getProduct(
+                        productId: (int)$data['selected_product']['id'],
+                        lang: $language,
+                        currency: $locale['currency']
+                    );
+                    $data['selected_product']['title'] = $translated['name'] ?? $data['selected_product']['title'];
+                } catch (\Throwable) { /* keep original title on failure */ }
+            }
 
-        $res = HttpClient::request(
-            'POST',
-            $restBase . '/product-compatibility',
-            [],
-            $payload
-        );
+            // Case B: disambiguation options list for product_name — batch-translate titles
+            if (
+                ($data['error'] ?? '') === 'options'
+                && ($data['field'] ?? '') === 'product_name'
+                && !empty($data['options'])
+                && is_array($data['options'])
+            ) {
+                $ids = array_values(array_filter(array_column($data['options'], 'id')));
+                if (!empty($ids)) {
+                    try {
+                        $translated  = $woo->getProductsByIds($ids, $language, $locale['currency']);
+                        $byId        = [];
+                        foreach ($translated as $tp) {
+                            $byId[(int)($tp['id'] ?? 0)] = $tp['name'] ?? null;
+                        }
+                        foreach ($data['options'] as &$opt) {
+                            $pid = (int)($opt['id'] ?? 0);
+                            if ($pid && isset($byId[$pid])) {
+                                $opt['title'] = $byId[$pid];
+                            }
+                        }
+                        unset($opt);
+                    } catch (\Throwable) { /* keep original titles on failure */ }
+                }
+            }
+        }
 
-        Router::sendJson(200, ['ok' => true, 'data' => $res['json'] ?? $res['body']]);
+        $response = [
+            'ok'       => true,
+            'data'     => $data,
+            'currency' => $locale['currency'],
+        ];
+        if ($locale['fallback']) {
+            $response['currency_fallback'] = true;
+            $response['currency_note']     = self::buildCurrencyNote($locale, $language);
+        }
+
+        Router::sendJson(200, $response);
     }
 
     // =================================================================
@@ -284,6 +421,15 @@ final class WooToolsHandler
         ];
     }
 
+    private static function buildCurrencyNote(array $locale, ?string $language): string
+    {
+        if (($locale['reason'] ?? '') === 'unknown_language') {
+            return 'Unknown language code ' . ($language ?? '?') . '. Prices shown in ' . StoreLocales::FALLBACK_CURRENCY . '.';
+        }
+        $natural = $locale['natural_currency'] ?? ($language ?? '?');
+        return 'Currency for ' . $natural . ' is not supported in the store. Prices shown in ' . StoreLocales::FALLBACK_CURRENCY . '.';
+    }
+
     private static function formatProduct(array $product): array
     {
         return [

+ 142 - 35
wp-plugins/eksrelay_api.php

@@ -27,6 +27,19 @@ add_action( 'rest_api_init', function () {
         'callback'            => 'eksrelay_product_compatibility',
         'permission_callback' => '__return_true',
     ] );
+
+    /**
+     * GET /wp-json/eksrelay/v1/shipping-costs?zone_id=X&currency=EUR
+     *
+     * Returns shipping methods for a zone with costs in the requested currency.
+     * WCML stores per-currency costs in wp_options under the key
+     * woocommerce_{method_id}_{instance_id}_settings as cost_EUR, cost_CZK, etc.
+     */
+    register_rest_route( 'eksrelay/v1', '/shipping-costs', [
+        'methods'             => 'GET',
+        'callback'            => 'eksrelay_shipping_costs',
+        'permission_callback' => '__return_true',
+    ] );
 } );
 
 // ═══════════════════════════════════════════════════════════════════
@@ -237,6 +250,7 @@ function eksrelay_product_compatibility( WP_REST_Request $request ) {
     }
 
     $lang      = sanitize_text_field( $body['lang'] ?? 'pl' );
+    $currency  = strtoupper( sanitize_text_field( $body['currency'] ?? 'PLN' ) );
     $car_year  = intval( sanitize_text_field( $body['car_year'] ) );
     $has_brand = ! empty( $body['car_brand'] );
     $has_model = ! empty( $body['car_model'] );
@@ -297,13 +311,20 @@ function eksrelay_product_compatibility( WP_REST_Request $request ) {
         $car_model = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_model'] ) ) );
     }
 
-    $car_engine   = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_engine']   ?? '' ) ) );
-    $product_name = eksrelay_replace_ascii( trim( sanitize_text_field( $body['product_name'] ?? '' ) ) );
+    $car_engine       = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_engine']       ?? '' ) ) );
+    $car_engine_index = isset( $body['car_engine_index'] ) ? intval( $body['car_engine_index'] ) : -1;
+    $product_name     = eksrelay_replace_ascii( trim( sanitize_text_field( $body['product_name'] ?? '' ) ) );
+    $product_id       = intval( $body['product_id'] ?? 0 );
 
     // ── Znajdź produkt ──────────────────────────────────────────────
     $product = false;
 
-    if ( $product_name ) {
+    if ( $product_id > 0 ) {
+        $p = wc_get_product( $product_id );
+        if ( $p && ! is_wp_error( $p ) ) {
+            $product = $p;
+        }
+    } elseif ( $product_name ) {
         $products = eksrelay_search_products_by_name( $product_name );
 
         if ( count( $products ) > 1 ) {
@@ -318,7 +339,7 @@ function eksrelay_product_compatibility( WP_REST_Request $request ) {
                 return [
                     'error'    => 'options',
                     'field'    => 'product_name',
-                    'options'  => array_map( fn( $p ) => $p->get_title(), $products ),
+                    'options'  => array_map( fn( $p ) => [ 'id' => $p->get_id(), 'title' => $p->get_title() ], $products ),
                     'filtered' => true,
                 ];
             }
@@ -349,19 +370,35 @@ function eksrelay_product_compatibility( WP_REST_Request $request ) {
         return [ 'error' => 'nie znaleziono takiego samochodu', 'car_brand' => $car_brand, 'car_model' => $car_model ];
     }
 
+    // If year filter returns nothing, fall back to all engines for this model (year=0 disables filter).
+    // This handles cases where the user provides an approximate or incorrect year.
+    if ( empty( $engine_info ) && $car_year ) {
+        $engine_info = eksrelay_get_engines_info( $cars, false, 0 );
+    }
+
     $engine_names = array_column( $engine_info, 'name' );
 
     if ( count( $engine_info ) === 1 ) {
         $car_engine = $engine_info[0]['name'];
     }
 
-    if ( count( $engine_info ) > 1 && ! $car_engine ) {
-        return [ 'error' => 'options', 'field' => 'car_engine', 'options' => $engine_names, 'filtered' => false ];
+    if ( count( $engine_info ) > 1 && ! $car_engine && $car_engine_index < 0 ) {
+        // Return options as {index, name} objects so the caller can select by numeric index,
+        // avoiding string-matching issues (e.g. LLM stripping engine-type prefixes like "B ", "D ").
+        $engine_options = array_map(
+            fn( $i, $eng ) => [ 'index' => $i, 'name' => $eng['name'] ],
+            array_keys( $engine_info ),
+            array_values( $engine_info )
+        );
+        return [ 'error' => 'options', 'field' => 'car_engine', 'options' => $engine_options, 'filtered' => false ];
     }
 
-    foreach ( $engine_info as $eng ) {
-        if ( strtolower( trim( $eng['name'] ) ) !== strtolower( trim( $car_engine ) ) ) {
-            continue;
+    foreach ( $engine_info as $idx => $eng ) {
+        // Prefer index-based selection (unambiguous) over name-based (fragile string match).
+        if ( $car_engine_index >= 0 ) {
+            if ( $idx !== $car_engine_index ) continue;
+        } else {
+            if ( strtolower( trim( $eng['name'] ) ) !== strtolower( trim( $car_engine ) ) ) continue;
         }
 
         $engine_gas      = array_values( array_filter( [ $eng['ac_gas_type'] ?? '', $eng['ac_gas_type_2'] ?? '' ] ) );
@@ -398,32 +435,44 @@ function eksrelay_product_compatibility( WP_REST_Request $request ) {
         }
 
         // ── Brak konkretnego produktu – lista wszystkich kompatybilnych ─
-        $all_products = wc_get_products( [
-            'status'     => 'publish',
-            'visibility' => 'visible',
-            'limit'      => -1,
-            'orderby'    => 'name',
-        ] );
-
-        $compatible = [];
-        foreach ( $all_products as $p ) {
-            $attrs = eksrelay_product_attributes( $p );
-            foreach ( $attrs as $k => $arr ) {
-                $attrs[ $k ] = array_map( fn( $slug ) => str_replace( "-{$lang}", '', $slug ), $arr );
+        // Switch WPML language so product titles and WCML prices are returned
+        // in the requested language/currency. Restored in finally block below.
+        if ( $lang && $lang !== 'pl' ) {
+            do_action( 'wpml_switch_language', $lang );
+        }
+
+        try {
+            $all_products = wc_get_products( [
+                'status'     => 'publish',
+                'visibility' => 'visible',
+                'limit'      => -1,
+                'orderby'    => 'name',
+            ] );
+
+            $compatible = [];
+            foreach ( $all_products as $p ) {
+                $attrs = eksrelay_product_attributes( $p );
+                foreach ( $attrs as $k => $arr ) {
+                    $attrs[ $k ] = array_map( fn( $slug ) => str_replace( "-{$lang}", '', $slug ), $arr );
+                }
+                $p_gas      = ( isset( $attrs['pa_rg'] ) && ! empty( $attrs['pa_rg'] ) ) ? $attrs['pa_rg'] : [ 'no-gas' ];
+                $p_adapters = ( isset( $attrs['pa_ra'] ) && ! empty( $attrs['pa_ra'] ) ) ? $attrs['pa_ra'] : [ 'no' ];
+
+                if (
+                    count( array_intersect( $engine_gas,      $p_gas      ) ) > 0 &&
+                    count( array_intersect( $engine_adapters, $p_adapters ) ) > 0
+                ) {
+                    $compatible[] = [
+                        'id'        => $p->get_id(),
+                        'title'     => $p->get_title(),
+                        'price'     => $p->get_price(),
+                        'permalink' => get_permalink( $p->get_id() ),
+                    ];
+                }
             }
-            $p_gas      = ( isset( $attrs['pa_rg'] ) && ! empty( $attrs['pa_rg'] ) ) ? $attrs['pa_rg'] : [ 'no-gas' ];
-            $p_adapters = ( isset( $attrs['pa_ra'] ) && ! empty( $attrs['pa_ra'] ) ) ? $attrs['pa_ra'] : [ 'no' ];
-
-            if (
-                count( array_intersect( $engine_gas,      $p_gas      ) ) > 0 &&
-                count( array_intersect( $engine_adapters, $p_adapters ) ) > 0
-            ) {
-                $compatible[] = [
-                    'id'        => $p->get_id(),
-                    'title'     => $p->get_title(),
-                    'price'     => $p->get_price(),
-                    'permalink' => get_permalink( $p->get_id() ),
-                ];
+        } finally {
+            if ( $lang && $lang !== 'pl' ) {
+                do_action( 'wpml_switch_language', null );
             }
         }
 
@@ -434,7 +483,13 @@ function eksrelay_product_compatibility( WP_REST_Request $request ) {
         ];
     }
 
-    return [ 'error' => 'options', 'field' => 'car_engine', 'options' => $engine_names, 'filtered' => false ];
+    // No engine matched — return options again so user can pick
+    $engine_options = array_map(
+        fn( $i, $eng ) => [ 'index' => $i, 'name' => $eng['name'] ],
+        array_keys( $engine_info ),
+        array_values( $engine_info )
+    );
+    return [ 'error' => 'options', 'field' => 'car_engine', 'options' => $engine_options, 'filtered' => false ];
 }
 
 // ═══════════════════════════════════════════════════════════════════
@@ -655,6 +710,58 @@ function eksrelay_wc_query_like( array $query, array $query_vars ): array {
     return $query;
 }
 
+// ═══════════════════════════════════════════════════════════════════
+// Handler: GET /wp-json/eksrelay/v1/shipping-costs?zone_id=X&currency=EUR
+//
+// Returns shipping methods for a zone with costs in the requested currency.
+// WCML stores per-currency costs in wp_options:
+//   woocommerce_{method_id}_{instance_id}_settings → cost_EUR, cost_CZK, etc.
+// ═══════════════════════════════════════════════════════════════════
+
+function eksrelay_shipping_costs( WP_REST_Request $request ) {
+    $zone_id  = intval( $request->get_param( 'zone_id' ) );
+    $currency = strtoupper( sanitize_text_field( $request->get_param( 'currency' ) ?: 'PLN' ) );
+
+    $zone    = new WC_Shipping_Zone( $zone_id );
+    $methods = $zone->get_shipping_methods( true ); // true = enabled only
+
+    $result = [];
+    foreach ( $methods as $method ) {
+        $instance_id = $method->instance_id;
+        $method_id   = $method->id; // e.g. 'flat_rate', 'free_shipping'
+
+        // WCML stores per-currency costs in wp_options
+        $option_key = "woocommerce_{$method_id}_{$instance_id}_settings";
+        $settings   = get_option( $option_key, [] );
+
+        // Base cost is the default (PLN) cost
+        $base_cost = $settings['cost'] ?? '0';
+        $cost_key  = "cost_{$currency}";
+
+        if ( $currency !== 'PLN' && isset( $settings[ $cost_key ] ) && $settings[ $cost_key ] !== '' ) {
+            $cost          = $settings[ $cost_key ];
+            $cost_currency = $currency;
+        } else {
+            $cost          = $base_cost;
+            $cost_currency = 'PLN';
+        }
+
+        $result[] = [
+            'instance_id'   => $instance_id,
+            'method_id'     => $method_id,
+            'title'         => $method->get_title(),
+            'cost'          => $cost,
+            'cost_currency' => $cost_currency,
+        ];
+    }
+
+    return [
+        'zone_id'  => $zone_id,
+        'currency' => $currency,
+        'methods'  => $result,
+    ];
+}
+
 /**
  * Usuwa znaki diakrytyczne z ciągu znaków.
  * Mapowanie identyczne z replace_ascii() w aiac_chat_api.php.

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů