diff --git a/DATABASE_STRUCTURE.md b/DATABASE_STRUCTURE.md
index 6cacc26..69ed2b1 100644
--- a/DATABASE_STRUCTURE.md
+++ b/DATABASE_STRUCTURE.md
@@ -320,6 +320,32 @@ Tlumaczenia kontenerow statycznych (per jezyk).
**Aktualizacja 2026-02-12 (ver. 0.260):** modul `/admin/articles_archive` korzysta z `Domain\Article\ArticleRepository` (`listArchivedForAdmin`, `restore`, `deletePermanently`) przez `admin\Controllers\ArticlesArchiveController`.
+## pp_shop_promotion
+Promocje sklepu (modul `/admin/shop_promotion`).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Nazwa promocji |
+| status | Status: 1 = aktywna, 0 = nieaktywna |
+| condition_type | Typ warunku promocji (slownik w `shop\Promotion::$condition_type`) |
+| discount_type | Typ rabatu (slownik w `shop\Promotion::$discount_type`) |
+| amount | Wartosc rabatu (np. procent) |
+| date_from | Data startu promocji (NULL = aktywna od razu) |
+| date_to | Data konca promocji (NULL = bez daty konca) |
+| categories | JSON z ID kategorii grupy I |
+| condition_categories | JSON z ID kategorii grupy II |
+| include_coupon | Czy laczyc z kuponami rabatowymi (0/1) |
+| include_product_promo | Czy uwzgledniac produkty przecenione (0/1) |
+| min_product_count | Minimalna liczba produktow (dla wybranych warunkow) |
+| price_cheapest_product | Cena najtanszego produktu (dla wybranych warunkow) |
+
+**Uzywane w:** `Domain\Promotion\PromotionRepository`, `admin\Controllers\ShopPromotionController`, `shop\Promotion`, `front\factory\ShopPromotion`
+
+**Aktualizacja 2026-02-13:** modul `/admin/shop_promotion` korzysta z `Domain\Promotion\PromotionRepository` przez `admin\Controllers\ShopPromotionController`. Usunieto legacy klasy `admin\controls\ShopPromotion` i `admin\factory\ShopPromotion`.
+
+**Aktualizacja 2026-02-13 (ver. 0.265):** dodano obsluge `date_from` (repozytorium, formularz admin, lista admin, filtr aktywnych promocji na froncie) oraz poprawke zapisu edycji promocji po `id`.
+
## pp_shop_apilo_settings
Ustawienia integracji Apilo (key-value).
diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md
index ce15768..819c691 100644
--- a/PROJECT_STRUCTURE.md
+++ b/PROJECT_STRUCTURE.md
@@ -475,3 +475,20 @@ Aktualnie w suite są też testy modułów `Dictionaries`, `Articles` i `Users`
- CLEANUP: **usunieto integracje Sellasist i Baselinker z calego projektu** - kontrolery, factory, szablony, referencje w cron.php, Order, ShopStatuses, ShopTransport, ShopPaymentMethod, ShopProduct, config.php, front/factory/*.
- CLEANUP: usuniete pliki: `autoload/admin/controls/class.Integrations.php`, `autoload/admin/controls/class.Baselinker.php`, `autoload/admin/factory/class.Baselinker.php`, `autoload/front/factory/class.Shop.php`, `autoload/shop/class.ShopStatus.php`, szablony sellasist/baselinker.
- Testy: **OK (212 tests, 577 assertions)**.
+
+## Aktualizacja 2026-02-13 (ShopPromotion refactor, ver. 0.264)
+- NOWE: `Domain\Promotion\PromotionRepository` (listForAdmin, find, save, delete, categoriesTree + invalidacja cache aktywnych promocji).
+- NOWE: `admin\Controllers\ShopPromotionController` (DI) dla akcji `list`, `edit`, `save`, `delete`.
+- UPDATE: modul `/admin/shop_promotion/*` przepiety z legacy `grid/gridEdit` na `components/table-list` i `components/form-edit`.
+- NOWE: widoki `admin/templates/shop-promotion/promotions-list.php`, `admin/templates/shop-promotion/promotion-edit.php`.
+- NOWE: partiale drzewa kategorii: `admin/templates/shop-promotion/promotion-categories-selector.php`, `admin/templates/shop-promotion/promotion-categories-tree.php`.
+- NOWE: `admin/templates/shop-promotion/promotion-edit-custom-script.php` (logika warunkow promocji + drzewo kategorii).
+- CLEANUP: usuniete legacy klasy/pliki `autoload/admin/controls/class.ShopPromotion.php`, `autoload/admin/factory/class.ShopPromotion.php`, `admin/templates/shop-promotion/view-list.php`.
+- UPDATE: menu admin wskazuje kanoniczny URL `/admin/shop_promotion/list/`.
+- Testy: **OK (222 tests, 609 assertions)**.
+
+## Aktualizacja 2026-02-13 (ShopPromotion poprawki, ver. 0.265)
+- UPDATE: dodano pole `Data od` (`date_from`) w module `/admin/shop_promotion` (repozytorium, formularz i lista).
+- UPDATE: `shop\Promotion::get_active_promotions()` uwzglednia `date_from` (`NULL` lub `<= dzisiaj`) obok `date_to`.
+- FIX: edycja promocji zapisuje update zamiast insert (stabilne przekazanie `id` przez hidden field + fallback `id` z URL w `save()`).
+- Testy: **OK (222 tests, 614 assertions)**.
diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md
index dd34300..c0118bf 100644
--- a/REFACTORING_PLAN.md
+++ b/REFACTORING_PLAN.md
@@ -725,3 +725,60 @@ Gdy `persist = true`:
- Wyczyszczone referencje w: `cron.php`, `cron/cron-xml.php`, `shop\Order`, `admin\controls\ShopStatuses`, `admin\controls\ShopTransport`, `admin\controls\ShopPaymentMethod`, `admin\controls\ShopProduct`, `admin\factory\ShopStatuses`, `admin\factory\ShopTransport`, `admin\factory\ShopProduct`, `front\factory\ShopStatuses`, `front\factory\ShopTransport`, `front\factory\ShopPaymentMethod`, `front\factory\ShopProduct`, `front\factory\ShopOrder`, `shop\Product`, `config.php`
- Wyczyszczone szablony: `shop-statuses/*`, `shop-transport/*`, `shop-payment-method/*`, `shop-product/*`, `site/main-layout.php`
- Testy: **OK (212 tests, 577 assertions)**.
+
+## Plan 2026-02-13 - Refaktoryzacja `/admin/shop_promotion/` (HITL)
+- [x] Etap 1 (analiza i kontrakt): potwierdzic docelowy kontrakt URL i kompatybilnosc wsteczna:
+ - kontrakt docelowy: tylko `/admin/shop_promotion/list/`, `/admin/shop_promotion/edit/`, `/admin/shop_promotion/save/`, `/admin/shop_promotion/delete/`
+ - brak kompatybilnosci ze starymi URL i aliasami akcji (`view_list`, `promotion_delete`)
+- [x] Etap 2 (Domain): dodac `Domain\Promotion\PromotionRepository`:
+ - `listForAdmin(filters, sort, dir, page, perPage)` z whitelist sortowania i bind params
+ - `find(int $id)` + domyslne dane dla nowego formularza
+ - `save(array $data): ?int` (insert/update, normalizacja switchy, JSON dla kategorii)
+ - `delete(int $id): bool`
+ - `categoriesTree(?int $parentId): array` (drzewo kategorii z tlumaczeniami, bez zaleznosci od `admin\factory\ShopCategory`)
+- [x] Etap 3 (Admin Controller + routing DI): dodac `admin\Controllers\ShopPromotionController` i przepiac routing:
+ - rejestracja factory w `admin\Site::$newControllers` pod modulem `ShopPromotion`
+ - akcje: `list`, `edit`, `save`, `delete`
+ - zachowac obsluge legacy payload (`values` JSON) oraz obsluge `form-edit` (`$_POST`)
+- [x] Etap 4 (widoki): przepiac modul z `grid/gridEdit` na nowe komponenty:
+ - nowy widok listy oparty o `components/table-list` (filtry: nazwa, aktywny)
+ - nowy widok edycji oparty o `components/form-edit` (+ pola custom dla drzew kategorii)
+ - nowe partiale dla drzewa kategorii w module `shop-promotion` (usuniecie zaleznosci od `shop-product/subcategories-list`)
+ - nowy `shop-promotion/promotion-edit-custom-script.php` (warunkowe pola po `condition_type`, obsluga drzewa kategorii)
+- [x] Etap 5 (zaleznosci i cleanup): przeszukac i odpiac legacy zaleznosci:
+ - menu admin: link kanoniczny na `/admin/shop_promotion/list/`
+ - usunac legacy pliki po pelnym przepieciu:
+ - `autoload/admin/controls/class.ShopPromotion.php`
+ - `autoload/admin/factory/class.ShopPromotion.php`
+ - `admin/templates/shop-promotion/view-list.php` (grid)
+ - `admin/templates/shop-promotion/promotion-edit.php` (gridEdit)
+ - sprawdzic pozostale odwolania `ShopPromotion` i `shop_promotion/view_list` w calym repo
+- [x] Etap 6 (testy): dodac/uzupelnic testy:
+ - `tests/Unit/Domain/Promotion/PromotionRepositoryTest.php`
+ - `tests/Unit/admin/Controllers/ShopPromotionControllerTest.php`
+ - uruchomic minimum: nowe testy modulu + pelny `composer test`
+- [x] Etap 7 (dokumentacja po wdrozeniu): zaktualizowac:
+ - `DATABASE_STRUCTURE.md` (dodac `pp_shop_promotion`, jesli nadal brak)
+ - `PROJECT_STRUCTURE.md`
+ - `REFACTORING_PLAN.md` (sekcja "Aktualizacja ...")
+ - `TESTING.md` (nowy wynik suite)
+
+## Aktualizacja 2026-02-13 (ver. 0.264)
+- **ShopPromotion** - migracja `/admin/shop_promotion` na Domain + DI + nowe widoki
+ - NOWE: `Domain\Promotion\PromotionRepository` (`listForAdmin`, `find`, `save`, `delete`, `categoriesTree`, invalidacja cache aktywnych promocji)
+ - NOWE: `admin\Controllers\ShopPromotionController` (DI) z akcjami `list`, `edit`, `save`, `delete`
+ - UPDATE: routing DI (`admin\Site`) rozszerzony o modul `ShopPromotion`
+ - UPDATE: modul `/admin/shop_promotion/*` dziala na `components/table-list` i `components/form-edit`
+ - NOWE: widoki/partiale `shop-promotion/promotions-list`, `shop-promotion/promotion-edit`, `shop-promotion/promotion-categories-selector`, `shop-promotion/promotion-categories-tree`, `shop-promotion/promotion-edit-custom-script`
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopPromotion.php`, `autoload/admin/factory/class.ShopPromotion.php`, `admin/templates/shop-promotion/view-list.php`
+ - UPDATE: menu admin przepiete na kanoniczny URL `/admin/shop_promotion/list/`
+- Testy po zmianie: **OK (222 tests, 609 assertions)**.
+
+## Aktualizacja 2026-02-13 (ver. 0.265)
+- **ShopPromotion** - stabilizacja po migracji
+ - UPDATE: dodane `date_from` w `Domain\Promotion\PromotionRepository` (save/find/list/sort)
+ - UPDATE: `admin\Controllers\ShopPromotionController` rozszerzony o pole `Data od` na formularzu i kolumne `Data od` na liscie
+ - UPDATE: `shop\Promotion::get_active_promotions()` filtruje aktywnosc po `date_from` i `date_to`
+ - FIX: zapis edycji promocji nie tworzy nowego rekordu (hidden `id` + fallback `id` z URL)
+ - TEST: rozszerzono `PromotionRepositoryTest` o asercje `date_from`
+- Testy po zmianie: **OK (222 tests, 614 assertions)**.
diff --git a/TESTING.md b/TESTING.md
index 5e0ed91..11d99f9 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -297,3 +297,24 @@ Nowe testy dodane 2026-02-13:
Zaktualizowane pliki:
- `tests/bootstrap.php` (dodany stub `S::remove_special_chars()`)
+
+## Aktualizacja suite (ShopPromotion refactor, ver. 0.264)
+Ostatnio zweryfikowano: 2026-02-13
+
+```text
+OK (222 tests, 609 assertions)
+```
+
+Nowe testy dodane 2026-02-13:
+- `tests/Unit/Domain/Promotion/PromotionRepositoryTest.php` (6 testow: find default, save insert, delete, whitelist sortowania, drzewo kategorii)
+- `tests/Unit/admin/Controllers/ShopPromotionControllerTest.php` (4 testy: kontrakty metod i DI konstruktora)
+
+## Aktualizacja suite (ShopPromotion fix + date_from, ver. 0.265)
+Ostatnio zweryfikowano: 2026-02-13
+
+```text
+OK (222 tests, 614 assertions)
+```
+
+Zmiany testowe 2026-02-13:
+- rozszerzenie `tests/Unit/Domain/Promotion/PromotionRepositoryTest.php` o asercje `date_from`
diff --git a/admin/templates/shop-promotion/promotion-categories-selector.php b/admin/templates/shop-promotion/promotion-categories-selector.php
new file mode 100644
index 0000000..88652ae
--- /dev/null
+++ b/admin/templates/shop-promotion/promotion-categories-selector.php
@@ -0,0 +1,19 @@
+label ?? 'Kategorie'));
+$inputName = trim((string)($this->inputName ?? 'categories[]'));
+$categories = is_array($this->categories ?? null) ? $this->categories : [];
+$selectedIds = is_array($this->selectedIds ?? null) ? $this->selectedIds : [];
+?>
+
+
diff --git a/admin/templates/shop-promotion/promotion-categories-tree.php b/admin/templates/shop-promotion/promotion-categories-tree.php
new file mode 100644
index 0000000..34114f4
--- /dev/null
+++ b/admin/templates/shop-promotion/promotion-categories-tree.php
@@ -0,0 +1,68 @@
+categories ?? null) ? $this->categories : [];
+$inputName = trim((string)($this->inputName ?? 'categories[]'));
+$selectedRaw = is_array($this->selectedIds ?? null) ? $this->selectedIds : [];
+$selected = [];
+foreach ($selectedRaw as $value) {
+ $id = (int)$value;
+ if ($id > 0) {
+ $selected[$id] = true;
+ }
+}
+?>
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = \Tpl::view('shop-promotion/promotion-categories-tree', [
+ 'categories' => $children,
+ 'selectedIds' => array_keys($selected),
+ 'inputName' => $inputName,
+ ]); ?>
+
+
+
+
+
+
diff --git a/admin/templates/shop-promotion/promotion-edit-custom-script.php b/admin/templates/shop-promotion/promotion-edit-custom-script.php
new file mode 100644
index 0000000..69e0189
--- /dev/null
+++ b/admin/templates/shop-promotion/promotion-edit-custom-script.php
@@ -0,0 +1,115 @@
+
+
+
+
diff --git a/admin/templates/shop-promotion/promotion-edit.php b/admin/templates/shop-promotion/promotion-edit.php
index 44bce00..1b557e2 100644
--- a/admin/templates/shop-promotion/promotion-edit.php
+++ b/admin/templates/shop-promotion/promotion-edit.php
@@ -1,257 +1,3 @@
-
-
-global $db;
-ob_start();
-?>
-
-
-
-
- =
- \Html::input( [
- 'label' => 'Nazwa',
- 'name' => 'name',
- 'id' => 'name',
- 'value' => $this -> promotion['name']
- ] );
- ?>
- =
- \Html::input_switch( [
- 'label' => 'Aktywna',
- 'name' => 'status',
- 'checked' => $this -> promotion['status'] == 1 ? true : false
- ] );
- ?>
- =
- \Html::input_switch( [
- 'label' => 'Łącz z kuponami rabatowymi',
- 'name' => 'include_coupon',
- 'checked' => $this -> promotion['include_coupon'] == 1 ? true : false
- ] );
- ?>
- =
- \Html::input_switch( [
- 'label' => 'Uwzględnij produkty przecenione',
- 'name' => 'include_product_promo',
- 'checked' => $this -> promotion['include_product_promo'] == 1 ? true : false
- ] );
- ?>
- =
- \Html::select( [
- 'label' => 'Warunki promocji',
- 'id' => 'condition_type',
- 'name' => 'condition_type',
- 'values' => \shop\Promotion::$condition_type,
- 'value' => $this -> promotion['condition_type'],
- ] );
- ?>
- =
- \Html::select( [
- 'label' => 'Typ rabatu',
- 'id' => 'discount_type',
- 'name' => 'discount_type',
- 'values' => \shop\Promotion::$discount_type,
- 'value' => $this -> promotion['discount_type'],
- ] );
- ?>
- = \Html::input( [
- 'label' => 'Min. ilość produktów z danej kategorii',
- 'class' => 'int-format',
- 'name' => 'min_product_count',
- 'id' => 'min_product_count',
- 'value' => $this -> promotion['min_product_count']
- ] );?>
- = \Html::input( [
- 'label' => 'Cena najtańszego produktu',
- 'class' => 'number-format',
- 'name' => 'price_cheapest_product',
- 'id' => 'price_cheapest_product',
- 'value' => $this -> promotion['price_cheapest_product']
- ] );
- ?>
- =
- \Html::input( [
- 'label' => 'Wartość',
- 'class' => 'number-format',
- 'name' => 'amount',
- 'id' => 'amount',
- 'value' => $this -> promotion['amount']
- ] );
- ?>
- =
- \Html::input( [
- 'label' => 'Data do',
- 'class' => 'date',
- 'name' => 'date_to',
- 'id' => 'date_to',
- 'value' => $this -> promotion['date_to']
- ] );
- ?>
-
-
-
-
-
-
-$out = ob_get_clean();
+= \Tpl::view('components/form-edit', ['form' => $this->form]); ?>
+= \Tpl::view('shop-promotion/promotion-edit-custom-script'); ?>
-$grid = new \gridEdit;
-$grid -> id = 'promotion-edit';
-$grid -> gdb_opt = $gdb;
-$grid -> include_plugins = true;
-$grid -> title = $this -> promotion['id'] ? 'Edycja promocji: ' . $this -> promotion['name'] . '' : 'Nowa promocja';
-$grid -> fields = [
- [
- 'db' => 'id',
- 'type' => 'hidden',
- 'value' => $this -> promotion['id']
- ]
-];
-$grid -> actions = [
- 'save' => [ 'url' => '/admin/shop_promotion/save/', 'back_url' => '/admin/shop_promotion/view_list/' ],
- 'cancel' => [ 'url' => '/admin/shop_promotion/view_list/' ]
-];
-$grid -> external_code = $out;
-$grid -> persist_edit = true;
-$grid -> id_param = 'id';
-
-echo $grid -> draw();
-?>
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/admin/templates/shop-promotion/promotions-list.php b/admin/templates/shop-promotion/promotions-list.php
new file mode 100644
index 0000000..b336124
--- /dev/null
+++ b/admin/templates/shop-promotion/promotions-list.php
@@ -0,0 +1,2 @@
+= \Tpl::view('components/table-list', ['list' => $this->viewModel]); ?>
+
diff --git a/admin/templates/shop-promotion/view-list.php b/admin/templates/shop-promotion/view-list.php
deleted file mode 100644
index 6938a65..0000000
--- a/admin/templates/shop-promotion/view-list.php
+++ /dev/null
@@ -1,59 +0,0 @@
- gdb_opt = $gdb;
-$grid -> debug = true;
-$grid -> order = [ 'column' => 'id', 'type' => 'DESC' ];
-$grid -> search = [
- [ 'name' => 'Nazwa', 'db' => 'name', 'type' => 'text' ],
- [ 'name' => 'Aktywny', 'db' => 'status', 'type' => 'select', 'replace' => [ 'array' => [ 0 => 'nie', 1 => 'tak' ] ] ]
-];
-$grid -> columns_view = [
- [
- 'name' => 'Lp.',
- 'th' => [ 'class' => 'g-lp' ],
- 'td' => [ 'class' => 'g-center' ],
- 'autoincrement' => true
- ], [
- 'name' => 'Aktywny',
- 'db' => 'status',
- 'replace' => [ 'array' => [ 0 => 'nie', 1 => 'tak' ] ],
- 'td' => [ 'class' => 'g-center' ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 150px;' ],
- 'sort' => true
- ], [
- 'name' => 'Nazwa',
- 'db' => 'name',
- 'sort' => true,
- 'php' => 'echo "[name]";'
- ], [
- 'name' => 'Typ kuponu',
- 'db' => 'condition_type',
- 'replace' => [ 'array' => \shop\Promotion::$condition_type ]
- ], [
- 'name' => 'Data do',
- 'db' => 'date_to',
- 'td' => [ 'class' => 'g-center' ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 150px;' ]
- ], [
- 'name' => 'Edytuj',
- 'action' => [ 'type' => 'edit', 'url' => '/admin/shop_promotion/edit/id=[id]' ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 70px;' ],
- 'td' => [ 'class' => 'g-center' ]
- ], [
- 'name' => 'Usuń',
- 'action' => [ 'type' => 'delete', 'url' => '/admin/shop_promotion/promotion_delete/id=[id]' ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 70px;' ],
- 'td' => [ 'class' => 'g-center' ]
- ]
-];
-$grid -> buttons = [
- [
- 'label' => 'Dodaj promocję',
- 'url' => '/admin/shop_promotion/edit/',
- 'icon' => 'fa-plus-circle',
- 'class' => 'btn-success'
- ]
-];
-echo $grid -> draw();
diff --git a/admin/templates/site/main-layout.php b/admin/templates/site/main-layout.php
index fc02fed..aeeb88d 100644
--- a/admin/templates/site/main-layout.php
+++ b/admin/templates/site/main-layout.php
@@ -76,7 +76,7 @@
Statusy zamówień
Kody rabatowe
-
Promocje
+
Promocje
Zawartość
diff --git a/autoload/Domain/Promotion/PromotionRepository.php b/autoload/Domain/Promotion/PromotionRepository.php
new file mode 100644
index 0000000..9222431
--- /dev/null
+++ b/autoload/Domain/Promotion/PromotionRepository.php
@@ -0,0 +1,420 @@
+db = $db;
+ }
+
+ /**
+ * @return array{items: array>, total: int}
+ */
+ public function listForAdmin(
+ array $filters,
+ string $sortColumn = 'id',
+ string $sortDir = 'DESC',
+ int $page = 1,
+ int $perPage = 15
+ ): array {
+ $allowedSortColumns = [
+ 'id' => 'sp.id',
+ 'name' => 'sp.name',
+ 'status' => 'sp.status',
+ 'condition_type' => 'sp.condition_type',
+ 'date_from' => 'sp.date_from',
+ 'date_to' => 'sp.date_to',
+ ];
+
+ $sortSql = $allowedSortColumns[$sortColumn] ?? 'sp.id';
+ $sortDir = strtoupper(trim($sortDir)) === 'ASC' ? 'ASC' : 'DESC';
+ $page = max(1, $page);
+ $perPage = min(self::MAX_PER_PAGE, max(1, $perPage));
+ $offset = ($page - 1) * $perPage;
+
+ $where = ['1 = 1'];
+ $params = [];
+
+ $name = trim((string)($filters['name'] ?? ''));
+ if ($name !== '') {
+ if (strlen($name) > 255) {
+ $name = substr($name, 0, 255);
+ }
+ $where[] = 'sp.name LIKE :name';
+ $params[':name'] = '%' . $name . '%';
+ }
+
+ $status = trim((string)($filters['status'] ?? ''));
+ if ($status === '0' || $status === '1') {
+ $where[] = 'sp.status = :status';
+ $params[':status'] = (int)$status;
+ }
+
+ $whereSql = implode(' AND ', $where);
+
+ $sqlCount = "
+ SELECT COUNT(0)
+ FROM pp_shop_promotion AS sp
+ WHERE {$whereSql}
+ ";
+
+ $stmtCount = $this->db->query($sqlCount, $params);
+ $countRows = $stmtCount ? $stmtCount->fetchAll() : [];
+ $total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0;
+
+ $sql = "
+ SELECT
+ sp.id,
+ sp.name,
+ sp.status,
+ sp.condition_type,
+ sp.discount_type,
+ sp.amount,
+ sp.date_from,
+ sp.date_to,
+ sp.include_coupon,
+ sp.include_product_promo,
+ sp.min_product_count,
+ sp.price_cheapest_product
+ FROM pp_shop_promotion AS sp
+ WHERE {$whereSql}
+ ORDER BY {$sortSql} {$sortDir}, sp.id DESC
+ LIMIT {$perPage} OFFSET {$offset}
+ ";
+
+ $stmt = $this->db->query($sql, $params);
+ $items = $stmt ? $stmt->fetchAll() : [];
+
+ return [
+ 'items' => is_array($items) ? $items : [],
+ 'total' => $total,
+ ];
+ }
+
+ public function find(int $promotionId): array
+ {
+ if ($promotionId <= 0) {
+ return $this->defaultPromotion();
+ }
+
+ $promotion = $this->db->get('pp_shop_promotion', '*', ['id' => $promotionId]);
+ if (!is_array($promotion)) {
+ return $this->defaultPromotion();
+ }
+
+ $promotion['id'] = (int)($promotion['id'] ?? 0);
+ $promotion['status'] = $this->toSwitchValue($promotion['status'] ?? 0);
+ $promotion['include_coupon'] = $this->toSwitchValue($promotion['include_coupon'] ?? 0);
+ $promotion['include_product_promo'] = $this->toSwitchValue($promotion['include_product_promo'] ?? 0);
+ $promotion['condition_type'] = (int)($promotion['condition_type'] ?? 1);
+ $promotion['discount_type'] = (int)($promotion['discount_type'] ?? 1);
+ $promotion['categories'] = $this->decodeIdList($promotion['categories'] ?? null);
+ $promotion['condition_categories'] = $this->decodeIdList($promotion['condition_categories'] ?? null);
+
+ return $promotion;
+ }
+
+ public function save(array $data): ?int
+ {
+ $promotionId = (int)($data['id'] ?? 0);
+
+ $row = [
+ 'name' => trim((string)($data['name'] ?? '')),
+ 'status' => $this->toSwitchValue($data['status'] ?? 0),
+ 'condition_type' => (int)($data['condition_type'] ?? 1),
+ 'discount_type' => (int)($data['discount_type'] ?? 1),
+ 'amount' => $this->toNullableNumeric($data['amount'] ?? null),
+ 'date_from' => $this->toNullableDate($data['date_from'] ?? null),
+ 'date_to' => $this->toNullableDate($data['date_to'] ?? null),
+ 'categories' => $this->encodeIdList($data['categories'] ?? null),
+ 'condition_categories' => $this->encodeIdList($data['condition_categories'] ?? null),
+ 'include_coupon' => $this->toSwitchValue($data['include_coupon'] ?? 0),
+ 'include_product_promo' => $this->toSwitchValue($data['include_product_promo'] ?? 0),
+ 'min_product_count' => $this->toNullableInt($data['min_product_count'] ?? null),
+ 'price_cheapest_product' => $this->toNullableNumeric($data['price_cheapest_product'] ?? null),
+ ];
+
+ if ($promotionId <= 0) {
+ $this->db->insert('pp_shop_promotion', $row);
+ $id = (int)$this->db->id();
+ if ($id <= 0) {
+ return null;
+ }
+
+ $this->invalidateActivePromotionsCache();
+ return $id;
+ }
+
+ $this->db->update('pp_shop_promotion', $row, ['id' => $promotionId]);
+ $this->invalidateActivePromotionsCache();
+
+ return $promotionId;
+ }
+
+ public function delete(int $promotionId): bool
+ {
+ if ($promotionId <= 0) {
+ return false;
+ }
+
+ $deleted = $this->db->delete('pp_shop_promotion', ['id' => $promotionId]);
+ $ok = (bool)$deleted;
+
+ if ($ok) {
+ $this->invalidateActivePromotionsCache();
+ }
+
+ return $ok;
+ }
+
+ /**
+ * @return array>
+ */
+ public function categoriesTree($parentId = null): array
+ {
+ $rows = $this->db->select('pp_shop_categories', ['id'], [
+ 'parent_id' => $parentId,
+ 'ORDER' => ['o' => 'ASC'],
+ ]);
+
+ if (!is_array($rows)) {
+ return [];
+ }
+
+ $categories = [];
+ foreach ($rows as $row) {
+ $categoryId = (int)($row['id'] ?? 0);
+ if ($categoryId <= 0) {
+ continue;
+ }
+
+ $category = $this->db->get('pp_shop_categories', '*', ['id' => $categoryId]);
+ if (!is_array($category)) {
+ continue;
+ }
+
+ $translations = $this->db->select('pp_shop_categories_langs', '*', ['category_id' => $categoryId]);
+ $category['languages'] = [];
+ if (is_array($translations)) {
+ foreach ($translations as $translation) {
+ $langId = (string)($translation['lang_id'] ?? '');
+ if ($langId !== '') {
+ $category['languages'][$langId] = $translation;
+ }
+ }
+ }
+
+ $category['title'] = $this->categoryTitle($category['languages']);
+ $category['subcategories'] = $this->categoriesTree($categoryId);
+ $categories[] = $category;
+ }
+
+ return $categories;
+ }
+
+ private function defaultPromotion(): array
+ {
+ return [
+ 'id' => 0,
+ 'name' => '',
+ 'status' => 1,
+ 'condition_type' => 1,
+ 'discount_type' => 1,
+ 'amount' => null,
+ 'date_from' => null,
+ 'date_to' => null,
+ 'categories' => [],
+ 'condition_categories' => [],
+ 'include_coupon' => 0,
+ 'include_product_promo' => 0,
+ 'min_product_count' => null,
+ 'price_cheapest_product' => null,
+ ];
+ }
+
+ private function toSwitchValue($value): int
+ {
+ if (is_bool($value)) {
+ return $value ? 1 : 0;
+ }
+
+ if (is_numeric($value)) {
+ return ((int)$value) === 1 ? 1 : 0;
+ }
+
+ if (is_string($value)) {
+ $normalized = strtolower(trim($value));
+ return in_array($normalized, ['1', 'on', 'true', 'yes'], true) ? 1 : 0;
+ }
+
+ return 0;
+ }
+
+ private function toNullableInt($value): ?int
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if (is_string($value)) {
+ $value = trim($value);
+ if ($value === '') {
+ return null;
+ }
+ }
+
+ $intValue = (int)$value;
+ return $intValue > 0 ? $intValue : null;
+ }
+
+ private function toNullableNumeric($value): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $stringValue = trim((string)$value);
+ if ($stringValue === '') {
+ return null;
+ }
+
+ return str_replace(',', '.', $stringValue);
+ }
+
+ private function toNullableDate($value): ?string
+ {
+ $date = trim((string)$value);
+ if ($date === '') {
+ return null;
+ }
+
+ return $date;
+ }
+
+ private function encodeIdList($values): ?string
+ {
+ $ids = $this->normalizeIdList($values);
+ if (empty($ids)) {
+ return null;
+ }
+
+ return json_encode($ids);
+ }
+
+ /**
+ * @return int[]
+ */
+ private function decodeIdList($raw): array
+ {
+ if (is_array($raw)) {
+ return $this->normalizeIdList($raw);
+ }
+
+ $text = trim((string)$raw);
+ if ($text === '') {
+ return [];
+ }
+
+ $decoded = json_decode($text, true);
+ if (!is_array($decoded)) {
+ return [];
+ }
+
+ return $this->normalizeIdList($decoded);
+ }
+
+ /**
+ * @return int[]
+ */
+ private function normalizeIdList($values): array
+ {
+ if ($values === null) {
+ return [];
+ }
+
+ if (!is_array($values)) {
+ $text = trim((string)$values);
+ if ($text === '') {
+ return [];
+ }
+
+ if (strpos($text, ',') !== false) {
+ $values = explode(',', $text);
+ } else {
+ $values = [$text];
+ }
+ }
+
+ $ids = [];
+ foreach ($values as $value) {
+ $id = (int)$value;
+ if ($id > 0) {
+ $ids[$id] = $id;
+ }
+ }
+
+ return array_values($ids);
+ }
+
+ private function categoryTitle(array $languages): string
+ {
+ $defaultLang = $this->defaultLanguageId();
+ if ($defaultLang !== '' && isset($languages[$defaultLang]['title'])) {
+ $title = trim((string)$languages[$defaultLang]['title']);
+ if ($title !== '') {
+ return $title;
+ }
+ }
+
+ foreach ($languages as $language) {
+ $title = trim((string)($language['title'] ?? ''));
+ if ($title !== '') {
+ return $title;
+ }
+ }
+
+ return '';
+ }
+
+ private function defaultLanguageId(): string
+ {
+ if ($this->defaultLangId !== null) {
+ return $this->defaultLangId;
+ }
+
+ $rows = $this->db->select('pp_langs', ['id', 'start', 'o'], [
+ 'status' => 1,
+ 'ORDER' => ['start' => 'DESC', 'o' => 'ASC'],
+ ]);
+
+ if (is_array($rows) && !empty($rows)) {
+ $this->defaultLangId = (string)($rows[0]['id'] ?? '');
+ } else {
+ $this->defaultLangId = '';
+ }
+
+ return $this->defaultLangId;
+ }
+
+ private function invalidateActivePromotionsCache(): void
+ {
+ if (!class_exists('\CacheHandler')) {
+ return;
+ }
+
+ try {
+ $cache = new \CacheHandler();
+ if (method_exists($cache, 'delete')) {
+ $cache->delete('\shop\Promotion::get_active_promotions');
+ }
+ } catch (\Throwable $e) {
+ // Cache invalidation should not block save/delete.
+ }
+ }
+}
diff --git a/autoload/admin/Controllers/ShopPromotionController.php b/autoload/admin/Controllers/ShopPromotionController.php
new file mode 100644
index 0000000..4786fbf
--- /dev/null
+++ b/autoload/admin/Controllers/ShopPromotionController.php
@@ -0,0 +1,327 @@
+repository = $repository;
+ }
+
+ public function list(): string
+ {
+ $sortableColumns = ['id', 'name', 'status', 'condition_type', 'date_from', 'date_to'];
+ $filterDefinitions = [
+ [
+ 'key' => 'name',
+ 'label' => 'Nazwa',
+ 'type' => 'text',
+ ],
+ [
+ 'key' => 'status',
+ 'label' => 'Aktywny',
+ 'type' => 'select',
+ 'options' => [
+ '' => '- aktywny -',
+ '1' => 'tak',
+ '0' => 'nie',
+ ],
+ ],
+ ];
+
+ $listRequest = \admin\Support\TableListRequestFactory::fromRequest(
+ $filterDefinitions,
+ $sortableColumns,
+ 'id'
+ );
+
+ $sortDir = $listRequest['sortDir'];
+ if (trim((string)\S::get('sort')) === '') {
+ $sortDir = 'DESC';
+ }
+
+ $result = $this->repository->listForAdmin(
+ $listRequest['filters'],
+ $listRequest['sortColumn'],
+ $sortDir,
+ $listRequest['page'],
+ $listRequest['perPage']
+ );
+
+ $rows = [];
+ $lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1;
+ foreach ($result['items'] as $item) {
+ $id = (int)($item['id'] ?? 0);
+ $name = trim((string)($item['name'] ?? ''));
+ $status = (int)($item['status'] ?? 0);
+ $conditionType = (int)($item['condition_type'] ?? 0);
+ $dateFrom = trim((string)($item['date_from'] ?? ''));
+ $dateTo = trim((string)($item['date_to'] ?? ''));
+
+ $rows[] = [
+ 'lp' => $lp++ . '.',
+ 'status' => $status === 1 ? 'tak' : 'nie',
+ 'name' => '' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '',
+ 'condition_type' => htmlspecialchars((string)(\shop\Promotion::$condition_type[$conditionType] ?? '-'), ENT_QUOTES, 'UTF-8'),
+ 'date_from' => $dateFrom !== '' ? htmlspecialchars($dateFrom, ENT_QUOTES, 'UTF-8') : '-',
+ 'date_to' => $dateTo !== '' ? htmlspecialchars($dateTo, ENT_QUOTES, 'UTF-8') : '-',
+ '_actions' => [
+ [
+ 'label' => 'Edytuj',
+ 'url' => '/admin/shop_promotion/edit/id=' . $id,
+ 'class' => 'btn btn-xs btn-primary',
+ ],
+ [
+ 'label' => 'Usun',
+ 'url' => '/admin/shop_promotion/delete/id=' . $id,
+ 'class' => 'btn btn-xs btn-danger',
+ 'confirm' => 'Na pewno chcesz usunac wybrana promocje?',
+ 'confirm_ok' => 'Usun',
+ 'confirm_cancel' => 'Anuluj',
+ ],
+ ],
+ ];
+ }
+
+ $total = (int)$result['total'];
+ $totalPages = max(1, (int)ceil($total / $listRequest['perPage']));
+
+ $viewModel = new PaginatedTableViewModel(
+ [
+ ['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false],
+ ['key' => 'status', 'sort_key' => 'status', 'label' => 'Aktywny', 'class' => 'text-center', 'sortable' => true, 'raw' => true],
+ ['key' => 'name', 'sort_key' => 'name', 'label' => 'Nazwa', 'sortable' => true, 'raw' => true],
+ ['key' => 'condition_type', 'sort_key' => 'condition_type', 'label' => 'Typ kuponu', 'sortable' => true],
+ ['key' => 'date_from', 'sort_key' => 'date_from', 'label' => 'Data od', 'class' => 'text-center', 'sortable' => true, 'raw' => true],
+ ['key' => 'date_to', 'sort_key' => 'date_to', 'label' => 'Data do', 'class' => 'text-center', 'sortable' => true, 'raw' => true],
+ ],
+ $rows,
+ $listRequest['viewFilters'],
+ [
+ 'column' => $listRequest['sortColumn'],
+ 'dir' => $sortDir,
+ ],
+ [
+ 'page' => $listRequest['page'],
+ 'per_page' => $listRequest['perPage'],
+ 'total' => $total,
+ 'total_pages' => $totalPages,
+ ],
+ array_merge($listRequest['queryFilters'], [
+ 'sort' => $listRequest['sortColumn'],
+ 'dir' => $sortDir,
+ 'per_page' => $listRequest['perPage'],
+ ]),
+ $listRequest['perPageOptions'],
+ $sortableColumns,
+ '/admin/shop_promotion/list/',
+ 'Brak danych w tabeli.',
+ '/admin/shop_promotion/edit/',
+ 'Dodaj promocje'
+ );
+
+ return \Tpl::view('shop-promotion/promotions-list', [
+ 'viewModel' => $viewModel,
+ ]);
+ }
+
+ public function edit(): string
+ {
+ $promotion = $this->repository->find((int)\S::get('id'));
+ $categories = $this->repository->categoriesTree(null);
+
+ return \Tpl::view('shop-promotion/promotion-edit', [
+ 'form' => $this->buildFormViewModel($promotion, $categories),
+ ]);
+ }
+
+ public function save(): void
+ {
+ $legacyValues = \S::get('values');
+
+ if ($legacyValues) {
+ $values = json_decode((string)$legacyValues, true);
+ $response = [
+ 'status' => 'error',
+ 'msg' => 'Podczas zapisywania promocji wystapil blad. Prosze sprobowac ponownie.',
+ ];
+
+ if (is_array($values)) {
+ $id = $this->repository->save($values);
+ if (!empty($id)) {
+ $response = [
+ 'status' => 'ok',
+ 'msg' => 'Promocja zostala zapisana.',
+ 'id' => (int)$id,
+ ];
+ }
+ }
+
+ echo json_encode($response);
+ exit;
+ }
+
+ $payload = $_POST;
+ if (empty($payload['id'])) {
+ $routeId = (int)\S::get('id');
+ if ($routeId > 0) {
+ $payload['id'] = $routeId;
+ }
+ }
+
+ $id = $this->repository->save($payload);
+ if (!empty($id)) {
+ echo json_encode([
+ 'success' => true,
+ 'id' => (int)$id,
+ 'message' => 'Promocja zostala zapisana.',
+ ]);
+ exit;
+ }
+
+ echo json_encode([
+ 'success' => false,
+ 'errors' => ['general' => 'Podczas zapisywania promocji wystapil blad.'],
+ ]);
+ exit;
+ }
+
+ public function delete(): void
+ {
+ if ($this->repository->delete((int)\S::get('id'))) {
+ \S::alert('Promocja zostala usunieta.');
+ }
+
+ header('Location: /admin/shop_promotion/list/');
+ exit;
+ }
+
+ private function buildFormViewModel(array $promotion, array $categories): FormEditViewModel
+ {
+ $id = (int)($promotion['id'] ?? 0);
+ $isNew = $id <= 0;
+
+ $data = [
+ 'id' => $id,
+ 'name' => (string)($promotion['name'] ?? ''),
+ 'status' => (int)($promotion['status'] ?? 1),
+ 'include_coupon' => (int)($promotion['include_coupon'] ?? 0),
+ 'include_product_promo' => (int)($promotion['include_product_promo'] ?? 0),
+ 'condition_type' => (int)($promotion['condition_type'] ?? 1),
+ 'discount_type' => (int)($promotion['discount_type'] ?? 1),
+ 'min_product_count' => $promotion['min_product_count'] ?? '',
+ 'price_cheapest_product' => $promotion['price_cheapest_product'] ?? '',
+ 'amount' => $promotion['amount'] ?? '',
+ 'date_from' => (string)($promotion['date_from'] ?? ''),
+ 'date_to' => (string)($promotion['date_to'] ?? ''),
+ ];
+
+ $fields = [
+ FormField::hidden('id', $id),
+ FormField::text('name', [
+ 'label' => 'Nazwa',
+ 'tab' => 'settings',
+ 'required' => true,
+ ]),
+ FormField::switch('status', [
+ 'label' => 'Aktywna',
+ 'tab' => 'settings',
+ 'value' => true,
+ ]),
+ FormField::switch('include_coupon', [
+ 'label' => 'Lacz z kuponami rabatowymi',
+ 'tab' => 'settings',
+ ]),
+ FormField::switch('include_product_promo', [
+ 'label' => 'Uwzglednij produkty przecenione',
+ 'tab' => 'settings',
+ ]),
+ FormField::select('condition_type', [
+ 'label' => 'Warunki promocji',
+ 'tab' => 'settings',
+ 'options' => \shop\Promotion::$condition_type,
+ 'required' => true,
+ ]),
+ FormField::select('discount_type', [
+ 'label' => 'Typ rabatu',
+ 'tab' => 'settings',
+ 'options' => \shop\Promotion::$discount_type,
+ 'required' => true,
+ ]),
+ FormField::text('min_product_count', [
+ 'label' => 'Min. ilosc produktow z danej kategorii',
+ 'tab' => 'settings',
+ 'attributes' => ['class' => 'int-format'],
+ ]),
+ FormField::text('price_cheapest_product', [
+ 'label' => 'Cena najtanszego produktu',
+ 'tab' => 'settings',
+ 'attributes' => ['class' => 'number-format'],
+ ]),
+ FormField::text('amount', [
+ 'label' => 'Wartosc',
+ 'tab' => 'settings',
+ 'attributes' => ['class' => 'number-format'],
+ ]),
+ FormField::date('date_from', [
+ 'label' => 'Data od',
+ 'tab' => 'settings',
+ ]),
+ FormField::date('date_to', [
+ 'label' => 'Data do',
+ 'tab' => 'settings',
+ ]),
+ FormField::custom('categories_group_1', \Tpl::view('shop-promotion/promotion-categories-selector', [
+ 'label' => 'Kategorie grupa I',
+ 'inputName' => 'categories[]',
+ 'categories' => $categories,
+ 'selectedIds' => is_array($promotion['categories'] ?? null) ? $promotion['categories'] : [],
+ ]), [
+ 'tab' => 'categories',
+ ]),
+ FormField::custom('categories_group_2', \Tpl::view('shop-promotion/promotion-categories-selector', [
+ 'label' => 'Kategorie grupa II',
+ 'inputName' => 'condition_categories[]',
+ 'categories' => $categories,
+ 'selectedIds' => is_array($promotion['condition_categories'] ?? null) ? $promotion['condition_categories'] : [],
+ ]), [
+ 'tab' => 'categories',
+ ]),
+ ];
+
+ $tabs = [
+ new FormTab('settings', 'Ustawienia', 'fa-wrench'),
+ new FormTab('categories', 'Kategorie', 'fa-folder-open'),
+ ];
+
+ $actionUrl = '/admin/shop_promotion/save/' . ($isNew ? '' : ('id=' . $id));
+ $actions = [
+ FormAction::save($actionUrl, '/admin/shop_promotion/list/'),
+ FormAction::cancel('/admin/shop_promotion/list/'),
+ ];
+
+ return new FormEditViewModel(
+ 'shop-promotion-edit',
+ $isNew ? 'Nowa promocja' : ('Edycja promocji: ' . (string)($promotion['name'] ?? '')),
+ $data,
+ $fields,
+ $tabs,
+ $actions,
+ 'POST',
+ $actionUrl,
+ '/admin/shop_promotion/list/',
+ true,
+ ['id' => $id]
+ );
+ }
+}
+
diff --git a/autoload/admin/class.Site.php b/autoload/admin/class.Site.php
index cb10768..2cb0584 100644
--- a/autoload/admin/class.Site.php
+++ b/autoload/admin/class.Site.php
@@ -302,6 +302,13 @@ class Site
new \Domain\Languages\LanguagesRepository( $mdb )
);
},
+ 'ShopPromotion' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopPromotionController(
+ new \Domain\Promotion\PromotionRepository( $mdb )
+ );
+ },
'Pages' => function() {
global $mdb;
diff --git a/autoload/admin/controls/class.ShopPromotion.php b/autoload/admin/controls/class.ShopPromotion.php
deleted file mode 100644
index ae27a20..0000000
--- a/autoload/admin/controls/class.ShopPromotion.php
+++ /dev/null
@@ -1,59 +0,0 @@
- 'error', 'msg' => 'Podczas zapisywania promocji wystąpił błąd. Proszę spróbować ponownie' ];
- $values = json_decode( \S::get( 'values' ), true );
-
- if ( $id = \admin\factory\ShopPromotion::save(
- $values['id'],
- $values['name'],
- $values['status'] == 'on' ? 1 : 0,
- $values['condition_type'],
- $values['discount_type'],
- $values['amount'],
- $values['date_to'],
- $values['categories'],
- $values['condition_categories'],
- $values['include_coupon'] == 'on' ? 1 : 0,
- $values['include_product_promo'] == 'on' ? 1 : 0,
- $values['min_product_count'],
- $values['price_cheapest_product']
- ) )
- $response = [ 'status' => 'ok', 'msg' => 'Promocja została zapisana', 'id' => $id ];
-
- echo json_encode( $response );
- exit;
- }
-
- // edycja promocji
- static public function edit()
- {
- return \Tpl::view( 'shop-promotion/promotion-edit', [
- 'promotion' => \admin\factory\ShopPromotion::promotion_details( (int)\S::get( 'id' ) ),
- 'categories' => \admin\factory\ShopCategory::subcategories( null ),
- 'dlang' => \front\factory\Languages::default_language()
- ] );
- }
-
- // lista promocji
- public static function view_list()
- {
- return \Tpl::view( 'shop-promotion/view-list' );
- }
-
-}
\ No newline at end of file
diff --git a/autoload/admin/factory/class.ShopPromotion.php b/autoload/admin/factory/class.ShopPromotion.php
deleted file mode 100644
index fbc7e04..0000000
--- a/autoload/admin/factory/class.ShopPromotion.php
+++ /dev/null
@@ -1,55 +0,0 @@
- get( 'pp_shop_promotion', '*', [ 'id' => $promotion_id ] );
- }
-
- static public function save( $promotion_id, $name, $status, $condition_type, $discount_type, $amount, $date_to, $categories, $condition_categories, $include_coupon, $include_product_promo, $min_product_count, $price_cheapest_product )
- {
- global $mdb, $user;
-
- if ( !$promotion_id )
- {
- $mdb -> insert( 'pp_shop_promotion', [
- 'name' => $name,
- 'status' => $status,
- 'condition_type' => $condition_type,
- 'discount_type' => $discount_type,
- 'amount' => $amount,
- 'date_to' => $date_to != '' ? $date_to : null,
- 'categories' => $categories != null ? ( is_array( $categories ) ? json_encode( $categories ) : json_encode( [ $categories ] ) ) : null,
- 'condition_categories' => $condition_categories != null ? ( is_array( $condition_categories ) ? json_encode( $condition_categories ) : json_encode( [ $condition_categories ] ) ) : null,
- 'include_coupon' => $include_coupon,
- 'include_product_promo' => $include_product_promo,
- 'min_product_count' => $min_product_count ? $min_product_count : null,
- 'price_cheapest_product' => $price_cheapest_product ? $price_cheapest_product : null
- ] );
-
- return $mdb -> id();
- }
- else
- {
- $mdb -> update( 'pp_shop_promotion', [
- 'name' => $name,
- 'status' => $status,
- 'condition_type' => $condition_type,
- 'discount_type' => $discount_type,
- 'amount' => $amount,
- 'date_to' => $date_to != '' ? $date_to : null,
- 'categories' => $categories != null ? ( is_array( $categories ) ? json_encode( $categories ) : json_encode( [ $categories ] ) ) : null,
- 'condition_categories' => $condition_categories != null ? ( is_array( $condition_categories ) ? json_encode( $condition_categories ) : json_encode( [ $condition_categories ] ) ) : null,
- 'include_coupon' => $include_coupon,
- 'include_product_promo' => $include_product_promo,
- 'min_product_count' => $min_product_count ? $min_product_count : null,
- 'price_cheapest_product' => $price_cheapest_product ? $price_cheapest_product : null
- ], [
- 'id' => $promotion_id
- ] );
- return $promotion_id;
- }
- }
-}
\ No newline at end of file
diff --git a/autoload/shop/class.Promotion.php b/autoload/shop/class.Promotion.php
index d7271dd..17655dd 100644
--- a/autoload/shop/class.Promotion.php
+++ b/autoload/shop/class.Promotion.php
@@ -28,7 +28,7 @@ class Promotion extends DbModel
if ( !$objectData )
{
- $results = $mdb -> select( 'pp_shop_promotion', 'id', [ 'AND' => [ 'status' => 1, 'OR' => [ 'date_to' => null, 'date_to[>=]' => date( 'Y-m-d' ) ] ], 'ORDER' => [ 'id' => 'DESC' ] ] );
+ $results = $mdb -> select( 'pp_shop_promotion', 'id', [ 'AND' => [ 'status' => 1, 'OR #date_from' => [ 'date_from' => null, 'date_from[<=]' => date( 'Y-m-d' ) ], 'OR #date_to' => [ 'date_to' => null, 'date_to[>=]' => date( 'Y-m-d' ) ] ], 'ORDER' => [ 'id' => 'DESC' ] ] );
$cacheHandler -> set( $cacheKey, $results );
}
@@ -84,4 +84,4 @@ class Promotion extends DbModel
}
return $basket;
}
-}
\ No newline at end of file
+}
diff --git a/tests/Unit/Domain/Promotion/PromotionRepositoryTest.php b/tests/Unit/Domain/Promotion/PromotionRepositoryTest.php
new file mode 100644
index 0000000..709195e
--- /dev/null
+++ b/tests/Unit/Domain/Promotion/PromotionRepositoryTest.php
@@ -0,0 +1,179 @@
+createMock(\medoo::class);
+ $repository = new PromotionRepository($mockDb);
+
+ $result = $repository->find(0);
+
+ $this->assertIsArray($result);
+ $this->assertSame(0, (int)$result['id']);
+ $this->assertSame(1, (int)$result['status']);
+ $this->assertNull($result['date_from']);
+ $this->assertSame([], $result['categories']);
+ $this->assertSame([], $result['condition_categories']);
+ }
+
+ public function testSaveInsertsPromotionAndReturnsId(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $insertRow = null;
+
+ $mockDb->expects($this->once())
+ ->method('insert')
+ ->willReturnCallback(function ($table, $row) use (&$insertRow) {
+ $this->assertSame('pp_shop_promotion', $table);
+ $this->assertArrayHasKey('name', $row);
+ $insertRow = $row;
+ });
+
+ $mockDb->expects($this->once())
+ ->method('id')
+ ->willReturn(123);
+
+ $repository = new PromotionRepository($mockDb);
+ $id = $repository->save([
+ 'name' => 'Promocja testowa',
+ 'status' => 'on',
+ 'condition_type' => 1,
+ 'discount_type' => 1,
+ 'amount' => '10',
+ 'date_from' => '2026-02-01',
+ 'categories' => [1, 2],
+ ]);
+
+ $this->assertSame(123, $id);
+ $this->assertIsArray($insertRow);
+ $this->assertSame('2026-02-01', $insertRow['date_from'] ?? null);
+ }
+
+ public function testDeleteReturnsFalseForInvalidId(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->expects($this->never())->method('delete');
+
+ $repository = new PromotionRepository($mockDb);
+ $this->assertFalse($repository->delete(0));
+ }
+
+ public function testDeleteReturnsTrueWhenDatabaseDeleteSucceeds(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->expects($this->once())
+ ->method('delete')
+ ->with('pp_shop_promotion', ['id' => 55])
+ ->willReturn(true);
+
+ $repository = new PromotionRepository($mockDb);
+ $this->assertTrue($repository->delete(55));
+ }
+
+ public function testListForAdminWhitelistsSortAndDirection(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $queries = [];
+
+ $mockDb->method('query')
+ ->willReturnCallback(function ($sql, $params = []) use (&$queries) {
+ $queries[] = ['sql' => $sql, 'params' => $params];
+
+ if (strpos($sql, 'COUNT(0)') !== false) {
+ return new class {
+ public function fetchAll()
+ {
+ return [[1]];
+ }
+ };
+ }
+
+ return new class {
+ public function fetchAll()
+ {
+ return [[
+ 'id' => 1,
+ 'name' => 'Promo',
+ 'status' => 1,
+ 'condition_type' => 1,
+ 'date_to' => null,
+ ]];
+ }
+ };
+ });
+
+ $repository = new PromotionRepository($mockDb);
+ $repository->listForAdmin(
+ [],
+ 'date_to DESC; DROP TABLE pp_shop_promotion; --',
+ 'DESC; DELETE FROM pp_users; --',
+ 1,
+ 500
+ );
+
+ $this->assertCount(2, $queries);
+ $dataSql = $queries[1]['sql'];
+
+ $this->assertMatchesRegularExpression('/ORDER BY\s+sp\.id\s+DESC,\s+sp\.id\s+DESC/i', $dataSql);
+ $this->assertStringNotContainsString('DROP TABLE', $dataSql);
+ $this->assertStringNotContainsString('DELETE FROM pp_users', $dataSql);
+ $this->assertMatchesRegularExpression('/LIMIT\s+100\s+OFFSET\s+0/i', $dataSql);
+ }
+
+ public function testCategoriesTreeReturnsHierarchy(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+
+ $mockDb->method('select')
+ ->willReturnCallback(function ($table, $columns, $where) {
+ if ($table === 'pp_shop_categories' && array_key_exists('parent_id', $where)) {
+ if ($where['parent_id'] === null) {
+ return [['id' => 10]];
+ }
+ if ((int)$where['parent_id'] === 10) {
+ return [['id' => 11]];
+ }
+ return [];
+ }
+
+ if ($table === 'pp_shop_categories_langs') {
+ if ((int)$where['category_id'] === 10) {
+ return [['lang_id' => 'pl', 'title' => 'Kategoria A']];
+ }
+ if ((int)$where['category_id'] === 11) {
+ return [['lang_id' => 'pl', 'title' => 'Podkategoria A1']];
+ }
+ return [];
+ }
+
+ if ($table === 'pp_langs') {
+ return [['id' => 'pl', 'start' => 1, 'o' => 1]];
+ }
+
+ return [];
+ });
+
+ $mockDb->method('get')
+ ->willReturnCallback(function ($table, $columns, $where) {
+ if ($table === 'pp_shop_categories') {
+ $id = (int)$where['id'];
+ return ['id' => $id, 'status' => 1];
+ }
+ return null;
+ });
+
+ $repository = new PromotionRepository($mockDb);
+ $tree = $repository->categoriesTree(null);
+
+ $this->assertCount(1, $tree);
+ $this->assertSame(10, (int)$tree[0]['id']);
+ $this->assertSame('Kategoria A', $tree[0]['title']);
+ $this->assertCount(1, $tree[0]['subcategories']);
+ $this->assertSame(11, (int)$tree[0]['subcategories'][0]['id']);
+ }
+}
diff --git a/tests/Unit/admin/Controllers/ShopPromotionControllerTest.php b/tests/Unit/admin/Controllers/ShopPromotionControllerTest.php
new file mode 100644
index 0000000..c25cd13
--- /dev/null
+++ b/tests/Unit/admin/Controllers/ShopPromotionControllerTest.php
@@ -0,0 +1,53 @@
+repository = $this->createMock(PromotionRepository::class);
+ $this->controller = new ShopPromotionController($this->repository);
+ }
+
+ public function testConstructorAcceptsRepository(): void
+ {
+ $controller = new ShopPromotionController($this->repository);
+ $this->assertInstanceOf(ShopPromotionController::class, $controller);
+ }
+
+ public function testHasMainActionMethods(): void
+ {
+ $this->assertTrue(method_exists($this->controller, 'list'));
+ $this->assertTrue(method_exists($this->controller, 'edit'));
+ $this->assertTrue(method_exists($this->controller, 'save'));
+ $this->assertTrue(method_exists($this->controller, 'delete'));
+ }
+
+ public function testActionMethodReturnTypes(): void
+ {
+ $reflection = new \ReflectionClass($this->controller);
+
+ $this->assertEquals('string', (string)$reflection->getMethod('list')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('edit')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('save')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('delete')->getReturnType());
+ }
+
+ public function testConstructorRequiresPromotionRepository(): void
+ {
+ $reflection = new \ReflectionClass(ShopPromotionController::class);
+ $constructor = $reflection->getConstructor();
+ $params = $constructor->getParameters();
+
+ $this->assertCount(1, $params);
+ $this->assertEquals('Domain\Promotion\PromotionRepository', $params[0]->getType()->getName());
+ }
+}
+
diff --git a/updates/0.20/ver_0.265.zip b/updates/0.20/ver_0.265.zip
new file mode 100644
index 0000000..4cf11c7
Binary files /dev/null and b/updates/0.20/ver_0.265.zip differ
diff --git a/updates/0.20/ver_0.265_files.txt b/updates/0.20/ver_0.265_files.txt
new file mode 100644
index 0000000..0d5d73f
--- /dev/null
+++ b/updates/0.20/ver_0.265_files.txt
@@ -0,0 +1,3 @@
+F: ../admin/templates/shop-promotion/view-list.php
+F: ../autoload/admin/controls/class.ShopPromotion.php
+F: ../autoload/admin/factory/class.ShopPromotion.php
diff --git a/updates/0.20/ver_0.265_sql.txt b/updates/0.20/ver_0.265_sql.txt
new file mode 100644
index 0000000..2eb8cb8
--- /dev/null
+++ b/updates/0.20/ver_0.265_sql.txt
@@ -0,0 +1 @@
+ALTER TABLE pp_shop_promotion ADD COLUMN date_from date DEFAULT NULL AFTER amount;
diff --git a/updates/changelog.php b/updates/changelog.php
index edffadc..8d248a0 100644
--- a/updates/changelog.php
+++ b/updates/changelog.php
@@ -1,4 +1,16 @@
-ver. 0.263 - 13.02.2026
+ver. 0.265 - 13.02.2026
+- UPDATE - modul `/admin/shop_promotion/*`: dodano pole `Data od` (`date_from`) w repozytorium, formularzu i liscie
+- UPDATE - front: `shop\Promotion::get_active_promotions()` uwzglednia `date_from` (okno aktywnosci od-do)
+- FIX - edycja promocji zapisuje aktualizacje rekordu zamiast tworzenia nowego (`id` przekazywane przez hidden field + fallback z URL)
+- UPDATE - testy: `OK (222 tests, 614 assertions)`
+
ver. 0.264 - 13.02.2026
+- NEW - migracja modulu `ShopPromotion` do architektury Domain + DI (`Domain\Promotion\PromotionRepository`, `admin\Controllers\ShopPromotionController`)
+- UPDATE - modul `/admin/shop_promotion/*` przepiety z legacy `grid/gridEdit` na `components/table-list` i `components/form-edit`
+- UPDATE - nowe widoki i partiale: `shop-promotion/promotions-list`, `shop-promotion/promotion-edit`, `shop-promotion/promotion-categories-selector`, `shop-promotion/promotion-categories-tree`, `shop-promotion/promotion-edit-custom-script`
+- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopPromotion.php`, `autoload/admin/factory/class.ShopPromotion.php`, `admin/templates/shop-promotion/view-list.php`
+- UPDATE - menu admin wskazuje kanoniczny URL `/admin/shop_promotion/list/`
+- UPDATE - testy: `OK (222 tests, 609 assertions)` + nowe pliki testowe `PromotionRepositoryTest`, `ShopPromotionControllerTest`
+
ver. 0.263 - 13.02.2026
- NEW - migracja modulu `Integrations` do architektury Domain + DI (`Domain\Integrations\IntegrationsRepository`, `admin\Controllers\IntegrationsController`)
- CLEANUP - usunieto integracje Sellasist i Baselinker z calego projektu (kontrolery, factory, szablony, referencje w cron/Order/ShopStatuses/ShopTransport/ShopPaymentMethod/ShopProduct)
- UPDATE - `admin\factory\Integrations` jako fasada delegujaca do repozytorium (tylko Apilo + ShopPRO)
@@ -443,5 +455,3 @@
-
-
diff --git a/updates/versions.php b/updates/versions.php
index 7f5b851..bb1f9e4 100644
--- a/updates/versions.php
+++ b/updates/versions.php
@@ -1,5 +1,5 @@
-$current_ver = 263;
+$current_ver = 265;
for ($i = 1; $i <= $current_ver; $i++)
{