From bcf078baac4822eec896bec8e3e767e28892ac5e Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Fri, 27 Feb 2026 21:43:15 +0100 Subject: [PATCH] update --- .vscode/ftp-kr.sync.cache.json | 180 ++-- ...-marketplace-category-assignment-design.md | 92 ++ ...6-02-27-marketplace-category-assignment.md | 907 ++++++++++++++++++ ...-per-integration-product-content-design.md | 73 ++ ...6-02-27-per-integration-product-content.md | 600 ++++++++++++ 5 files changed, 1754 insertions(+), 98 deletions(-) create mode 100644 DOCS/plans/2026-02-27-marketplace-category-assignment-design.md create mode 100644 DOCS/plans/2026-02-27-marketplace-category-assignment.md create mode 100644 DOCS/plans/2026-02-27-per-integration-product-content-design.md create mode 100644 DOCS/plans/2026-02-27-per-integration-product-content.md diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 90baa07..d9042ea 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -31,6 +31,12 @@ "size": 7348, "lmtime": 1771964550467, "modified": false + }, + "fix_gs1_brand.php": { + "type": "-", + "size": 5808, + "lmtime": 1772132695646, + "modified": false } }, "bootstrap": { @@ -153,6 +159,12 @@ "size": 398, "lmtime": 1771961063035, "modified": false + }, + "20260227_000014_create_product_integration_translations.sql": { + "type": "-", + "size": 1962, + "lmtime": 1772212375028, + "modified": false } }, "seeders": {} @@ -206,10 +218,36 @@ "lmtime": 1771460804927, "modified": false }, + "plans": { + "2026-02-27-marketplace-category-assignment-design.md": { + "type": "-", + "size": 3711, + "lmtime": 1772214308359, + "modified": false + }, + "2026-02-27-marketplace-category-assignment.md": { + "type": "-", + "size": 31532, + "lmtime": 1772214545805, + "modified": false + }, + "2026-02-27-per-integration-product-content-design.md": { + "type": "-", + "size": 3409, + "lmtime": 1772211731678, + "modified": false + }, + "2026-02-27-per-integration-product-content.md": { + "type": "-", + "size": 21040, + "lmtime": 1772211846717, + "modified": false + } + }, "TODO.md": { "type": "-", - "size": 1223, - "lmtime": 1771975661209, + "size": 677, + "lmtime": 1772213566566, "modified": false } }, @@ -255,12 +293,6 @@ "lmtime": 1771963733140, "modified": false }, - "MojeGS1 API.htm": { - "type": "-", - "size": 736, - "lmtime": 1771958496070, - "modified": false - }, "node_modules": { ".bin": { "sass": { @@ -1432,8 +1464,8 @@ "css": { "app.css": { "type": "-", - "size": 14093, - "lmtime": 1771954643148, + "size": 15404, + "lmtime": 1772212909926, "modified": false }, "app.css.map": { @@ -1444,8 +1476,8 @@ }, "login.css": { "type": "-", - "size": 4627, - "lmtime": 1771954643626, + "size": 4665, + "lmtime": 1772212910423, "modified": false }, "login.css.map": { @@ -1487,20 +1519,14 @@ "lmtime": 1771866989000, "modified": false }, - "test_gs1.php": { - "type": "-", - "size": 7047, - "lmtime": 1771963652805, - "modified": false - }, "uploads": {} }, "resources": { "lang": { "pl.php": { "type": "-", - "size": 22503, - "lmtime": 1771961097428, + "size": 23210, + "lmtime": 1772216124634, "modified": false } }, @@ -1523,8 +1549,8 @@ "scss": { "app.scss": { "type": "-", - "size": 14427, - "lmtime": 1771954582660, + "size": 16579, + "lmtime": 1772212895245, "modified": false }, "login.scss": { @@ -1536,8 +1562,8 @@ "shared": { "_ui-components.scss": { "type": "-", - "size": 3370, - "lmtime": 1771872950487, + "size": 3436, + "lmtime": 1772210480823, "modified": false } } @@ -1570,14 +1596,14 @@ "layouts": { "app.php": { "type": "-", - "size": 3772, - "lmtime": 1771955025325, + "size": 3997, + "lmtime": 1772213532035, "modified": false }, "auth.php": { "type": "-", - "size": 785, - "lmtime": 1771866989000, + "size": 860, + "lmtime": 1772213533314, "modified": false } }, @@ -1590,8 +1616,8 @@ }, "offers.php": { "type": "-", - "size": 2493, - "lmtime": 1771922330901, + "size": 11081, + "lmtime": 1772216038911, "modified": false } }, @@ -1604,8 +1630,8 @@ }, "edit.php": { "type": "-", - "size": 17991, - "lmtime": 1771876603970, + "size": 25177, + "lmtime": 1772213273221, "modified": false }, "index.php": { @@ -1622,8 +1648,8 @@ }, "show.php": { "type": "-", - "size": 9047, - "lmtime": 1771960934569, + "size": 9833, + "lmtime": 1772210477665, "modified": false } }, @@ -1666,8 +1692,8 @@ "routes": { "web.php": { "type": "-", - "size": 9501, - "lmtime": 1771961099697, + "size": 10039, + "lmtime": 1772216828150, "modified": false } }, @@ -1854,17 +1880,17 @@ } }, "Marketplace": { + "MarketplaceController.php": { + "type": "-", + "size": 9893, + "lmtime": 1772216848957, + "modified": false + }, "MarketplaceRepository.php": { "type": "-", "size": 4917, "lmtime": 1771922289322, "modified": false - }, - "MarketplaceController.php": { - "type": "-", - "size": 2742, - "lmtime": 1771922301785, - "modified": false } }, "ProductLinks": { @@ -1908,14 +1934,14 @@ "Products": { "ProductRepository.php": { "type": "-", - "size": 25459, - "lmtime": 1771960837012, + "size": 28350, + "lmtime": 1772212492614, "modified": false }, "ProductsController.php": { "type": "-", - "size": 48055, - "lmtime": 1771960879304, + "size": 48255, + "lmtime": 1772213190553, "modified": false }, "ProductService.php": { @@ -1932,8 +1958,8 @@ }, "ShopProExportService.php": { "type": "-", - "size": 44306, - "lmtime": 1771953661660, + "size": 45242, + "lmtime": 1772216412254, "modified": false } }, @@ -1952,14 +1978,14 @@ }, "SettingsController.php": { "type": "-", - "size": 59467, - "lmtime": 1771961091915, + "size": 59948, + "lmtime": 1772212549465, "modified": false }, "ShopProClient.php": { "type": "-", - "size": 28291, - "lmtime": 1771955377138, + "size": 29504, + "lmtime": 1772214966055, "modified": false } }, @@ -1972,16 +1998,16 @@ }, "UsersController.php": { "type": "-", - "size": 7018, - "lmtime": 1771922207339, + "size": 5410, + "lmtime": 1772210292906, "modified": false } }, "GS1": { "GS1Service.php": { "type": "-", - "size": 2449, - "lmtime": 1771963453169, + "size": 2412, + "lmtime": 1772132619262, "modified": false }, "MojeGS1Client.php": { @@ -3023,53 +3049,11 @@ }, "tmp": {} }, - "tmp_api_v2_index.js": { - "type": "-", - "size": 3211, - "lmtime": 1771964166094, - "modified": false - }, - "tmp_external_api_swagger.json": { - "type": "-", - "size": 67252, - "lmtime": 1771966012602, - "modified": false - }, - "tmp_gs1_excel_extract.txt": { - "type": "-", - "size": 33407, - "lmtime": 1771966910045, - "modified": false - }, "tmp_gs1_test.php": { "type": "-", "size": 3392, "lmtime": 1771959054615, "modified": false - }, - "tmp_mojegs1_bundle.js": { - "type": "-", - "size": 908942, - "lmtime": 1771963932203, - "modified": false - }, - "tmp_mojegs1_openapi.json": { - "type": "-", - "size": 516433, - "lmtime": 1771963972027, - "modified": false - }, - "tmp_portal_swagger.json": { - "type": "-", - "size": 516433, - "lmtime": 1771966037575, - "modified": false - }, - "tmp_swagger_index.html": { - "type": "-", - "size": 1136, - "lmtime": 1771963925300, - "modified": false } } }, diff --git a/DOCS/plans/2026-02-27-marketplace-category-assignment-design.md b/DOCS/plans/2026-02-27-marketplace-category-assignment-design.md new file mode 100644 index 0000000..c27b52f --- /dev/null +++ b/DOCS/plans/2026-02-27-marketplace-category-assignment-design.md @@ -0,0 +1,92 @@ +# Design: Przypisywanie kategorii shopPRO z poziomu Marketplace orderPRO + +**Data:** 2026-02-27 +**Status:** Zatwierdzony + +## Cel + +Umożliwienie przypisywania produktów do kategorii instancji shopPRO bezpośrednio z widoku "Powiązane oferty" w orderPRO, bez konieczności logowania się do panelu shopPRO. + +## Architektura + +``` +Przeglądarka → orderPRO (proxy AJAX) → shopPRO API +``` + +orderPRO działa jako bezpieczny proxy — klucz API shopPRO nigdy nie trafia do przeglądarki. + +## Zakres zmian + +### shopPRO (2 pliki) + +**1. `autoload/api/Controllers/CategoriesApiController.php`** (nowy) +- Akcja `list` (GET) — zwraca płaską listę **aktywnych** kategorii: + ```json + {"status": "ok", "data": {"categories": [{"id": 1, "parent_id": null, "title": "Nazwa"}]}} + ``` +- Tytuł z `pp_shop_categories_langs` w domyślnym języku sklepu (`pp_langs` WHERE `start=1`) +- Tylko `status=1` + +**2. `autoload/api/ApiRouter.php`** +- Rejestracja endpointu `'categories'` → `CategoriesApiController` + +### orderPRO (4 pliki) + +**1. `src/Modules/Settings/ShopProClient.php`** +- Nowa metoda `fetchCategories(baseUrl, apiKey, timeoutSeconds)`: + ``` + GET api.php?endpoint=categories&action=list + Zwraca: array{ok:bool, categories:array, message:string} + ``` + +**2. `src/Modules/Marketplace/MarketplaceController.php`** +- `categoriesJson(Request)` → `GET /marketplace/{id}/categories` + - Sprawdza czy integracja jest aktywna i typu `shoppro` + - Wywołuje `ShopProClient::fetchCategories()` + - Zwraca JSON z listą kategorii +- `saveProductCategoriesJson(Request)` → `POST /marketplace/{id}/product/{pid}/categories` + - Waliduje CSRF + - Pobiera `category_ids[]` z body + - Wywołuje `ShopProClient::updateProduct()` z `{"categories": [...]}` + - Zwraca JSON sukces/błąd + +**3. `routes/web.php`** +``` +GET /marketplace/{integration_id}/categories → categoriesJson +POST /marketplace/{integration_id}/product/{pid}/categories → saveProductCategoriesJson +``` + +**4. `resources/views/marketplace/offers.php`** +- Nowa kolumna "Kategorie" (tylko gdy `integration.type === 'shoppro'`) +- Przycisk "Przypisz kategorie" z `data-product-id="{external_product_id}"` +- Modal z drzewkiem kategorii (checkbox tree, vanilla JS) +- Aktualne kategorie produktu pobierane z istniejącego `fetchProductById()` przez nowy endpoint + +## Przepływ danych (kliknięcie przycisku) + +1. Klik "Przypisz kategorie" → spinner w przycisku +2. Równoległe AJAX GET: + - `/marketplace/{id}/categories` → lista kategorii instancji + - `/marketplace/{id}/product/{pid}/categories` → aktualne kategorie produktu + (endpoint wewnętrznie woła `products/get` na shopPRO i zwraca `categories` array) +3. JS buduje drzewo kategorii z płaskiej listy (rekurencyjnie po `parent_id`) +4. Pre-zaznacza checkboxy dla już przypisanych kategorii +5. Modal otwarty +6. Użytkownik zaznacza/odznacza, klika "Zapisz" +7. POST `/marketplace/{id}/product/{pid}/categories` z `{category_ids: [1,5], csrf_token: "..."}` +8. Toast sukcesu lub błędu + +## Bezpieczeństwo + +- Klucz API shopPRO nigdy nie opuszcza serwera orderPRO +- CSRF token wymagany przy POST +- Walidacja `integration_id` i `external_product_id` (muszą być int > 0) +- Integracja musi być aktywna i należeć do zalogowanego użytkownika (istniejąca AuthService) + +## Decyzje projektowe + +- Płaska lista kategorii (nie zagnieżdżona) — drzewo buduje JS po stronie klienta +- Vanilla JS — brak dodatkowych zależności +- Tylko aktywne kategorie (status=1) +- Tytuł w domyślnym języku sklepu +- Kategorie cacheowane w pamięci JS na czas sesji strony (jeden fetch per integration) diff --git a/DOCS/plans/2026-02-27-marketplace-category-assignment.md b/DOCS/plans/2026-02-27-marketplace-category-assignment.md new file mode 100644 index 0000000..389a90a --- /dev/null +++ b/DOCS/plans/2026-02-27-marketplace-category-assignment.md @@ -0,0 +1,907 @@ +# Marketplace Category Assignment Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Dodaj kolumnę "Przypisz kategorie" w widoku powiązanych ofert marketplace orderPRO, która otwiera modal z drzewkiem kategorii shopPRO i umożliwia zapis wybranych kategorii do instancji shopPRO. + +**Architecture:** orderPRO działa jako proxy AJAX — przeglądarka nigdy nie widzi klucza API shopPRO. shopPRO dostaje nowy endpoint `categories/list` zwracający płaską listę aktywnych kategorii. Drzewo kategorii buduje vanilla JS po stronie klienta. Zapis kategorii używa istniejącego `products/update` w shopPRO API. + +**Tech Stack:** PHP 8.x, vanilla JS (bez dodatkowych bibliotek), `window.OrderProAlerts` (globalny, już załadowany w layoucie), `Response::json()` dla AJAX. + +--- + +## Kontekst — kluczowe pliki + +| Plik | Rola | +|------|------| +| `C:\visual studio code\projekty\shopPRO\autoload\api\ApiRouter.php` | Rejestracja endpointów shopPRO API | +| `C:\visual studio code\projekty\shopPRO\autoload\api\Controllers\ProductsApiController.php` | Wzorzec dla nowego kontrolera | +| `C:\visual studio code\projekty\shopPRO\autoload\Domain\Category\CategoryRepository.php` | Metody DB kategorii | +| `C:\visual studio code\projekty\orderPRO\src\Modules\Settings\ShopProClient.php` | Klient HTTP do shopPRO | +| `C:\visual studio code\projekty\orderPRO\src\Modules\Marketplace\MarketplaceController.php` | Kontroler do rozszerzenia | +| `C:\visual studio code\projekty\orderPRO\routes\web.php` | Trasy — dodać 2 nowe | +| `C:\visual studio code\projekty\orderPRO\resources\views\marketplace\offers.php` | Widok tabeli ofert | +| `C:\visual studio code\projekty\orderPRO\resources\lang\pl.php` | Tłumaczenia PL | + +--- + +## Task 1: Nowy endpoint `categories/list` w shopPRO + +**Files:** +- Create: `C:\visual studio code\projekty\shopPRO\autoload\api\Controllers\CategoriesApiController.php` +- Modify: `C:\visual studio code\projekty\shopPRO\autoload\api\ApiRouter.php` + +### Step 1: Utwórz `CategoriesApiController.php` + +```php +categoryRepo = $categoryRepo; + } + + public function list(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $db = $GLOBALS['mdb'] ?? null; + if (!$db) { + ApiRouter::sendError('INTERNAL_ERROR', 'Database not available', 500); + return; + } + + // Pobierz domyślny język sklepu + $defaultLang = $db->get('pp_langs', 'id', ['start' => 1]); + if (!$defaultLang) { + $defaultLang = 'pl'; + } + $defaultLang = (string)$defaultLang; + + // Pobierz wszystkie aktywne kategorie (płaska lista) + $rows = $db->select( + 'pp_shop_categories', + ['id', 'parent_id'], + [ + 'status' => 1, + 'ORDER' => ['o' => 'ASC'], + ] + ); + + if (!is_array($rows)) { + ApiRouter::sendSuccess(['categories' => []]); + return; + } + + $categories = []; + foreach ($rows as $row) { + $categoryId = (int)($row['id'] ?? 0); + if ($categoryId <= 0) { + continue; + } + + $title = $db->get('pp_shop_categories_langs', 'title', [ + 'AND' => [ + 'category_id' => $categoryId, + 'lang_id' => $defaultLang, + ], + ]); + + // Fallback: jeśli brak tłumaczenia w domyślnym języku, weź pierwsze dostępne + if (!$title) { + $title = $db->get('pp_shop_categories_langs', 'title', [ + 'category_id' => $categoryId, + 'title[!]' => '', + 'LIMIT' => 1, + ]); + } + + $parentId = $row['parent_id'] !== null ? (int)$row['parent_id'] : null; + + $categories[] = [ + 'id' => $categoryId, + 'parent_id' => $parentId, + 'title' => (string)($title ?? 'Kategoria #' . $categoryId), + ]; + } + + ApiRouter::sendSuccess(['categories' => $categories]); + } +} +``` + +### Step 2: Zarejestruj endpoint w `ApiRouter.php` + +W metodzie `getControllerFactories()` dodaj wpis `'categories'` **po** wpisie `'dictionaries'`: + +```php +'categories' => function () use ($db) { + $categoryRepo = new \Domain\Category\CategoryRepository($db); + return new Controllers\CategoriesApiController($categoryRepo); +}, +``` + +### Step 3: Przetestuj endpoint ręcznie + +Wywołaj z terminala (zastąp URL, klucz i ID instancji shopPRO): +```bash +curl -s -H "X-Api-Key: TWOJ_KLUCZ" \ + "https://INSTANCJA_SHOPPRO/api.php?endpoint=categories&action=list" +``` + +Oczekiwany wynik: +```json +{ + "status": "ok", + "data": { + "categories": [ + {"id": 1, "parent_id": null, "title": "Główna kategoria"}, + {"id": 3, "parent_id": 1, "title": "Podkategoria"} + ] + } +} +``` + +### Step 4: Commit w shopPRO + +```bash +cd "C:\visual studio code\projekty\shopPRO" +git add autoload/api/Controllers/CategoriesApiController.php autoload/api/ApiRouter.php +git commit -m "feat: add categories/list API endpoint" +``` + +--- + +## Task 2: Metoda `fetchCategories()` w `ShopProClient` + +**Files:** +- Modify: `C:\visual studio code\projekty\orderPRO\src\Modules\Settings\ShopProClient.php` + +### Step 1: Dodaj metodę po `ensureProducer()` (przed `testConnection()`) + +```php +/** + * @return array{ok:bool,http_code:int|null,message:string,categories:array>} + */ +public function fetchCategories( + string $baseUrl, + string $apiKey, + int $timeoutSeconds +): array { + $normalizedBaseUrl = rtrim(trim($baseUrl), '/'); + $endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=categories&action=list'; + + $response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds); + if (($response['ok'] ?? false) !== true) { + return [ + 'ok' => false, + 'http_code' => $response['http_code'] ?? null, + 'message' => (string) ($response['message'] ?? 'Nie mozna pobrac kategorii z shopPRO.'), + 'categories' => [], + ]; + } + + $data = is_array($response['data'] ?? null) ? $response['data'] : []; + $categories = isset($data['categories']) && is_array($data['categories']) + ? $data['categories'] + : []; + + return [ + 'ok' => true, + 'http_code' => $response['http_code'] ?? null, + 'message' => '', + 'categories' => $categories, + ]; +} +``` + +### Step 2: Commit + +```bash +cd "C:\visual studio code\projekty\orderPRO" +git add src/Modules/Settings/ShopProClient.php +git commit -m "feat: add ShopProClient::fetchCategories() method" +``` + +--- + +## Task 3: Dwa nowe endpointy AJAX w `MarketplaceController` + +**Files:** +- Modify: `C:\visual studio code\projekty\orderPRO\src\Modules\Marketplace\MarketplaceController.php` + +### Kontekst — co robi kontroler + +Kontroler dostaje z konstruktora: `$template`, `$translator`, `$auth`, `$marketplace` (MarketplaceRepository). +Nie ma `$shopProClient` ani `$integrationRepository` — musimy je dodać przez `IntegrationRepository`. + +Spójrz jak `routes/web.php` tworzy kontroler (linia 89-94) — musimy tam dodać `ShopProClient` i `IntegrationRepository`. + +### Step 1: Rozszerz konstruktor kontrolera + +Zmień sygnaturę konstruktora na: + +```php +public function __construct( + private readonly Template $template, + private readonly Translator $translator, + private readonly AuthService $auth, + private readonly MarketplaceRepository $marketplace, + private readonly \App\Modules\Settings\IntegrationRepository $integrationRepository, + private readonly \App\Modules\Settings\ShopProClient $shopProClient +) { +} +``` + +### Step 2: Dodaj metodę `categoriesJson()` + +Uwaga: używamy `findApiCredentials()` (nie `findById()`) — tylko ta metoda zwraca odszyfrowany `api_key`. + +```php +public function categoriesJson(Request $request): Response +{ + $integrationId = max(0, (int) $request->input('integration_id', 0)); + if ($integrationId <= 0) { + return Response::json(['ok' => false, 'message' => 'Brak integration_id.'], 400); + } + + $integration = $this->marketplace->findActiveIntegrationById($integrationId); + if ($integration === null) { + return Response::json(['ok' => false, 'message' => 'Integracja nie istnieje lub jest nieaktywna.'], 404); + } + + $creds = $this->integrationRepository->findApiCredentials($integrationId); + if ($creds === null) { + return Response::json(['ok' => false, 'message' => 'Brak danych uwierzytelniających.'], 404); + } + + $result = $this->shopProClient->fetchCategories( + (string) ($creds['base_url'] ?? ''), + (string) ($creds['api_key'] ?? ''), + (int) ($creds['timeout_seconds'] ?? 10) + ); + + if (!($result['ok'] ?? false)) { + return Response::json(['ok' => false, 'message' => $result['message']], 502); + } + + return Response::json(['ok' => true, 'categories' => $result['categories']]); +} +``` + +### Step 3: Dodaj metodę `saveProductCategoriesJson()` + +Uwaga: Request::capture() buduje z `$_POST` — dla JSON body potrzebujemy `file_get_contents('php://input')`. Parsujemy ręcznie. + +```php +public function saveProductCategoriesJson(Request $request): Response +{ + $integrationId = max(0, (int) $request->input('integration_id', 0)); + $externalProductId = max(0, (int) $request->input('external_product_id', 0)); + + if ($integrationId <= 0 || $externalProductId <= 0) { + return Response::json(['ok' => false, 'message' => 'Brak wymaganych parametrów.'], 400); + } + + // CSRF z JSON body + $rawBody = (string) file_get_contents('php://input'); + $body = json_decode($rawBody, true); + if (!is_array($body)) { + return Response::json(['ok' => false, 'message' => 'Nieprawidłowe ciało żądania JSON.'], 400); + } + + $csrfToken = (string) ($body['_token'] ?? ''); + if (!\App\Core\Security\Csrf::validate($csrfToken)) { + return Response::json(['ok' => false, 'message' => 'Nieprawidłowy token CSRF.'], 403); + } + + $integration = $this->marketplace->findActiveIntegrationById($integrationId); + if ($integration === null) { + return Response::json(['ok' => false, 'message' => 'Integracja nie istnieje lub jest nieaktywna.'], 404); + } + + $creds = $this->integrationRepository->findApiCredentials($integrationId); + if ($creds === null) { + return Response::json(['ok' => false, 'message' => 'Brak danych uwierzytelniających.'], 404); + } + + $categoryIds = isset($body['category_ids']) && is_array($body['category_ids']) + ? array_values(array_filter(array_map('intval', $body['category_ids']), static fn(int $id): bool => $id > 0)) + : []; + + $result = $this->shopProClient->updateProduct( + (string) ($creds['base_url'] ?? ''), + (string) ($creds['api_key'] ?? ''), + (int) ($creds['timeout_seconds'] ?? 10), + $externalProductId, + ['categories' => $categoryIds] + ); + + if (!($result['ok'] ?? false)) { + return Response::json(['ok' => false, 'message' => $result['message']], 502); + } + + return Response::json(['ok' => true]); +} +``` + +### Step 4: Metoda `IntegrationRepository::findApiCredentials()` — potwierdzone + +Użyj `findApiCredentials(int $id): ?array` — zwraca `['id', 'name', 'base_url', 'timeout_seconds', 'api_key']` z odszyfrowanym kluczem. **Nie używaj `findById()`** — ta metoda nie zwraca klucza API. + +--- + +## Task 4: Zaktualizuj `routes/web.php` — konstruktor + 2 nowe trasy + +**Files:** +- Modify: `C:\visual studio code\projekty\orderPRO\routes\web.php` + +### Step 1: Dodaj `IntegrationRepository` i `ShopProClient` do konstruktora kontrolera + +Znajdź blok tworzenia `$marketplaceController` (linia ~89): +```php +$marketplaceController = new MarketplaceController( + $template, + $translator, + $auth, + $marketplaceRepository +); +``` + +Zastąp: +```php +$marketplaceController = new MarketplaceController( + $template, + $translator, + $auth, + $marketplaceRepository, + $integrationRepository, + $shopProClient +); +``` + +### Step 2: Dodaj 2 nowe trasy po linii z `/marketplace/{integration_id}` + +```php +$router->get('/marketplace/{integration_id}/categories', [$marketplaceController, 'categoriesJson'], [$authMiddleware]); +$router->post('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'saveProductCategoriesJson'], [$authMiddleware]); +``` + +### Step 3: Sprawdź czy router obsługuje parametry w środku ścieżki + +Jeśli router nie obsługuje `/marketplace/{id}/product/{pid}/categories` (dwa parametry), użyj query stringa dla `external_product_id`: + +``` +POST /marketplace/{integration_id}/product-categories?external_product_id={pid} +``` + +I odpowiednio zaktualizuj metodę kontrolera i JS. Sprawdź jak router obsługuje routing w `src/Core/Router.php`. + +### Step 4: Commit + +```bash +git add routes/web.php src/Modules/Marketplace/MarketplaceController.php +git commit -m "feat: add AJAX category endpoints to MarketplaceController" +``` + +--- + +## Task 5: Sprawdź `IntegrationRepository::findById()` + +**Files:** +- Read: `C:\visual studio code\projekty\orderPRO\src\Modules\Settings\IntegrationRepository.php` + +Otwórz plik i potwierdź: +1. Nazwa metody zwracającej pełne dane integracji po ID (z odszyfrowanym `api_key`, `base_url`, `timeout_seconds`) +2. Zaktualizuj wywołania w `MarketplaceController` jeśli nazwa jest inna niż `findById()` + +Typowe warianty nazwy: `findById()`, `findByIdDecrypted()`, `getById()`, `findWithCredentials()`. + +--- + +## Task 6: Zaktualizuj widok `offers.php` — kolumna + modal + JS + +**Files:** +- Modify: `C:\visual studio code\projekty\orderPRO\resources\views\marketplace\offers.php` + +### Step 1: Dodaj nagłówek kolumny w `` + +Po ostatnim `` (Ostatnia zmiana), dodaj: +```php +Kategorie +``` + +### Step 2: Dodaj komórkę z przyciskiem w każdym wierszu `` + +Po ostatniej komórce `` (`updated_at`), dodaj: +```php + + + +``` + +Gdzie `$integrationId` to zmienna dostępna z danych integracji. Pobierz ją z `$integrationData['id']` na początku widoku: +```php + +``` + +### Step 3: Dodaj modal HTML na końcu widoku (przed zamknięciem ``) + +```php + + +``` + +### Step 4: Dodaj ` +``` + +**Uwaga do `productCategoriesPromise`:** Aktualny endpoint `GET /marketplace/{id}/categories` zwraca listę wszystkich kategorii. Potrzebujemy osobnego endpointu dla aktualnych kategorii produktu ALBO możemy użyć query stringa by wskazać produkt. Patrz Task 7 poniżej. + +### Step 5: Commit + +```bash +git add resources/views/marketplace/offers.php +git commit -m "feat: add category assignment column and modal to marketplace offers view" +``` + +--- + +## Task 7: Trzeci endpoint AJAX — aktualne kategorie produktu + +W kroku Task 3 mamy 2 endpointy. Potrzebujemy trzeciego do pobierania aktualnych kategorii produktu z shopPRO. + +**Files:** +- Modify: `C:\visual studio code\projekty\orderPRO\src\Modules\Marketplace\MarketplaceController.php` +- Modify: `C:\visual studio code\projekty\orderPRO\routes\web.php` + +### Step 1: Dodaj metodę `productCategoriesJson()` + +```php +public function productCategoriesJson(Request $request): Response +{ + $integrationId = max(0, (int) $request->input('integration_id', 0)); + $externalProductId = max(0, (int) $request->input('external_product_id', 0)); + + if ($integrationId <= 0 || $externalProductId <= 0) { + return Response::json(['ok' => false, 'message' => 'Brak wymaganych parametrów.'], 400); + } + + $integration = $this->marketplace->findActiveIntegrationById($integrationId); + if ($integration === null) { + return Response::json(['ok' => false, 'message' => 'Integracja nie istnieje.'], 404); + } + + $creds = $this->integrationRepository->findById($integrationId); + if ($creds === null) { + return Response::json(['ok' => false, 'message' => 'Brak danych uwierzytelniających.'], 404); + } + + $result = $this->shopProClient->fetchProductById( + (string) ($creds['base_url'] ?? ''), + (string) ($creds['api_key'] ?? ''), + (int) ($creds['timeout_seconds'] ?? 10), + $externalProductId + ); + + if (!($result['ok'] ?? false)) { + return Response::json(['ok' => false, 'message' => $result['message']], 502); + } + + $product = is_array($result['product'] ?? null) ? $result['product'] : []; + $categoryIds = isset($product['categories']) && is_array($product['categories']) + ? array_values(array_filter(array_map('intval', $product['categories']), static fn(int $id): bool => $id > 0)) + : []; + + return Response::json(['ok' => true, 'current_category_ids' => $categoryIds]); +} +``` + +### Step 2: Dodaj trasę w `routes/web.php` + +```php +$router->get('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'productCategoriesJson'], [$authMiddleware]); +``` + +Jeśli router nie obsługuje dwóch parametrów w środku ścieżki, użyj: +```php +$router->get('/marketplace/{integration_id}/product-categories', [$marketplaceController, 'productCategoriesJson'], [$authMiddleware]); +``` + +I zaktualizuj URL w JS (`productCategoriesPromise`) odpowiednio. + +### Step 3: Zaktualizuj JS w `offers.php` — URL dla `productCategoriesPromise` + +Zmień URL w fetch: +```js +var productCategoriesPromise = fetch( + '/marketplace/' + integrationId + '/product/' + productId + '/categories', + { headers: { 'Accept': 'application/json' } } +) +``` + +(lub `/product-categories?external_product_id=` jeśli router nie obsługuje dwóch parametrów) + +### Step 4: Commit + +```bash +git add src/Modules/Marketplace/MarketplaceController.php routes/web.php resources/views/marketplace/offers.php +git commit -m "feat: add productCategoriesJson endpoint and fix JS fetch URL" +``` + +--- + +## Task 8: Sprawdź router — obsługa parametrów URL + +**Files:** +- Read: `C:\visual studio code\projekty\orderPRO\src\Core\Router.php` (lub podobna ścieżka) + +Otwórz plik routera i sprawdź: +1. Jak są przetwarzane segmenty `{param}` — czy obsługuje wiele parametrów w jednej trasie +2. Jak parametry trafiają do `Request` — przez `$request->input('param_name')` czy `$request->attributes` + +Jeśli router **nie obsługuje** tras w stylu `/marketplace/{id}/product/{pid}/categories` (dwa parametry dynamic), wybierz alternatywę: +``` +GET /marketplace/{integration_id}/product-categories?external_product_id={pid} +POST /marketplace/{integration_id}/product-categories (body: {external_product_id, category_ids, _token}) +``` + +Zaktualizuj odpowiednio routing, metody kontrolera i JS. + +--- + +## Task 9: Tłumaczenia w `pl.php` + +**Files:** +- Modify: `C:\visual studio code\projekty\orderPRO\resources\lang\pl.php` + +Dodaj klucze do tablicy `'marketplace'`: + +```php +'fields' => [ + // ... istniejące ... + 'categories' => 'Kategorie', +], +'actions' => [ + // ... istniejące ... + 'assign_categories' => 'Przypisz kategorie', +], +'category_modal' => [ + 'title' => 'Przypisz kategorie', + 'loading' => 'Ładowanie kategorii...', + 'no_categories' => 'Brak dostępnych kategorii.', + 'save' => 'Zapisz', + 'cancel' => 'Anuluj', + 'saving' => 'Zapisuję...', + 'saved' => 'Kategorie zapisane.', + 'error_save' => 'Błąd zapisu.', + 'error_network' => 'Błąd sieci.', +], +``` + +### Step 2: Commit + +```bash +git add resources/lang/pl.php +git commit -m "feat: add category assignment translation keys" +``` + +--- + +## Task 10: Weryfikacja end-to-end + +### Checklist testów manualnych + +1. Otwórz `https://orderpro.projectpro.pl/marketplace/1` +2. Sprawdź czy tabela ma nową kolumnę "Kategorie" +3. Kliknij "Przypisz kategorie" przy dowolnym produkcie +4. Sprawdź: modal otwiera się, spinner "Ładowanie kategorii..." widoczny +5. Sprawdź: drzewo kategorii pojawia się z rozwijanymi gałęziami +6. Sprawdź: kategorie już przypisane do produktu są wstępnie zaznaczone +7. Zaznacz/odznacz kilka kategorii, kliknij "Zapisz" +8. Sprawdź: toast "Kategorie zapisane." pojawia się, modal zamknięty +9. Otwórz modal ponownie — sprawdź czy zaznaczone kategorie są aktualne +10. Sprawdź DevTools Network — żadna odpowiedź nie może zawierać klucza API + +### Obsługa błędów + +- Jeśli shopPRO niedostępny → modal pokazuje alert z komunikatem błędu +- Jeśli CSRF wygasł → odpowiedź 403 → toast "Nieprawidłowy token CSRF." +- Jeśli produkt nie istnieje w shopPRO → toast z błędem API + +### Commit końcowy + +```bash +cd "C:\visual studio code\projekty\orderPRO" +git add -A +git commit -m "feat: marketplace category assignment complete" +``` + +--- + +## Ważne: Kluczowe ustalenia + +- **Router** obsługuje wiele parametrów `{param}` w jednej ścieżce — trasy jak `/marketplace/{id}/product/{pid}/categories` działają +- **IntegrationRepository**: używaj `findApiCredentials(int $id)` (nie `findById()`) — tylko ta metoda zwraca odszyfrowany `api_key` +- **`productCategoriesJson()`** w Task 7 też musi używać `findApiCredentials()` diff --git a/DOCS/plans/2026-02-27-per-integration-product-content-design.md b/DOCS/plans/2026-02-27-per-integration-product-content-design.md new file mode 100644 index 0000000..d2a7930 --- /dev/null +++ b/DOCS/plans/2026-02-27-per-integration-product-content-design.md @@ -0,0 +1,73 @@ +# Design: Per-Integration Product Content + +**Date:** 2026-02-27 +**Status:** Approved + +## Summary + +Products need separate `name`, `short_description`, and `description` for each integration. Global values in `product_translations` remain the fallback. Integration-specific overrides are stored in a new table. + +## Model + +**Global + override per integration:** +- `product_translations` stays as the global/base content (unchanged) +- New table `product_integration_translations` stores per-integration overrides +- NULL field = use global value +- When exporting to a specific integration, prefer integration-specific content, fall back to global + +## Database + +New migration file: `20260227_000014_create_product_integration_translations.sql` + +```sql +CREATE TABLE product_integration_translations ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + product_id INT UNSIGNED NOT NULL, + integration_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NULL, + short_description TEXT NULL, + description LONGTEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY pit_product_integration_unique (product_id, integration_id), + CONSTRAINT pit_product_fk FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE, + CONSTRAINT pit_integration_fk FOREIGN KEY (integration_id) REFERENCES integrations(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +Data migration: For all products currently linked to the "marianek.pl" integration via `product_channel_map`, copy `name`, `short_description`, `description` from `product_translations` to `product_integration_translations`. + +## Import Flow + +In `SettingsController::importExternalProductById`: +1. Save to `product_translations` as now (global, unchanged) +2. Additionally upsert `name`, `short_description`, `description` to `product_integration_translations` for the current `integration_id` + +## Repository + +New methods in `ProductRepository`: +- `findIntegrationTranslations(int $productId): array` — returns all per-integration translation rows for a product +- `upsertIntegrationTranslation(int $productId, int $integrationId, string|null $name, string|null $shortDescription, string|null $description): void` + +## Edit UI + +In `products/edit.php`, the Name/Short description/Description section gets tabs at the top: + +``` +[ Globalna ] [ marianek.pl ] [ inny sklep... ] +``` + +- Each tab shows: Nazwa, Krótki opis, Opis (WYSIWYG with Quill) +- "Globalna" tab = existing global fields (`name`, `short_description`, `description`) +- Integration tabs = per-integration overrides (`integration_content[{id}][name]`, etc.) +- Rest of the form (prices, SKU, images, meta) is global — no tabs + +## Controller Changes + +`ProductsController`: +- `edit` action: load active integrations + `findIntegrationTranslations($id)`, pass to view +- `update` action: process `integration_content[{id}]` array, call `upsertIntegrationTranslation` for each + +## Existing Products Migration + +One-off SQL script assigns existing product content to "marianek.pl" integration. All products in `product_channel_map` linked to the marianek.pl integration get their current `product_translations` content copied to `product_integration_translations`. diff --git a/DOCS/plans/2026-02-27-per-integration-product-content.md b/DOCS/plans/2026-02-27-per-integration-product-content.md new file mode 100644 index 0000000..bc276a8 --- /dev/null +++ b/DOCS/plans/2026-02-27-per-integration-product-content.md @@ -0,0 +1,600 @@ +# Per-Integration Product Content Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Store separate `name`, `short_description`, and `description` per integration (shopPRO instance), with global `product_translations` as fallback. + +**Architecture:** New table `product_integration_translations (product_id, integration_id, name, short_description, description)` stores overrides. Import saves content to both global and per-integration tables. Edit form shows tabs: Globalna | per-integration. + +**Tech Stack:** PHP 8.4, MariaDB, vanilla JS (Quill WYSIWYG already loaded on edit page) + +--- + +### Task 1: Database migration — create table + +**Files:** +- Create: `database/migrations/20260227_000014_create_product_integration_translations.sql` + +**Step 1: Create the migration file** + +```sql +CREATE TABLE IF NOT EXISTS product_integration_translations ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + product_id INT UNSIGNED NOT NULL, + integration_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NULL, + short_description TEXT NULL, + description LONGTEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY pit_product_integration_unique (product_id, integration_id), + KEY pit_product_idx (product_id), + KEY pit_integration_idx (integration_id), + CONSTRAINT pit_product_fk + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT pit_integration_fk + FOREIGN KEY (integration_id) REFERENCES integrations(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Migrate existing products to marianek.pl integration. +-- Finds the integration by name 'marianek.pl' and copies current +-- product_translations content for all linked products. +INSERT INTO product_integration_translations + (product_id, integration_id, name, short_description, description, created_at, updated_at) +SELECT + pt.product_id, + i.id AS integration_id, + pt.name, + pt.short_description, + pt.description, + NOW(), + NOW() +FROM product_translations pt +INNER JOIN product_channel_map pcm ON pcm.product_id = pt.product_id +INNER JOIN integrations i ON i.id = pcm.integration_id +WHERE i.name = 'marianek.pl' + AND pt.lang = 'pl' +ON DUPLICATE KEY UPDATE + name = VALUES(name), + short_description = VALUES(short_description), + description = VALUES(description), + updated_at = VALUES(updated_at); +``` + +**Step 2: Run the migration via settings panel** + +Navigate to `/settings/database` and run pending migrations, or trigger via the app's migration runner. Verify table exists: +```sql +SHOW TABLES LIKE 'product_integration_translations'; +SELECT COUNT(*) FROM product_integration_translations; +``` + +**Step 3: Commit** + +```bash +git add database/migrations/20260227_000014_create_product_integration_translations.sql +git commit -m "feat: add product_integration_translations table and migrate marianek.pl data" +``` + +--- + +### Task 2: ProductRepository — two new methods + +**Files:** +- Modify: `src/Modules/Products/ProductRepository.php` + +**Step 1: Add `findIntegrationTranslations` method** + +Add after the `findImagesByProductId` method (around line 250): + +```php +/** + * @return array> + */ +public function findIntegrationTranslations(int $productId): array +{ + $stmt = $this->pdo->prepare( + 'SELECT pit.id, pit.product_id, pit.integration_id, + pit.name, pit.short_description, pit.description, + i.name AS integration_name + FROM product_integration_translations pit + INNER JOIN integrations i ON i.id = pit.integration_id + WHERE pit.product_id = :product_id + ORDER BY i.name ASC' + ); + $stmt->execute(['product_id' => $productId]); + $rows = $stmt->fetchAll(); + + if (!is_array($rows)) { + return []; + } + + return array_map(static fn (array $row): array => [ + 'id' => (int) ($row['id'] ?? 0), + 'product_id' => (int) ($row['product_id'] ?? 0), + 'integration_id' => (int) ($row['integration_id'] ?? 0), + 'integration_name' => (string) ($row['integration_name'] ?? ''), + 'name' => isset($row['name']) ? (string) $row['name'] : null, + 'short_description' => isset($row['short_description']) ? (string) $row['short_description'] : null, + 'description' => isset($row['description']) ? (string) $row['description'] : null, + ], $rows); +} +``` + +**Step 2: Add `upsertIntegrationTranslation` method** + +Add immediately after the method above: + +```php +public function upsertIntegrationTranslation( + int $productId, + int $integrationId, + ?string $name, + ?string $shortDescription, + ?string $description +): void { + $now = date('Y-m-d H:i:s'); + $stmt = $this->pdo->prepare( + 'INSERT INTO product_integration_translations + (product_id, integration_id, name, short_description, description, created_at, updated_at) + VALUES + (:product_id, :integration_id, :name, :short_description, :description, :created_at, :updated_at) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + short_description = VALUES(short_description), + description = VALUES(description), + updated_at = VALUES(updated_at)' + ); + $stmt->execute([ + 'product_id' => $productId, + 'integration_id' => $integrationId, + 'name' => $name !== '' ? $name : null, + 'short_description' => $shortDescription !== '' ? $shortDescription : null, + 'description' => $description !== '' ? $description : null, + 'created_at' => $now, + 'updated_at' => $now, + ]); +} +``` + +**Step 3: Commit** + +```bash +git add src/Modules/Products/ProductRepository.php +git commit -m "feat: add findIntegrationTranslations and upsertIntegrationTranslation to ProductRepository" +``` + +--- + +### Task 3: SettingsController — save per-integration content on import + +**Files:** +- Modify: `src/Modules/Settings/SettingsController.php` + +The import flow is in `importExternalProductById` (line ~677). After the transaction commits (line ~783), `$savedProductId` and `$integrationId` are both set. + +**Step 1: Inject ProductRepository into SettingsController** + +Check the constructor of `SettingsController`. Add `ProductRepository` as a dependency if it is not already present. Look for the constructor and add: + +```php +use App\Modules\Products\ProductRepository; +``` + +And in the constructor parameter list: +```php +private readonly ProductRepository $products, +``` + +If `$this->products` already exists (check the constructor), skip adding it — just use the existing reference. + +**Step 2: Add upsert call after transaction commit in `importExternalProductById`** + +Locate the block after `$this->pdo->commit();` (around line 783). Add the upsert call inside the try block, before the commit: + +```php +// Save per-integration content override +if ($integrationId > 0) { + $this->products->upsertIntegrationTranslation( + $savedProductId, + $integrationId, + $normalized['translation']['name'] ?? null, + $normalized['translation']['short_description'] ?? null, + $normalized['translation']['description'] ?? null + ); +} +``` + +Place this BEFORE `$this->pdo->commit()` so it's inside the transaction. + +**Step 3: Commit** + +```bash +git add src/Modules/Settings/SettingsController.php +git commit -m "feat: save per-integration name/short_description/description on product import" +``` + +--- + +### Task 4: ProductsController — load per-integration data for edit + +**Files:** +- Modify: `src/Modules/Products/ProductsController.php` + +**Step 1: Update the `edit` action (line ~186)** + +Find the block that builds data for the edit view. Currently it passes `form`, `productImages`, etc. Add two new variables: + +```php +$activeIntegrations = $this->integrations->listByType('shoppro'); +$integrationTranslations = $this->products->findIntegrationTranslations($id); + +// Index integration translations by integration_id for easy lookup in view +$integrationTranslationsMap = []; +foreach ($integrationTranslations as $it) { + $integrationTranslationsMap[(int) $it['integration_id']] = $it; +} +``` + +Add them to the `render()` call: +```php +'activeIntegrations' => $activeIntegrations, +'integrationTranslationsMap' => $integrationTranslationsMap, +``` + +**Step 2: Commit** + +```bash +git add src/Modules/Products/ProductsController.php +git commit -m "feat: pass active integrations and per-integration translations to product edit view" +``` + +--- + +### Task 5: ProductsController — save per-integration content on update + +**Files:** +- Modify: `src/Modules/Products/ProductsController.php` + +**Step 1: Update the `update` action (line ~416)** + +After the successful `$this->service->update(...)` call (and before the redirect), add: + +```php +// Save per-integration content overrides +$integrationContent = $request->input('integration_content', []); +if (is_array($integrationContent)) { + foreach ($integrationContent as $rawIntegrationId => $content) { + $integrationId = (int) $rawIntegrationId; + if ($integrationId <= 0 || !is_array($content)) { + continue; + } + $this->products->upsertIntegrationTranslation( + $id, + $integrationId, + isset($content['name']) ? trim((string) $content['name']) : null, + isset($content['short_description']) ? trim((string) $content['short_description']) : null, + isset($content['description']) ? trim((string) $content['description']) : null + ); + } +} +``` + +Place this block AFTER the image changes block and BEFORE the success Flash/redirect. + +**Step 2: Commit** + +```bash +git add src/Modules/Products/ProductsController.php +git commit -m "feat: save per-integration content overrides on product update" +``` + +--- + +### Task 6: Edit view — content tabs UI + +**Files:** +- Modify: `resources/views/products/edit.php` + +**Step 1: Replace the static name/short_description/description fields with a tabbed section** + +Current structure (around line 20-25 for name, and lines 111-125 for descriptions): + +```php + +``` + +And: +```php +
+
+``` + +**New structure:** wrap name + short_description + description in a tabbed card. Add this BEFORE the `
` (the existing grid with SKU, EAN etc.), replacing the name field in the grid: + +Remove the `name` label from `form-grid` and create a new card section above it: + +```php + + +
+
+ + + + + + +
+ + +
+ + +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+
+ + + + +
+

+ Puste pole = używana wartość globalna. +

+ + + +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+
+ +
+``` + +**Step 2: Update the Quill initialization script at the bottom of edit.php** + +The current script initializes `quillShort` and `quillDesc` for global fields. Extend it to also initialize editors for each per-integration tab, and sync all on form submit: + +```js +// --- existing global editors --- +var quillShort = new Quill('#editor-short-description', { theme: 'snow', modules: { toolbar: toolbarShort } }); +var quillDesc = new Quill('#editor-description', { theme: 'snow', modules: { toolbar: toolbarFull } }); + +if (shortInput && shortInput.value) quillShort.clipboard.dangerouslyPasteHTML(shortInput.value); +if (descInput && descInput.value) quillDesc.clipboard.dangerouslyPasteHTML(descInput.value); + +// --- per-integration editors --- +var intEditors = []; // array of {shortQuill, descQuill, shortInput, descInput} + +document.querySelectorAll('[id^="editor-int-short-"]').forEach(function(el) { + var suffix = el.id.replace('editor-int-short-', ''); + var shortEl = el; + var descEl = document.getElementById('editor-int-desc-' + suffix); + var shortInp = document.getElementById('input-int-short-' + suffix); + var descInp = document.getElementById('input-int-desc-' + suffix); + + if (!shortEl || !descEl || !shortInp || !descInp) return; + + var qShort = new Quill(shortEl, { theme: 'snow', modules: { toolbar: toolbarShort } }); + var qDesc = new Quill(descEl, { theme: 'snow', modules: { toolbar: toolbarFull } }); + + if (shortInp.value) qShort.clipboard.dangerouslyPasteHTML(shortInp.value); + if (descInp.value) qDesc.clipboard.dangerouslyPasteHTML(descInp.value); + + intEditors.push({ shortQuill: qShort, descQuill: qDesc, shortInput: shortInp, descInput: descInp }); +}); + +// --- sync all on submit --- +var form = document.querySelector('.product-form'); +if (form) { + form.addEventListener('submit', function() { + if (shortInput) shortInput.value = quillShort.root.innerHTML; + if (descInput) descInput.value = quillDesc.root.innerHTML; + intEditors.forEach(function(e) { + e.shortInput.value = e.shortQuill.root.innerHTML; + e.descInput.value = e.descQuill.root.innerHTML; + }); + }); +} +``` + +**Step 3: Commit** + +```bash +git add resources/views/products/edit.php +git commit -m "feat: add per-integration content tabs to product edit form" +``` + +--- + +### Task 7: Tab switching CSS + JS + +**Files:** +- Modify: `resources/scss/app.scss` +- JS inline in `resources/views/products/edit.php` + +**Step 1: Add tab styles to app.scss** + +```scss +.content-tabs-card { + margin-top: 0; +} + +.content-tabs-nav { + display: flex; + gap: 4px; + border-bottom: 2px solid var(--c-border); + margin-bottom: 16px; + flex-wrap: wrap; +} + +.content-tab-btn { + padding: 8px 16px; + border: none; + background: none; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: var(--c-text-muted, #6b7280); + border-bottom: 2px solid transparent; + margin-bottom: -2px; + border-radius: 4px 4px 0 0; + transition: color 0.15s, border-color 0.15s; + + &:hover { + color: var(--c-text-strong, #111827); + } + + &.is-active { + color: var(--c-primary, #2563eb); + border-bottom-color: var(--c-primary, #2563eb); + } +} + +.content-tab-panel { + display: none; + + &.is-active { + display: block; + } +} +``` + +**Step 2: Add tab-switching JS in edit.php (inline, after the Quill script)** + +```js +(function() { + var nav = document.getElementById('content-tabs-nav'); + if (!nav) return; + + nav.addEventListener('click', function(e) { + var btn = e.target.closest('.content-tab-btn'); + if (!btn) return; + + var tabId = btn.getAttribute('data-tab'); + if (!tabId) return; + + // deactivate all + nav.querySelectorAll('.content-tab-btn').forEach(function(b) { + b.classList.remove('is-active'); + }); + document.querySelectorAll('.content-tab-panel').forEach(function(p) { + p.classList.remove('is-active'); + }); + + // activate selected + btn.classList.add('is-active'); + var panel = document.getElementById('content-tab-' + tabId); + if (panel) panel.classList.add('is-active'); + }); +})(); +``` + +**Step 3: Rebuild CSS** + +```bash +cd "C:/visual studio code/projekty/orderPRO" && npm run build:css +``` + +**Step 4: Commit** + +```bash +git add resources/scss/app.scss resources/views/products/edit.php public/assets/css/app.css +git commit -m "feat: tab switching styles and JS for per-integration content" +``` + +--- + +### Task 8: Add translations key + +**Files:** +- Modify: `resources/lang/pl.php` + +**Step 1: Add `content_tabs` key under `products`** + +Find the `products` array and add: + +```php +'content_tabs' => [ + 'global' => 'Globalna', +], +``` + +**Step 2: Commit** + +```bash +git add resources/lang/pl.php +git commit -m "feat: add content_tabs translation key" +``` + +--- + +### Task 9: Manual smoke test + +1. Navigate to `/settings/database` → run pending migrations → confirm `product_integration_translations` exists and has rows for marianek.pl products +2. Navigate to `/products/edit?id=32` → confirm tabs appear: "Globalna" and "marianek.pl" +3. Switch tabs → confirm fields toggle correctly +4. Edit marianek.pl tab name/description → Save → confirm saved in DB: + ```sql + SELECT * FROM product_integration_translations WHERE product_id = 32; + ``` +5. Import a product from shopPRO → confirm row created in `product_integration_translations` +6. Verify global fields unchanged after editing integration tab