update
This commit is contained in:
180
.vscode/ftp-kr.sync.cache.json
vendored
180
.vscode/ftp-kr.sync.cache.json
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
907
DOCS/plans/2026-02-27-marketplace-category-assignment.md
Normal file
907
DOCS/plans/2026-02-27-marketplace-category-assignment.md
Normal 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()`
|
||||
@@ -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`.
|
||||
600
DOCS/plans/2026-02-27-per-integration-product-content.md
Normal file
600
DOCS/plans/2026-02-27-per-integration-product-content.md
Normal 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
|
||||
Reference in New Issue
Block a user