This commit is contained in:
2026-02-27 21:43:15 +01:00
parent 1cbbc76a17
commit bcf078baac
5 changed files with 1754 additions and 98 deletions

View File

@@ -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)

View File

@@ -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
<?php
namespace api\Controllers;
use api\ApiRouter;
use Domain\Category\CategoryRepository;
class CategoriesApiController
{
private $categoryRepo;
public function __construct(CategoryRepository $categoryRepo)
{
$this->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<int,array<string,mixed>>}
*/
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 `<thead>`
Po ostatnim `<th>` (Ostatnia zmiana), dodaj:
```php
<th>Kategorie</th>
```
### Step 2: Dodaj komórkę z przyciskiem w każdym wierszu `<tbody>`
Po ostatniej komórce `<td>` (`updated_at`), dodaj:
```php
<td>
<button
type="button"
class="btn btn--secondary btn--sm js-assign-categories"
data-integration-id="<?= $e((string) $integrationId) ?>"
data-product-id="<?= $e((string) ($row['external_product_id'] ?? '')) ?>"
>Przypisz kategorie</button>
</td>
```
Gdzie `$integrationId` to zmienna dostępna z danych integracji. Pobierz ją z `$integrationData['id']` na początku widoku:
```php
<?php $integrationId = (int) ($integrationData['id'] ?? 0); ?>
```
### Step 3: Dodaj modal HTML na końcu widoku (przed zamknięciem `</section>`)
```php
<!-- Modal kategorii -->
<div id="categories-modal-backdrop" class="jq-alert-modal-backdrop" style="display:none" aria-hidden="true">
<div class="jq-alert-modal" role="dialog" aria-modal="true" aria-labelledby="categories-modal-title" style="max-width:520px;width:100%">
<div class="jq-alert-modal__header">
<h3 id="categories-modal-title">Przypisz kategorie</h3>
</div>
<div class="jq-alert-modal__body" style="max-height:420px;overflow-y:auto">
<div id="categories-modal-loading" style="padding:1rem;text-align:center">Ładowanie kategorii...</div>
<div id="categories-modal-error" style="display:none" class="alert alert--danger"></div>
<div id="categories-modal-tree" style="display:none"></div>
</div>
<div class="jq-alert-modal__footer">
<button type="button" class="btn btn--secondary" id="categories-modal-cancel">Anuluj</button>
<button type="button" class="btn btn--primary" id="categories-modal-save" style="display:none">Zapisz</button>
</div>
</div>
</div>
```
### Step 4: Dodaj `<script>` na końcu widoku
```php
<script>
(function () {
'use strict';
var csrfToken = <?= json_encode($csrfToken ?? '') ?>;
var backdrop = document.getElementById('categories-modal-backdrop');
var treeEl = document.getElementById('categories-modal-tree');
var loadingEl = document.getElementById('categories-modal-loading');
var errorEl = document.getElementById('categories-modal-error');
var saveBtn = document.getElementById('categories-modal-save');
var cancelBtn = document.getElementById('categories-modal-cancel');
// Stan aktualnie otwartego modalu
var state = {
integrationId: 0,
productId: 0,
allCategories: null, // cache per integration
cachedIntegrationId: 0,
};
// ===== Otwieranie modalu =====
document.addEventListener('click', function (e) {
var btn = e.target.closest('.js-assign-categories');
if (!btn) return;
state.integrationId = parseInt(btn.dataset.integrationId, 10) || 0;
state.productId = parseInt(btn.dataset.productId, 10) || 0;
if (state.integrationId <= 0 || state.productId <= 0) return;
openModal();
loadData();
});
function openModal() {
backdrop.style.display = '';
backdrop.setAttribute('aria-hidden', 'false');
backdrop.classList.add('is-visible');
loadingEl.style.display = '';
treeEl.style.display = 'none';
errorEl.style.display = 'none';
saveBtn.style.display = 'none';
treeEl.innerHTML = '';
}
function closeModal() {
backdrop.classList.remove('is-visible');
backdrop.style.display = 'none';
backdrop.setAttribute('aria-hidden', 'true');
}
cancelBtn.addEventListener('click', closeModal);
backdrop.addEventListener('click', function (e) {
if (e.target === backdrop) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && backdrop.style.display !== 'none') closeModal();
});
// ===== Ładowanie danych =====
function loadData() {
var integrationId = state.integrationId;
var productId = state.productId;
// Pobierz kategorie i aktualne kategorie produktu równolegle
var categoriesPromise;
if (state.cachedIntegrationId === integrationId && state.allCategories !== null) {
categoriesPromise = Promise.resolve(state.allCategories);
} else {
categoriesPromise = fetch('/marketplace/' + integrationId + '/categories', {
headers: { 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) throw new Error(data.message || 'Błąd pobierania kategorii');
state.allCategories = data.categories;
state.cachedIntegrationId = integrationId;
return data.categories;
});
}
var productCategoriesPromise = fetch('/marketplace/' + integrationId + '/categories?_pc=1&external_product_id=' + productId + '&_method=product', {
headers: { 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) throw new Error(data.message || 'Błąd pobierania kategorii produktu');
return data.current_category_ids || [];
});
Promise.all([categoriesPromise, productCategoriesPromise])
.then(function (results) {
renderTree(results[0], results[1]);
})
.catch(function (err) {
showError(err.message || 'Nieznany błąd');
});
}
// ===== Renderowanie drzewka =====
function buildTree(flat) {
var map = {};
var roots = [];
flat.forEach(function (cat) {
map[cat.id] = { id: cat.id, parent_id: cat.parent_id, title: cat.title, children: [] };
});
flat.forEach(function (cat) {
if (cat.parent_id && map[cat.parent_id]) {
map[cat.parent_id].children.push(map[cat.id]);
} else {
roots.push(map[cat.id]);
}
});
return roots;
}
function renderNode(node, checkedIds) {
var li = document.createElement('li');
li.style.listStyle = 'none';
li.style.padding = '0';
var row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '6px';
row.style.padding = '3px 0';
// Toggle gałęzi
if (node.children.length > 0) {
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.textContent = '▶';
toggle.style.cssText = 'background:none;border:none;cursor:pointer;font-size:10px;padding:0 2px;color:#666';
toggle.addEventListener('click', function () {
var childUl = li.querySelector('ul');
if (childUl) {
childUl.hidden = !childUl.hidden;
toggle.textContent = childUl.hidden ? '▶' : '▼';
}
});
row.appendChild(toggle);
} else {
var spacer = document.createElement('span');
spacer.style.display = 'inline-block';
spacer.style.width = '16px';
row.appendChild(spacer);
}
var label = document.createElement('label');
label.style.display = 'flex';
label.style.alignItems = 'center';
label.style.gap = '5px';
label.style.cursor = 'pointer';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = String(node.id);
cb.name = 'category_ids[]';
cb.checked = checkedIds.indexOf(node.id) !== -1;
label.appendChild(cb);
label.appendChild(document.createTextNode(node.title));
row.appendChild(label);
li.appendChild(row);
if (node.children.length > 0) {
var ul = document.createElement('ul');
ul.style.paddingLeft = '20px';
ul.style.margin = '0';
node.children.forEach(function (child) {
ul.appendChild(renderNode(child, checkedIds));
});
li.appendChild(ul);
}
return li;
}
function renderTree(flat, checkedIds) {
var roots = buildTree(flat);
var ul = document.createElement('ul');
ul.style.padding = '0';
ul.style.margin = '0';
roots.forEach(function (root) {
ul.appendChild(renderNode(root, checkedIds));
});
treeEl.innerHTML = '';
if (roots.length === 0) {
treeEl.textContent = 'Brak dostępnych kategorii.';
} else {
treeEl.appendChild(ul);
}
loadingEl.style.display = 'none';
treeEl.style.display = '';
saveBtn.style.display = '';
}
function showError(msg) {
loadingEl.style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = '';
}
// ===== Zapis =====
saveBtn.addEventListener('click', function () {
var checkboxes = treeEl.querySelectorAll('input[type=checkbox]:checked');
var ids = [];
checkboxes.forEach(function (cb) {
var id = parseInt(cb.value, 10);
if (id > 0) ids.push(id);
});
saveBtn.disabled = true;
saveBtn.textContent = 'Zapisuję...';
fetch('/marketplace/' + state.integrationId + '/product/' + state.productId + '/categories', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ _token: csrfToken, category_ids: ids }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
saveBtn.disabled = false;
saveBtn.textContent = 'Zapisz';
if (data.ok) {
closeModal();
if (window.OrderProAlerts) {
window.OrderProAlerts.show({ type: 'success', message: 'Kategorie zapisane.', timeout: 3000 });
}
} else {
if (window.OrderProAlerts) {
window.OrderProAlerts.show({ type: 'danger', message: data.message || 'Błąd zapisu.', timeout: 5000 });
}
}
})
.catch(function (err) {
saveBtn.disabled = false;
saveBtn.textContent = 'Zapisz';
if (window.OrderProAlerts) {
window.OrderProAlerts.show({ type: 'danger', message: 'Błąd sieci: ' + err.message, timeout: 5000 });
}
});
});
})();
</script>
```
**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()`

View File

@@ -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`.

View File

@@ -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<int, array<string, mixed>>
*/
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
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text" name="name" required value="...">
</label>
```
And:
```php
<div class="form-field mt-16"> <!-- short_description -->
<div class="form-field mt-12"> <!-- description -->
```
**New structure:** wrap name + short_description + description in a tabbed card. Add this BEFORE the `<div class="form-grid">` (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
<?php
$activeIntegrations = is_array($activeIntegrations ?? null) ? $activeIntegrations : [];
$integrationTranslationsMap = is_array($integrationTranslationsMap ?? null) ? $integrationTranslationsMap : [];
?>
<div class="content-tabs-card mt-0">
<div class="content-tabs-nav" id="content-tabs-nav">
<button type="button" class="content-tab-btn is-active" data-tab="global">
<?= $e($t('products.content_tabs.global')) ?>
</button>
<?php foreach ($activeIntegrations as $integration): ?>
<?php $intId = (int) ($integration['id'] ?? 0); ?>
<?php if ($intId <= 0) continue; ?>
<button type="button" class="content-tab-btn" data-tab="integration-<?= $e((string) $intId) ?>">
<?= $e((string) ($integration['name'] ?? '#' . $intId)) ?>
</button>
<?php endforeach; ?>
</div>
<!-- GLOBAL TAB -->
<div class="content-tab-panel is-active" id="content-tab-global">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?> *</span>
<input class="form-control" type="text" name="name" required value="<?= $e((string) ($form['name'] ?? '')) ?>">
</label>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<div class="wysiwyg-wrap">
<div id="editor-short-description"></div>
</div>
<textarea name="short_description" id="input-short-description" style="display:none"><?= $e((string) ($form['short_description'] ?? '')) ?></textarea>
</div>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<div class="wysiwyg-wrap" style="--editor-min-height:180px">
<div id="editor-description"></div>
</div>
<textarea name="description" id="input-description" style="display:none"><?= $e((string) ($form['description'] ?? '')) ?></textarea>
</div>
</div>
<!-- PER-INTEGRATION TABS -->
<?php foreach ($activeIntegrations as $integration): ?>
<?php
$intId = (int) ($integration['id'] ?? 0);
if ($intId <= 0) continue;
$intData = $integrationTranslationsMap[$intId] ?? [];
$intName = isset($intData['name']) ? (string) $intData['name'] : '';
$intShort = isset($intData['short_description']) ? (string) $intData['short_description'] : '';
$intDesc = isset($intData['description']) ? (string) $intData['description'] : '';
?>
<div class="content-tab-panel" id="content-tab-integration-<?= $e((string) $intId) ?>">
<p class="muted" style="margin-bottom:8px">
Puste pole = używana wartość globalna.
</p>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text"
name="integration_content[<?= $e((string) $intId) ?>][name]"
value="<?= $e($intName) ?>">
</label>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<div class="wysiwyg-wrap">
<div id="editor-int-short-<?= $e((string) $intId) ?>"></div>
</div>
<textarea name="integration_content[<?= $e((string) $intId) ?>][short_description]"
id="input-int-short-<?= $e((string) $intId) ?>"
style="display:none"><?= $e($intShort) ?></textarea>
</div>
<div class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<div class="wysiwyg-wrap" style="--editor-min-height:180px">
<div id="editor-int-desc-<?= $e((string) $intId) ?>"></div>
</div>
<textarea name="integration_content[<?= $e((string) $intId) ?>][description]"
id="input-int-desc-<?= $e((string) $intId) ?>"
style="display:none"><?= $e($intDesc) ?></textarea>
</div>
</div>
<?php endforeach; ?>
</div>
```
**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