+
+ - Treść
+ - Ustawienia
+ - SEO
+
+
+
+
+
+ if ( is_array( $this -> languages ) ): foreach ( $this -> languages as $lg ):?>
+ if ( $lg['status'] ):?>
+ - if ( $lg['id'] == \front\factory\Languages::default_language() ) echo ' ';?>= $lg['name'];?>
+ endif;?>
+ endforeach; endif;?>
+
+
+ if ( is_array( $this -> languages ) ): foreach ( $this -> languages as $lg ):?>
+ if ( $lg['status'] ):?>
+
+ = \Html::input(
+ array(
+ 'label' => 'Nazwa kategorii',
+ 'name' => 'title[' . $lg['id'] . ']',
+ 'id' => 'title_' . $lg['id'],
+ 'value' => $this -> category[ 'languages' ][ $lg['id'] ]['title'],
+ 'inline' => true
+ )
+ );?>
+ = \Html::textarea(
+ array(
+ 'label' => 'Opis kategorii',
+ 'name' => 'text[' . $lg['id'] . ']',
+ 'id' => 'text_' . $lg['id'],
+ 'value' => $this -> category['languages'][ $lg['id'] ]['text'],
+ 'inline' => true
+ )
+ );?>
+ = \Html::textarea(
+ array(
+ 'label' => 'Opis kategorii (rozwinięcie)',
+ 'name' => 'text_hidden[' . $lg['id'] . ']',
+ 'id' => 'text_hidden_' . $lg['id'],
+ 'value' => $this -> category['languages'][ $lg['id'] ]['text_hidden'],
+ 'inline' => true
+ )
+ );?>
+ = \Html::textarea( [
+ 'label' => 'Dodatkowy tekst (nad produktami)',
+ 'name' => 'additional_text[' . $lg['id'] . ']',
+ 'id' => 'additional_text_' . $lg['id'],
+ 'value' => $this -> category['languages'][ $lg['id'] ]['additional_text'],
+ 'inline' => true
+ ] );?>
+
+
+ endif;?>
+ endforeach; endif;?>
+
+
+
+
+
+ = \Html::input_switch(
+ array(
+ 'label' => 'Aktywna',
+ 'name' => 'status',
+ 'checked' => $this -> category['status'] == 1 or !$this -> category['id'] ? true : false
+ )
+ );?>
+ = \Html::select(
+ [
+ 'label' => 'Sortowanie produktĂłw',
+ 'name' => 'sort_type',
+ 'id' => 'sort_type',
+ 'values' => is_array( $this -> sort_types ) ? $this -> sort_types : [],
+ 'value' => $this -> category['sort_type']
+ ]
+ );?>
+ = \Html::input_switch(
+ array(
+ 'label' => 'Wyświetlić podkategorie',
+ 'name' => 'view_subcategories',
+ 'checked' => $this -> category['view_subcategories'] == 1 ? true : false
+ )
+ );?>
+
+
+
+
+ if ( is_array( $this -> languages ) ): foreach ( $this -> languages as $lg ):?>
+ if ( $lg['status'] ):?>
+ - if ( $lg['id'] == \front\factory\Languages::default_language() ) echo ' ';?>= $lg['name'];?>
+ endif;?>
+ endforeach; endif;?>
+
+
+ if ( is_array( $this -> languages ) ): foreach ( $this -> languages as $lg ):?>
+ if ( $lg['status'] ):?>
+
+ = \Html::input_icon(
+ array(
+ 'label' => 'Link SEO',
+ 'name' => 'seo_link[' . $lg['id'] . ']',
+ 'id' => 'seo_link_' . $lg['id'],
+ 'value' => $this -> category['languages' ][ $lg['id'] ]['seo_link'],
+ 'icon_content' => 'generuj',
+ 'icon_js' => 'generate_seo_links( "' . $lg['id'] . '", $( "#title_' . $lg['id'] . '" ).val(), ' . (int)$this -> category['id'] . ' );'
+ )
+ );?>
+ = \Html::input(
+ array(
+ 'label' => 'Tytuł kategorii (h1)',
+ 'name' => 'category_title[' . $lg['id'] . ']',
+ 'id' => 'category_title_' . $lg['id'],
+ 'value' => $this -> category['languages' ][ $lg['id'] ]['category_title']
+ )
+ );?>
+ = \Html::input(
+ array(
+ 'label' => 'Meta title',
+ 'name' => 'meta_title[' . $lg['id'] . ']',
+ 'id' => 'meta_title_' . $lg['id'],
+ 'value' => $this -> category['languages'][ $lg['id'] ]['meta_title']
+ )
+ );?>
+ = \Html::textarea(
+ array(
+ 'label' => 'Meta description',
+ 'name' => 'meta_description[' . $lg['id'] . ']',
+ 'id' => 'meta_description_' . $lg['id'],
+ 'value' => $this -> category['languages'][ $lg['id'] ]['meta_description']
+ )
+ );?>
+ = \Html::textarea(
+ array(
+ 'label' => 'Meta keywords',
+ 'name' => 'meta_keywords[' . $lg['id'] . ']',
+ 'id' => 'meta_keywords_' . $lg['id'],
+ 'value' => $this -> category['languages'][ $lg['id'] ]['meta_keywords']
+ )
+ );?>
+ = \Html::select(
+ array(
+ 'label' => 'Blokuj indeksacjÄ™',
+ 'name' => 'noindex[' . $lg['id'] . ']',
+ 'id' => 'noindex_' . $lg['id'],
+ 'values' => array(
+ 0 => 'nie', 1 => 'tak'
+ ),
+ 'value' => $this -> category['languages'][ $lg['id'] ]['noindex'] == 1 ? 1 : 0
+ )
+ );?>
+
+ endif;?>
+ endforeach; endif;?>
+
+
+
+
+
+
+
+$out = ob_get_clean();
+
+$grid = new \gridEdit;
+$grid -> id = 'category-edit';
+$grid -> gdb_opt = $gdb;
+$grid -> include_plugins = true;
+$grid -> title = 'Edycja kategorii';
+$grid -> fields = [
+ [
+ 'db' => 'id',
+ 'type' => 'hidden',
+ 'value' => $this -> category['id']
+ ],
+ [
+ 'db' => 'parent_id',
+ 'type' => 'hidden',
+ 'value' => $this -> category['id'] ? $this -> category['parent_id'] : $this -> pid
+ ]
+ ];
+$grid -> actions = [
+ 'save' => [ 'url' => '/admin/shop_category/save/', 'back_url' => '/admin/shop_category/view_list/' ],
+ 'cancel' => [ 'url' => '/admin/shop_category/view_list/' ]
+ ];
+$grid -> external_code = $out;
+$grid -> persist_edit = true;
+$grid -> id_param = 'id';
+
+echo $grid -> draw();
+?>
+
+= \Tpl::view( 'shop-category/category-edit-custom-script' ); ?>
+
diff --git a/temp/update_build/tmp_0.275/admin/templates/shop-category/category-products-custom-script.php b/temp/update_build/tmp_0.275/admin/templates/shop-category/category-products-custom-script.php
new file mode 100644
index 0000000..3a43fa0
--- /dev/null
+++ b/temp/update_build/tmp_0.275/admin/templates/shop-category/category-products-custom-script.php
@@ -0,0 +1,62 @@
+
+
+
diff --git a/temp/update_build/tmp_0.275/admin/templates/shop-category/category-products.php b/temp/update_build/tmp_0.275/admin/templates/shop-category/category-products.php
new file mode 100644
index 0000000..7885fd8
--- /dev/null
+++ b/temp/update_build/tmp_0.275/admin/templates/shop-category/category-products.php
@@ -0,0 +1,37 @@
+
+global $gdb;
+
+ob_start();
+?>
+
+
+ if ( is_array( $this -> products ) ) foreach ( $this -> products as $product )
+ {
+ ?>
+ -
+
= $product['name'];?>
+
+
+ }
+ ?>
+
+
+$out = ob_get_clean();
+
+$grid = new \gridEdit;
+$grid -> gdb_opt = $gdb;
+$grid -> include_plugins = true;
+$grid -> default_buttons = false;
+$grid -> external_code = $out;
+$grid -> title = 'Lista produktów';
+$grid -> buttons = [
+ [
+ 'label' => 'Wstecz',
+ 'url' => '/admin/shop_category/view_list/',
+ 'icon' => 'fa-reply',
+ 'class' => 'btn-dark'
+ ]
+ ];
+echo $grid -> draw();
+?>
+= \Tpl::view( 'shop-category/category-products-custom-script', [ 'category_id' => $this -> category_id ] ); ?>
\ No newline at end of file
diff --git a/temp/update_build/tmp_0.275/admin/templates/shop-category/subcategories-list.php b/temp/update_build/tmp_0.275/admin/templates/shop-category/subcategories-list.php
new file mode 100644
index 0000000..739fe25
--- /dev/null
+++ b/temp/update_build/tmp_0.275/admin/templates/shop-category/subcategories-list.php
@@ -0,0 +1,36 @@
+ if ( is_array( $this -> categories ) ):?>
+
+ foreach ( $this -> categories as $category ):?>
+ -
+
+ = \Tpl::view( 'shop-category/subcategories-list', [
+ 'categories' => ( new \Domain\Category\CategoryRepository( $GLOBALS['mdb'] ) )->subcategories( $category['id'] ),
+ 'level' => $this -> level + 1,
+ 'dlang' => $this -> dlang
+ ] );?>
+
+ endforeach;?>
+
+ endif;?>
\ No newline at end of file
diff --git a/temp/update_build/tmp_0.275/admin/templates/shop-category/subcategory-browse-list.php b/temp/update_build/tmp_0.275/admin/templates/shop-category/subcategory-browse-list.php
new file mode 100644
index 0000000..1fafb54
--- /dev/null
+++ b/temp/update_build/tmp_0.275/admin/templates/shop-category/subcategory-browse-list.php
@@ -0,0 +1,25 @@
+ if ( is_array( $this -> categories ) ):?>
+
+ foreach ( $this -> categories as $category ):?>
+ -
+
+ = \Tpl::view( 'shop-category/subcategory-browse-list', [
+ 'categories' => ( new \Domain\Category\CategoryRepository( $GLOBALS['mdb'] ) )->subcategories( $category['id'] ),
+ 'level' => $this -> level + 1,
+ 'dlang' => $this -> dlang
+ ] );?>
+
+ endforeach;?>
+
+ endif;?>
\ No newline at end of file
diff --git a/temp/update_build/tmp_0.275/admin/templates/shop-product/mass-edit.php b/temp/update_build/tmp_0.275/admin/templates/shop-product/mass-edit.php
new file mode 100644
index 0000000..0823132
--- /dev/null
+++ b/temp/update_build/tmp_0.275/admin/templates/shop-product/mass-edit.php
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+ Masowa edycja produktów
+
+
+
+
+
+
+
+
+
+ products ) ): foreach ( $this->products as $key => $product ): ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+= \Tpl::view( 'shop-product/mass-edit-custom-script' ); ?>
diff --git a/temp/update_build/tmp_0.275/admin/templates/shop-product/product-edit.php b/temp/update_build/tmp_0.275/admin/templates/shop-product/product-edit.php
new file mode 100644
index 0000000..8d1519c
--- /dev/null
+++ b/temp/update_build/tmp_0.275/admin/templates/shop-product/product-edit.php
@@ -0,0 +1,1348 @@
+
+
+
+
+global $db;
+
+$upload_token = bin2hex( random_bytes(24) );
+$_SESSION['upload_tokens'][$upload_token] = [
+ 'user_id' => $this -> user['id'],
+ 'expires' => time() + 60*20
+];
+
+$_SESSION['rfm_akey'] = bin2hex(random_bytes(16));
+$_SESSION['rfm_akey_expires'] = time() + 20*60;
+$_SESSION['can_use_rfm'] = true;
+$rfmAkeyJS = $_SESSION['rfm_akey'];
+
+ob_start();
+?>
+
+
+
+ - Opis
+ - Zakładki
+ - Cena
+ - Magazyn
+ - Ustawienia
+ - SEO
+ - Wyświetlanie
+ - Galeria
+ - Załączniki
+ - Produkty powiązane
+ - XML
+ - Dodatkowe pola
+ - GPSR
+
+
+
+
+
+ if (is_array($this->languages)) : foreach ($this->languages as $lg) : ?>
+ if ($lg['status']) : ?>
+ - if ($lg['id'] == \front\factory\Languages::default_language()) echo ' '; ?>= $lg['name']; ?>
+ endif; ?>
+
+ endforeach;
+ endif;
+ ?>
+
+
+ if (is_array($this->languages)) : foreach ($this->languages as $lg) : ?>
+
+ $languages = array();
+
+ $languages[''] = '---- wersja językowa ----';
+ if (is_array($this->languages))
+ foreach ($this->languages as $lg_tmp)
+ {
+ if ($lg_tmp['id'] != $lg['id'])
+ $languages[$lg_tmp['id']] = $lg_tmp['name'];
+ }
+ ?>
+ if ($lg['status']) : ?>
+
+ =
+ \Html::select(
+ array(
+ 'label' => 'Wyświetlaj treść z wersji',
+ 'name' => 'copy_from[' . $lg['id'] . ']',
+ 'values' => $languages,
+ 'value' => $this->product['languages'][$lg['id']]['copy_from'],
+ )
+ );
+ ?>
+ =
+ \Html::input(
+ array(
+ 'label' => 'Nazwa',
+ 'name' => 'name[' . $lg['id'] . ']',
+ 'id' => 'name_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['name'],
+ 'inline' => true
+ )
+ );
+ ?>
+ =
+ \Html::input(
+ array(
+ 'label' => 'Komunikat gdy stan magazynowy równy 0',
+ 'name' => 'warehouse_message_zero[' . $lg['id'] . ']',
+ 'id' => 'warehouse_message_zero_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['warehouse_message_zero'],
+ 'inline' => true
+ )
+ );
+ ?>
+ =
+ \Html::input(
+ array(
+ 'label' => 'Komunikat gdy stan magazynowy większy niż 0',
+ 'name' => 'warehouse_message_nonzero[' . $lg['id'] . ']',
+ 'id' => 'warehouse_message_nonzero_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['warehouse_message_nonzero'],
+ 'inline' => true
+ )
+ );
+ ?>
+ =
+ \Html::textarea(
+ array(
+ 'label' => 'Krótki opis',
+ 'name' => 'short_description[' . $lg['id'] . ']',
+ 'id' => 'short_description_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['short_description'],
+ 'inline' => true
+ )
+ );
+ ?>
+ =
+ \Html::textarea(
+ array(
+ 'label' => 'Opis',
+ 'name' => 'description[' . $lg['id'] . ']',
+ 'id' => 'description_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['description'],
+ 'inline' => true
+ )
+ );
+ ?>
+
+
+ endif; ?>
+
+ endforeach;
+ endif;
+ ?>
+
+
+
+
+
+
+
+ if (is_array($this->languages)) : foreach ($this->languages as $lg) : ?>
+ if ($lg['status']) : ?>
+ - if ($lg['id'] == \front\factory\Languages::default_language()) echo ' '; ?>= $lg['name']; ?>
+ endif; ?>
+
+ endforeach;
+ endif;
+ ?>
+
+
+ if (is_array($this->languages)) : foreach ($this->languages as $lg) : ?>
+
+ $languages = array();
+
+ $languages[''] = '---- wersja językowa ----';
+ if (is_array($this->languages))
+ foreach ($this->languages as $lg_tmp)
+ {
+ if ($lg_tmp['id'] != $lg['id'])
+ $languages[$lg_tmp['id']] = $lg_tmp['name'];
+ }
+ ?>
+ if ($lg['status']) : ?>
+
+ =
+ \Html::input(
+ array(
+ 'label' => 'Nazwa zakładki (1)',
+ 'name' => 'tab_name_1[' . $lg['id'] . ']',
+ 'id' => 'tab_name_1_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['tab_name_1'],
+ 'inline' => true
+ )
+ );
+ ?>
+ =
+ \Html::textarea(
+ array(
+ 'label' => 'Zawartość zakładki (1)',
+ 'name' => 'tab_description_1[' . $lg['id'] . ']',
+ 'id' => 'tab_description_1_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['tab_description_1'],
+ 'inline' => true
+ )
+ );
+ ?>
+ =
+ \Html::input(
+ array(
+ 'label' => 'Nazwa zakładki (2)',
+ 'name' => 'tab_name_2[' . $lg['id'] . ']',
+ 'id' => 'tab_name_2_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['tab_name_2'],
+ 'inline' => true
+ )
+ );
+ ?>
+ =
+ \Html::textarea(
+ array(
+ 'label' => 'Zawartość zakładki (2)',
+ 'name' => 'tab_description_2[' . $lg['id'] . ']',
+ 'id' => 'tab_description_2_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['tab_description_2'],
+ 'inline' => true
+ )
+ );
+ ?>
+
+
+ endif; ?>
+
+ endforeach;
+ endif;
+ ?>
+
+
+
+
+
+ =
+ \Html::input(
+ array(
+ 'label' => 'VAT (%)',
+ 'name' => 'vat',
+ 'id' => 'vat',
+ 'class' => 'int-format',
+ 'value' => $this->product['id'] ? $this->product['vat'] : 23,
+ 'onchange' => 'calculate_price_brutto(); return false;'
+ )
+ );
+ ?>
+ =
+ \Html::input(
+ array(
+ 'label' => 'Cena netto (PLN)',
+ 'name' => 'price_netto',
+ 'id' => 'price_netto',
+ 'class' => 'number-format',
+ 'value' => $this->product['price_netto'],
+ 'onchange' => 'calculate_price_brutto(); return false;'
+ )
+ );
+ ?>
+ =
+ \Html::input(
+ array(
+ 'label' => 'Cena brutto (PLN)',
+ 'name' => 'price_brutto',
+ 'id' => 'price_brutto',
+ 'class' => 'number-format',
+ 'value' => $this->product['price_brutto'],
+ 'onchange' => 'calculate_price_netto(); return false;'
+ )
+ );
+ ?>
+ =
+ \Html::input(
+ array(
+ 'label' => 'Promocyjna cena netto (PLN)',
+ 'name' => 'price_netto_promo',
+ 'id' => 'price_netto_promo',
+ 'class' => 'number-format',
+ 'value' => $this->product['price_netto_promo'],
+ 'onchange' => 'calculate_price_brutto_promo(); return false;'
+ )
+ );
+ ?>
+ =
+ \Html::input(
+ array(
+ 'label' => 'Promocyjna cena brutto (PLN)',
+ 'name' => 'price_brutto_promo',
+ 'id' => 'price_brutto_promo',
+ 'class' => 'number-format',
+ 'value' => $this->product['price_brutto_promo'],
+ 'onchange' => 'calculate_price_netto_promo(); return false;'
+ )
+ );
+ ?>
+
+
+ $units[] = '--- wybierze jednostkę miary ---';
+ foreach ($this->units as $unit)
+ $units[$unit['id']] = $unit['text'];
+ ?>
+ = \Html::select([
+ 'label' => 'Jednostka miary',
+ 'name' => 'product_unit',
+ 'id' => 'product_unit',
+ 'values' => $units,
+ 'value' => $this->product['product_unit_id']
+ ]); ?>
+ = \Html::input([
+ 'label' => 'Waga/pojemność',
+ 'name' => 'weight',
+ 'id' => 'weight',
+ 'class' => 'number-format',
+ 'value' => $this->product['weight']
+ ]); ?>
+
+
+ =
+ \Html::input([
+ 'label' => 'Stan magazynowy',
+ 'name' => 'quantity',
+ 'id' => 'quantity',
+ 'class' => 'int-format',
+ 'value' => $this->product['quantity']
+ ]);
+ ?>
+ =
+ \Html::input_switch([
+ 'label' => 'Pozwól zamawiać gdy stan 0',
+ 'name' => 'stock_0_buy',
+ 'checked' => $this->product['stock_0_buy'] == 1 ? true : false
+ ]);
+ ?>
+ =
+ \Html::input([
+ 'label' => 'Współczynnik WP',
+ 'name' => 'wp',
+ 'id' => 'wp',
+ 'class' => 'number-format',
+ 'value' => $this->product['wp']
+ ]);
+ ?>
+ = \Html::input_icon([
+ 'label' => 'Kod SKU',
+ 'name' => 'sku',
+ 'id' => 'sku',
+ 'value' => $this->product['sku'],
+ 'icon_content' => 'generuj',
+ 'icon_js' => 'generate_sku_code( ' . (int)$this->product['id'] . ' );'
+ ]); ?>
+ = \Html::input([
+ 'label' => 'EAN',
+ 'name' => 'ean',
+ 'id' => 'ean',
+ 'value' => $this->product['ean']
+ ]); ?>
+
+
+ =
+ \Html::input_switch([
+ 'label' => 'Widoczny',
+ 'name' => 'status',
+ 'checked' => $this->product['status'] == 1 or !$this->product['id'] ? true : false
+ ]);
+ ?>
+ =
+ \Html::input_switch([
+ 'label' => 'Promowany',
+ 'name' => 'promoted',
+ 'checked' => $this->product['promoted'] == 1 ? true : false
+ ]);
+ ?>
+ =
+ \Html::input([
+ 'label' => 'Nowość do dnia',
+ 'name' => 'new_to_date',
+ 'id' => 'new_to_date',
+ 'class' => 'date',
+ 'value' => $this->product['new_to_date']
+ ]);
+ ?>
+ = \Html::input_switch([
+ 'label' => 'Wyświetlaj pole na dodatkową wiadomość',
+ 'name' => 'additional_message',
+ 'checked' => $this->product['additional_message'] == 1 ? true : false
+ ]);
+ ?>
+ = \Html::input_switch([
+ 'label' => 'Dodatkowa wiadomość jest wymagana',
+ 'name' => 'additional_message_required',
+ 'checked' => $this->product['additional_message_required'] == 1 ? true : false
+ ]);
+ ?>
+ = \Html::input([
+ 'label' => 'Dodatkowa wiadomość (treść komunikatu)',
+ 'name' => 'additional_message_text',
+ 'id' => 'additional_message_text',
+ 'value' => $this->product['additional_message_text']
+ ]);
+ ?>
+
+
+
+
+ if (is_array($this->languages)) : foreach ($this->languages as $lg) : ?>
+ if ($lg['status']) : ?>
+ - if ($lg['id'] == \front\factory\Languages::default_language()) echo ' '; ?>= $lg['name']; ?>
+ endif; ?>
+
+ endforeach;
+ endif;
+ ?>
+
+
+ if (is_array($this->languages)) : foreach ($this->languages as $lg) : ?>
+ if ($lg['status']) : ?>
+
+ =
+ \Html::input_icon(
+ array(
+ 'label' => 'Link SEO',
+ 'name' => 'seo_link[' . $lg['id'] . ']',
+ 'id' => 'seo_link_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['seo_link'],
+ 'icon_content' => 'generuj',
+ 'icon_js' => 'generate_seo_links( "' . $lg['id'] . '", $( "#name_' . $lg['id'] . '" ).val(), ' . (int)$this->product['id'] . ' );'
+ )
+ );
+ ?>
+ = \Html::input([
+ 'label' => 'Meta title',
+ 'name' => 'meta_title[' . $lg['id'] . ']',
+ 'id' => 'meta_title_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['meta_title']
+ ]); ?>
+ =
+ \Html::textarea(
+ array(
+ 'label' => 'Meta description',
+ 'name' => 'meta_description[' . $lg['id'] . ']',
+ 'id' => 'meta_description_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['meta_description']
+ )
+ );
+ ?>
+ =
+ \Html::textarea(
+ array(
+ 'label' => 'Meta keywords',
+ 'name' => 'meta_keywords[' . $lg['id'] . ']',
+ 'id' => 'meta_keywords_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['meta_keywords']
+ )
+ );
+ ?>
+ = \Html::input([
+ 'label' => 'Canonical',
+ 'name' => 'canonical[' . $lg['id'] . ']',
+ 'id' => 'canonical_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['canonical']
+ ]); ?>
+
+ endif; ?>
+
+ endforeach;
+ endif;
+ ?>
+
+
+
+
+
+
+ $layouts[''] = '---- szablon domyślny ----';
+ if (is_array($this->layouts)) : foreach ($this->layouts as $layout) :
+ $layouts[$layout['id']] = $layout['name'];
+ endforeach;
+ endif;
+ ?>
+ =
+ \Html::select([
+ 'label' => 'Szablon',
+ 'name' => 'layout_id',
+ 'id' => 'layout_id',
+ 'values' => $layouts,
+ 'value' => $this->product['layout_id']
+ ]);
+ ?>
+
+
+
+
+
You browser doesn't have Flash installed.
+
+
+
+
+ $files_count = 0;
+ if (is_array($this->product['files'])) : foreach ($this->product['files'] as $file) :
+
+ if ($file['name'])
+ $name = $file['name'];
+ else
+ {
+ $name = explode('/', $file['src']);
+ $name = $name[count($name) - 1];
+ }
+ ?>
+ -
+
+
+
+ $files_count++;
+ endforeach;
+ endif;
+ ?>
+
+
You browser doesn't have Flash installed.
+
+
+
+
+
+ $z = 0; ?>
+ if (is_array($this->languages)) : foreach ($this->languages as $lg) : ?>
+ if ($lg['status']) : ?>
+ -
+ = $lg['name']; ?>
+
+ endif; ?>
+ endforeach;
+ endif; ?>
+
+
+ $z = 0; ?>
+ if (is_array($this->languages)) : foreach ($this->languages as $lg) : ?>
+ if ($lg['status']) : ?>
+
+
+
+
+
+
+ endif; ?>
+ endforeach;
+ endif; ?>
+
+
+ = \Html::input([
+ 'label' => 'Custom label 0',
+ 'name' => 'custom_label_0',
+ 'id' => 'custom_label_0',
+ 'value' => $this->product['custom_label_0']
+ ]);
+ ?>
+ = \Html::input([
+ 'label' => 'Custom label 1',
+ 'name' => 'custom_label_1',
+ 'id' => 'custom_label_1',
+ 'value' => $this->product['custom_label_1']
+ ]);
+ ?>
+ = \Html::input([
+ 'label' => 'Custom label 2',
+ 'name' => 'custom_label_2',
+ 'id' => 'custom_label_2',
+ 'value' => $this->product['custom_label_2']
+ ]);
+ ?>
+ = \Html::input([
+ 'label' => 'Custom label 3',
+ 'name' => 'custom_label_3',
+ 'id' => 'custom_label_3',
+ 'value' => $this->product['custom_label_3']
+ ]);
+ ?>
+ = \Html::input([
+ 'label' => 'Custom label 4',
+ 'name' => 'custom_label_4',
+ 'id' => 'custom_label_4',
+ 'value' => $this->product['custom_label_4']
+ ]);
+ ?>
+
+
+
dodaj niestandardowe pole
+
+ if ( is_array( $this->product['custom_fields'] ) ) : foreach ( $this->product['custom_fields'] as $field ):?>
+ $isRequired = !empty($field['is_required']); ?>
+
+ endforeach; endif;?>
+
+
+
+
+ $producers[''] = '--- wybierz producenta ---';
+ foreach ($this->producers as $producer)
+ $producers[$producer['id']] = $producer['name'];
+ ?>
+ = \Html::select([
+ 'label' => 'Producent',
+ 'name' => 'producer_id',
+ 'id' => 'producer_id',
+ 'values' => $producers,
+ 'value' => $this->product['producer_id']
+ ]); ?>
+
+
+ if ( is_array( $this -> languages ) ): foreach ( $this -> languages as $lg ):?>
+ if ($lg['status']) : ?>
+ - if ($lg['id'] == \front\factory\Languages::default_language()) echo ' '; ?>= $lg['name']; ?>
+ endif; ?>
+ endforeach; endif; ?>
+
+
+ if ( is_array ($this -> languages ) ): foreach ( $this -> languages as $lg ):?>
+ if ( $lg['status'] ):?>
+
+ = \Html::textarea( [
+ 'label' => 'Informacje o bezpieczeństwie ('.$lg['name'].')',
+ 'name' => 'security_information[' . $lg['id'] . ']',
+ 'id' => 'security_information_' . $lg['id'],
+ 'value' => $this->product['languages'][$lg['id']]['security_information']
+ ] );?>
+
+
+ endif; ?>
+ endforeach; endif;?>
+
+
+
+
+
+
+
+$out = ob_get_clean();
+
+$grid = new \gridEdit;
+$grid->id = 'product-edit';
+$grid->gdb_opt = $gdb;
+$grid->include_plugins = true;
+$grid->title = $this->product['id'] ? 'Edycja produktu:
' . $this->product['languages'][\front\factory\Languages::default_language()]['name'] . '' : 'Edycja produktu';
+$grid->fields = [
+ [
+ 'db' => 'id',
+ 'type' => 'hidden',
+ 'value' => $this->product['id']
+ ]
+];
+$grid->actions = [
+ 'save' => ['url' => '/admin/shop_product/save/', 'back_url' => '/admin/shop_product/view_list/'],
+ 'cancel' => ['url' => '/admin/shop_product/view_list/']
+];
+$grid->buttons = [
+ [
+ 'label' => 'Podgląd',
+ 'id' => 'product-preview',
+ 'url' => '#',
+ 'icon' => 'fa-search',
+ 'class' => 'btn-primary'
+ ]
+];
+$grid->external_code = $out;
+$grid->persist_edit = true;
+$grid->id_param = 'id';
+
+echo $grid->draw();
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/temp/update_build/tmp_0.275/admin/templates/shop-product/subcategories-list.php b/temp/update_build/tmp_0.275/admin/templates/shop-product/subcategories-list.php
new file mode 100644
index 0000000..5c7f796
--- /dev/null
+++ b/temp/update_build/tmp_0.275/admin/templates/shop-product/subcategories-list.php
@@ -0,0 +1,24 @@
+ if ( is_array( $this -> categories ) ):?>
+
+ foreach ( $this -> categories as $category ):?>
+ -
+
+ =
+ \Tpl::view( 'shop-product/subcategories-list', [
+ 'categories' => ( new \Domain\Category\CategoryRepository( $GLOBALS['mdb'] ) )->subcategories( $category[ 'id' ] ),
+ 'product_categories' => $this -> product_categories,
+ 'dlang' => $this -> dlang,
+ 'name' => $this -> name
+ ] );
+ ?>
+
+ endforeach;?>
+
+ endif;?>
\ No newline at end of file
diff --git a/temp/update_build/tmp_0.275/admin/templates/site/main-layout.php b/temp/update_build/tmp_0.275/admin/templates/site/main-layout.php
new file mode 100644
index 0000000..e090e08
--- /dev/null
+++ b/temp/update_build/tmp_0.275/admin/templates/site/main-layout.php
@@ -0,0 +1,273 @@
+ global $user, $settings;?>
+
+
+
+
shopPro
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ if ( $user[ 'name' ] or $user[ 'surname' ] )
+ echo $user[ 'surname' ] . ' ' . $user[ 'name' ];
+ else
+ echo $user[ 'login' ];
+ ?>
+

+
+
+
+
+
+ if ( $alert = \S::get_session( 'alert' ) ):
+ \S::alert( false );
+ ?>
+
+
+
+
+
+ = $alert;?>
+
+
+
+ endif;?>
+
+
+ = $this -> content;?>
+
+
+
+
+
+
+
+
diff --git a/temp/update_build/tmp_0.275/autoload/admin/Controllers/ShopCategoryController.php b/temp/update_build/tmp_0.275/autoload/admin/Controllers/ShopCategoryController.php
new file mode 100644
index 0000000..61d70b5
--- /dev/null
+++ b/temp/update_build/tmp_0.275/autoload/admin/Controllers/ShopCategoryController.php
@@ -0,0 +1,163 @@
+repository = $repository;
+ $this->languagesRepository = $languagesRepository;
+ }
+
+ public function view_list(): string
+ {
+ return \Tpl::view('shop-category/categories-list', [
+ 'categories' => $this->repository->subcategories(null),
+ 'level' => 0,
+ 'dlang' => \front\factory\Languages::default_language(),
+ ]);
+ }
+
+ public function list(): string
+ {
+ return $this->view_list();
+ }
+
+ public function category_edit(): string
+ {
+ return \Tpl::view('shop-category/category-edit', [
+ 'category' => $this->repository->categoryDetails(\S::get('id')),
+ 'pid' => \S::get('pid'),
+ 'languages' => $this->languagesRepository->languagesList(),
+ 'sort_types' => $this->repository->sortTypes(),
+ ]);
+ }
+
+ public function edit(): string
+ {
+ return $this->category_edit();
+ }
+
+ public function save(): void
+ {
+ $response = [
+ 'status' => 'error',
+ 'msg' => 'Podczas zapisywania kategorii wystąpił błąd. Proszę spróbować ponownie.',
+ ];
+
+ $values = json_decode((string)\S::get('values'), true);
+ if (is_array($values)) {
+ $savedId = $this->repository->save($values);
+ if (!empty($savedId)) {
+ $response = [
+ 'status' => 'ok',
+ 'msg' => 'Kategoria została zapisana.',
+ 'id' => (int)$savedId,
+ ];
+ }
+ }
+
+ echo json_encode($response);
+ exit;
+ }
+
+ public function category_delete(): void
+ {
+ if ($this->repository->categoryDelete(\S::get('id'))) {
+ \S::set_message('Kategoria została usunięta.');
+ } else {
+ \S::alert('Podczas usuwania kategorii wystąpił błąd. Aby usunąć kategorię nie może ona posiadać przypiętych podkategorii.');
+ }
+
+ header('Location: /admin/shop_category/view_list/');
+ exit;
+ }
+
+ public function delete(): void
+ {
+ $this->category_delete();
+ }
+
+ public function category_products(): string
+ {
+ return \Tpl::view('shop-category/category-products', [
+ 'category_id' => \S::get('id'),
+ 'products' => $this->repository->categoryProducts((int)\S::get('id')),
+ ]);
+ }
+
+ public function products(): string
+ {
+ return $this->category_products();
+ }
+
+ public function category_url_browser(): void
+ {
+ echo \Tpl::view('shop-category/category-browse-list', [
+ 'categories' => $this->repository->subcategories(null),
+ 'level' => 0,
+ 'dlang' => \front\factory\Languages::default_language(),
+ ]);
+ exit;
+ }
+
+ public function save_categories_order(): void
+ {
+ $response = [
+ 'status' => 'error',
+ 'msg' => 'Podczas zapisywania kolejności kategorii wystąpił błąd. Proszę spróbować ponownie.',
+ ];
+
+ if ( $this->repository->saveCategoriesOrder( \S::get( 'categories' ) ) ) {
+ $response = [ 'status' => 'ok' ];
+ }
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ public function save_products_order(): void
+ {
+ $response = [
+ 'status' => 'error',
+ 'msg' => 'Podczas zapisywania kolejności wyświetlania produktów wystąpił błąd. Proszę spróbować ponownie.',
+ ];
+
+ if ( $this->repository->saveProductOrder( \S::get( 'category_id' ), \S::get( 'products' ) ) ) {
+ $response = [ 'status' => 'ok' ];
+ }
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ public function cookie_categories(): void
+ {
+ $categoryId = (string) \S::get( 'category_id' );
+ if ( $categoryId === '' ) {
+ echo json_encode( [ 'status' => 'error' ] );
+ exit;
+ }
+
+ $array = [];
+ if ( isset( $_COOKIE['cookie_categories'] ) ) {
+ $tmp = @unserialize( (string) $_COOKIE['cookie_categories'] );
+ if ( is_array( $tmp ) ) {
+ $array = $tmp;
+ }
+ }
+
+ $array[$categoryId] = isset( $array[$categoryId] ) && (int) $array[$categoryId] === 1 ? 0 : 1;
+
+ setcookie( 'cookie_categories', serialize( $array ), time() + 3600 * 24 * 365, '/' );
+
+ echo json_encode( [ 'status' => 'ok' ] );
+ exit;
+ }
+}
diff --git a/temp/update_build/tmp_0.275/autoload/admin/Controllers/ShopProductController.php b/temp/update_build/tmp_0.275/autoload/admin/Controllers/ShopProductController.php
new file mode 100644
index 0000000..3cb3372
--- /dev/null
+++ b/temp/update_build/tmp_0.275/autoload/admin/Controllers/ShopProductController.php
@@ -0,0 +1,73 @@
+repository = $repository;
+ }
+
+ /**
+ * Widok masowej edycji produktów.
+ */
+ public function mass_edit(): string
+ {
+ $categoryRepository = new CategoryRepository( $GLOBALS['mdb'] );
+
+ return \Tpl::view( 'shop-product/mass-edit', [
+ 'products' => $this->repository->allProductsForMassEdit(),
+ 'categories' => $categoryRepository->subcategories( null ),
+ 'dlang' => \front\factory\Languages::default_language()
+ ] );
+ }
+
+ /**
+ * AJAX: zastosowanie rabatu procentowego na zaznaczonych produktach.
+ */
+ public function mass_edit_save(): void
+ {
+ $discountPercent = \S::get( 'discount_percent' );
+ $products = \S::get( 'products' );
+
+ if ( $discountPercent != '' && $products && is_array( $products ) && count( $products ) > 0 ) {
+ $productId = (int) $products[0];
+ $result = $this->repository->applyDiscountPercent( $productId, (float) $discountPercent );
+
+ if ( $result !== null ) {
+ echo json_encode( [
+ 'status' => 'ok',
+ 'price_brutto_promo' => $result['price_brutto_promo'],
+ 'price_brutto' => $result['price_brutto']
+ ] );
+ exit;
+ }
+ }
+
+ echo json_encode( [ 'status' => 'error' ] );
+ exit;
+ }
+
+ /**
+ * AJAX: pobranie ID produktów z danej kategorii.
+ */
+ public function get_products_by_category(): void
+ {
+ $categoryId = (int) \S::get( 'category_id' );
+ $products = $this->repository->getProductsByCategory( $categoryId );
+
+ echo json_encode( [ 'status' => 'ok', 'products' => $products ] );
+ exit;
+ }
+}
diff --git a/temp/update_build/tmp_0.275/autoload/admin/class.Site.php b/temp/update_build/tmp_0.275/autoload/admin/class.Site.php
new file mode 100644
index 0000000..2d6c1ad
--- /dev/null
+++ b/temp/update_build/tmp_0.275/autoload/admin/class.Site.php
@@ -0,0 +1,493 @@
+ $user['login'],
+ 'ts' => time()
+ ];
+
+ $json = json_encode($payloadArr, JSON_UNESCAPED_SLASHES);
+ $sig = hash_hmac('sha256', $json, self::APP_SECRET_KEY);
+ $payload = base64_encode($json . '.' . $sig);
+
+ setcookie( $cookie_name, $payload, [
+ 'expires' => time() + (86400 * 14),
+ 'path' => '/',
+ 'domain' => $domain,
+ 'secure' => true,
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+ ]);
+ }
+ }
+
+ public static function special_actions()
+ {
+ global $mdb;
+
+ $sa = \S::get('s-action');
+ $domain = preg_replace('/^www\./', '', $_SERVER['SERVER_NAME']);
+ $cookie_name = 'admin_remember_' . str_replace( '.', '-', $domain );
+ $users = new \Domain\User\UserRepository($mdb);
+
+ switch ($sa)
+ {
+ case 'user-logon':
+ {
+ $login = \S::get('login');
+ $pass = \S::get('password');
+
+ $result = $users->logon($login, $pass);
+
+ if ( $result == 1 )
+ {
+ $user = $users->details($login);
+
+ if ( $user['twofa_enabled'] == 1 )
+ {
+ \S::set_session( 'twofa_pending', [
+ 'uid' => (int)$user['id'],
+ 'login' => $login,
+ 'remember' => (bool)\S::get('remember'),
+ 'started' => time(),
+ ] );
+
+ if ( !$users->sendTwofaCode( (int)$user['id'] ) )
+ {
+ \S::alert('Nie udało się wysłać kodu 2FA. Spróbuj ponownie.');
+ \S::delete_session('twofa_pending');
+ header('Location: /admin/');
+ exit;
+ }
+
+ header('Location: /admin/user/twofa/');
+ exit;
+ }
+ else
+ {
+ $user = $users->details($login);
+
+ self::finalize_admin_login(
+ $user,
+ $domain,
+ $cookie_name,
+ (bool)\S::get('remember')
+ );
+
+ header('Location: /admin/articles/list/');
+ exit;
+ }
+ }
+ else
+ {
+ if ($result == -1)
+ {
+ \S::alert('Z powodu 5 nieudanych prób Twoje konto zostało zablokowane.');
+ }
+ else
+ {
+ \S::alert('Podane hasło jest nieprawidłowe lub użytkownik nie istnieje.');
+ }
+ header('Location: /admin/');
+ exit;
+ }
+ }
+ break;
+
+ case 'user-2fa-verify':
+ {
+ $pending = \S::get_session('twofa_pending');
+ if ( !$pending || empty( $pending['uid'] ) ) {
+ \S::alert('Sesja 2FA wygasła. Zaloguj się ponownie.');
+ header('Location: /admin/');
+ exit;
+ }
+
+ $code = trim((string)\S::get('twofa'));
+ if (!preg_match('/^\d{6}$/', $code))
+ {
+ \S::alert('Nieprawidłowy format kodu.');
+ header('Location: /admin/user/twofa/');
+ exit;
+ }
+
+ $ok = $users->verifyTwofaCode((int)$pending['uid'], $code);
+ if (!$ok)
+ {
+ \S::alert('Błędny lub wygasły kod.');
+ header('Location: /admin/user/twofa/');
+ exit;
+ }
+
+ // 2FA OK - finalna sesja
+ $user = $users->details($pending['login']);
+
+ self::finalize_admin_login(
+ $user,
+ $domain,
+ $cookie_name,
+ $pending['remember'] ? true : false
+ );
+
+ header('Location: /admin/articles/list/');
+ exit;
+ }
+ break;
+
+ case 'user-2fa-resend':
+ {
+ $pending = \S::get_session('twofa_pending');
+ if (!$pending || empty($pending['uid']))
+ {
+ \S::alert('Sesja 2FA wygasła. Zaloguj się ponownie.');
+ header('Location: /admin/');
+ exit;
+ }
+
+ if (!$users->sendTwofaCode((int)$pending['uid'], true))
+ {
+ \S::alert('Kod można wysłać ponownie po krótkiej przerwie.');
+ }
+ else
+ {
+ \S::alert('Nowy kod został wysłany.');
+ }
+ header('Location: /admin/user/twofa/');
+ exit;
+ }
+ break;
+
+ case 'user-logout':
+ {
+ setcookie($cookie_name, "", [
+ 'expires' => time() - 86400,
+ 'path' => '/',
+ 'domain' => $domain,
+ 'secure' => true,
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+ ]);
+ \S::delete_session('twofa_pending');
+ session_destroy();
+ header('Location: /admin/');
+ exit;
+ }
+ break;
+ }
+ }
+
+ /**
+ * Mapa nowych kontrolerów: module => fabryka kontrolera (DI)
+ * Przy migracji kolejnego kontrolera - dodaj wpis tutaj
+ */
+ private static $newControllers = [];
+
+ /**
+ * Zwraca mapę fabryk kontrolerów (inicjalizacja runtime)
+ */
+ private static function getControllerFactories(): array
+ {
+ if ( !empty( self::$newControllers ) )
+ return self::$newControllers;
+
+ self::$newControllers = [
+ 'Articles' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ArticlesController(
+ new \Domain\Article\ArticleRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb ),
+ new \Domain\Layouts\LayoutsRepository( $mdb ),
+ new \Domain\Pages\PagesRepository( $mdb )
+ );
+ },
+ 'ArticlesArchive' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ArticlesArchiveController(
+ new \Domain\Article\ArticleRepository( $mdb )
+ );
+ },
+ 'Banners' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\BannerController(
+ new \Domain\Banner\BannerRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb )
+ );
+ },
+ 'Settings' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\SettingsController(
+ new \Domain\Settings\SettingsRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb )
+ );
+ },
+ 'ProductArchive' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ProductArchiveController(
+ new \Domain\Product\ProductRepository( $mdb )
+ );
+ },
+ // Alias dla starego modułu /admin/archive/list/
+ 'Archive' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ProductArchiveController(
+ new \Domain\Product\ProductRepository( $mdb )
+ );
+ },
+ 'Dictionaries' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\DictionariesController(
+ new \Domain\Dictionaries\DictionariesRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb )
+ );
+ },
+ 'Filemanager' => function() {
+ return new \admin\Controllers\FilemanagerController();
+ },
+ 'Users' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\UsersController(
+ new \Domain\User\UserRepository( $mdb )
+ );
+ },
+ 'Languages' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\LanguagesController(
+ new \Domain\Languages\LanguagesRepository( $mdb )
+ );
+ },
+ 'Layouts' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\LayoutsController(
+ new \Domain\Layouts\LayoutsRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb )
+ );
+ },
+ 'Newsletter' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\NewsletterController(
+ new \Domain\Newsletter\NewsletterRepository(
+ $mdb,
+ new \Domain\Settings\SettingsRepository( $mdb )
+ ),
+ new \Domain\Newsletter\NewsletterPreviewRenderer()
+ );
+ },
+ 'Scontainers' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ScontainersController(
+ new \Domain\Scontainers\ScontainersRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb )
+ );
+ },
+ 'ShopPromotion' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopPromotionController(
+ new \Domain\Promotion\PromotionRepository( $mdb )
+ );
+ },
+ 'ShopCoupon' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopCouponController(
+ new \Domain\Coupon\CouponRepository( $mdb )
+ );
+ },
+ 'ShopAttribute' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopAttributeController(
+ new \Domain\Attribute\AttributeRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb )
+ );
+ },
+ 'ShopPaymentMethod' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopPaymentMethodController(
+ new \Domain\PaymentMethod\PaymentMethodRepository( $mdb )
+ );
+ },
+ 'ShopTransport' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopTransportController(
+ new \Domain\Transport\TransportRepository( $mdb ),
+ new \Domain\PaymentMethod\PaymentMethodRepository( $mdb )
+ );
+ },
+ 'Pages' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\PagesController(
+ new \Domain\Pages\PagesRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb ),
+ new \Domain\Layouts\LayoutsRepository( $mdb )
+ );
+ },
+ 'Integrations' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\IntegrationsController(
+ new \Domain\Integrations\IntegrationsRepository( $mdb )
+ );
+ },
+ 'ShopStatuses' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopStatusesController(
+ new \Domain\ShopStatus\ShopStatusRepository( $mdb )
+ );
+ },
+ 'ShopProductSets' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopProductSetsController(
+ new \Domain\ProductSet\ProductSetRepository( $mdb )
+ );
+ },
+ 'ShopProducer' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopProducerController(
+ new \Domain\Producer\ProducerRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb )
+ );
+ },
+ 'ShopCategory' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopCategoryController(
+ new \Domain\Category\CategoryRepository( $mdb ),
+ new \Domain\Languages\LanguagesRepository( $mdb )
+ );
+ },
+ 'ShopProduct' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopProductController(
+ new \Domain\Product\ProductRepository( $mdb )
+ );
+ },
+ 'ShopClients' => function() {
+ global $mdb;
+
+ return new \admin\Controllers\ShopClientsController(
+ new \Domain\Client\ClientRepository( $mdb )
+ );
+ },
+ ];
+
+ return self::$newControllers;
+ }
+
+ /**
+ * Tworzy instancję nowego kontrolera z Dependency Injection
+ */
+ private static function createController( string $moduleName )
+ {
+ global $mdb;
+
+ $factories = self::getControllerFactories();
+ if ( !isset( $factories[$moduleName] ) )
+ return null;
+
+ $factory = $factories[$moduleName];
+ if ( !is_callable( $factory ) )
+ return null;
+
+ return $factory();
+ }
+
+
+ public static function route()
+ {
+ $_SESSION['admin'] = true;
+
+ if ( \S::get( 'p' ) )
+ \S::set_session( 'p' , \S::get( 'p' ) );
+
+ $page = \S::get_session( 'p' );
+
+ // Budowanie nazwy modułu
+ $moduleName = '';
+ $results = explode( '_', \S::get( 'module' ) );
+ if ( is_array( $results ) ) foreach ( $results as $row )
+ $moduleName .= ucfirst( $row );
+
+ $action = \S::get( 'action' );
+
+ // 1. Sprawdź czy istnieje nowy kontroler
+ $factories = self::getControllerFactories();
+ if ( isset( $factories[$moduleName] ) )
+ {
+ $controller = self::createController( $moduleName );
+ if ( $controller )
+ {
+ if ( method_exists( $controller, $action ) )
+ {
+ return $controller->$action();
+ }
+
+ if ( $moduleName === 'ShopAttribute' )
+ {
+ \S::alert( 'Nieprawidłowy adres url.' );
+ return false;
+ }
+ }
+
+ }
+
+ // 2. Fallback na stary kontroler
+ $class = '\admin\controls\\' . $moduleName;
+
+ if ( class_exists( $class ) and method_exists( new $class, $action ) )
+ return call_user_func_array( array( $class, $action ), array() );
+ else
+ {
+ \S::alert( 'Nieprawidłowy adres url.' );
+ return false;
+ }
+ }
+
+ static public function update()
+ {
+ global $mdb;
+
+ if ( $results = $mdb -> select( 'pp_updates', [ 'name' ], [ 'done' => 0 ] ) )
+ {
+ foreach ( $results as $row )
+ {
+ $class = '\admin\factory\Update';
+ $method = $row['name'];
+
+ if ( class_exists( $class ) and method_exists( new $class, $method ) )
+ call_user_func_array( array( $class, $method ), array() );
+ }
+ }
+ }
+}
+
diff --git a/temp/update_build/tmp_0.275/autoload/admin/controls/class.ShopProduct.php b/temp/update_build/tmp_0.275/autoload/admin/controls/class.ShopProduct.php
new file mode 100644
index 0000000..c7113d4
--- /dev/null
+++ b/temp/update_build/tmp_0.275/autoload/admin/controls/class.ShopProduct.php
@@ -0,0 +1,373 @@
+ $val )
+ {
+ if ( strpos( $key, 'attribute_' ) !== false )
+ {
+ $attribute = explode( 'attribute_', $key );
+ $attributes[ $attribute[1] ] = $val;
+ }
+ }
+
+ if ( \admin\factory\ShopProduct::generate_permutation( (int) \S::get( 'product_id' ), $attributes ) )
+ \S::alert( 'Kombinacje produktu zostały wygenerowane.' );
+
+ header( 'Location: /admin/shop_product/product_combination/product_id=' . (int) \S::get( 'product_id' ) );
+ exit;
+ }
+
+ //usunięcie kombinacji produktu
+ static public function delete_combination()
+ {
+ if ( \admin\factory\ShopProduct::delete_combination( (int)\S::get( 'combination_id' ) ) )
+ \S::alert( 'Kombinacja produktu została usunięta' );
+ else
+ \S::alert( 'Podczas usuwania kombinacji produktu wystąpił błąd. Proszę spróbować ponownie' );
+
+ header( 'Location: /admin/shop_product/product_combination/product_id=' . \S::get( 'product_id' ) );
+ exit;
+ }
+
+ static public function duplicate_product()
+ {
+ if ( \admin\factory\ShopProduct::duplicate_product( (int)\S::get( 'product-id' ), (int)\S::get( 'combination' ) ) )
+ \S::set_message( 'Produkt został zduplikowany.' );
+ else
+ \S::alert( 'Podczas duplikowania produktu wystąpił błąd. Proszę spróbować ponownie' );
+
+ header( 'Location: /admin/shop_product/view_list/' );
+ exit;
+ }
+
+ public static function image_delete()
+ {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas usuwania zdjecia wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ if ( \admin\factory\ShopProduct::delete_img( \S::get( 'image_id' ) ) )
+ $response = [ 'status' => 'ok' ];
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ public static function images_order_save()
+ {
+ if ( \admin\factory\ShopProduct::images_order_save( \S::get( 'product_id' ), \S::get( 'order' ) ) )
+ echo json_encode( [ 'status' => 'ok', 'msg' => 'Produkt został zapisany.' ] );
+
+ exit;
+ }
+
+ public static function image_alt_change()
+ {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas zmiany atrybutu alt zdjęcia wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ if ( \admin\factory\ShopProduct::image_alt_change( \S::get( 'image_id' ), \S::get( 'image_alt' ) ) )
+ $response = [ 'status' => 'ok' ];
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ // szybka zmiana statusu produktu
+ static public function change_product_status() {
+
+ if ( \admin\factory\ShopProduct::change_product_status( (int)\S::get( 'product-id' ) ) )
+ \S::set_message( 'Status produktu został zmieniony' );
+
+ header( 'Location: ' . $_SERVER['HTTP_REFERER'] );
+ exit;
+ }
+
+ // szybka zmiana google xml label
+ static public function product_change_custom_label()
+ {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas zmiany google xml label wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ if ( \admin\factory\ShopProduct::product_change_custom_label( (int) \S::get( 'product_id' ), \S::get( 'custom_label' ), \S::get( 'value' ) ) )
+ $response = [ 'status' => 'ok' ];
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ // szybka zmiana ceny promocyjnej
+ static public function product_change_price_brutto_promo()
+ {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas zmiany ceny wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ if ( \admin\factory\ShopProduct::product_change_price_brutto_promo( (int) \S::get( 'product_id' ), \S::get( 'price' ) ) )
+ $response = [ 'status' => 'ok' ];
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ // szybka zmiana ceny
+ static public function product_change_price_brutto()
+ {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas zmiany ceny wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ if ( \admin\factory\ShopProduct::product_change_price_brutto( (int) \S::get( 'product_id' ), \S::get( 'price' ) ) )
+ $response = [ 'status' => 'ok' ];
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ // pobierz bezpośredni url produktu
+ static public function ajax_product_url()
+ {
+ echo json_encode( [ 'url' => \shop\Product::getProductUrl( \S::get( 'product_id' ) ) ] );
+ exit;
+ }
+
+ // zapisanie produktu
+ public static function save()
+ {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas zapisywania produktu wystąpił błąd. Proszę spróbować ponownie.' ];
+ $values = json_decode( \S::get( 'values' ), true );
+
+ if ( $id = \admin\factory\ShopProduct::save(
+ $values['id'], $values['name'], $values['short_description'], $values['description'], $values['status'], $values['meta_description'], $values['meta_keywords'], $values['seo_link'],
+ $values['copy_from'], $values['categories'], $values['price_netto'], $values['price_brutto'], $values['vat'], $values['promoted'], $values['warehouse_message_zero'], $values['warehouse_message_nonzero'], $values['tab_name_1'],
+ $values['tab_description_1'], $values['tab_name_2'], $values['tab_description_2'], $values['layout_id'], $values['products_related'], (int) $values['set'], $values['price_netto_promo'], $values['price_brutto_promo'],
+ $values['new_to_date'], $values['stock_0_buy'], $values['wp'], $values['custom_label_0'], $values['custom_label_1'], $values['custom_label_2'], $values['custom_label_3'], $values['custom_label_4'], $values['additional_message'], (int)$values['quantity'], $values['additional_message_text'], $values['additional_message_required'] == 'on' ? 1 : 0, $values['canonical'], $values['meta_title'], $values['producer_id'], $values['sku'], $values['ean'], $values['product_unit'], $values['weight'], $values['xml_name'], $values['custom_field_name'], $values['custom_field_required'], $values['security_information'], $values['custom_field_type']
+ ) ) {
+ $response = [ 'status' => 'ok', 'msg' => 'Produkt został zapisany.', 'id' => $id ];
+ }
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ // product_unarchive
+ static public function product_unarchive()
+ {
+ if ( \admin\factory\ShopProduct::product_unarchive( (int) \S::get( 'product_id' ) ) )
+ \S::alert( 'Produkt został przywrócony z archiwum.' );
+ else
+ \S::alert( 'Podczas przywracania produktu z archiwum wystąpił błąd. Proszę spróbować ponownie' );
+
+ header( 'Location: /admin/product_archive/list/' );
+ exit;
+ }
+
+ static public function product_archive()
+ {
+ if ( \admin\factory\ShopProduct::product_archive( (int) \S::get( 'product_id' ) ) )
+ \S::alert( 'Produkt został przeniesiony do archiwum.' );
+ else
+ \S::alert( 'Podczas przenoszenia produktu do archiwum wystąpił błąd. Proszę spróbować ponownie' );
+
+ header( 'Location: /admin/shop_product/view_list/' );
+ exit;
+ }
+
+ public static function product_delete()
+ {
+ if ( \admin\factory\ShopProduct::product_delete( (int) \S::get( 'id' ) ) )
+ \S::set_message( 'Produkt został usunięty.' );
+ else
+ \S::alert( 'Podczas usuwania produktu wystąpił błąd. Proszę spróbować ponownie' );
+ header( 'Location: /admin/shop_product/view_list/' );
+ exit;
+ }
+
+ // edycja produktu
+ public static function product_edit() {
+ global $user, $mdb;
+
+ if ( !$user ) {
+ header( 'Location: /admin/' );
+ exit;
+ }
+
+ \admin\factory\ShopProduct::delete_nonassigned_images();
+ \admin\factory\ShopProduct::delete_nonassigned_files();
+
+ return \Tpl::view( 'shop-product/product-edit', [
+ 'product' => \admin\factory\ShopProduct::product_details( (int) \S::get( 'id' ) ),
+ 'languages' => ( new \Domain\Languages\LanguagesRepository( $GLOBALS['mdb'] ) )->languagesList(),
+ 'categories' => ( new \Domain\Category\CategoryRepository( $GLOBALS['mdb'] ) )->subcategories( null ),
+ 'layouts' => self::layouts_for_product_edit( $mdb ),
+ 'products' => \admin\factory\ShopProduct::products_list(),
+ 'dlang' => \front\factory\Languages::default_language(),
+ 'sets' => \shop\ProductSet::sets_list(),
+ 'producers' => ( new \Domain\Producer\ProducerRepository( $mdb ) )->allProducers(),
+ 'units' => ( new \Domain\Dictionaries\DictionariesRepository( $mdb ) ) -> allUnits(),
+ 'user' => $user
+ ] );
+ }
+
+ private static function layouts_for_product_edit( $db )
+ {
+ if ( class_exists( '\Domain\Layouts\LayoutsRepository' ) )
+ {
+ $rows = ( new \Domain\Layouts\LayoutsRepository( $db ) ) -> listAll();
+ return is_array( $rows ) ? $rows : [];
+ }
+
+ return [];
+ }
+
+ // ajax_load_products ARCHIVE
+ static public function ajax_load_products_archive()
+ {
+ echo json_encode( [
+ 'status' => 'deprecated',
+ 'msg' => 'Endpoint nie jest juz wspierany. Uzyj /admin/product_archive/list/.',
+ 'redirect_url' => '/admin/product_archive/list/'
+ ] );
+ exit;
+ }
+
+ // ajax_load_products
+ static public function ajax_load_products() {
+
+ $response = [ 'status' => 'error', 'msg' => 'Podczas ładowania produktów wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ \S::set_session( 'products_list_current_page', \S::get( 'current_page' ) );
+ \S::set_session( 'products_list_query', \S::get( 'query' ) );
+
+ if ( $products = \admin\factory\ShopProduct::ajax_products_list( \S::get_session( 'products_list_current_page' ), \S::get_session( 'products_list_query' ) ) ) {
+ $response = [
+ 'status' => 'ok',
+ 'pagination_max' => ceil( $products['products_count'] / 10 ),
+ 'html' => \Tpl::view( 'shop-product/products-list-table', [
+ 'products' => $products['products'],
+ 'current_page' => \S::get( 'current_page' ),
+ 'apilo_enabled' => \admin\factory\Integrations::apilo_settings( 'enabled' ),
+ 'show_xml_data' => \S::get_session( 'show_xml_data' )
+ ] )
+ ];
+ }
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ static public function view_list()
+ {
+ $current_page = \S::get_session( 'products_list_current_page' );
+
+ if ( !$current_page ) {
+ $current_page = 1;
+ \S::set_session( 'products_list_current_page', $current_page );
+ }
+
+ $query = \S::get_session( 'products_list_query' );
+ if ( $query ) {
+ $query_array = [];
+ parse_str( $query, $query_array );
+ }
+
+ if ( \S::get( 'show_xml_data' ) === 'true' ) {
+ \S::set_session( 'show_xml_data', true );
+ } else if ( \S::get( 'show_xml_data' ) === 'false' ) {
+ \S::set_session( 'show_xml_data', false );
+ }
+
+ return \Tpl::view( 'shop-product/products-list', [
+ 'current_page' => $current_page,
+ 'query_array' => $query_array,
+ 'pagination_max' => ceil( \admin\factory\ShopProduct::count_product() / 10 ),
+ 'apilo_enabled' => \admin\factory\Integrations::apilo_settings( 'enabled' ),
+ 'show_xml_data' => \S::get_session( 'show_xml_data' ),
+ 'shoppro_enabled' => \admin\factory\Integrations::shoppro_settings( 'enabled' )
+ ] );
+ }
+
+ //
+ // KOMBINACJE PRODUKTU
+ //
+
+ // zapisanie możliwości zakupu przy stanie 0 w kombinacji produktu
+ static public function product_combination_stock_0_buy_save()
+ {
+ \admin\factory\ShopProduct::product_combination_stock_0_buy_save( (int)\S::get( 'product_id' ), \S::get( 'stock_0_buy' ) );
+ exit;
+ }
+
+ // zapisanie sku w kombinacji produktu
+ static public function product_combination_sku_save()
+ {
+ \admin\factory\ShopProduct::product_combination_sku_save( (int)\S::get( 'product_id' ), \S::get( 'sku' ) );
+ exit;
+ }
+
+ // zapisanie ilości w kombinacji produktu
+ static public function product_combination_quantity_save()
+ {
+ \admin\factory\ShopProduct::product_combination_quantity_save( (int)\S::get( 'product_id' ), \S::get( 'quantity' ) );
+ exit;
+ }
+
+ // zapisanie ceny w kombinacji produktu
+ static public function product_combination_price_save()
+ {
+ \admin\factory\ShopProduct::product_combination_price_save( (int)\S::get( 'product_id' ), \S::get( 'price' ) );
+ exit;
+ }
+
+ //wyświetlenie kombinacji produktu
+ static public function product_combination()
+ {
+ global $mdb;
+
+ return \Tpl::view( 'shop-product/product-combination', [
+ 'product' => \admin\factory\ShopProduct::product_details( (int) \S::get( 'product_id' ) ),
+ 'attributes' => ( new \Domain\Attribute\AttributeRepository( $mdb ) ) -> getAttributesListForCombinations(),
+ 'default_language' => \front\factory\Languages::default_language(),
+ 'product_permutations' => \admin\factory\ShopProduct::get_product_permutations( (int) \S::get( 'product_id' ) )
+ ] );
+ }
+
+ // generate_sku_code
+ static public function generate_sku_code() {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas generowania kodu sku wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ if ( $sku = \shop\Product::generate_sku_code( \S::get( 'product_id' ) ) )
+ $response = [ 'status' => 'ok', 'sku' => $sku ];
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ // product_xml_name_save
+ static public function product_xml_name_save() {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas zapisywania nazwy produktu wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ if ( \shop\Product::product_xml_name_save( \S::get( 'product_id' ), \S::get( 'product_xml_name' ), \S::get( 'lang_id' ) ) )
+ $response = [ 'status' => 'ok' ];
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ // product_custom_label_suggestions
+ static public function product_custom_label_suggestions() {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas pobierania sugestii dla custom label wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ if ( $suggestions = \shop\Product::product_custom_label_suggestions( \S::get( 'custom_label' ), \S::get( 'label_type' ) ) )
+ $response = [ 'status' => 'ok', 'suggestions' => $suggestions ];
+
+ echo json_encode( $response );
+ exit;
+ }
+
+ // product_custom_label_save
+ static public function product_custom_label_save() {
+ $response = [ 'status' => 'error', 'msg' => 'Podczas zapisywania custom label wystąpił błąd. Proszę spróbować ponownie.' ];
+
+ if ( \shop\Product::product_custom_label_save( \S::get( 'product_id' ), \S::get( 'custom_label' ), \S::get( 'label_type' ) ) )
+ $response = [ 'status' => 'ok' ];
+
+ echo json_encode( $response );
+ exit;
+ }
+}
diff --git a/temp/update_build/tmp_0.275/autoload/admin/factory/class.ShopProduct.php b/temp/update_build/tmp_0.275/autoload/admin/factory/class.ShopProduct.php
new file mode 100644
index 0000000..633cf32
--- /dev/null
+++ b/temp/update_build/tmp_0.275/autoload/admin/factory/class.ShopProduct.php
@@ -0,0 +1,1578 @@
+ count( 'pp_shop_products_langs', [
+ 'AND' => [
+ 'lang_id' => $lang_id,
+ 'seo_link' => $seo_link,
+ 'product_id[!]' => $product_id,
+ ],
+ ] );
+ }
+
+ private static function removeConflictingRedirectSources( int $product_id, string $lang_id, string $from ): void
+ {
+ global $mdb;
+
+ if ( !$from )
+ return;
+
+ $mdb -> delete( 'pp_redirects', [
+ 'AND' => [
+ 'from' => $from,
+ 'lang_id' => $lang_id,
+ 'product_id[!]' => $product_id,
+ ],
+ ] );
+ }
+
+ // count_product
+ static public function count_product( $where = null )
+ {
+ global $mdb;
+
+ if ( $where )
+ return $mdb -> count( 'pp_shop_products', $where );
+ else
+ return $mdb -> count( 'pp_shop_products', [ 'archive' => 0 ] );
+ }
+
+ static public function update_product_combinations_prices( int $product_id, $price_brutto, $vat, $price_brutto_promo )
+ {
+ global $mdb;
+
+ $products = $mdb -> query( 'SELECT psp.id, parent_id '
+ . 'FROM pp_shop_products AS psp '
+ . 'INNER JOIN pp_shop_products_attributes AS pspa ON psp.id = pspa.product_id '
+ . 'INNER JOIN pp_shop_attributes_values AS psav ON pspa.value_id = psav.id '
+ . 'WHERE psav.impact_on_the_price > 0 AND psp.parent_id = :product_id', [ ':product_id' => $product_id ] ) -> fetchAll( \PDO::FETCH_ASSOC );
+ foreach ( $products as $product )
+ {
+ $price_brutto_combination = $price_brutto;
+ $price_brutto_promo_combination = $price_brutto_promo;
+
+ $values = $mdb -> query( 'SELECT impact_on_the_price FROM pp_shop_attributes_values AS psav INNER JOIN pp_shop_products_attributes AS pspa ON pspa.value_id = psav.id WHERE impact_on_the_price IS NOT NULL AND product_id = :product_id', [ ':product_id' => $product['id'] ] ) -> fetchAll( \PDO::FETCH_ASSOC );
+ foreach ( $values as $value )
+ {
+ $price_brutto_combination += $value['impact_on_the_price'];
+ if ( $price_brutto_promo )
+ $price_brutto_promo_combination += $value['impact_on_the_price'];
+ else
+ $price_brutto_promo_combination = null;
+ }
+
+ $price_netto_combination = \S::normalize_decimal( $price_brutto_combination / ( 100 + $vat ) * 100, 2 );
+ if ( $price_brutto_promo_combination )
+ $price_netto_promo_combination = \S::normalize_decimal( $price_brutto_promo_combination / ( 100 + $vat ) * 100, 2 );
+ else
+ $price_netto_promo_combination = null;
+
+ $mdb -> update( 'pp_shop_products', [ 'price_netto' => $price_netto_combination, 'price_brutto' => $price_brutto_combination, 'price_netto_promo' => $price_netto_promo_combination, 'price_brutto_promo' => $price_brutto_promo_combination ], [ 'id' => $product['id'] ] );
+ }
+ }
+
+ // szybka zmiana statusu produktu
+ static public function change_product_status( int $product_id ) {
+ global $mdb;
+
+ $status = $mdb -> get( 'pp_shop_products', 'status', [ 'id' => $product_id ] );
+ $status = $status == 1 ? 0 : 1;
+ return $mdb -> update( 'pp_shop_products', [ 'status' => $status ], [ 'id' => $product_id ] );
+ }
+
+ // domyślna nazwa produktu
+ static public function product_default_name( int $product_id ) {
+ global $mdb;
+
+ $default_lang = $mdb -> get( 'pp_langs', 'id', [ 'start' => 1 ] );
+ return $mdb -> get( 'pp_shop_products_langs', 'name', [ 'AND' => [ 'product_id' => $product_id, 'lang_id' => $default_lang ] ] );
+ }
+
+ // szybka zmiana google xml label
+ static public function product_change_custom_label( int $product_id, $custom_label, $value )
+ {
+ global $mdb;
+ return $mdb -> update( 'pp_shop_products', [ 'custom_label_' . $custom_label => $value ? $value : null ], [ 'id' => $product_id ] );
+ }
+
+ // szybka zmiana ceny promocyjnej
+ static public function product_change_price_brutto_promo( int $product_id, $price )
+ {
+ global $mdb;
+
+ $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'id' => $product_id ] );
+ $price_netto = \S::normalize_decimal( (float)$price / ( 100 + (float)$vat ) * 100, 2 );
+
+ return $mdb -> update( 'pp_shop_products', [ 'price_brutto_promo' => $price != 0.00 ? $price : null, 'price_netto_promo' => $price_netto != 0.00 ? $price : null ], [ 'id' => $product_id ] );
+ }
+
+ // szybka zmiana ceny
+ static public function product_change_price_brutto( int $product_id, $price )
+ {
+ global $mdb;
+
+ $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'id' => $product_id ] );
+ $price_netto = \S::normalize_decimal( (float)$price / ( 100 + (float)$vat ) * 100, 2 );
+
+ return $mdb -> update( 'pp_shop_products', [ 'price_brutto' => $price != 0.00 ? $price : null, 'price_netto' => $price_netto != 0.00 ? $price : null ], [ 'id' => $product_id ] );
+ }
+
+ // pobierz id produktu głównego
+ static public function get_product_parent_id( int $product_id )
+ {
+ global $mdb;
+ return $mdb -> get( 'pp_shop_products', 'parent_id', [ 'id' => $product_id ] );
+ }
+
+ // usunięcie kombinacji produktu
+ static public function delete_combination( int $combination_id )
+ {
+ global $mdb;
+
+ $mdb -> delete( 'pp_shop_products', [ 'id' => $combination_id ] );
+ $mdb -> delete( 'pp_shop_products_attributes', [ 'product_id' => $combination_id ] );
+
+ return true;
+ }
+
+ // pobranie permutacji produktu
+ static public function get_product_permutations( int $product_id )
+ {
+ global $mdb;
+
+ $results = $mdb -> select( 'pp_shop_products', 'id', [ 'parent_id' => $product_id ] );
+ if ( \S::is_array_fix( $results ) ) foreach ( $results as $row )
+ $products[] = \admin\factory\ShopProduct::product_details( $row );
+
+ return $products;
+ }
+
+ // generowanie kombinacji produktu
+ static public function generate_permutation( int $product_id, $attributes )
+ {
+ global $mdb;
+
+ $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'id' => $product_id ] );
+ $attributeRepository = new \Domain\Attribute\AttributeRepository( $mdb );
+
+ $permutations = \shop\Product::array_cartesian( $attributes );
+ if ( \S::is_array_fix( $permutations ) ) foreach ( $permutations as $permutation )
+ {
+ $product = null;
+ ksort( $permutation );
+
+ $permutation_hash = '';
+
+ if ( \S::is_array_fix( $permutation ) ) foreach ( $permutation as $key => $val )
+ {
+ if ( $permutation_hash )
+ $permutation_hash .= '|';
+
+ $permutation_hash .= $key . '-' . $val;
+
+ // sprawdzenie czy atrybut ma wpływ na cenę
+ $value_details = $attributeRepository -> valueDetails( (int)$val );
+ $impact_on_the_price = $value_details[ 'impact_on_the_price' ];
+
+ if ( $impact_on_the_price > 0 )
+ {
+ if ( !$product )
+ $product = \admin\factory\ShopProduct::product_details( $product_id );
+
+ $product_price_brutto = $product['price_brutto'] + $impact_on_the_price;
+ $product_price_netto = $product_price_brutto / ( 1 + ( $product['vat'] / 100 ) );
+
+ if ( $product['price_brutto_promo'] )
+ {
+ $product_price_brutto_promo = $product['price_brutto_promo'] + $impact_on_the_price;
+ $product_price_netto_promo = $product_price_brutto_promo / ( 1 + ( $product['vat'] / 100 ) );
+ }
+ else
+ {
+ $product_price_brutto_promo = null;
+ $product_price_netto_promo = null;
+ }
+ }
+
+ if ( $permutation_hash and !$mdb -> count( 'pp_shop_products', [ 'AND' => [ 'parent_id' => $product_id, 'permutation_hash' => $permutation_hash ] ] ) )
+ {
+ if ( $mdb -> insert( 'pp_shop_products', [ 'parent_id' => $product_id, 'permutation_hash' => $permutation_hash, 'vat' => $vat ] ) )
+ {
+ $combination_id = $mdb -> id();
+ if ( $product )
+ {
+ $mdb -> update( 'pp_shop_products', [ 'price_netto' => $product_price_netto, 'vat' => $product['vat'], 'price_brutto' => $product_price_brutto, 'price_netto_promo' => $product_price_netto_promo, 'price_brutto_promo' => $product_price_brutto_promo ], [ 'id' => $combination_id ] );
+ }
+
+ $permutation_hash_rev_rows = explode( '|', $permutation_hash );
+ foreach ( $permutation_hash_rev_rows as $permutation_hash_rev )
+ {
+ $attribute_rev = explode( '-', $permutation_hash_rev );
+ $mdb -> insert( 'pp_shop_products_attributes', [ 'product_id' => $combination_id, 'attribute_id' => $attribute_rev[0], 'value_id' => $attribute_rev[1] ] );
+ }
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ public static function product_name($product_id)
+ {
+ global $mdb;
+
+ $results = $mdb -> query("SELECT pspl.name FROM pp_shop_products_langs AS pspl, pp_langs AS pl WHERE lang_id = pl.id AND product_id = :product_id AND pspl.name != '' ORDER BY o ASC LIMIT 1", [':product_id' => $product_id]) -> fetchAll();
+
+ return $results[0]['name'];
+ }
+
+ public static function get_product_images($product_id)
+ {
+ global $mdb;
+
+ return $mdb -> select('pp_shop_products_images', 'src', ['product_id' => (int) $product_id, 'ORDER' => ['o' => 'ASC', 'id' => 'ASC']]);
+ }
+
+ static public function generate_EAN( $number )
+ {
+ $code = '200' . str_pad($number, 9, '0');
+ $weightflag = true;
+ $sum = 0;
+
+ for ($i = strlen($code) - 1; $i >= 0; $i--)
+ {
+ $sum += (int)$code[$i] * ($weightflag?3:1);
+ $weightflag = !$weightflag;
+ }
+
+ $code .= (10 - ($sum % 10)) % 10;
+ return $code;
+ }
+
+ static public function generate_google_feed_xml()
+ {
+ global $mdb, $lang_id;
+
+ $settings = \front\factory\Settings::settings_details(true);
+
+ $domain_prefix = 'https';
+ $url = preg_replace('#^(http(s)?://)?w{3}\.#', '$1', $_SERVER['SERVER_NAME']);
+
+ $main_language = \front\factory\Languages::default_language();
+
+ $doc = new \DOMDocument('1.0', 'UTF-8');
+ $xmlRoot = $doc -> createElement('rss');
+ $xmlRoot = $doc -> appendChild($xmlRoot);
+ $xmlRoot -> setAttribute('version', '2.0');
+ $xmlRoot -> setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:g', 'http://base.google.com/ns/1.0');
+ $channelNode = $xmlRoot -> appendChild($doc -> createElement('channel'));
+ $channelNode -> appendChild( $doc -> createElement( 'title', $settings['firm_name']));
+ $channelNode -> appendChild( $doc -> createElement( 'link', $domain_prefix . '://' . $url ) );
+
+ $rows = $mdb -> select( 'pp_shop_products', 'id', [ 'AND' => [ 'status' => '1', 'archive' => 0, 'parent_id' => null ] ] );
+ if ( \S::is_array_fix( $rows ) ) foreach ( $rows as $product_id )
+ {
+ $product = \shop\Product::getFromCache( $product_id, $lang_id );
+
+ if ( is_array( $product -> product_combinations ) and count( $product -> product_combinations ) )
+ {
+ foreach ( $product -> product_combinations as $product_combination )
+ {
+ if ( $product_combination -> quantity !== null or $product_combination -> stock_0_buy )
+ {
+ $itemNode = $channelNode -> appendChild( $doc -> createElement( 'item' ) );
+ $p_gid = $itemNode -> appendChild( $doc -> createElement('g:id', $product_combination -> id ) );
+ $p_groupid = $itemNode -> appendChild( $doc -> createElement( 'g:item_group_id', $product -> id ) );
+
+ if ( $product -> custom_label_0 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_0', $product -> custom_label_0 ) );
+
+ if ( $product -> custom_label_1 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_1', $product -> custom_label_1 ) );
+
+ if ( $product -> custom_label_2 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_2', $product -> custom_label_2 ) );
+
+ if ( $product -> custom_label_3 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_3', $product -> custom_label_3 ) );
+
+ if ( $product -> custom_label_4 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_4', $product -> custom_label_4 ) );
+
+ if ( $product -> language['xml_name'] )
+ $p_title = $itemNode -> appendChild( $doc -> createElement( 'title', str_replace( '&', '&', $product -> language['xml_name'] ) . ' - ' . $product -> generateSubtitleFromAttributes( $product_combination -> permutation_hash ) ) );
+ else
+ $p_title = $itemNode -> appendChild( $doc -> createElement( 'title', str_replace( '&', '&', $product -> language['name'] ) . ' - ' . $product -> generateSubtitleFromAttributes( $product_combination -> permutation_hash ) ) );
+
+ if ( $product -> ean )
+ $p_gtin = $itemNode -> appendChild( $doc -> createElement( 'g:gtin', $product -> ean ) );
+ else
+ $p_gtin = $itemNode -> appendChild( $doc -> createElement( 'g:gtin', self::generate_EAN( $product -> id ) ) );
+
+ // opis produktu
+ if ( $product -> language['short_description'] )
+ $p_description = $itemNode -> appendChild( $doc -> createElement( 'g:description', html_entity_decode( strip_tags( $product -> language['short_description'] ) ) ) );
+ else
+ $p_description = $itemNode -> appendChild( $doc -> createElement( 'g:description', html_entity_decode( strip_tags( $product -> language['name'] ) ) ) );
+
+ if ( $product -> language['seo_link'] )
+ $link = $domain_prefix . '://' . $url . '/' . \S::seo( $product -> language['seo_link'] ) . '/' . str_replace( '|', '/', $product_combination -> permutation_hash );
+ else
+ $link = $domain_prefix . '://' . $url . '/' . 'p-' . $product -> id . '-' . \S::seo( $product -> language['name'] ) . '/' . str_replace( '|', '/', $product_combination -> permutation_hash );
+
+ $p_link = $itemNode -> appendChild( $doc -> createElement( 'link', $link ) );
+
+ if ( $product -> images[0] )
+ $p_gimage_link = $itemNode -> appendChild( $doc -> createElement( 'g:image_link', $domain_prefix . '://' . $url . $product -> images[0]['src'] ) );
+
+ if ( count( $product -> images ) > 1 )
+ {
+ for ( $i = 1; $i < count( $product -> images ); ++$i )
+ $p_gimage_link = $itemNode -> appendChild( $doc -> createElement( 'g:additional_image_link', $domain_prefix . '://' . $url . $product -> images[$i]['src'] ) );
+ }
+
+ $p_gcondition = $itemNode -> appendChild( $doc -> createElement( 'g:condition', 'new' ) );
+
+ if ( $product_combination -> quantity !== null )
+ {
+ if ( $product_combination -> quantity > 0 )
+ {
+ $p_gavailability = $itemNode -> appendChild( $doc -> createElement( 'g:availability', 'in stock' ) );
+ $p_gquantity = $itemNode -> appendChild( $doc -> createElement( 'g:quantity', $product_combination -> quantity ) );
+ }
+ else
+ {
+ if ( $product_combination -> stock_0_buy )
+ $p_availability = $itemNode -> appendChild( $doc -> createElement( 'g:availability', 'in stock' ) );
+ else
+ $p_availability = $itemNode -> appendChild( $doc -> createElement( 'g:availability', 'out of stock' ) );
+ }
+ }
+ else
+ {
+ if ( $product -> quantity > 0 )
+ {
+ $p_gavailability = $itemNode -> appendChild( $doc -> createElement( 'g:availability', 'in stock' ) );
+ $p_gquantity = $itemNode -> appendChild( $doc -> createElement( 'g:quantity', $product -> quantity ) );
+ }
+ else
+ {
+ if ( $product -> stock_0_buy )
+ {
+ $p_availability = $itemNode -> appendChild( $doc -> createElement( 'g:availability', 'in stock' ) );
+ $p_gquantity = $itemNode -> appendChild( $doc -> createElement( 'g:quantity', 999 ) );
+ }
+ else
+ {
+ $p_availability = $itemNode -> appendChild( $doc -> createElement( 'g:availability', 'out of stock' ) );
+ $p_gquantity = $itemNode -> appendChild( $doc -> createElement( 'g:quantity', 0 ) );
+ }
+ }
+ }
+
+ if ( $product_combination -> price_brutto )
+ {
+ $p_gprice = $itemNode -> appendChild( $doc -> createElement( 'g:price', $product_combination -> price_brutto . ' PLN' ) );
+
+ if ( $product_combination -> price_brutto_promo )
+ $p_gsale_price = $itemNode -> appendChild( $doc -> createElement( 'g:sale_price', $product_combination -> price_brutto_promo . ' PLN' ) );
+ }
+ else
+ {
+ $p_gprice = $itemNode -> appendChild( $doc -> createElement( 'g:price', $product -> price_brutto . ' PLN' ) );
+
+ if ( $product -> price_brutto_promo )
+ $p_gsale_price = $itemNode -> appendChild( $doc -> createElement( 'g:sale_price', $product -> price_brutto_promo . ' PLN' ) );
+ }
+
+ $p_gshipping = $itemNode -> appendChild( $doc -> createElement( 'g:shipping' ) );
+ $p_gcountry = $p_gshipping -> appendChild( $doc -> createElement( 'g:country', 'PL' ) );
+ $p_gservice = $p_gshipping -> appendChild( $doc -> createElement( 'g:service', '1 dzień roboczy' ) );
+ $p_gprice = $p_gshipping -> appendChild( $doc -> createElement( 'g:price', ( new \Domain\Transport\TransportRepository( $mdb ) )->lowestTransportPrice( (int) $product -> wp ) . ' PLN' ) );
+ }
+ }
+ }
+ else
+ {
+ $itemNode = $channelNode -> appendChild( $doc -> createElement( 'item' ) );
+ $p_gid = $itemNode -> appendChild( $doc -> createElement('g:id', $product -> id ) );
+ $p_groupid = $itemNode -> appendChild( $doc -> createElement( 'g:item_group_id', $product -> id ) );
+
+ if ( $product -> google_xml_label )
+ $p_label = $itemNode -> appendChild($doc -> createElement('g:custom_label_0', $product -> google_xml_label ) );
+
+ if ( $product -> language['xml_name'] )
+ $p_title = $itemNode -> appendChild( $doc -> createElement( 'title', str_replace( '&', '&', $product -> language['xml_name'] ) ) );
+ else
+ $p_title = $itemNode -> appendChild( $doc -> createElement( 'title', str_replace( '&', '&', $product -> language['name'] ) ) );
+
+ if ( $product -> ean )
+ $p_gtin = $itemNode -> appendChild( $doc -> createElement( 'g:gtin', $product -> ean ) );
+ else
+ $p_gtin = $itemNode -> appendChild( $doc -> createElement( 'g:gtin', self::generate_EAN( $product -> id ) ) );
+
+ // opis produktu
+ if ( $product -> language['short_description'] )
+ $p_description = $itemNode -> appendChild( $doc -> createElement( 'g:description', html_entity_decode( strip_tags( $product -> language['short_description'] ) ) ) );
+ else
+ $p_description = $itemNode -> appendChild( $doc -> createElement( 'g:description', html_entity_decode( strip_tags( $product -> language['name'] ) ) ) );
+
+ if ( $product -> language['seo_link'] )
+ $link = $domain_prefix . '://' . $url . '/' . \S::seo( $product -> language['seo_link'] );
+ else
+ $link = $domain_prefix . '://' . $url . '/' . 'p-' . $product -> id . '-' . \S::seo( $product -> language['name'] );
+
+ $p_link = $itemNode -> appendChild( $doc -> createElement( 'link', $link ) );
+
+ if ( $product -> custom_label_0 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_0', $product -> custom_label_0 ) );
+
+ if ( $product -> custom_label_1 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_1', $product -> custom_label_1 ) );
+
+ if ( $product -> custom_label_2 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_2', $product -> custom_label_2 ) );
+
+ if ( $product -> custom_label_3 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_3', $product -> custom_label_3 ) );
+
+ if ( $product -> custom_label_4 )
+ $p_label = $itemNode -> appendChild( $doc -> createElement('g:custom_label_4', $product -> custom_label_4 ) );
+
+ if ( $product -> images[0] )
+ $p_gimage_link = $itemNode -> appendChild( $doc -> createElement( 'g:image_link', $domain_prefix . '://' . $url . $product -> images[0]['src'] ) );
+
+ if ( count( $product -> images ) > 1 )
+ {
+ for ( $i = 1; $i < count( $product -> images ); ++$i )
+ $p_gimage_link = $itemNode -> appendChild( $doc -> createElement( 'g:additional_image_link', $domain_prefix . '://' . $url . $product -> images[$i]['src'] ) );
+ }
+
+ $p_gcondition = $itemNode -> appendChild( $doc -> createElement( 'g:condition', 'new' ) );
+
+ if ( $product -> quantity )
+ {
+ $p_gavailability = $itemNode -> appendChild( $doc -> createElement( 'g:availability', 'in stock' ) );
+ $p_gquantity = $itemNode -> appendChild( $doc -> createElement( 'g:quantity', $product -> quantity ) );
+ }
+ else
+ {
+ if ( $product -> stock_0_buy ) {
+ $p_availability = $itemNode -> appendChild( $doc -> createElement( 'g:availability', 'in stock' ) );
+ $p_gquantity = $itemNode -> appendChild( $doc -> createElement( 'g:quantity', 999 ) );
+ }
+ else {
+ $p_availability = $itemNode -> appendChild( $doc -> createElement( 'g:availability', 'out of stock' ) );
+ $p_gquantity = $itemNode -> appendChild( $doc -> createElement( 'g:quantity', 0 ) );
+ }
+ }
+
+ $p_gprice = $itemNode -> appendChild( $doc -> createElement( 'g:price', $product -> price_brutto . ' PLN' ) );
+
+ if ( $product -> price_brutto_promo )
+ $p_gsale_price = $itemNode -> appendChild( $doc -> createElement( 'g:sale_price', $product -> price_brutto_promo . ' PLN' ) );
+
+ $p_gshipping = $itemNode -> appendChild( $doc -> createElement( 'g:shipping' ) );
+ $p_gcountry = $p_gshipping -> appendChild( $doc -> createElement( 'g:country', 'PL' ) );
+ $p_gservice = $p_gshipping -> appendChild( $doc -> createElement( 'g:service', '1 dzień roboczy' ) );
+ $p_gprice = $p_gshipping -> appendChild( $doc -> createElement( 'g:price', ( new \Domain\Transport\TransportRepository( $mdb ) )->lowestTransportPrice( (int) $product -> wp ) . ' PLN' ) );
+ }
+ }
+ file_put_contents('../google-feed.xml', $doc -> saveXML());
+ }
+
+ static public function count_product_combinations( int $product_id )
+ {
+ global $mdb;
+
+ return $mdb -> count( 'pp_shop_products', [ 'parent_id' => $product_id ] );
+ }
+
+ // ajax_products_list
+ static public function ajax_products_list_archive( $current_page = null, $query = null )
+ {
+ global $mdb;
+
+ $search = '';
+
+ if ( $query )
+ {
+ $query_array = [];
+ parse_str( $query, $query_array );
+
+ foreach ( $query_array as $key => $val ) {
+ if ( $val !== '' )
+ $search .= ' AND ' . $key . ' LIKE \'%' . $val . '%\'';
+ }
+ }
+
+ $results = $mdb -> query( 'SELECT '
+ . 'DISTINCT( psp.id )'
+ . 'FROM '
+ . 'pp_shop_products AS psp '
+ . 'INNER JOIN pp_shop_products_langs AS pspl ON pspl.product_id = psp.id '
+ . 'WHERE archive = 1 AND parent_id IS NULL ' . $search . ' ORDER BY id DESC LIMIT ' . ( $current_page - 1 ) * 10 . ', 10' ) -> fetchAll( \PDO::FETCH_ASSOC );
+ $results2 = $mdb -> query( 'SELECT '
+ . 'COUNT( DISTINCT( psp.id ) ) AS products_count '
+ . 'FROM '
+ . 'pp_shop_products AS psp '
+ . 'INNER JOIN pp_shop_products_langs AS pspl ON pspl.product_id = psp.id '
+ . 'WHERE archive = 1 AND parent_id IS NULL ' . $search ) -> fetchAll( \PDO::FETCH_ASSOC );
+
+ if ( is_array( $results ) ) foreach ( $results as $row ) {
+ $products[] = \admin\factory\ShopProduct::product_details( $row['id'] );
+ }
+
+ return [ 'products' => $products, 'products_count' => $results2[0]['products_count'] ];
+ }
+
+ // ajax_products_list
+ static public function ajax_products_list( $current_page = null, $query = null )
+ {
+ global $mdb;
+
+ if ( $query )
+ {
+ $search = '';
+ $query_array = [];
+
+ parse_str( $query, $query_array );
+
+ foreach ( $query_array as $key => $val ) {
+ if ( strpos( $key, '|' ) !== false )
+ {
+ $keys_tmp = explode( '|', $key );
+ $search .= ' AND ( ';
+ foreach ( $keys_tmp as $key_tmp )
+ {
+ if ( $key_tmp != reset( $keys_tmp ) )
+ $search .= ' OR ' . $key_tmp . ' LIKE \'%' . $val . '%\'';
+ else
+ $search .= ' ' . $key_tmp . ' LIKE \'%' . $val . '%\'';
+ }
+ $search .= ' )';
+ }
+ else
+ {
+ $search .= ' AND ' . $key . ' LIKE \'%' . $val . '%\'';
+ }
+ }
+
+ $results = $mdb -> query( 'SELECT '
+ . 'DISTINCT( psp.id )'
+ . 'FROM '
+ . 'pp_shop_products AS psp '
+ . 'INNER JOIN pp_shop_products_langs AS pspl ON pspl.product_id = psp.id '
+ . 'WHERE archive = 0 AND parent_id IS NULL ' . $search . ' ORDER BY id DESC LIMIT ' . ( $current_page - 1 ) * 10 . ', 10' ) -> fetchAll( \PDO::FETCH_ASSOC );
+ $results2 = $mdb -> query( 'SELECT '
+ . 'COUNT( DISTINCT( psp.id ) ) AS products_count '
+ . 'FROM '
+ . 'pp_shop_products AS psp '
+ . 'INNER JOIN pp_shop_products_langs AS pspl ON pspl.product_id = psp.id '
+ . 'WHERE archive = 0 AND parent_id IS NULL ' . $search ) -> fetchAll( \PDO::FETCH_ASSOC );
+ } else {
+ $results = $mdb -> query( 'SELECT id FROM pp_shop_products WHERE parent_id IS NULL ORDER BY id DESC LIMIT ' . ( $current_page - 1 ) * 10 . ', 10' ) -> fetchAll( \PDO::FETCH_ASSOC );
+ $results2 = $mdb -> query( 'SELECT COUNT( id ) AS products_count FROM pp_shop_products WHERE parent_id IS NULL' ) -> fetchAll( \PDO::FETCH_ASSOC );
+ }
+
+ if ( is_array( $results ) ) foreach ( $results as $row ) {
+ $products[] = \admin\factory\ShopProduct::product_details( $row['id'] );
+ }
+
+ return [ 'products' => $products, 'products_count' => $results2[0]['products_count'] ];
+ }
+
+ public static function products_list()
+ {
+ global $mdb;
+
+ $results = $mdb -> select( 'pp_shop_products', 'id', [ 'parent_id' => null ] );
+ if ( is_array( $results ) ) foreach ( $results as $row )
+ {
+ $products[ $row ] = $mdb -> get ('pp_shop_products_langs', 'name', ['AND' => [ 'product_id' => $row, 'lang_id' => 'pl' ] ] );
+ }
+
+ return $products;
+ }
+
+ public static function images_order_save($product_id, $order)
+ {
+ global $mdb;
+
+ $order = explode(';', $order);
+ if (\is_array($order) && !empty($order))
+ {
+ foreach ($order as $image_id)
+ {
+ $mdb -> update('pp_shop_products_images', [
+ 'o' => $i++,
+ ], [
+ 'AND' => [
+ 'product_id' => $product_id,
+ 'id' => $image_id,
+ ],
+ ]);
+ }
+ }
+
+ return true;
+ }
+
+ public static function image_alt_change($image_id, $image_alt)
+ {
+ global $mdb;
+ $result = $mdb -> update('pp_shop_products_images', [
+ 'alt' => $image_alt,
+ ], [
+ 'id' => $image_id,
+ ]);
+ \S::delete_cache();
+
+ return $result;
+ }
+
+ // product_unarchive
+ static public function product_unarchive( int $product_id )
+ {
+ global $mdb;
+
+ $mdb -> update( 'pp_shop_products', [ 'status' => 1, 'archive' => 0 ], [ 'id' => $product_id ] );
+ $mdb -> update( 'pp_shop_products', [ 'status' => 1, 'archive' => 0 ], [ 'parent_id' => $product_id ] );
+
+ return true;
+ }
+
+ static public function product_archive( int $product_id )
+ {
+ global $mdb;
+
+ $mdb -> update( 'pp_shop_products', [ 'status' => 0, 'archive' => 1 ], [ 'id' => $product_id ] );
+ $mdb -> update( 'pp_shop_products', [ 'status' => 0, 'archive' => 1 ], [ 'parent_id' => $product_id ] );
+
+ return true;
+ }
+
+ public static function product_delete( int $product_id)
+ {
+ global $mdb;
+
+ $mdb -> delete( 'pp_shop_products_categories', ['product_id' => $product_id ] );
+ $mdb -> delete( 'pp_shop_products_langs', ['product_id' => $product_id ] );
+ $mdb -> delete( 'pp_shop_products_images', ['product_id' => $product_id ] );
+ $mdb -> delete( 'pp_shop_products_files', ['product_id' => $product_id ] );
+ $mdb -> delete( 'pp_shop_products_attributes', ['product_id' => $product_id ] );
+ $mdb -> delete( 'pp_shop_products', ['id' => $product_id ] );
+ $mdb -> delete( 'pp_shop_product_sets_products', [ 'product_id' => $product_id ] );
+ // pp_routes
+ $mdb -> delete( 'pp_routes', [ 'product_id' => $product_id ] );
+ // pp_redirects
+ $mdb -> delete( 'pp_redirects', [ 'product_id' => $product_id ] );
+
+ \S::delete_dir( '../upload/product_images/product_' . $product_id . '/' );
+ \S::delete_dir( '../upload/product_files/product_' . $product_id . '/' );
+
+ return true;
+ }
+
+ public static function product_categories($product_id)
+ {
+ global $mdb;
+
+ $results = $mdb -> query('SELECT category_id FROM pp_shop_products_categories WHERE product_id = '.(int) $product_id) -> fetchAll();
+ if (\is_array($results) && !empty($results))
+ {
+ foreach ($results as $row)
+ {
+ if ('' === $out)
+ {
+ $out .= ' - ';
+ }
+
+ $out .= ( new \Domain\Category\CategoryRepository( $mdb ) )->categoryTitle( (int) $row['category_id'] );
+
+ if (end($results) !== $row)
+ {
+ $out .= ' / ';
+ }
+ }
+ }
+
+ return $out;
+ }
+
+ public static function max_order()
+ {
+ global $mdb;
+
+ return $mdb -> max('pp_shop_products_categories', 'o');
+ }
+
+ public static function delete_img($image_id)
+ {
+ global $mdb;
+ $mdb -> update('pp_shop_products_images', ['to_delete' => 1], ['id' => (int) $image_id]);
+
+ return true;
+ }
+
+ public static function delete_nonassigned_images()
+ {
+ global $mdb;
+
+ $results = $mdb -> select('pp_shop_products_images', '*', ['product_id' => null]);
+ if (\is_array($results))
+ {
+ foreach ($results as $row)
+ {
+ if (file_exists('../'.$row['src']))
+ {
+ unlink('../'.$row['src']);
+ }
+ }
+ }
+
+ $mdb -> delete('pp_shop_products_images', ['product_id' => null]);
+ }
+
+ public static function file_name_change($file_id, $file_name)
+ {
+ global $mdb;
+ $mdb -> update('pp_shop_products_files', ['name' => $file_name], ['id' => (int) $file_id]);
+
+ return true;
+ }
+
+ public static function delete_file($file_id)
+ {
+ global $mdb;
+ $mdb -> update('pp_shop_products_files', ['to_delete' => 1], ['id' => (int) $file_id]);
+
+ return true;
+ }
+
+ public static function delete_nonassigned_files()
+ {
+ global $mdb;
+
+ $results = $mdb -> select('pp_shop_products_files', '*', ['product_id' => null]);
+ if (\is_array($results))
+ {
+ foreach ($results as $row)
+ {
+ if (file_exists('../'.$row['src']))
+ {
+ unlink('../'.$row['src']);
+ }
+ }
+ }
+
+ $mdb -> delete('pp_shop_products_files', ['product_id' => null]);
+ }
+
+ public static function save(
+ $product_id, $name, $short_description, $description, $status, $meta_description, $meta_keywords, $seo_link, $copy_from, $categories, $price_netto, $price_brutto, $vat, $promoted, $warehouse_message_zero, $warehouse_message_nonzero, $tab_name_1, $tab_description_1, $tab_name_2, $tab_description_2, $layout_id, $products_related, int $set_id, $price_netto_promo, $price_brutto_promo, $new_to_date, $stock_0_buy, $wp, $custom_label_0, $custom_label_1, $custom_label_2, $custom_label_3, $custom_label_4, $additional_message, int $quantity, $additional_message_text, int $additional_message_required, $canonical, $meta_title, $producer_id, $sku, $ean, $product_unit, $weight, $xml_name, $custom_field_name, $custom_field_required, $security_information, $custom_field_type
+ )
+ {
+ global $mdb, $user;
+
+ if ( !$product_id )
+ {
+ $mdb -> insert('pp_shop_products', [
+ 'date_add' => date('Y-m-d H:i:s'),
+ 'date_modify' => date('Y-m-d H:i:s'),
+ 'modify_by' => $user['id'],
+ 'status' => 'on' === $status ? 1 : 0,
+ 'price_netto' => ($price_netto && 0.00 !== $price_netto) ? $price_netto : null,
+ 'price_brutto' => ($price_brutto && 0.00 !== $price_brutto) ? $price_brutto : null,
+ 'vat' => $vat,
+ 'promoted' => 'on' === $promoted ? 1 : 0,
+ 'layout_id' => $layout_id ? $layout_id : null,
+ 'price_netto_promo' => ($price_netto_promo && 0.00 !== $price_netto_promo) ? $price_netto_promo : null,
+ 'price_brutto_promo' => ($price_brutto_promo && 0.00 !== $price_brutto_promo) ? $price_brutto_promo : null,
+ 'new_to_date' => $new_to_date ? $new_to_date : null,
+ 'stock_0_buy' => 'on' === $stock_0_buy ? 1 : 0,
+ 'wp' => $wp ? $wp : null,
+ 'sku' => $sku ? $sku : null,
+ 'ean' => $ean ? $ean : null,
+ 'custom_label_0' => $custom_label_0 ? $custom_label_0 : null,
+ 'custom_label_1' => $custom_label_1 ? $custom_label_1 : null,
+ 'custom_label_2' => $custom_label_2 ? $custom_label_2 : null,
+ 'custom_label_3' => $custom_label_3 ? $custom_label_3 : null,
+ 'custom_label_4' => $custom_label_4 ? $custom_label_4 : null,
+ 'additional_message' => $additional_message == 'on' ? 1 : 0,
+ 'set_id' => $set_id ? $set_id : null,
+ 'quantity' => $quantity,
+ 'additional_message_text' => $additional_message_text ? $additional_message_text : null,
+ 'additional_message_required' => $additional_message_required,
+ 'producer_id' => !empty( $producer_id ) ? $producer_id : null,
+ 'product_unit_id' => !empty( $product_unit ) ? $product_unit : null,
+ 'weight' => !empty( $weight ) ? $weight : null,
+ ] );
+
+ $id = $mdb -> id();
+
+ if ( $id )
+ {
+ $langs = ( new \Domain\Languages\LanguagesRepository( $mdb ) )->languagesList( true );
+ foreach ( $langs as $lg )
+ {
+ $mdb -> insert( 'pp_shop_products_langs', [
+ 'product_id' => (int) $id,
+ 'lang_id' => $lg['id'],
+ 'name' => $name[$lg['id']] ? $name[$lg['id']] : null,
+ 'short_description' => $short_description[$lg['id']] ? $short_description[$lg['id']] : null,
+ 'description' => $description[$lg['id']] ? $description[$lg['id']] : null,
+ 'meta_description' => $meta_description[$lg['id']] ? $meta_description[$lg['id']] : null,
+ 'meta_keywords' => $meta_keywords[$lg['id']] ? $meta_keywords[$lg['id']] : null,
+ 'seo_link' => $seo_link[$lg['id']] ? \S::seo($seo_link[$lg['id']]) : null,
+ 'copy_from' => $copy_from[$lg['id']] ? $copy_from[$lg['id']] : null,
+ 'warehouse_message_zero' => $warehouse_message_zero[$lg['id']] ? $warehouse_message_zero[$lg['id']] : null,
+ 'warehouse_message_nonzero' => $warehouse_message_nonzero[$lg['id']] ? $warehouse_message_nonzero[$lg['id']] : null,
+ 'tab_name_1' => $tab_name_1[$lg['id']] ? $tab_name_1[$lg['id']] : null,
+ 'tab_description_1' => $tab_description_1[$lg['id']] ? $tab_description_1[$lg['id']] : null,
+ 'tab_name_2' => $tab_name_2[$lg['id']] ? $tab_name_2[$lg['id']] : null,
+ 'tab_description_2' => $tab_description_2[$lg['id']] ? $tab_description_2[$lg['id']] : null,
+ 'canonical' => $canonical[$lg['id']] ? $canonical[$lg['id']] : null,
+ 'meta_title' => $meta_title[$lg['id']] ? $meta_title[$lg['id']] : null,
+ 'xml_name' => $xml_name[$lg['id']] ? $xml_name[$lg['id']] : null,
+ 'security_information' => $security_information[$lg['id']] ? $security_information[$lg['id']] : null,
+ ] );
+ }
+
+ if ( is_array($categories))
+ {
+ foreach ($categories as $category)
+ {
+ $order = self::max_order() + 1;
+
+ $mdb -> insert('pp_shop_products_categories', [
+ 'product_id' => (int) $id,
+ 'category_id' => (int) $category,
+ 'o' => (int) $order,
+ ]);
+ }
+ }
+ elseif ($categories)
+ {
+ $order = self::max_order() + 1;
+
+ $mdb -> insert('pp_shop_products_categories', [
+ 'product_id' => (int) $id,
+ 'category_id' => (int) $categories,
+ 'o' => (int) $order,
+ ]);
+ }
+
+ if (\is_array($products_related))
+ {
+ foreach ($products_related as $product_related )
+ {
+ $mdb -> insert('pp_shop_products_related', [
+ 'product_id' => (int) $id,
+ 'product_related_id' => (int) $product_related,
+ ]);
+ }
+ }
+ elseif ( $products_related )
+ {
+ $mdb -> insert('pp_shop_products_related', [
+ 'product_id' => (int) $id,
+ 'product_related_id' => (int) $products_related,
+ ]);
+ }
+
+ $created = false;
+
+ $results = $mdb -> select('pp_shop_products_files', '*', ['product_id' => null]);
+ if (\is_array($results))
+ {
+ foreach ($results as $row)
+ {
+ $dir = '/upload/product_files/product_'.$id;
+
+ $new_file_name = str_replace('/upload/product_files/tmp', $dir, $row['src']);
+
+ if (file_exists('..'.$row['src']))
+ {
+ if (!is_dir('../'.$dir) && true !== $created)
+ {
+ if (mkdir('../'.$dir, 0755, true))
+ {
+ $created = true;
+ }
+ }
+ rename('..'.$row['src'], '..'.$new_file_name);
+ }
+
+ $mdb -> update('pp_shop_products_files', ['src' => $new_file_name, 'product_id' => $id], ['id' => $row['id']]);
+ }
+ }
+
+ $created = false;
+
+ $results = $mdb -> select('pp_shop_products_images', '*', ['product_id' => null]);
+ if (\is_array($results))
+ {
+ foreach ($results as $row)
+ {
+ $dir = '/upload/product_images/product_'.$id;
+
+ $new_file_name = str_replace('/upload/product_images/tmp', $dir, $row['src']);
+
+ if (file_exists('../'.$new_file_name))
+ {
+ $ext = strrpos($new_file_name, '.');
+ $fileName_a = substr($new_file_name, 0, $ext);
+ $fileName_b = substr($new_file_name, $ext);
+
+ $count = 1;
+
+ while (file_exists('../'.$fileName_a.'_'.$count.$fileName_b))
+ {
+ ++$count;
+ }
+
+ $new_file_name = $fileName_a.'_'.$count.$fileName_b;
+ }
+
+ if (file_exists('..'.$row['src']))
+ {
+ if (!is_dir('../'.$dir) && true !== $created)
+ {
+ if (mkdir('../'.$dir, 0755, true))
+ {
+ $created = true;
+ }
+ }
+ rename('..'.$row['src'], '..'.$new_file_name);
+ }
+
+ $mdb -> update('pp_shop_products_images', ['src' => $new_file_name, 'product_id' => (int) $id], ['id' => $row['id']]);
+ }
+ }
+
+ // dodatkowe pola
+ for ( $i = 0; $i < count( $custom_field_name ); ++$i )
+ {
+ if ( !empty( $custom_field_name[$i] ) )
+ {
+ $custom_field = $custom_field_name[$i];
+ $custom_field_type_data = $custom_field_type[$i];
+ $custom_field_required = isset( $custom_field_required[$i] ) ? 1 : 0;
+
+ $mdb -> insert( 'pp_shop_products_custom_fields', [
+ 'id_product' => (int) $id,
+ 'name' => $custom_field,
+ 'type' => $custom_field_type_data,
+ 'is_required' => $custom_field_required,
+ ] );
+ }
+ }
+
+ \S::htacces();
+
+ \S::delete_dir('../temp/');
+ \S::delete_dir('../thumbs/');
+
+ return $id;
+ }
+ }
+ else
+ {
+ $mdb -> update( 'pp_shop_products', [
+ 'date_modify' => date('Y-m-d H:i:s'),
+ 'modify_by' => $user['id'],
+ 'status' => 'on' === $status ? 1 : 0,
+ 'price_netto' => ($price_netto && 0.00 !== $price_netto) ? $price_netto : null,
+ 'price_brutto' => ($price_brutto && 0.00 !== $price_brutto) ? $price_brutto : null,
+ 'vat' => $vat,
+ 'promoted' => 'on' === $promoted ? 1 : 0,
+ 'layout_id' => $layout_id ? $layout_id : null,
+ 'price_netto_promo' => ($price_netto_promo && 0.00 !== $price_netto_promo) ? $price_netto_promo : null,
+ 'price_brutto_promo' => ($price_brutto_promo && 0.00 !== $price_brutto_promo) ? $price_brutto_promo : null,
+ 'new_to_date' => $new_to_date ? $new_to_date : null,
+ 'stock_0_buy' => 'on' === $stock_0_buy ? 1 : 0,
+ 'wp' => $wp ? $wp : null,
+ 'sku' => $sku ? $sku : null,
+ 'ean' => $ean ? $ean : null,
+ 'custom_label_0' => $custom_label_0 ? $custom_label_0 : null,
+ 'custom_label_1' => $custom_label_1 ? $custom_label_1 : null,
+ 'custom_label_2' => $custom_label_2 ? $custom_label_2 : null,
+ 'custom_label_3' => $custom_label_3 ? $custom_label_3 : null,
+ 'custom_label_4' => $custom_label_4 ? $custom_label_4 : null,
+ 'additional_message' => $additional_message == 'on' ? 1 : 0,
+ 'set_id' => $set_id ? $set_id : null,
+ 'quantity' => $quantity,
+ 'additional_message_text' => $additional_message_text ? $additional_message_text : null,
+ 'additional_message_required' => $additional_message_required,
+ 'producer_id' => !empty( $producer_id ) ? $producer_id : null,
+ 'product_unit_id' => !empty( $product_unit ) ? $product_unit : null,
+ 'weight' => !empty( $weight ) ? $weight : null,
+ ], [
+ 'id' => (int) $product_id,
+ ] );
+
+ $mdb -> update( 'pp_shop_products', [
+ 'additional_message' => $additional_message == 'on' ? 1 : 0,
+ ], [
+ 'parent_id' => (int) $product_id,
+ ] );
+
+ \admin\factory\ShopProduct::update_product_combinations_prices( $product_id, $price_brutto, $vat, $price_brutto_promo );
+
+ $langs = ( new \Domain\Languages\LanguagesRepository( $mdb ) )->languagesList( true );
+ foreach ( $langs as $lg )
+ {
+ if ( $translation_id = $mdb -> get( 'pp_shop_products_langs', 'id', [ 'AND' => [ 'product_id' => $product_id, 'lang_id' => $lg['id'] ] ] ) )
+ {
+ $current_seo_link = $mdb -> get( 'pp_shop_products_langs', 'seo_link', [ 'id' => $translation_id ] );
+
+ if ( $seo_link[$lg['id']] )
+ $new_seo_link = \S::seo( $seo_link[$lg['id']] );
+ else
+ $new_seo_link = \S::seo( 'p-' . $product_id . '-' . $name[$lg['id']] );
+
+ if ( $new_seo_link !== $current_seo_link and $current_seo_link != '' )
+ {
+ if ( $mdb -> count( 'pp_redirects', [ 'from' => $new_seo_link, 'to' => $current_seo_link, 'lang_id' => $lg['id'], 'product_id' => $product_id ] ) )
+ $mdb -> delete( 'pp_redirects', [ 'from' => $new_seo_link, 'to' => $current_seo_link, 'lang_id' => $lg['id'], 'product_id' => $product_id ] );
+
+ $mdb -> delete( 'pp_redirects', [
+ 'AND' => [
+ 'product_id' => $product_id,
+ 'lang_id' => $lg['id'],
+ 'from' => $current_seo_link,
+ 'to[!]' => $new_seo_link,
+ ],
+ ] );
+
+ if ( !self::seoLinkUsedByOtherProduct( (int) $product_id, (string) $lg['id'], (string) $current_seo_link ) )
+ {
+ self::removeConflictingRedirectSources( (int) $product_id, (string) $lg['id'], (string) $current_seo_link );
+
+ if ( !$mdb -> count( 'pp_redirects', [ 'from' => $current_seo_link, 'to' => $new_seo_link, 'lang_id' => $lg['id'], 'product_id' => $product_id ] ) )
+ {
+ if ( \S::canAddRedirect( $current_seo_link, $new_seo_link, $lg['id'] ) )
+ $mdb -> insert( 'pp_redirects', [ 'from' => $current_seo_link, 'to' => $new_seo_link, 'lang_id' => $lg['id'], 'product_id' => $product_id ] );
+ }
+ }
+ else
+ $mdb -> delete( 'pp_redirects', [ 'AND' => [ 'product_id' => $product_id, 'lang_id' => $lg['id'], 'from' => $current_seo_link ] ] );
+ }
+
+ $mdb -> update( 'pp_shop_products_langs', [
+ 'name' => $name[$lg['id']] ? $name[$lg['id']] : null,
+ 'short_description' => $short_description[$lg['id']] ? $short_description[$lg['id']] : null,
+ 'description' => $description[$lg['id']] ? $description[$lg['id']] : null,
+ 'meta_description' => $meta_description[$lg['id']] ? $meta_description[$lg['id']] : null,
+ 'meta_keywords' => $meta_keywords[$lg['id']] ? $meta_keywords[$lg['id']] : null,
+ 'seo_link' => $seo_link[$lg['id']] ? \S::seo($seo_link[$lg['id']]) : null,
+ 'copy_from' => $copy_from[$lg['id']] ? $copy_from[$lg['id']] : null,
+ 'warehouse_message_zero' => $warehouse_message_zero[$lg['id']] ? $warehouse_message_zero[$lg['id']] : null,
+ 'warehouse_message_nonzero' => $warehouse_message_nonzero[$lg['id']] ? $warehouse_message_nonzero[$lg['id']] : null,
+ 'tab_name_1' => $tab_name_1[$lg['id']] ? $tab_name_1[$lg['id']] : null,
+ 'tab_description_1' => $tab_description_1[$lg['id']] ? $tab_description_1[$lg['id']] : null,
+ 'tab_name_2' => $tab_name_2[$lg['id']] ? $tab_name_2[$lg['id']] : null,
+ 'tab_description_2' => $tab_description_2[$lg['id']] ? $tab_description_2[$lg['id']] : null,
+ 'canonical' => $canonical[$lg['id']] ? $canonical[$lg['id']] : null,
+ 'meta_title' => $meta_title[$lg['id']] ? $meta_title[$lg['id']] : null,
+ 'xml_name' => $xml_name[$lg['id']] ? $xml_name[$lg['id']] : null,
+ 'security_information' => $security_information[$lg['id']] ? $security_information[$lg['id']] : null,
+ ], [
+ 'id' => $translation_id
+ ] );
+ }
+ else
+ {
+ $mdb -> insert( 'pp_shop_products_langs', [
+ 'product_id' => (int) $product_id,
+ 'lang_id' => $lg['id'],
+ 'name' => $name[$lg['id']] ? $name[$lg['id']] : null,
+ 'short_description' => $short_description[$lg['id']] ? $short_description[$lg['id']] : null,
+ 'description' => $description[$lg['id']] ? $description[$lg['id']] : null,
+ 'meta_description' => $meta_description[$lg['id']] ? $meta_description[$lg['id']] : null,
+ 'meta_keywords' => $meta_keywords[$lg['id']] ? $meta_keywords[$lg['id']] : null,
+ 'seo_link' => $seo_link[$lg['id']] ? \S::seo($seo_link[$lg['id']]) : null,
+ 'copy_from' => $copy_from[$lg['id']] ? $copy_from[$lg['id']] : null,
+ 'warehouse_message_zero' => $warehouse_message_zero[$lg['id']] ? $warehouse_message_zero[$lg['id']] : null,
+ 'warehouse_message_nonzero' => $warehouse_message_nonzero[$lg['id']] ? $warehouse_message_nonzero[$lg['id']] : null,
+ 'tab_name_1' => $tab_name_1[$lg['id']] ? $tab_name_1[$lg['id']] : null,
+ 'tab_description_1' => $tab_description_1[$lg['id']] ? $tab_description_1[$lg['id']] : null,
+ 'tab_name_2' => $tab_name_2[$lg['id']] ? $tab_name_2[$lg['id']] : null,
+ 'tab_description_2' => $tab_description_2[$lg['id']] ? $tab_description_2[$lg['id']] : null,
+ 'canonical' => $canonical[$lg['id']] ? $canonical[$lg['id']] : null,
+ 'meta_title' => $meta_title[$lg['id']] ? $meta_title[$lg['id']] : null,
+ 'xml_name' => $xml_name[$lg['id']] ? $xml_name[$lg['id']] : null,
+ 'security_information' => $security_information[$lg['id']] ? $security_information[$lg['id']] : null,
+ ] );
+ }
+ }
+
+ $not_in = [0];
+
+ if (\is_array($categories))
+ {
+ foreach ($categories as $category)
+ {
+ $not_in[] = $category;
+ }
+ }
+ elseif ($categories)
+ {
+ $not_in[] = $categories;
+ }
+
+ $mdb -> delete('pp_shop_products_categories', ['AND' => ['product_id' => (int) $product_id, 'category_id[!]' => $not_in]]);
+
+ $categories_tmp = $mdb -> select('pp_shop_products_categories', 'category_id', ['product_id' => (int) $product_id]);
+
+ if (!\is_array($categories))
+ {
+ $categories = [$categories];
+ }
+
+ $categories = array_diff($categories, $categories_tmp);
+
+ if (\is_array($categories))
+ {
+ foreach ($categories as $category)
+ {
+ $order = self::max_order() + 1;
+
+ if ( $product_id and $category )
+ $mdb -> insert( 'pp_shop_products_categories', [
+ 'product_id' => (int)$product_id,
+ 'category_id' => (int)$category,
+ 'o' => (int) $order,
+ ] );
+ }
+ }
+
+ // produkty powiązane
+ $not_in = [0];
+
+ if (\is_array($products_related))
+ {
+ foreach ($products_related as $product_related)
+ {
+ $not_in[] = $product_related;
+ }
+ }
+ elseif ($products_related)
+ {
+ $not_in[] = $products_related;
+ }
+
+ $mdb -> delete('pp_shop_products_related', ['AND' => ['product_id' => (int) $product_id, 'product_related_id[!]' => $not_in]]);
+
+ $products_related_tmp = $mdb -> select('pp_shop_products_related', 'product_related_id', ['product_id' => (int) $product_id]);
+
+ if (!\is_array($products_related))
+ {
+ $products_related = [$products_related];
+ }
+
+ $products_related = array_diff($products_related, $products_related_tmp);
+
+ if (\is_array($products_related))
+ {
+ foreach ($products_related as $product_related)
+ {
+ if ($product_id && $product_related)
+ {
+ $mdb -> insert('pp_shop_products_related', [
+ 'product_id' => (int) $product_id,
+ 'product_related_id' => (int) $product_related,
+ ]);
+ }
+ }
+ }
+
+ $created = false;
+
+ $results = $mdb -> select('pp_shop_products_files', '*', ['product_id' => null]);
+ if (\is_array($results))
+ {
+ foreach ($results as $row)
+ {
+ $dir = '/upload/product_files/product_'.$product_id;
+
+ $new_file_name = str_replace('/upload/product_files/tmp', $dir, $row['src']);
+
+ if (file_exists('..'.$row['src']))
+ {
+ if (!is_dir('../'.$dir) && true !== $created)
+ {
+ if (mkdir('../'.$dir, 0755, true))
+ {
+ $created = true;
+ }
+ }
+ rename('..'.$row['src'], '..'.$new_file_name);
+ }
+
+ $mdb -> update('pp_shop_products_files', ['src' => $new_file_name, 'product_id' => (int) $product_id], ['id' => $row['id']]);
+ }
+ }
+
+ $results = $mdb -> select('pp_shop_products_files', '*', ['AND' => ['product_id' => (int) $product_id, 'to_delete' => 1]]);
+ if (\is_array($results))
+ {
+ foreach ($results as $row)
+ {
+ if (file_exists('../'.$row['src']))
+ {
+ unlink('../'.$row['src']);
+ }
+ }
+ }
+
+ $mdb -> delete('pp_shop_products_files', ['AND' => ['product_id' => (int) $product_id, 'to_delete' => 1]]);
+
+ $created = false;
+
+ // zdjęcia
+ $results = $mdb -> select('pp_shop_products_images', '*', ['product_id' => null]);
+ if (\is_array($results))
+ {
+ foreach ($results as $row)
+ {
+ $dir = '/upload/product_images/product_'.$product_id;
+
+ $new_file_name = str_replace('/upload/product_images/tmp', $dir, $row['src']);
+
+ if (file_exists('../'.$new_file_name))
+ {
+ $ext = strrpos($new_file_name, '.');
+ $fileName_a = substr($new_file_name, 0, $ext);
+ $fileName_b = substr($new_file_name, $ext);
+
+ $count = 1;
+
+ while (file_exists('../'.$fileName_a.'_'.$count.$fileName_b))
+ {
+ ++$count;
+ }
+
+ $new_file_name = $fileName_a.'_'.$count.$fileName_b;
+ }
+
+ if (file_exists('..'.$row['src']))
+ {
+ if (!is_dir('../'.$dir) && true !== $created)
+ {
+ if (mkdir('../'.$dir, 0755, true))
+ {
+ $created = true;
+ }
+ }
+ rename('..'.$row['src'], '..'.$new_file_name);
+ }
+
+ $mdb -> update('pp_shop_products_images', ['src' => $new_file_name, 'product_id' => (int) $product_id], ['id' => $row['id']]);
+ }
+ }
+
+ $results = $mdb -> select('pp_shop_products_images', '*', ['AND' => ['product_id' => (int) $product_id, 'to_delete' => 1]]);
+ if (\is_array($results))
+ {
+ foreach ($results as $row)
+ {
+ if (file_exists('../'.$row['src']))
+ {
+ unlink('../'.$row['src']);
+ }
+ }
+ }
+
+ $mdb -> delete('pp_shop_products_images', ['AND' => ['product_id' => (int) $product_id, 'to_delete' => 1]]);
+
+ // dodatkowe pola
+ // delete only custom fields that are not in the new list
+ foreach ( $custom_field_name as $custom_field )
+ {
+ if ( !empty( $custom_field ) )
+ {
+ $exits_custom_ids[] = (int)$mdb -> get( 'pp_shop_products_custom_fields', 'id_additional_field', [ 'AND' => [ 'id_product' => $product_id, 'name' => $custom_field ] ] );
+ }
+ }
+
+ $mdb -> delete( 'pp_shop_products_custom_fields', [ 'AND' => [ 'id_product' => $product_id, 'id_additional_field[!]' => $exits_custom_ids ] ] );
+
+ // $custom_field_name i $custom_field_required
+ foreach ( $custom_field_name as $i => $custom_field )
+ {
+ if ( !empty( $custom_field ) )
+ {
+ $custom_field_type_data = $custom_field_type[$i];
+ $is_required = !empty( $custom_field_required[$i] ) ? 1 : 0;
+
+ if ( !$mdb -> count( 'pp_shop_products_custom_fields', [ 'AND' => [ 'id_product' => $product_id, 'name' => $custom_field ] ] ) )
+ {
+ $mdb -> insert( 'pp_shop_products_custom_fields', [
+ 'id_product' => $product_id,
+ 'name' => $custom_field,
+ 'type' => $custom_field_type_data,
+ 'is_required' => $is_required
+ ]);
+ }
+ else
+ {
+ $mdb -> update( 'pp_shop_products_custom_fields',
+ [
+ 'type' => $custom_field_type_data,
+ 'is_required' => $is_required
+ ],
+ [ 'AND' => [ 'id_product' => $product_id, 'name' => $custom_field ] ]);
+ }
+ }
+ }
+
+ \S::htacces();
+
+ \S::delete_dir( '../temp/' );
+ \S::delete_dir( '../thumbs/' );
+
+ $redis = \RedisConnection::getInstance() -> getConnection();
+ if ( $redis )
+ $redis -> flushAll();
+
+ return $product_id;
+ }
+ }
+
+ // pobierz prostą listę z ilościami produktu
+ static public function get_product_quantity_list( int $product_id )
+ {
+ global $mdb;
+
+ return $mdb -> get( 'pp_shop_products', 'quantity', [ 'id' => $product_id ] );
+ }
+
+ // ADMIN - szczególy produktu
+ static public function product_details( int $product_id )
+ {
+ global $mdb;
+
+ if ( $product = $mdb -> get( 'pp_shop_products', '*', [ 'id' => $product_id ] ) )
+ {
+ $results = $mdb -> select( 'pp_shop_products_langs', '*', [ 'product_id' => $product_id ] );
+ if ( is_array( $results ) ) foreach ($results as $row)
+ $product['languages'][ $row['lang_id'] ] = $row;
+
+ $product['images'] = $mdb -> select( 'pp_shop_products_images', '*', [ 'product_id' => $product_id, 'ORDER' => [ 'o' => 'ASC', 'id' => 'ASC' ] ] );
+ $product['files'] = $mdb -> select( 'pp_shop_products_files', '*', [ 'product_id' => $product_id ] );
+ $product['categories'] = $mdb -> select( 'pp_shop_products_categories', 'category_id', [ 'product_id' => $product_id ] );
+ $product['attributes'] = $mdb -> select( 'pp_shop_products_attributes', [ 'attribute_id', 'value_id' ], [ 'product_id' => $product_id ] );
+ $product['products_related'] = $mdb -> select( 'pp_shop_products_related', 'product_related_id', [ 'product_id' => $product_id ] );
+ $product['custom_fields'] = $mdb -> select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $product_id ] );
+ }
+
+ return $product;
+ }
+
+ // duplikowanie produktu w panelu administratora
+ static public function duplicate_product( int $product_id, int $with_combinations = 0 )
+ {
+ global $mdb;
+
+ $product = $mdb -> get( 'pp_shop_products', '*', [ 'id' => $product_id ] );
+ if ( $product )
+ {
+ $mdb -> insert( 'pp_shop_products', [
+ 'price_netto' => $product['price_netto'],
+ 'price_brutto' => $product['price_brutto'],
+ 'price_netto_promo' => $product['price_netto_promo'],
+ 'price_brutto_promo' => $product['price_brutto_promo'],
+ 'vat' => $product['vat'],
+ 'promoted' => $product['promoted'],
+ 'layout_id' => $product['layout_id'],
+ 'new_to_date' => $product['new_to_date'],
+ 'stock_0_buy' => $product['stock_0_buy'],
+ 'wp' => $product['wp'],
+ 'custom_label_0' => $product['custom_label_0'],
+ 'custom_label_1' => $product['custom_label_1'],
+ 'custom_label_2' => $product['custom_label_2'],
+ 'custom_label_3' => $product['custom_label_3'],
+ 'custom_label_4' => $product['custom_label_4'],
+ 'additional_message' => $product['additional_message']
+ ] );
+
+ $new_product_id = $mdb -> id();
+ if ( $new_product_id )
+ {
+ $attributes = $mdb -> select( 'pp_shop_products_attributes', '*', [ 'product_id' => $product_id ] );
+ if ( \S::is_array_fix( $attributes ) ) foreach ( $attributes as $row )
+ {
+ $mdb -> insert( 'pp_shop_products_attributes', [
+ 'product_id' => $new_product_id,
+ 'attribute_id' => $row['attribute_id'],
+ 'value_id' => $row['value_id']
+ ] );
+ }
+
+ $categories = $mdb -> select( 'pp_shop_products_categories', '*', [ 'product_id' => $product_id ] );
+ if ( \S::is_array_fix( $categories ) ) foreach ( $categories as $row )
+ {
+ $mdb -> insert( 'pp_shop_products_categories', [
+ 'product_id' => $new_product_id,
+ 'category_id' => $row['category_id'],
+ 'o' => $row['o']
+ ] );
+ }
+
+ $langs = $mdb -> select( 'pp_shop_products_langs', '*', [ 'product_id' => $product_id ] );
+ if ( \S::is_array_fix( $langs ) ) foreach ( $langs as $row )
+ {
+ $mdb -> insert( 'pp_shop_products_langs', [
+ 'product_id' => $new_product_id,
+ 'lang_id' => $row['lang_id'],
+ 'name' => $row['name'] . ' - kopia',
+ 'short_description' => $row['short_description'],
+ 'description' => $row['description'],
+ 'tab_name_1' => $row['tab_name_1'],
+ 'tab_description_1' => $row['tab_description_1'],
+ 'tab_name_2' => $row['tab_name_2'],
+ 'tab_description_2' => $row['tab_description_2'],
+ 'meta_description' => $row['meta_description'],
+ 'meta_keywords' => $row['meta_keywords'],
+ 'copy_from' => $row['copy_from'],
+ 'warehouse_message_zero' => $row['warehouse_message_zero'],
+ 'warehouse_message_nonzero' => $row['warehouse_message_nonzero']
+ ] );
+ }
+
+ // custom fields
+ $custom_fields = $mdb -> select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $product_id ] );
+ if ( \S::is_array_fix( $custom_fields ) ) foreach ( $custom_fields as $row )
+ {
+ $mdb -> insert( 'pp_shop_products_custom_fields', [
+ 'id_product' => $new_product_id,
+ 'name' => $row['name']
+ ] );
+ }
+ }
+
+ // duplikowanie kombinacji produktu
+ if ( $with_combinations )
+ {
+ $product_combinations = $mdb -> select( 'pp_shop_products', '*', [ 'parent_id' => $product_id ] );
+ foreach ( $product_combinations as $product_combination )
+ {
+ $mdb -> insert( 'pp_shop_products', [
+ 'parent_id' => $new_product_id,
+ 'permutation_hash' => $product_combination['permutation_hash'],
+ 'price_netto' => $product_combination['price_netto'],
+ 'price_brutto' => $product_combination['price_brutto'],
+ 'price_netto_promo' => $product_combination['price_netto_promo'],
+ 'price_brutto_promo' => $product_combination['price_brutto_promo'],
+ 'vat' => $product_combination['vat'],
+ 'stock_0_buy' => $product_combination['stock_0_buy'],
+ 'quantity' => $product_combination['quantity'],
+ 'wp' => $product_combination['wp'],
+ 'additional_message' => $product_combination['additional_message'],
+ 'additional_message_text' => $product_combination['additional_message_text'],
+ 'additional_message_required' => $product_combination['additional_message_required']
+ ] );
+
+ $combination_id = $mdb -> id();
+ if ( $combination_id )
+ {
+ $pp_shop_products_attributes = $mdb -> select( 'pp_shop_products_attributes', '*', [ 'product_id' => $product_combination['id'] ] );
+ foreach ( $pp_shop_products_attributes as $pp_shop_products_attribute )
+ {
+ $mdb -> insert( 'pp_shop_products_attributes', [
+ 'product_id' => $combination_id,
+ 'attribute_id' => $pp_shop_products_attribute['attribute_id'],
+ 'value_id' => $pp_shop_products_attribute['value_id']
+ ] );
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+ return false;
+ }
+
+ //
+ // KOMBINACJE PRODUKTU
+ //
+
+ static public function product_combination_stock_0_buy_save( int $product_id, $stock_0_buy )
+ {
+ global $mdb;
+ return $mdb -> update( 'pp_shop_products', [ 'stock_0_buy' => $stock_0_buy == 'true' ? 1 : 0 ], [ 'id' => $product_id ] );
+ }
+
+ static public function product_combination_sku_save( int $product_id, $sku )
+ {
+ global $mdb;
+ return $mdb -> update( 'pp_shop_products', [ 'sku' => $sku ], [ 'id' => $product_id ] );
+ }
+
+ static public function product_combination_quantity_save( int $product_id, $quantity )
+ {
+ global $mdb;
+ return $mdb -> update( 'pp_shop_products', [ 'quantity' => $quantity == '' ? $quantity = null : $quantity = (int) $quantity ], [ 'id' => $product_id ] );
+ }
+
+ static public function product_combination_price_save( int $product_id, $price_netto )
+ {
+ global $mdb;
+
+ $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'id' => $product_id ] );
+
+ $price_brutto = $price_netto * ( 1 + ( $vat / 100 ) );
+
+ return $mdb -> update( 'pp_shop_products', [ 'price_netto' => $price_netto == '' ? $price_netto = null : $price_netto = (float) $price_netto, 'price_brutto' => $price_brutto == '' ? $price_brutto = null : $price_brutto = (float) $price_brutto ], [ 'id' => $product_id ] );
+ }
+
+ // aktualizacja ceny produktu pod wpływem zmiany ceny wartości atrybutu
+ static public function update_product_price_by_attribute_value_impact( $value_id, $impact_on_the_price )
+ {
+ global $mdb;
+
+ $products = $mdb -> select( 'pp_shop_products_attributes', [ 'product_id' ], [ 'value_id' => $value_id ] );
+ if ( is_array( $products ) ) foreach ( $products as $row )
+ {
+ $parent_id = $mdb -> get( 'pp_shop_products', 'parent_id', [ 'id' => $row['product_id'] ] );
+ $product = $mdb -> get( 'pp_shop_products', '*', [ 'id' => $parent_id ] );
+ if ( $product )
+ {
+ $price_brutto = $product['price_brutto'] + \S::normalize_decimal( $impact_on_the_price );
+ $price_netto = \S::normalize_decimal( $price_brutto / ( 1 + ( $product['vat'] / 100 ) ) );
+
+ if ( $product['price_netto_promo'] )
+ {
+ $price_brutto_promo = $product['price_brutto_promo'] + \S::normalize_decimal( $impact_on_the_price );
+ $price_netto_promo = \S::normalize_decimal( $price_brutto_promo / ( 1 + ( $product['vat'] / 100 ) ) );
+ }
+ else
+ {
+ $price_netto_promo = null;
+ $price_brutto_promo = null;
+ }
+
+ if ( $impact_on_the_price > 0 )
+ {
+ $mdb -> update( 'pp_shop_products', [ 'price_netto' => $price_netto, 'price_brutto' => $price_brutto, 'price_netto_promo' => $price_netto_promo, 'price_brutto_promo' => $price_brutto_promo, 'date_modify' => date( 'Y-m-d H:i:s' ) ], [ 'id' => $row['product_id'] ] );
+ }
+ else if ( isset( $impact_on_the_price ) && $impact_on_the_price == 0 && $impact_on_the_price != '' )
+ {
+ $mdb -> update( 'pp_shop_products', [ 'price_netto' => null, 'price_brutto' => null, 'price_netto_promo' => null, 'price_brutto_promo' => null, 'quantity' => null, 'stock_0_buy' => null, 'date_modify' => date( 'Y-m-d H:i:s' ) ], [ 'id' => $row['product_id'] ] );
+ }
+ }
+ }
+ }
+}
diff --git a/temp/update_build/tmp_0.275/docs/CHANGELOG.md b/temp/update_build/tmp_0.275/docs/CHANGELOG.md
new file mode 100644
index 0000000..2bc9e81
--- /dev/null
+++ b/temp/update_build/tmp_0.275/docs/CHANGELOG.md
@@ -0,0 +1,458 @@
+# Changelog shopPRO
+
+Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
+
+---
+
+## ver. 0.275 (2026-02-15) - ShopCategory
+
+- **ShopCategory** - migracja `/admin/shop_category/*` na Domain + DI + nowe endpointy AJAX
+ - NOWE: `Domain\Category\CategoryRepository` (`sortTypes`, `subcategories`, `categoryDetails`, `categoryProducts`, `save`, `categoryDelete`, `saveCategoriesOrder`, `saveProductOrder`, `categoryTitle`)
+ - NOWE: `admin\Controllers\ShopCategoryController` (DI) z akcjami `list/view_list`, `edit/category_edit`, `save`, `delete/category_delete`, `products/category_products`, `category_url_browser`, `save_categories_order`, `save_products_order`, `cookie_categories`
+ - UPDATE: routing DI (`admin\Site`) rozszerzony o modul `ShopCategory`
+ - UPDATE: menu admin przepiete na kanoniczny URL `/admin/shop_category/list/`
+ - UPDATE: widoki `shop-category/*` - wydzielenie skryptow do `*-custom-script.php`, ujednolicone strzalki drzewa (`button + caret + aria-expanded`)
+ - UPDATE: AJAX drzewek przepiety z `/admin/ajax.php?a=*` na `/admin/shop_category/*`
+ - UPDATE: zaleznosci `ShopProduct` przepiete z `admin\factory\ShopCategory` na `Domain\Category\CategoryRepository`
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopCategory.php`, `autoload/admin/factory/class.ShopCategory.php`, `autoload/admin/view/class.ShopCategory.php`
+ - CLEANUP: usuniety preload `class.ShopCategory.php` z `libraries/grid/config.php`
+- TEST:
+ - NOWE: `tests/Unit/Domain/Category/CategoryRepositoryTest.php`
+ - NOWE: `tests/Unit/admin/Controllers/ShopCategoryControllerTest.php`
+ - Testy punktowe: **OK (16 tests, 72 assertions)**
+
+---
+
+## ver. 0.274 (2026-02-15) - ShopProduct mass_edit + UI trees
+
+- **ShopProduct (mass_edit)** - migracja akcji masowej edycji na Domain + DI
+ - NOWE: `admin\Controllers\ShopProductController` (DI) z akcjami `mass_edit`, `mass_edit_save`, `get_products_by_category`
+ - UPDATE: routing DI (`admin\Site`) rozszerzony o modul `ShopProduct`
+ - UPDATE: `Domain\Product\ProductRepository` rozszerzone o metody `allProductsForMassEdit`, `getProductsByCategory`, `applyDiscountPercent` (+ aktualizacja cen kombinacji)
+ - CLEANUP: usuniete legacy akcje `mass_edit`, `mass_edit_save`, `get_products_by_category` z `admin\controls\ShopProduct`
+- **ShopProduct mass_edit UI** - przebudowa widoku i skryptu
+ - UPDATE: `admin/templates/shop-product/mass-edit.php` przepiety na nowy partial JS `mass-edit-custom-script`
+ - NOWE: `admin/templates/shop-product/mass-edit-custom-script.php` (nestedSortable + iCheck + stabilizacja drzewka)
+ - UPDATE: `admin/templates/shop-product/subcategories-list.php` ujednolicone strzalki (button + caret)
+ - FIX: zaznaczenie kategorii w drzewku nie zaznacza automatycznie produktow na liscie
+- **Pages / Articles UI** - ujednolicenie drzewek
+ - UPDATE: `/admin/pages/list/` - nowe strzalki drzewa + `aria-expanded` + odswiezanie stanu branch/leaf
+ - UPDATE: `/admin/articles/edit/*` (zakladka wyswietlania) - nowe strzalki i checkboxy (iCheck) dla drzewka stron
+- **ShopClients** - migracja `/admin/shop_clients` na Domain + DI + nowe widoki
+ - NOWE: `Domain\Client\ClientRepository` (`listForAdmin`, `ordersForClient`, `totalsForClient`)
+ - NOWE: `admin\Controllers\ShopClientsController` (DI) z akcjami `list`, `details` + aliasy legacy `view_list`, `clients_details`
+ - UPDATE: routing DI (`admin\Site`) rozszerzony o modul `ShopClients`
+ - UPDATE: menu admin przepiete na kanoniczny URL `/admin/shop_clients/list/`
+ - UPDATE: widoki `shop-clients/view-list` i `shop-clients/clients-details` przepiete na `components/table-list`
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopClients.php`, `autoload/admin/factory/class.ShopClients.php`
+- TEST:
+ - NOWE: `tests/Unit/admin/Controllers/ShopProductControllerTest.php`
+ - NOWE: `tests/Unit/Domain/Client/ClientRepositoryTest.php`, `tests/Unit/admin/Controllers/ShopClientsControllerTest.php`
+ - UPDATE: `tests/Unit/Domain/Product/ProductRepositoryTest.php` (nowe przypadki dla mass_edit)
+ - UPDATE: `tests/bootstrap.php` (stub `S::normalize_decimal()`)
+- Testy: **OK (361 tests, 1125 assertions)**
+
+---
+
+## ver. 0.273 (2026-02-15) - ShopProducer
+
+- **ShopProducer** - migracja `/admin/shop_producer` na Domain + DI + nowe widoki
+ - NOWE: `Domain\Producer\ProducerRepository` (`listForAdmin`, `find`, `save`, `delete`, `allProducers`, `findForFrontend`, `producerProducts`, `allActiveIds`)
+ - NOWE: `admin\Controllers\ShopProducerController` (DI) z akcjami `list`, `edit`, `save`, `delete`
+ - UPDATE: modul `/admin/shop_producer/*` dziala na `components/table-list` i `components/form-edit` z zakladkami jezykowymi (Opis + SEO)
+ - UPDATE: routing i menu admin na kanoniczny URL `/admin/shop_producer/list/`
+ - UPDATE: `shop\Producer` przepiety na fasade do `Domain\Producer\ProducerRepository`
+ - UPDATE: `admin\factory\ShopProduct` - 2 wywolania `admin\factory\ShopTransport` przepiete na `Domain\Transport\TransportRepository`
+ - UPDATE: `admin\controls\ShopProduct` - usuniety fallback do `admin\factory\Layouts`
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopProducer.php`, `admin/templates/shop-producer/list.php`, `admin/templates/shop-producer/edit.php`
+ - CLEANUP: usuniete 6 pustych factory facades: `admin\factory\Languages`, `admin\factory\Newsletter`, `admin\factory\Scontainers`, `admin\factory\ShopProducer`, `admin\factory\ShopTransport`, `admin\factory\Layouts`
+ - TEST: dodane `tests/Unit/Domain/Producer/ProducerRepositoryTest.php` i `tests/Unit/admin/Controllers/ShopProducerControllerTest.php`
+- Testy: **OK (338 tests, 1063 assertions)**
+
+---
+
+## ver. 0.272 (2026-02-15) - ShopProductSets
+
+- **ShopProductSets** - migracja `/admin/shop_product_sets` na Domain + DI + nowe widoki
+ - NOWE: `Domain\ProductSet\ProductSetRepository` (`listForAdmin`, `find`, `save`, `delete`, `allSets`, `allProductsMap`)
+ - NOWE: `admin\Controllers\ShopProductSetsController` (DI) z akcjami `list`, `edit`, `save`, `delete`
+ - UPDATE: modul `/admin/shop_product_sets/*` dziala na `components/table-list` i `components/form-edit` + Selectize multi-select produktow
+ - UPDATE: routing i menu admin na kanoniczny URL `/admin/shop_product_sets/list/`
+ - UPDATE: `shop\ProductSet` przepiety na fasade do `Domain\ProductSet\ProductSetRepository`
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopProductSets.php`, `autoload/admin/factory/class.ShopProductSet.php`, `admin/templates/shop-product-sets/view-list.php`, `admin/templates/shop-product-sets/set-edit.php`
+ - TEST: dodane `tests/Unit/Domain/ProductSet/ProductSetRepositoryTest.php` i `tests/Unit/admin/Controllers/ShopProductSetsControllerTest.php`
+- Testy: **OK (324 tests, 1000 assertions)**
+
+---
+
+## ver. 0.271 (2026-02-14) - ShopAttribute
+
+- **ShopAttribute** - migracja `/admin/shop_attribute` na Domain + DI + nowe widoki
+ - NOWE: `Domain\Attribute\AttributeRepository` (`listForAdmin`, `findAttribute`, `saveAttribute`, `deleteAttribute`, `findValues`, `saveValues`, `saveLegacyValues`, `valueDetails`)
+ - NOWE: `admin\Controllers\ShopAttributeController` (DI) z akcjami `list`, `edit`, `save`, `delete`, `values`, `values_save`, `value_row_tpl`
+ - UPDATE: modul `/admin/shop_attribute/*` dziala na `components/table-list` i `components/form-edit`
+ - UPDATE: nowy edytor wartosci cechy (`values-edit`) z walidacja serwerowa i stabilnym `row_key` (bez indeksow do wyboru domyslnej wartosci)
+ - UPDATE: routing i menu admin na kanoniczny URL `/admin/shop_attribute/list/` (bez aliasow legacy)
+ - UPDATE: przepiecie zaleznosci kombinacji produktu (`admin\controls\ShopProduct`, `admin\factory\ShopProduct`, `admin/templates/shop-product/product-combination.php`) na `Domain\Attribute\AttributeRepository` i `shop\ProductAttribute`
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopAttribute.php`, `autoload/admin/factory/class.ShopAttribute.php`, `autoload/admin/view/class.ShopAttribute.php`, `admin/templates/shop-attribute/_partials/value.php`
+ - TEST: dodane `tests/Unit/Domain/Attribute/AttributeRepositoryTest.php` i `tests/Unit/admin/Controllers/ShopAttributeControllerTest.php`
+- Testy: **OK (312 tests, 948 assertions)**
+
+---
+
+## ver. 0.270 (2026-02-14) - Apilo payment/status sync hardening
+
+- **Shop/Order + Apilo** - utwardzenie synchronizacji platnosci i statusow zamowien
+ - FIX: `shop\Order::set_as_paid()` wysyla do Apilo mapowany typ platnosci (`payment_method_id` -> `apilo_payment_type_id`) zamiast stalego `type = 1`
+ - NOWE: retry queue dla chwilowej niedostepnosci Apilo (`temp/apilo-sync-queue.json`) dla sync platnosci i statusu
+ - NOWE: `shop\Order::process_apilo_sync_queue()` przetwarza zalegle syncy
+ - UPDATE: `cron.php` uruchamia przetwarzanie kolejki sync Apilo przy aktywnej integracji
+ - UPDATE: rozszerzone logowanie debug (`logs/apilo.txt`) o HTTP code i bledy cURL dla sync platnosci/statusu
+- Testy: **OK (300 tests, 895 assertions)**
+
+---
+
+## ver. 0.269 (2026-02-14) - ShopTransport
+
+- **ShopTransport** - migracja `/admin/shop_transport` na Domain + DI + nowe widoki
+ - NOWE: `Domain\Transport\TransportRepository` (`listForAdmin`, `find`, `save`, `allActive`, `allForAdmin`, `findActiveById`, `getTransportCost`, `lowestTransportPrice`, `getApiloCarrierAccountId`)
+ - NOWE: `admin\Controllers\ShopTransportController` (DI) z akcjami `list`, `edit`, `save`
+ - NOWE: widoki `shop-transport/transports-list.php` i `shop-transport/transport-edit.php` + `transport-edit-custom-script.php`
+ - UPDATE: routing i menu admin na kanoniczny URL `/admin/shop_transport/list/`
+ - UPDATE: `admin\factory\ShopTransport`, `front\factory\ShopTransport` przepiete na nowe repozytorium
+ - FIX: `save()` return type `?int` zamiast `int|bool` (spojnosc z PaymentMethod)
+ - FIX: `toSwitchValue()` helper zamiast `=== 'on'` (obsluga '1', 'on', 'true', 'yes')
+ - FIX: `\S::delete_dir()` przeniesione z repozytorium do kontrolera (DDD)
+ - FIX: Medoo `select()` syntax - ORDER w WHERE zamiast 4-arg form
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopTransport.php`, `autoload/admin/view/class.ShopTransport.php`, `admin/templates/shop-transport/view-list.php`
+ - FIX: `transports-list.php` - zmienna `'viewModel'` zmieniona na `'list'` (zgodnie z `table-list.php` komponentem)
+- Testy: **OK (300 tests, 895 assertions)**
+
+---
+
+## ver. 0.268 (2026-02-14) - ShopPaymentMethod + Apilo token keepalive
+
+- **ShopPaymentMethod** - migracja `/admin/shop_payment_method` na Domain + DI + nowe widoki
+ - NOWE: `Domain\PaymentMethod\PaymentMethodRepository` (`listForAdmin`, `find`, `save`, `allActive`, `allForAdmin`, `findActiveById`, `isActive`, `getApiloPaymentTypeId`, `forTransport`)
+ - NOWE: `admin\Controllers\ShopPaymentMethodController` (DI) z akcjami `list`, `edit`, `save`
+ - NOWE: widoki `shop-payment-method/payment-methods-list.php` i `shop-payment-method/payment-method-edit.php`
+ - UPDATE: routing i menu admin na kanoniczny URL `/admin/shop_payment_method/list/`
+ - UPDATE: `admin\controls\ShopTransport`, `front\factory\ShopPaymentMethod`, `shop\PaymentMethod` przepiete na nowe repozytorium
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopPaymentMethod.php`, `autoload/admin/factory/class.ShopPaymentMethod.php`, `autoload/admin/view/class.ShopPaymentMethod.php`, `admin/templates/shop-payment-method/view-list.php`
+- **Integrations/Apilo** - stabilizacja tokenu i lepszy feedback
+ - NOWE: automatyczne odswiezanie tokenu Apilo przed wygasnieciem (`apiloKeepalive`, refresh lead time)
+ - UPDATE: cron uruchamia keepalive i odswieza konfiguracje Apilo
+ - UPDATE: bardziej szczegolowe komunikaty bledow dla przyciskow integracji Apilo (co zrobic dalej)
+- Testy: **OK (280 tests, 828 assertions)**
+
+---
+
+## ver. 0.267 (2026-02-14) - ShopStatuses
+
+- **ShopStatuses** - migracja `/admin/shop_statuses` na Domain + DI + nowe widoki
+ - NOWE: `Domain\ShopStatus\ShopStatusRepository` (`listForAdmin`, `find`, `save`, `getApiloStatusId`, `getByIntegrationStatusId`, `allStatuses`)
+ - NOWE: `admin\Controllers\ShopStatusesController` (DI) z akcjami `list`, `edit`, `save` (bez aliasow legacy)
+ - NOWE: typ pola `FormFieldType::COLOR` + `FormField::color()` + `FormFieldRenderer::renderColor()` (color picker HTML5 zsynchronizowany z polem tekstowym)
+ - UPDATE: modul `/admin/shop_statuses/*` dziala na `components/table-list` i `components/form-edit`
+ - UPDATE: `front\factory\ShopStatuses` jako fasada delegujaca do `Domain\ShopStatus\ShopStatusRepository`
+ - UPDATE: menu admin przepiete na kanoniczny URL `/admin/shop_statuses/list/`
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopStatuses.php`, `autoload/admin/factory/class.ShopStatuses.php`
+ - UWAGA: statusy maja ID od 0 - kluczowe dla walidacji (find/save uzywaja `$id < 0`)
+- Testy: **OK (254 tests, 736 assertions)**
+
+---
+
+## ver. 0.266 (2026-02-13) - ShopCoupon
+
+- **ShopCoupon** - migracja `/admin/shop_coupon` na Domain + DI + nowe widoki
+ - NOWE: `Domain\Coupon\CouponRepository` (`listForAdmin`, `find`, `save`, `delete`, `categoriesTree`)
+ - NOWE: `admin\Controllers\ShopCouponController` (DI) z akcjami `list`, `edit`, `save`, `delete`
+ - UPDATE: kompatybilnosc aliasow legacy (`view_list`, `coupon_edit`, `coupon_save`, `coupon_delete`) obslugiwana przez nowy kontroler
+ - UPDATE: modul `/admin/shop_coupon/*` dziala na `components/table-list` i `components/form-edit`
+ - NOWE: widoki/partiale `shop-coupon/coupons-list`, `shop-coupon/coupon-edit-new`, `shop-coupon/coupon-categories-selector`, `shop-coupon/coupon-categories-tree`, `shop-coupon/coupon-edit-custom-script`
+ - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopCoupon.php`, `autoload/admin/factory/class.ShopCoupon.php`, `admin/templates/shop-coupon/view-list.php`, `admin/templates/shop-coupon/coupon-edit.php`
+ - UPDATE: menu admin przepiete na kanoniczny URL `/admin/shop_coupon/list/`
+ - FIX: ujednolicone UI drzewek i checkboxow miedzy kuponami i layoutami
+- Testy: **OK (235 tests, 682 assertions)**
+
+---
+
+## ver. 0.265 (2026-02-13) - ShopPromotion poprawki
+
+- **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: **OK (222 tests, 614 assertions)**
+
+---
+
+## ver. 0.264 (2026-02-13) - ShopPromotion
+
+- **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: **OK (222 tests, 609 assertions)**
+
+---
+
+## ver. 0.263 (2026-02-13) - Integrations + cleanup Sellasist/Baselinker
+
+- NOWE: `Domain\Integrations\IntegrationsRepository` (settings Apilo/ShopPRO, OAuth, product linking, API fetch)
+- NOWE: `admin\Controllers\IntegrationsController` (DI) dla akcji Apilo i ShopPRO
+- UPDATE: `admin\factory\Integrations` jako fasada delegujaca do repozytorium
+- **CLEANUP: usunieto integracje Sellasist i Baselinker z calego projektu:**
+ - Usuniete klasy: `admin\controls\Integrations`, `admin\controls\Baselinker`, `admin\factory\Baselinker`, `front\factory\Shop`, `shop\ShopStatus`
+ - Usuniete szablony: `integrations/sellasist-settings.php`, `integrations/baselinker-settings.php`, `admin/templates/baselinker/`
+ - Wyczyszczone referencje w: `cron.php`, `cron/cron-xml.php`, `shop\Order`, kontrolery/factory/front Shop*
+- Testy: **OK (212 tests, 577 assertions)**
+
+---
+
+## ver. 0.262 (2026-02-13) - Pages
+
+- NOWE: `Domain\Pages\PagesRepository` (CRUD menu/stron, drzewo stron, sortowanie, SEO)
+- NOWE: `admin\Controllers\PagesController` (DI) dla akcji menu/page/AJAX
+- UPDATE: widoki `admin/templates/pages/*` przepiete na dane z kontrolera/repozytorium
+- UPDATE: endpointy AJAX przepiete z `admin/ajax.php?a=*` na `/admin/pages/*`
+- CLEANUP: usuniete legacy `controls/Pages`, `view/Pages`, `factory/Pages`, `ajax/pages.php`
+- Testy: **OK (186 tests, 478 assertions)**
+
+---
+
+## ver. 0.261 (2026-02-13) - Articles (dalsza refaktoryzacja)
+
+- UPDATE: `Domain\Article\ArticleRepository` rozszerzone o metody UI/admin i `saveFilesOrder()`
+- UPDATE: `admin\Controllers\ArticlesController` obsluguje AJAX: `article_image_alt_change`, `article_file_name_change`, `article_image_delete`, `article_file_delete`, `filesOrderSave`
+- UPDATE: lista artykulow nie korzysta juz z `admin\factory\Articles::article_pages()`
+- UPDATE: widok edycji przepiety z `/admin/ajax.php` na `/admin/articles/*`
+- UPDATE: drag&drop sortowania listy zalacznikow
+- CLEANUP: usuniete `autoload/admin/view/class.Articles.php` i `admin/ajax/articles.php`
+- Testy: **OK (178 tests, 443 assertions)**
+
+---
+
+## ver. 0.260 (2026-02-12) - ArticlesArchive
+
+- NOWE: `admin\Controllers\ArticlesArchiveController` (DI)
+- UPDATE: `Domain\Article\ArticleRepository` rozszerzone o `listArchivedForAdmin()`, `restore()`, `deletePermanently()`
+- UPDATE: `/admin/articles_archive/view_list/` migrowane na `components/table-list`
+- CLEANUP: usuniete legacy `controls/ArticlesArchive`, `factory/ArticlesArchive`, `view/ArticlesArchive`
+- Testy: **OK (165 tests, 424 assertions)**
+
+---
+
+## ver. 0.259 (2026-02-12) - Scontainers
+
+- NOWE: `Domain\Scontainers\ScontainersRepository` (listForAdmin, find, save, delete, detailsForLanguage)
+- NOWE: `admin\Controllers\ScontainersController` (DI)
+- UPDATE: `/admin/scontainers/*` migrowane na `components/table-list` i `components/form-edit`
+- UPDATE: `admin\factory\Scontainers` i `front\factory\Scontainers` jako fasady
+- CLEANUP: usuniete `controls/Scontainers`, `view/Scontainers`
+- Testy: **OK (158 tests, 397 assertions)**
+
+---
+
+## ver. 0.258 (2026-02-12) - Newsletter (stabilizacja)
+
+- UPDATE: tymczasowo wylaczono flow `prepare/send/preview` (wymaga przebudowy)
+- UPDATE: tymczasowo wylaczono modul `Szablony uzytkownika`
+- UPDATE: aktywna obsluga tylko szablonow administracyjnych (`is_admin = 1`)
+- CLEANUP: usuniete nieuzywane widoki `prepare.php`, `preview.php`, `email-templates-user.php`
+
+---
+
+## ver. 0.257 (2026-02-12) - Newsletter
+
+- NOWE: `Domain\Newsletter\NewsletterRepository` (subskrybenci, szablony, ustawienia, kolejka wysylki)
+- NOWE: `Domain\Newsletter\NewsletterPreviewRenderer` (render podgladu)
+- NOWE: `admin\Controllers\NewsletterController` (DI)
+- UPDATE: `/admin/newsletter/*` migrowane na `components/table-list` i `components/form-edit`
+- UPDATE: `admin\factory\Newsletter` jako fasada; `front\factory\Newsletter` bez `admin\view\Newsletter`
+- CLEANUP: usuniete `controls/Newsletter`, `view/Newsletter`
+- Testy: **OK (150 tests, 372 assertions)**
+
+---
+
+## ver. 0.256 (2026-02-12) - Layouts
+
+- NOWE: `Domain\Layouts\LayoutsRepository` (find, save, delete, listForAdmin, menusWithPages, categoriesTree)
+- NOWE: `admin\Controllers\LayoutsController` (DI)
+- UPDATE: lista `/admin/layouts/view_list/` migrowana na `components/table-list`
+- UPDATE: widok `layouts/layout-edit` korzysta z danych z repozytorium
+- NOWE: partial `admin/templates/layouts/subcategories-list.php`
+- UPDATE: `Domain\Languages\LanguagesRepository::defaultLanguageId()` jako wspolna metoda
+- UPDATE: `ArticlesController` korzysta z `LayoutsRepository` (DI)
+- CLEANUP: usuniete `controls/Layouts`, `view/Layouts`; `factory/Layouts` jako fasada
+- Testy: **OK (141 tests, 336 assertions)**
+
+---
+
+## ver. 0.255 (2026-02-12) - Languages DI cleanup
+
+- UPDATE: SettingsController, BannerController, DictionariesController, ArticlesController pobieraja liste jezykow przez `Domain/Languages/LanguagesRepository` (DI)
+- UPDATE: router DI przekazuje `LanguagesRepository` do kontrolerow
+- UPDATE: legacy `admin/controls`, `admin/factory/Shop*` przepiete na `LanguagesRepository`
+- FIX: `admin/factory/class.Languages.php` poprawione na ` | Kolumny dynamiczne per jezyk (np. pl, en) |
+
+**Uzywane w:** `Domain\\Languages\\LanguagesRepository`, `admin\\Controllers\\LanguagesController`, `front\\factory\\Languages`
+
+**Aktualizacja 2026-02-12:** modul jezykow i tlumaczen (`pp_langs`, `pp_langs_translations`) obslugiwany przez `Domain\\Languages\\LanguagesRepository`.
+
+## pp_layouts
+Szablony layoutow (HTML/CSS/JS + flagi domyslne).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Nazwa szablonu |
+| html | Kod HTML |
+| css | Kod CSS |
+| js | Kod JS |
+| m_html | Kod HTML mobilny |
+| m_css | Kod CSS mobilny |
+| m_js | Kod JS mobilny |
+| status | Domyslny layout stron (0/1) |
+| categories_default | Domyslny layout kategorii (0/1) |
+
+**Uzywane w:** `Domain\\Layouts\\LayoutsRepository`, `admin\\Controllers\\LayoutsController`, `front\\factory\\Layouts`
+
+## pp_layouts_pages
+Przypisanie layoutow do stron CMS.
+
+| Kolumna | Opis |
+|---------|------|
+| layout_id | FK do pp_layouts |
+| page_id | FK do pp_pages |
+
+**Uzywane w:** `Domain\\Layouts\\LayoutsRepository`, `front\\factory\\Layouts`
+
+## pp_layouts_categories
+Przypisanie layoutow do kategorii sklepu.
+
+| Kolumna | Opis |
+|---------|------|
+| layout_id | FK do pp_layouts |
+| category_id | FK do pp_shop_categories |
+
+**Uzywane w:** `Domain\\Layouts\\LayoutsRepository`, `front\\factory\\Layouts`
+
+**Aktualizacja 2026-02-12 (ver. 0.256):** modul `/admin/layouts` korzysta z `Domain\\Layouts\\LayoutsRepository` (DI kontroler + fasada legacy).
+
+## pp_newsletter
+Adresy e-mail zapisane do newslettera.
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| email | Adres e-mail subskrybenta |
+| hash | Hash potwierdzenia/wypisu |
+| status | 1 = potwierdzony, 0 = oczekujacy |
+
+**Uzywane w:** `Domain\\Newsletter\\NewsletterRepository`, `front\\factory\\Newsletter`
+
+## pp_newsletter_send
+Kolejka wysylki newslettera.
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| email | Adres docelowy |
+| dates | Zakres dat artykulow (tekst) |
+| id_template | FK do `pp_newsletter_templates` (NULL gdy brak szablonu) |
+
+**Uzywane w:** `Domain\\Newsletter\\NewsletterRepository`, `front\\factory\\Newsletter::newsletter_send()`
+
+## pp_newsletter_templates
+Szablony tresci e-maili (uzytkownik + administracyjne/systemowe).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Nazwa/klucz szablonu |
+| text | Tresc HTML szablonu |
+| is_admin | 1 = szablon administracyjny/systemowy, 0 = szablon uzytkownika |
+
+**Uzywane w:** `Domain\\Newsletter\\NewsletterRepository`, `admin\\Controllers\\NewsletterController`, `front\\factory\\Newsletter`
+
+**Aktualizacja 2026-02-12 (ver. 0.257):** modul `/admin/newsletter` korzysta z `Domain\\Newsletter\\NewsletterRepository` (DI kontroler + fasada legacy).
+
+## pp_scontainers
+Kontenery statyczne (modul /admin/scontainers).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| status | 1 = aktywny, 0 = nieaktywny |
+| show_title | 1 = pokaz tytul, 0 = ukryj tytul |
+
+**Uzywane w:** `Domain\Scontainers\ScontainersRepository`, `admin\Controllers\ScontainersController`, `front\factory\Scontainers`
+
+## pp_scontainers_langs
+Tlumaczenia kontenerow statycznych (per jezyk).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| container_id | FK do pp_scontainers |
+| lang_id | ID jezyka (np. pl, en) |
+| title | Tytul kontenera |
+| text | Tresc HTML kontenera |
+
+**Uzywane w:** `Domain\Scontainers\ScontainersRepository`, `front\factory\Scontainers`
+
+**Aktualizacja 2026-02-12 (ver. 0.259):** modul `/admin/scontainers` korzysta z `Domain\Scontainers\ScontainersRepository` (DI kontroler + fasada legacy).
+
+**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_attributes
+Cechy produktu (modul `/admin/shop_attribute`).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| status | Status: 1 = aktywny, 0 = nieaktywny |
+| type | Typ cechy: 0 = tekst, 1 = kolor, 2 = wzor |
+| o | Kolejnosc wyswietlania |
+
+**Uzywane w:** `Domain\Attribute\AttributeRepository`, `admin\Controllers\ShopAttributeController`, `admin\controls\ShopProduct`, `admin\factory\ShopProduct`
+
+## pp_shop_attributes_langs
+Tlumaczenia cech produktu (per jezyk).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| attribute_id | FK do pp_shop_attributes |
+| lang_id | ID jezyka (np. pl, en) |
+| name | Nazwa cechy |
+
+**Uzywane w:** `Domain\Attribute\AttributeRepository`, `shop\ProductAttribute`
+
+## pp_shop_attributes_values
+Wartosci cech produktu.
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| attribute_id | FK do pp_shop_attributes |
+| is_default | Czy wartosc domyslna dla cechy (0/1) |
+| impact_on_the_price | Wplyw na cene wariantu (NULL = brak) |
+
+**Uzywane w:** `Domain\Attribute\AttributeRepository`, `admin\Controllers\ShopAttributeController`, `admin\factory\ShopProduct`
+
+## pp_shop_attributes_values_langs
+Tlumaczenia wartosci cech (per jezyk).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| value_id | FK do pp_shop_attributes_values |
+| lang_id | ID jezyka (np. pl, en) |
+| name | Nazwa wyswietlana |
+| value | Wewnetrzna wartosc techniczna (opcjonalna) |
+
+**Uzywane w:** `Domain\Attribute\AttributeRepository`, `shop\ProductAttribute`
+
+## pp_shop_products_attributes
+Powiazanie kombinacji produktow z wartosciami cech.
+
+| Kolumna | Opis |
+|---------|------|
+| product_id | FK do pp_shop_products (kombinacja) |
+| value_id | FK do pp_shop_attributes_values |
+
+**Uzywane w:** `Domain\Attribute\AttributeRepository::refreshCombinationPricesForValue()`, `admin\controls\ShopProduct`, `admin\factory\ShopProduct`
+
+**Aktualizacja 2026-02-14 (ver. 0.271):** modul `/admin/shop_attribute` korzysta z `Domain\Attribute\AttributeRepository` przez `admin\Controllers\ShopAttributeController`. Usunieto legacy klasy `admin\controls\ShopAttribute`, `admin\factory\ShopAttribute`, `admin\view\ShopAttribute`.
+
+## pp_shop_coupon
+Kody rabatowe sklepu (modul `/admin/shop_coupon`).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Kod kuponu (UNIQUE) |
+| status | Status: 1 = aktywny, 0 = nieaktywny |
+| send | Czy kupon zostal wyslany (0/1) |
+| used | Czy kupon zostal wykorzystany (0/1) |
+| date_used | Data wykorzystania kuponu (NULL gdy brak) |
+| used_count | Licznik uzyc kuponu |
+| type | Typ kuponu (obecnie: 1 = rabat procentowy na koszyk) |
+| amount | Wartosc kuponu (np. procent) |
+| one_time | Czy kupon jednorazowy (0/1) |
+| include_discounted_product | Czy obejmuje rowniez produkty przecenione (0/1) |
+| categories | JSON z ID kategorii objetych kuponem (NULL = bez ograniczenia) |
+
+**Uzywane w:** `Domain\Coupon\CouponRepository`, `admin\Controllers\ShopCouponController`, `shop\Coupon`, `front\factory\ShopCoupon`, `front\factory\ShopOrder`
+
+**Aktualizacja 2026-02-13 (ver. 0.266):** modul `/admin/shop_coupon` korzysta z `Domain\Coupon\CouponRepository` przez `admin\Controllers\ShopCouponController`. Usunieto legacy klasy `admin\controls\ShopCoupon` i `admin\factory\ShopCoupon`.
+
+## 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_payment_methods
+Metody platnosci sklepu (modul `/admin/shop_payment_method`).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Nazwa metody platnosci |
+| description | Opis metody platnosci (wyswietlany m.in. w checkout) |
+| status | Status: 1 = aktywna, 0 = nieaktywna |
+| apilo_payment_type_id | ID typu platnosci Apilo (NULL gdy brak mapowania) |
+| sellasist_payment_type_id | DEPRECATED (integracja Sellasist usunieta w ver. 0.263) |
+
+**Uzywane w:** `Domain\PaymentMethod\PaymentMethodRepository`, `admin\Controllers\ShopPaymentMethodController`, `front\factory\ShopPaymentMethod`, `shop\PaymentMethod`, `admin\controls\ShopTransport`, `cron.php`
+
+**Aktualizacja 2026-02-14 (ver. 0.268):** modul `/admin/shop_payment_method` korzysta z `Domain\PaymentMethod\PaymentMethodRepository` przez `admin\Controllers\ShopPaymentMethodController`. Usunieto legacy klasy `admin\controls\ShopPaymentMethod`, `admin\factory\ShopPaymentMethod`, `admin\view\ShopPaymentMethod` oraz widok `admin/templates/shop-payment-method/view-list.php`.
+
+## pp_shop_transports
+Rodzaje transportu sklepu (modul `/admin/shop_transport`).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Nazwa (systemowa, readonly) |
+| name_visible | Nazwa widoczna dla klienta |
+| description | Opis metody transportu |
+| status | Status: 1 = aktywny, 0 = nieaktywny |
+| cost | Koszt dostawy (PLN) |
+| max_wp | Maksymalna waga paczki (NULL = bez limitu) |
+| default | Domyslna forma dostawy (0/1) |
+| delivery_free | Czy obsluguje darmowa dostawe (0/1) |
+| apilo_carrier_account_id | ID konta przewoznika w Apilo (NULL gdy brak mapowania) |
+| o | Kolejnosc wyswietlania |
+
+**Uzywane w:** `Domain\Transport\TransportRepository`, `admin\Controllers\ShopTransportController`, `front\factory\ShopTransport`
+
+## pp_shop_transport_payment_methods
+Powiazanie metod transportu z metodami platnosci (tabela lacznikowa).
+
+| Kolumna | Opis |
+|---------|------|
+| id_transport | FK do pp_shop_transports |
+| id_payment_method | FK do pp_shop_payment_methods |
+
+**Uzywane w:** `Domain\Transport\TransportRepository`, `Domain\PaymentMethod\PaymentMethodRepository::forTransport()`
+
+**Aktualizacja 2026-02-14 (ver. 0.269):** modul `/admin/shop_transport` korzysta z `Domain\Transport\TransportRepository` przez `admin\Controllers\ShopTransportController`. Usunieto legacy klasy `admin\controls\ShopTransport`, `admin\view\ShopTransport` oraz widok `admin/templates/shop-transport/view-list.php`.
+
+## pp_shop_apilo_settings
+Ustawienia integracji Apilo (key-value).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Klucz ustawienia (np. client-id, access-token) |
+| value | Wartosc ustawienia |
+
+**Uzywane w:** `Domain\Integrations\IntegrationsRepository`, `admin\Controllers\IntegrationsController`, `admin\factory\Integrations`
+
+## pp_shop_shoppro_settings
+Ustawienia integracji ShopPRO (key-value).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Klucz ustawienia (np. domain, db_name) |
+| value | Wartosc ustawienia |
+
+**Uzywane w:** `Domain\Integrations\IntegrationsRepository`, `admin\Controllers\IntegrationsController`, `admin\factory\Integrations`
+
+**Aktualizacja 2026-02-13:** modul `/admin/integrations/` korzysta z `Domain\Integrations\IntegrationsRepository` (DI kontroler + fasada legacy). Usunieto integracje Sellasist i Baselinker.
+
+## pp_shop_statuses
+Statusy zamowien sklepu (modul `/admin/shop_statuses`). Statusy sa predefiniowane - brak dodawania/usuwania, mozliwa edycja koloru i mapowania Apilo.
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK (zaczyna sie od 0!) |
+| status | Nazwa statusu (read-only) |
+| color | Kolor statusu (hex, np. #ff0000) |
+| o | Kolejnosc wyswietlania |
+| apilo_status_id | ID statusu w Apilo (NULL gdy brak mapowania) |
+| baselinker_status_id | DEPRECATED (usuniety w ver. 0.263) |
+| sellasist_status_id | DEPRECATED (usuniety w ver. 0.263) |
+
+**Uzywane w:** `Domain\ShopStatus\ShopStatusRepository`, `admin\Controllers\ShopStatusesController`, `front\factory\ShopStatuses`, `shop\Order`, `cron.php`
+
+**Aktualizacja 2026-02-14 (ver. 0.267):** modul `/admin/shop_statuses` korzysta z `Domain\ShopStatus\ShopStatusRepository` przez `admin\Controllers\ShopStatusesController`. Usunieto legacy klasy `admin\controls\ShopStatuses` i `admin\factory\ShopStatuses`. `front\factory\ShopStatuses` dziala jako fasada do repozytorium.
+
+## pp_shop_product_sets
+Komplety produktow (modul `/admin/shop_product_sets`).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Nazwa kompletu |
+| status | Status: 1 = aktywny, 0 = nieaktywny |
+
+**Uzywane w:** `Domain\ProductSet\ProductSetRepository`, `admin\Controllers\ShopProductSetsController`, `shop\ProductSet`, `shop\Product`
+
+## pp_shop_product_sets_products
+Powiazanie kompletow z produktami (tabela lacznikowa).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| set_id | FK do pp_shop_product_sets |
+| product_id | FK do pp_shop_products |
+
+**Uzywane w:** `Domain\ProductSet\ProductSetRepository`, `shop\Product`, `front\factory\ShopProduct`, `admin\factory\ShopProduct`
+
+**Aktualizacja 2026-02-15 (ver. 0.272):** modul `/admin/shop_product_sets` korzysta z `Domain\ProductSet\ProductSetRepository` przez `admin\Controllers\ShopProductSetsController`. Usunieto legacy klasy `admin\controls\ShopProductSets` i `admin\factory\ShopProductSet`. `shop\ProductSet` dziala jako fasada do repozytorium.
+
+## pp_shop_producer
+Producenci produktow (modul `/admin/shop_producer`).
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| name | Nazwa producenta |
+| status | Status: 1 = aktywny, 0 = nieaktywny |
+| img | Sciezka do logo producenta (NULL gdy brak) |
+
+**Uzywane w:** `Domain\Producer\ProducerRepository`, `admin\Controllers\ShopProducerController`, `shop\Producer`, `shop\Product`, `front\controls\ShopProducer`
+
+## pp_shop_producer_lang
+Tlumaczenia producentow (per jezyk). FK kaskadowe ON DELETE CASCADE.
+
+| Kolumna | Opis |
+|---------|------|
+| id | PK |
+| producer_id | FK do pp_shop_producer |
+| lang_id | ID jezyka (np. pl, en) |
+| description | Opis producenta (TEXT) |
+| data | Dane producenta (TEXT, HTML) |
+| meta_title | Meta title SEO (VARCHAR 255) |
+
+**Uzywane w:** `Domain\Producer\ProducerRepository`, `shop\Producer`, `shop\Product`
+
+**Aktualizacja 2026-02-15 (ver. 0.273):** modul `/admin/shop_producer` korzysta z `Domain\Producer\ProducerRepository` przez `admin\Controllers\ShopProducerController`. Usunieto legacy `admin\controls\ShopProducer` i `admin\factory\ShopProducer`. `shop\Producer` dziala jako fasada do repozytorium.
diff --git a/temp/update_build/tmp_0.275/docs/PROJECT_STRUCTURE.md b/temp/update_build/tmp_0.275/docs/PROJECT_STRUCTURE.md
new file mode 100644
index 0000000..f3de582
--- /dev/null
+++ b/temp/update_build/tmp_0.275/docs/PROJECT_STRUCTURE.md
@@ -0,0 +1,338 @@
+# Struktura Projektu shopPRO
+
+Dokumentacja struktury projektu shopPRO do szybkiego odniesienia.
+
+## System Cache (Redis)
+
+### Klasy odpowiedzialne za cache
+
+#### RedisConnection
+- **Plik:** `autoload/class.RedisConnection.php`
+- **Opis:** Singleton zarządzający połączeniem z Redis
+- **Metody:**
+ - `getInstance()` - pobiera instancję połączenia
+ - `getConnection()` - zwraca obiekt Redis
+
+#### CacheHandler
+- **Plik:** `autoload/class.CacheHandler.php`
+- **Opis:** Handler do obsługi cache Redis
+- **Metody:**
+ - `get($key)` - pobiera wartość z cache
+ - `set($key, $value, $ttl = 86400)` - zapisuje wartość do cache
+ - `exists($key)` - sprawdza czy klucz istnieje
+ - `delete($key)` - usuwa pojedynczy klucz
+ - `deletePattern($pattern)` - usuwa klucze według wzorca
+
+#### Klasa S (pomocnicza)
+- **Plik:** `autoload/class.S.php`
+- **Metody cache:**
+ - `clear_redis_cache()` - czyści cały cache Redis (flushAll)
+ - `clear_product_cache(int $product_id)` - czyści cache konkretnego produktu
+
+### Wzorce kluczy Redis
+
+#### Produkty
+```
+shop\product:{product_id}:{lang_id}:{permutation_hash}
+```
+- Przechowuje zserializowany obiekt produktu
+- TTL: 24 godziny (86400 sekund)
+- Klasa: `shop\Product::getFromCache()` - `autoload/shop/class.Product.php:121`
+
+#### Opcje ilościowe produktu
+```
+\shop\Product::get_product_permutation_quantity_options:{product_id}:{permutation}
+```
+- Przechowuje informacje o ilości i komunikatach magazynowych
+- Klasa: `shop\Product::get_product_permutation_quantity_options()` - `autoload/shop/class.Product.php:549`
+
+#### Zestawy produktów
+```
+\shop\Product::product_sets_when_add_to_basket:{product_id}
+```
+- Przechowuje produkty często kupowane razem
+- Klasa: `shop\Product::product_sets_when_add_to_basket()` - `autoload/shop/class.Product.php:316`
+
+## Integracje z systemami zewnętrznymi (CRON)
+
+### Plik: `cron.php`
+
+#### Apilo
+- **Aktualizacja pojedynczego produktu:** synchronizacja cen i stanow
+ - Czestotliwosc: Co 10 minut
+- **Synchronizacja cennika:** masowa aktualizacja cen z Apilo
+ - Czestotliwosc: Co 1 godzine
+- **Synchronizacja zaleglych syncow platnosci/statusow:** kolejka retry dla chwilowej niedostepnosci Apilo (`temp/apilo-sync-queue.json`)
+ - Przetwarzanie: przy kazdym uruchomieniu `cron.php` (limit wsadowy)
+
+**Uwaga:** Integracje Sellasist i Baselinker zostaly usuniete w ver. 0.263.
+
+## Panel Administratora
+
+### Routing
+- Główny katalog: `admin/`
+- Template główny: `admin/templates/site/main-layout.php`
+- Kontrolery (nowe): `autoload/admin/Controllers/`
+- Kontrolery legacy (fallback): `autoload/admin/controls/`
+
+### Przycisk "Wyczyść cache"
+- **Lokalizacja UI:** `admin/templates/site/main-layout.php:172`
+- **JavaScript:** `admin/templates/site/main-layout.php:235-274`
+- **Endpoint AJAX:** `/admin/settings/clear_cache_ajax/`
+- **Kontroler:** `autoload/admin/Controllers/SettingsController.php:43-60`
+- **Działanie:**
+ 1. Pokazuje spinner "Czyszczę cache..."
+ 2. Czyści katalogi: `temp/`, `thumbs/`
+ 3. Wykonuje `flushAll()` na Redis
+ 4. Pokazuje "Cache wyczyszczony!" przez 2 sekundy
+ 5. Przywraca stan początkowy
+
+## Struktura katalogów
+
+```
+shopPRO/
+├── admin/ # Panel administratora
+│ ├── templates/ # Szablony widoków
+│ └── layout/ # Zasoby CSS/JS/ikony
+├── autoload/ # Klasy autoloadowane
+│ ├── admin/ # Klasy panelu admin
+│ │ ├── Controllers/ # Nowe kontrolery DI
+│ │ ├── controls/ # Kontrolery legacy (fallback)
+│ │ └── factory/ # Fabryki/helpery
+│ ├── Domain/ # Repozytoria/logika domenowa
+│ ├── front/ # Klasy frontendu
+│ │ └── factory/ # Fabryki/helpery
+│ └── shop/ # Klasy sklepu
+├── docs/ # Dokumentacja techniczna
+├── libraries/ # Biblioteki zewnętrzne
+├── temp/ # Cache tymczasowy
+├── thumbs/ # Miniatury zdjęć
+└── cron.php # Zadania CRON
+```
+
+## Baza danych
+
+### Główne tabele produktów
+- `pp_shop_products` - produkty główne
+- `pp_shop_products_langs` - tłumaczenia produktów
+- `pp_shop_products_images` - zdjęcia produktów
+- `pp_shop_products_categories` - kategorie produktów
+- `pp_shop_products_custom_fields` - pola własne produktów
+
+### Tabele integracji
+- Kolumny w `pp_shop_products`:
+ - `apilo_product_id`, `apilo_product_name`, `apilo_get_data_date`
+- Tabele ustawien:
+ - `pp_shop_apilo_settings` (key-value)
+ - `pp_shop_shoppro_settings` (key-value)
+
+### Tabele checkout
+- `pp_shop_payment_methods` - metody platnosci sklepu (mapowanie `apilo_payment_type_id`)
+- `pp_shop_transports` - rodzaje transportu sklepu (mapowanie `apilo_carrier_account_id`)
+- `pp_shop_transport_payment_methods` - powiazanie metod transportu i platnosci
+
+Pelna dokumentacja tabel: `DATABASE_STRUCTURE.md`
+
+## Konfiguracja
+
+### Redis
+- Konfiguracja: `config.php` (zmienna `$config['redis']`)
+- Parametry: host, port, password
+
+### Autoload
+- Funkcja: `__autoload_my_classes()` w `cron.php:6`
+- Wzorzec: `autoload/{namespace}/class.{ClassName}.php`
+
+## Klasy pomocnicze
+
+### \S (autoload/class.S.php)
+Główna klasa helper z metodami:
+- `seo($val)` - generowanie URL SEO
+- `normalize_decimal($val, $precision)` - normalizacja liczb
+- `send_email()` - wysyłanie emaili
+- `delete_dir($dir)` - usuwanie katalogów
+- `htacces()` - generowanie .htaccess i sitemap.xml
+
+### Medoo
+- Plik: `libraries/medoo/medoo.php`
+- Zmienna: `$mdb`
+- ORM do operacji na bazie danych
+
+## Najważniejsze wzorce
+
+### Namespace'y
+- `\admin\Controllers\` - nowe kontrolery panelu admin (DI)
+- `\admin\controls\` - kontrolery legacy (fallback)
+- `\Domain\` - repozytoria/logika domenowa
+- `\admin\factory\` - helpery/fabryki admin
+- `\front\factory\` - helpery/fabryki frontend
+- `\shop\` - klasy sklepu (Product, Order, itp.)
+
+### Cachowanie produktów
+```php
+// Pobranie produktu z cache
+$product = \shop\Product::getFromCache($product_id, $lang_id, $permutation_hash);
+
+// Czyszczenie cache produktu
+\S::clear_product_cache($product_id);
+
+// Czyszczenie całego cache
+\S::clear_redis_cache();
+```
+
+## Refaktoryzacja do Domain-Driven Architecture
+
+### Nowa struktura (w trakcie migracji)
+```
+autoload/
+├── Domain/ # Nowa warstwa biznesowa (namespace \Domain\)
+│ ├── Product/
+│ │ └── ProductRepository.php
+│ ├── Banner/
+│ │ └── BannerRepository.php
+│ ├── Settings/
+│ │ └── SettingsRepository.php
+│ ├── Cache/
+│ │ └── CacheRepository.php
+│ ├── Article/
+│ │ └── ArticleRepository.php
+│ ├── User/
+│ │ └── UserRepository.php
+│ ├── Languages/
+│ │ └── LanguagesRepository.php
+│ ├── Layouts/
+│ │ └── LayoutsRepository.php
+│ ├── Newsletter/
+│ │ └── NewsletterRepository.php
+│ ├── Scontainers/
+│ │ └── ScontainersRepository.php
+│ ├── Dictionaries/
+│ │ └── DictionariesRepository.php
+│ ├── Pages/
+│ │ └── PagesRepository.php
+│ ├── Integrations/
+│ │ └── IntegrationsRepository.php
+│ ├── Promotion/
+│ │ └── PromotionRepository.php
+│ ├── Coupon/
+│ │ └── CouponRepository.php
+│ ├── ShopStatus/
+│ │ └── ShopStatusRepository.php
+│ ├── Transport/
+│ │ └── TransportRepository.php
+│ ├── ProductSet/
+│ │ └── ProductSetRepository.php
+│ ├── Producer/
+│ │ └── ProducerRepository.php
+│ └── ...
+├── admin/
+│ ├── Controllers/ # Nowe kontrolery (namespace \admin\Controllers\)
+│ ├── class.Site.php # Router: nowy kontroler → fallback stary
+│ ├── controls/ # Stare kontrolery (niezależny fallback)
+│ ├── factory/ # Stare helpery (niezależny fallback)
+│ └── view/ # Widoki (statyczne - bez zmian)
+├── shop/ # Legacy - fasady do Domain
+└── front/factory/ # Legacy - stopniowo migrowane
+```
+
+**Aktualizacja 2026-02-14 (ver. 0.268):**
+- Dodano modul domenowy `Domain/PaymentMethod/PaymentMethodRepository.php`.
+- Dodano kontroler DI `admin/Controllers/ShopPaymentMethodController.php`.
+- Modul `/admin/shop_payment_method/*` dziala na nowych widokach (`payment-methods-list`, `payment-method-edit`).
+- Usunieto legacy: `autoload/admin/controls/class.ShopPaymentMethod.php`, `autoload/admin/factory/class.ShopPaymentMethod.php`, `autoload/admin/view/class.ShopPaymentMethod.php`, `admin/templates/shop-payment-method/view-list.php`.
+
+**Aktualizacja 2026-02-14 (ver. 0.269):**
+- Dodano modul domenowy `Domain/Transport/TransportRepository.php`.
+- Dodano kontroler DI `admin/Controllers/ShopTransportController.php`.
+- Modul `/admin/shop_transport/*` dziala na nowych widokach (`transports-list`, `transport-edit`).
+- Usunieto legacy: `autoload/admin/controls/class.ShopTransport.php`, `autoload/admin/view/class.ShopTransport.php`, `admin/templates/shop-transport/view-list.php`.
+- `admin\factory\ShopTransport` i `front\factory\ShopTransport` przepiete na repozytorium.
+
+**Aktualizacja 2026-02-14 (ver. 0.270):**
+- `shop\Order` zapisuje nieudane syncy Apilo (status/platnosc) do kolejki `temp/apilo-sync-queue.json`.
+- `cron.php` automatycznie ponawia zalegle syncy (`Order::process_apilo_sync_queue()`).
+- `shop\Order::set_as_paid()` wysyla mapowany typ platnosci Apilo (z mapowania metody platnosci), bez stalej wartosci `type`.
+
+### Routing admin (admin\Site::route())
+1. Sprawdź mapę `$newControllers` → utwórz instancję z DI → wywołaj
+2. Jeśli nowy kontroler nie istnieje (`class_exists()` = false) → fallback na `admin\controls\`
+3. Stary kontroler jest NIEZALEŻNY od nowych klas (bezpieczny fallback)
+
+### Dependency Injection
+Nowe klasy używają **Dependency Injection** zamiast `global` variables:
+```php
+// STARE
+global $mdb;
+$quantity = $mdb->get('pp_shop_products', 'quantity', ['id' => $id]);
+
+// NOWE
+$repository = new \Domain\Product\ProductRepository($mdb);
+$quantity = $repository->getQuantity($id);
+```
+
+## Testowanie (tylko dla deweloperów)
+
+**UWAGA:** Pliki testów NIE są częścią aktualizacji dla klientów!
+
+### Narzędzia
+- **PHPUnit 9.6.34** - framework testowy
+- **test.bat** - uruchamianie testów
+- **composer.json** - autoloading PSR-4
+
+Pelna dokumentacja testow: `TESTING.md`
+
+## Dodatkowa aktualizacja 2026-02-14 (ver. 0.271)
+- Dodano modul domenowy `Domain/Attribute/AttributeRepository.php`.
+- Dodano kontroler DI `admin/Controllers/ShopAttributeController.php`.
+- Modul `/admin/shop_attribute/*` zostal przepiety na nowe widoki (`attributes-list`, `attribute-edit`, `values-edit`).
+- Usunieto legacy: `autoload/admin/controls/class.ShopAttribute.php`, `autoload/admin/factory/class.ShopAttribute.php`, `autoload/admin/view/class.ShopAttribute.php`, `admin/templates/shop-attribute/_partials/value.php`.
+- Przepieto zaleznosci kombinacji produktu na `Domain\Attribute\AttributeRepository` i `shop\ProductAttribute`.
+- Dla `ShopAttribute` routing celowo nie wykonuje fallbacku akcji do legacy kontrolera.
+
+## Dodatkowa aktualizacja 2026-02-15 (ver. 0.272)
+- Dodano modul domenowy `Domain/ProductSet/ProductSetRepository.php`.
+- Dodano kontroler DI `admin/Controllers/ShopProductSetsController.php`.
+- Modul `/admin/shop_product_sets/*` dziala na nowych widokach (`product-sets-list`, `product-set-edit`).
+- Usunieto legacy: `autoload/admin/controls/class.ShopProductSets.php`, `autoload/admin/factory/class.ShopProductSet.php`, `admin/templates/shop-product-sets/view-list.php`, `admin/templates/shop-product-sets/set-edit.php`.
+- `shop\ProductSet` przepiety na fasade do `Domain\ProductSet\ProductSetRepository`.
+
+## Dodatkowa aktualizacja 2026-02-15 (ver. 0.273)
+- Dodano modul domenowy `Domain/Producer/ProducerRepository.php`.
+
+## Dodatkowa aktualizacja 2026-02-15 (ver. 0.274)
+- Dodano modul domenowy `Domain/Client/ClientRepository.php`.
+- Dodano kontroler DI `admin/Controllers/ShopClientsController.php`.
+- Modul `/admin/shop_clients/*` dziala na nowych widokach opartych o `components/table-list`.
+- Usunieto legacy: `autoload/admin/controls/class.ShopClients.php`, `autoload/admin/factory/class.ShopClients.php`.
+- Routing i menu admin przepiete na kanoniczny URL `/admin/shop_clients/list/`.
+- Dodano kontroler DI `admin/Controllers/ShopProducerController.php`.
+- Modul `/admin/shop_producer/*` dziala na nowych widokach (`producers-list`, `producer-edit`).
+- Usunieto legacy: `autoload/admin/controls/class.ShopProducer.php`, `admin/templates/shop-producer/list.php`, `admin/templates/shop-producer/edit.php`.
+- `shop\Producer` przepiety na fasade do `Domain\Producer\ProducerRepository`.
+- `admin\controls\ShopProduct` uzywa `ProducerRepository::allProducers()`.
+- Usunieto 6 pustych factory facades: `admin\factory\Languages`, `admin\factory\Newsletter`, `admin\factory\Scontainers`, `admin\factory\ShopProducer`, `admin\factory\ShopTransport`, `admin\factory\Layouts`.
+- Przepieto 2 wywolania `admin\factory\ShopTransport` w `admin\factory\ShopProduct` na `Domain\Transport\TransportRepository`.
+- Usuniety fallback do `admin\factory\Layouts` w `admin\controls\ShopProduct`.
+
+## Dodatkowa aktualizacja 2026-02-15 (ver. 0.274)
+- Dodano kontroler DI `admin/Controllers/ShopProductController.php` (akcje `mass_edit`, `mass_edit_save`, `get_products_by_category`).
+- Routing `admin\Site` rozszerzono o mapowanie `ShopProduct` do nowego kontrolera.
+- `Domain/Product/ProductRepository.php` rozszerzono o metody dla mass-edit: `allProductsForMassEdit`, `getProductsByCategory`, `applyDiscountPercent`.
+- Usunieto legacy akcje mass-edit z `autoload/admin/controls/class.ShopProduct.php`.
+- Widok `/admin/shop_product/mass_edit/` przepiety na nowy partial `admin/templates/shop-product/mass-edit-custom-script.php`.
+- Ujednolicono UI drzewek (strzalki/expand) w:
+ - `admin/templates/pages/pages-list.php` + `admin/templates/pages/subpages-list.php`
+ - `admin/templates/articles/subpages-list.php` + `admin/templates/articles/article-edit-custom-script.php`
+
+## Dodatkowa aktualizacja 2026-02-15 (ver. 0.275)
+- Dodano modul domenowy `Domain/Category/CategoryRepository.php`.
+- Dodano kontroler DI `admin/Controllers/ShopCategoryController.php`.
+- Modul `/admin/shop_category/*` dziala przez DI i kanoniczny URL `/admin/shop_category/list/` (z zachowaniem aliasu `view_list`).
+- Widoki `shop-category/*` maja wydzielone skrypty `*-custom-script.php` i ujednolicone strzalki drzewa (`button + caret + aria-expanded`).
+- Endpointy AJAX dla drzewka kategorii i kolejnosci produktow przepiete na `/admin/shop_category/save_categories_order/`, `/admin/shop_category/save_products_order/`, `/admin/shop_category/cookie_categories/`.
+- Usunieto legacy: `autoload/admin/controls/class.ShopCategory.php`, `autoload/admin/factory/class.ShopCategory.php`, `autoload/admin/view/class.ShopCategory.php`.
+- Przepieto zaleznosci `ShopProduct` z `admin\factory\ShopCategory` na `Domain\Category\CategoryRepository`.
+- Usunieto preload `autoload/admin/factory/class.ShopCategory.php` z `libraries/grid/config.php`.
+
+---
+*Dokument aktualizowany: 2026-02-15*
diff --git a/temp/update_build/tmp_0.275/docs/REFACTORING_PLAN.md b/temp/update_build/tmp_0.275/docs/REFACTORING_PLAN.md
new file mode 100644
index 0000000..257c0f6
--- /dev/null
+++ b/temp/update_build/tmp_0.275/docs/REFACTORING_PLAN.md
@@ -0,0 +1,297 @@
+# Plan Refaktoryzacji shopPRO - Domain-Driven Architecture
+
+## Cel
+Stopniowe przeniesienie logiki biznesowej do architektury warstwowej:
+- **Domain/** - logika biznesowa (core)
+- **Admin/** - warstwa administratora
+- **Frontend/** - warstwa użytkownika
+- **Shared/** - współdzielone narzędzia
+
+## Docelowa struktura
+
+```
+autoload/
+├── Domain/ # Logika biznesowa (CORE) - namespace \Domain\
+│ ├── Product/
+│ │ ├── ProductRepository.php
+│ │ ├── ProductService.php # (przyszłość)
+│ │ └── ProductCacheService.php # (przyszłość)
+│ ├── Banner/
+│ │ └── BannerRepository.php
+│ ├── Settings/
+│ │ └── SettingsRepository.php
+│ ├── Cache/
+│ │ └── CacheRepository.php
+│ ├── Order/
+│ ├── Category/
+│ └── ...
+│
+├── admin/ # Warstwa administratora (istniejący katalog!)
+│ ├── Controllers/ # Nowe kontrolery - namespace \admin\Controllers\
+│ ├── controls/ # Stare kontrolery (legacy fallback)
+│ ├── factory/ # Stare helpery (legacy)
+│ └── view/ # Widoki (statyczne - OK bez zmian)
+│
+├── Frontend/ # Warstwa użytkownika (przyszłość)
+│ ├── Controllers/
+│ └── Services/
+│
+├── Shared/ # Współdzielone narzędzia
+│ ├── Cache/
+│ │ ├── CacheHandler.php
+│ │ └── RedisConnection.php
+│ └── Helpers/
+│ └── S.php
+│
+└── [LEGACY] # Stare klasy (stopniowo deprecated)
+ ├── shop/
+ ├── admin/factory/
+ └── front/factory/
+```
+
+### WAŻNE: Konwencja namespace → katalog (Linux case-sensitive!)
+- `\Domain\` → `autoload/Domain/` (duże D - nowy katalog)
+- `\admin\Controllers\` → `autoload/admin/Controllers/` (małe a - istniejący katalog)
+- NIE używać `\Admin\` (duże A) bo na serwerze Linux katalog to `admin/` (małe a)
+
+## Zasady migracji
+
+### 1. Stopniowość
+- Przenosimy **jedną funkcję na raz**
+- Zachowujemy kompatybilność wsteczną
+- Stare klasy działają jako fasady do nowych
+
+### 2. Dependency Injection zamiast statycznych metod
+```php
+// ❌ STARE - statyczne
+class Product {
+ public static function getQuantity($id) {
+ global $mdb;
+ return $mdb->get('pp_shop_products', 'quantity', ['id' => $id]);
+ }
+}
+
+// ✅ NOWE - instancje z DI
+class ProductRepository {
+ private $db;
+
+ public function __construct($db) {
+ $this->db = $db;
+ }
+
+ public function getQuantity($id) {
+ return $this->db->get('pp_shop_products', 'quantity', ['id' => $id]);
+ }
+}
+```
+
+### 3. Fasady dla kompatybilności
+```php
+// Stara klasa wywołuje nową
+namespace shop;
+
+class Product {
+ public static function getQuantity($id) {
+ global $mdb;
+ $repo = new \Domain\Product\ProductRepository($mdb);
+ return $repo->getQuantity($id);
+ }
+}
+```
+
+## Proces migracji funkcji
+
+### Krok 1: Wybór funkcji
+- Wybierz prostą funkcję statyczną
+- Sprawdź jej zależności
+- Przeanalizuj gdzie jest używana
+
+### Krok 2: Stworzenie nowej struktury
+- Utwórz folder `Domain/{Module}/`
+- Stwórz odpowiednią klasę (Repository/Service/Entity)
+- Przenieś logikę
+
+### Krok 3: Znalezienie użyć
+```bash
+grep -r "Product::getQuantity" .
+```
+
+### Krok 4: Aktualizacja wywołań
+- Opcja A: Bezpośrednie wywołanie nowej klasy
+- Opcja B: Fasada w starej klasie (zalecane na początek)
+
+### Krok 5: Testy
+- Napisz test jednostkowy dla nowej funkcji
+- Sprawdź czy stare wywołania działają
+
+## Status migracji
+
+### ✅ Zmigrowane moduły
+| # | Modul | Wersja | Zakres |
+|---|-------|--------|--------|
+| 1 | Cache | 0.237 | CacheHandler, RedisConnection, clear_product_cache |
+| 2 | Product | 0.238-0.252, 0.274 | getQuantity, getPrice, getName, archive/unarchive, allProductsForMassEdit, getProductsByCategory, applyDiscountPercent |
+| 3 | Banner | 0.239 | find, delete, save, kontroler DI |
+| 4 | Settings | 0.240/0.250 | saveSettings, getSettings, kontroler DI |
+| 5 | Dictionaries | 0.251 | listForAdmin, find, save, delete, kontroler DI |
+| 6 | ProductArchive | 0.252 | kontroler DI, table-list |
+| 7 | Filemanager | 0.252 | kontroler DI, fix Invalid Key |
+| 8 | Users | 0.253 | CRUD, logon, 2FA, kontroler DI |
+| 9 | Languages | 0.254 | languages + translations, kontroler DI |
+| 10 | Layouts | 0.256 | find, save, delete, menusWithPages, categoriesTree |
+| 11 | Newsletter | 0.257-0.258 | subskrybenci, szablony, ustawienia |
+| 12 | Scontainers | 0.259 | listForAdmin, find, save, delete |
+| 13 | ArticlesArchive | 0.260 | restore, deletePermanently |
+| 14 | Articles | 0.261 | pelna migracja (CRUD, AJAX, galeria, pliki) |
+| 15 | Pages | 0.262 | menu/page CRUD, drzewo stron, AJAX |
+| 16 | Integrations | 0.263 | Apilo/ShopPRO, cleanup Sellasist/Baselinker |
+| 17 | ShopPromotion | 0.264-0.265 | listForAdmin, find, save, delete, categoriesTree |
+| 18 | ShopCoupon | 0.266 | listForAdmin, find, save, delete, categoriesTree |
+| 19 | ShopStatuses | 0.267 | listForAdmin, find, save, color picker |
+| 20 | ShopPaymentMethod | 0.268 | listForAdmin, find, save, allActive, mapowanie Apilo, DI kontroler |
+| 21 | ShopTransport | 0.269 | listForAdmin, find, save, allActive, allForAdmin, findActiveById, getTransportCost, lowestTransportPrice, getApiloCarrierAccountId, powiazanie z PaymentMethod, DI kontroler |
+| 22 | ShopAttribute | 0.271 | list/edit/save/delete/values, nowy edytor wartosci, cleanup legacy, przepiecie zaleznosci kombinacji |
+| 23 | ShopProductSets | 0.272 | listForAdmin, find, save, delete, allSets, allProductsMap, multi-select Selectize, DI kontroler |
+| 24 | ShopProducer | 0.273 | listForAdmin, find, save, delete, allProducers, producerProducts, fasada shop\Producer, DI kontroler |
+| 25 | ShopProduct (mass_edit) | 0.274 | DI kontroler + routing dla `mass_edit`, `mass_edit_save`, `get_products_by_category`, cleanup legacy akcji |
+| 26 | ShopClients | 0.274 | DI kontroler + routing dla `list/details`, nowe listy na `components/table-list`, cleanup legacy controls/factory |
+| 27 | ShopCategory | 0.275 | CategoryRepository + DI kontroler + routing, endpointy AJAX (`save_categories_order`, `save_products_order`, `cookie_categories`), cleanup legacy controls/factory/view |
+
+### Product - szczegolowy status
+- ✅ getQuantity (ver. 0.238)
+- ✅ getPrice (ver. 0.239)
+- ✅ getName (ver. 0.239)
+- ✅ archive / unarchive (ver. 0.241/0.252)
+- ✅ allProductsForMassEdit (ver. 0.274)
+- ✅ getProductsByCategory (ver. 0.274)
+- ✅ applyDiscountPercent (ver. 0.274)
+- [ ] is_product_on_promotion
+- [ ] getFromCache
+- [ ] getProductImg
+
+### 📋 Do zrobienia
+- Order
+- ShopProduct (factory)
+
+## Kolejność refaktoryzacji (priorytet)
+
+1-27: ✅ Cache, Product, Banner, Settings, Dictionaries, ProductArchive, Filemanager, Users, Pages, Integrations, ShopPromotion, ShopCoupon, ShopStatuses, ShopPaymentMethod, ShopTransport, ShopAttribute, ShopProductSets, ShopProducer, ShopProduct (mass_edit), ShopClients, ShopCategory
+
+Nastepne:
+28. **Order**
+
+## Form Edit System
+
+Nowy uniwersalny system formularzy edycji:
+- ✅ Klasy ViewModel: `FormFieldType`, `FormField`, `FormTab`, `FormAction`, `FormEditViewModel`
+- ✅ Walidacja: `FormValidator` z obsługą reguł per pole i sekcje językowe
+- ✅ Persist: `FormRequestHandler` - zapamiętywanie danych przy błędzie walidacji
+- ✅ Renderer: `FormFieldRenderer` - renderowanie wszystkich typów pól
+- ✅ Szablon: `admin/templates/components/form-edit.php` - uniwersalny layout
+- Wspierane typy pól: text, number, email, password, date, datetime, switch, select, textarea, editor, image, file, hidden, lang_section, color
+- Obsługa zakładek (vertical) i sekcji językowych (horizontal)
+- **Do zrobienia**: Przerobić pozostałe kontrolery/formularze (Product, Category, Pages, itd.)
+
+Pelna dokumentacja: `docs/FORM_EDIT_SYSTEM.md`
+
+## Zasady kodu
+
+### 1. SOLID Principles
+- **S**ingle Responsibility - jedna klasa = jedna odpowiedzialność
+- **O**pen/Closed - otwarty na rozszerzenia, zamknięty na modyfikacje
+- **L**iskov Substitution - podklasy mogą zastąpić nadklasy
+- **I**nterface Segregation - wiele małych interfejsów
+- **D**ependency Inversion - zależności od abstrakcji
+
+### 2. Nazewnictwo
+- **Entity** - `Product.php` (reprezentuje obiekt domenowy)
+- **Repository** - `ProductRepository.php` (dostęp do danych)
+- **Service** - `ProductService.php` (logika biznesowa)
+- **Controller** - `ProductController.php` (obsługa requestów)
+
+### 3. Type Hinting
+```php
+// ✅ DOBRE
+public function getQuantity(int $id): ?int {
+ return $this->db->get('pp_shop_products', 'quantity', ['id' => $id]);
+}
+
+// ❌ ZŁE
+public function getQuantity($id) {
+ return $this->db->get('pp_shop_products', 'quantity', ['id' => $id]);
+}
+```
+
+## Narzędzia pomocnicze
+
+### Autoloader (produkcja)
+Autoloader w 9 entry pointach obsługuje dwie konwencje:
+1. `autoload/{namespace}/class.{ClassName}.php` (legacy)
+2. `autoload/{namespace}/{ClassName}.php` (PSR-4, fallback)
+
+Entry pointy: `index.php`, `ajax.php`, `api.php`, `cron.php`, `cron-turstmate.php`, `download.php`, `admin/index.php`, `admin/ajax.php`, `cron/cron-xml.php`
+
+### Static Analysis
+```bash
+composer require --dev phpstan/phpstan
+vendor/bin/phpstan analyse autoload/Domain
+```
+
+## Testowanie
+
+### Framework: PHPUnit
+```bash
+composer test
+```
+
+### Struktura testów
+```
+tests/
+├── Unit/
+│ ├── Domain/
+│ │ ├── Article/ArticleRepositoryTest.php
+│ │ ├── Banner/BannerRepositoryTest.php
+│ │ ├── Cache/CacheRepositoryTest.php
+│ │ ├── Coupon/CouponRepositoryTest.php
+│ │ ├── Dictionaries/DictionariesRepositoryTest.php
+│ │ ├── Integrations/IntegrationsRepositoryTest.php
+│ │ ├── PaymentMethod/PaymentMethodRepositoryTest.php
+│ │ ├── Producer/ProducerRepositoryTest.php
+│ │ ├── Product/ProductRepositoryTest.php
+│ │ ├── ProductSet/ProductSetRepositoryTest.php
+│ │ ├── Promotion/PromotionRepositoryTest.php
+│ │ ├── Settings/SettingsRepositoryTest.php
+│ │ ├── ShopStatus/ShopStatusRepositoryTest.php
+│ │ └── User/UserRepositoryTest.php
+│ └── admin/
+│ └── Controllers/
+│ ├── ArticlesControllerTest.php
+│ ├── DictionariesControllerTest.php
+│ ├── IntegrationsControllerTest.php
+│ ├── ProductArchiveControllerTest.php
+│ ├── SettingsControllerTest.php
+│ ├── ShopCouponControllerTest.php
+│ ├── ShopPaymentMethodControllerTest.php
+│ ├── ShopProducerControllerTest.php
+│ ├── ShopProductSetsControllerTest.php
+│ ├── ShopPromotionControllerTest.php
+│ ├── ShopStatusesControllerTest.php
+│ └── UsersControllerTest.php
+└── Integration/
+```
+**Lacznie: 338 testow, 1063 asercji**
+
+Aktualizacja 2026-02-15 (ver. 0.273):
+- dodano testy `tests/Unit/Domain/Producer/ProducerRepositoryTest.php`
+- dodano testy `tests/Unit/admin/Controllers/ShopProducerControllerTest.php`
+
+Aktualizacja 2026-02-14 (ver. 0.271):
+- dodano testy `tests/Unit/Domain/Attribute/AttributeRepositoryTest.php`
+- dodano testy `tests/Unit/admin/Controllers/ShopAttributeControllerTest.php`
+
+Pelna dokumentacja testow: `TESTING.md`
+
+---
+*Rozpoczęto: 2025-02-05*
+*Ostatnia aktualizacja: 2026-02-15*
+*Changelog zmian: `docs/CHANGELOG.md`*
diff --git a/temp/update_build/tmp_0.275/docs/TESTING.md b/temp/update_build/tmp_0.275/docs/TESTING.md
new file mode 100644
index 0000000..bcaefc2
--- /dev/null
+++ b/temp/update_build/tmp_0.275/docs/TESTING.md
@@ -0,0 +1,456 @@
+# Testowanie shopPRO
+
+## Szybki start
+
+### Pelny zestaw testow
+```bash
+composer test
+```
+
+Alternatywnie (Windows):
+```bash
+./test.ps1
+./test.bat
+./test-simple.bat
+./test-debug.bat
+```
+
+Alternatywnie (Git Bash):
+```bash
+./test.sh
+```
+
+### Konkretny plik testowy
+```bash
+./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php
+./test.ps1 tests/Unit/admin/Controllers/ArticlesControllerTest.php
+```
+
+### Konkretny test (`--filter`)
+```bash
+./test.ps1 --filter testGetQuantityReturnsCorrectValue
+```
+
+## Aktualny stan suite
+
+Ostatnio zweryfikowano: 2026-02-15
+
+```text
+OK (351 tests, 1091 assertions)
+```
+
+Aktualizacja po migracji ShopClients (2026-02-15, ver. 0.274) - testy punktowe:
+```text
+OK (10 tests, 34 assertions)
+```
+
+Aktualizacja po migracji ShopCategory (2026-02-15, ver. 0.275) - testy punktowe:
+```text
+OK (16 tests, 72 assertions)
+```
+
+Nowe testy dodane 2026-02-15:
+- `tests/Unit/Domain/Client/ClientRepositoryTest.php`
+- `tests/Unit/admin/Controllers/ShopClientsControllerTest.php`
+- `tests/Unit/Domain/Category/CategoryRepositoryTest.php`
+- `tests/Unit/admin/Controllers/ShopCategoryControllerTest.php`
+
+## Struktura testow
+
+```text
+tests/
+|-- bootstrap.php
+|-- Unit/
+| |-- Domain/
+| | |-- Article/ArticleRepositoryTest.php
+| | |-- Attribute/AttributeRepositoryTest.php
+| | |-- Banner/BannerRepositoryTest.php
+| | |-- Cache/CacheRepositoryTest.php
+| | |-- Coupon/CouponRepositoryTest.php
+| | |-- Category/CategoryRepositoryTest.php
+| | |-- Dictionaries/DictionariesRepositoryTest.php
+| | |-- Integrations/IntegrationsRepositoryTest.php
+| | |-- PaymentMethod/PaymentMethodRepositoryTest.php
+| | |-- Producer/ProducerRepositoryTest.php
+| | |-- Product/ProductRepositoryTest.php
+| | |-- ProductSet/ProductSetRepositoryTest.php
+| | |-- Promotion/PromotionRepositoryTest.php
+| | |-- Settings/SettingsRepositoryTest.php
+| | |-- ShopStatus/ShopStatusRepositoryTest.php
+| | |-- Transport/TransportRepositoryTest.php
+| | `-- User/UserRepositoryTest.php
+| `-- admin/
+| `-- Controllers/
+| |-- ArticlesControllerTest.php
+| |-- DictionariesControllerTest.php
+| |-- IntegrationsControllerTest.php
+| |-- ProductArchiveControllerTest.php
+| |-- SettingsControllerTest.php
+| |-- ShopAttributeControllerTest.php
+| |-- ShopCategoryControllerTest.php
+| |-- ShopCouponControllerTest.php
+| |-- ShopPaymentMethodControllerTest.php
+| |-- ShopProducerControllerTest.php
+| |-- ShopProductControllerTest.php
+| |-- ShopProductSetsControllerTest.php
+| |-- ShopPromotionControllerTest.php
+| |-- ShopStatusesControllerTest.php
+| |-- ShopTransportControllerTest.php
+| `-- UsersControllerTest.php
+`-- Integration/
+```
+
+## Tryby uruchamiania
+
+### 1. TestDox (czytelna lista)
+```bash
+./test.bat
+```
+Uruchamia:
+```bash
+C:\xampp\php\php.exe phpunit.phar --testdox
+```
+
+### 2. Standard (kropki)
+```bash
+./test-simple.bat
+```
+Uruchamia:
+```bash
+C:\xampp\php\php.exe phpunit.phar
+```
+
+### 3. Debug (pelne logowanie)
+```bash
+./test-debug.bat
+```
+Uruchamia:
+```bash
+C:\xampp\php\php.exe phpunit.phar --debug
+```
+
+### 4. PowerShell (najbardziej niezawodne)
+```bash
+./test.ps1
+```
+- najpierw probuje `php` z PATH
+- jesli brak, probuje m.in. `C:\xampp\php\php.exe`
+- zawsze dodaje `--do-not-cache-result`
+
+## Interpretacja wynikow
+
+```text
+. = test przeszedl
+E = error (blad wykonania)
+F = failure (niezgodna asercja)
+```
+
+Przyklad sukcesu:
+```text
+................................................................. 65 / 82 ( 79%)
+................. 82 / 82 (100%)
+
+OK (82 tests, 181 assertions)
+```
+
+## Dodawanie nowych testow
+
+1. Dodaj plik w odpowiednim module, np. `tests/Unit/Domain/
/Test.php`.
+2. Rozszerz `PHPUnit\Framework\TestCase`.
+3. Nazwy metod zaczynaj od `test`.
+4. Trzymaj sie wzorca AAA: Arrange, Act, Assert.
+
+## Mockowanie (przyklad)
+
+```php
+$mockDb = $this->createMock(\medoo::class);
+$mockDb->method('get')->willReturn(42);
+
+$repo = new ProductRepository($mockDb);
+$value = $repo->getQuantity(123);
+
+$this->assertEquals(42, $value);
+```
+
+## Przydatne informacje
+
+- Konfiguracja PHPUnit: `phpunit.xml`
+- Bootstrap testow: `tests/bootstrap.php`
+- Dodatkowy opis: `tests/README.md`
+
+## Aktualizacja suite
+
+Ostatnio zweryfikowano: 2026-02-12
+
+```text
+OK (119 tests, 256 assertions)
+```
+
+Nowe testy dodane 2026-02-12:
+- `tests/Unit/Domain/User/UserRepositoryTest.php` (25 testow: CRUD, logon, 2FA verify/send, checkLogin, updateById)
+- `tests/Unit/admin/Controllers/UsersControllerTest.php` (12 testow: kontrakty + normalizeUser)
+
+Aktualizacja po migracji widokow Users (2026-02-12):
+```text
+OK (120 tests, 262 assertions)
+```
+
+## Aktualizacja suite (finalizacja Users)
+Ostatnio zweryfikowano: 2026-02-12
+
+```text
+OK (120 tests, 262 assertions)
+```
+
+Aktualizacja po migracji Languages (2026-02-12):
+```text
+OK (130 tests, 301 assertions)
+```
+
+Nowe testy dodane 2026-02-12:
+- `tests/Unit/Domain/Languages/LanguagesRepositoryTest.php`
+- `tests/Unit/admin/Controllers/LanguagesControllerTest.php`
+
+## Aktualizacja suite (release 0.254)
+Ostatnio zweryfikowano: 2026-02-12
+
+```text
+OK (130 tests, 301 assertions)
+```
+
+Nowe testy dodane 2026-02-12:
+- `tests/Unit/Domain/Languages/LanguagesRepositoryTest.php`
+- `tests/Unit/admin/Controllers/LanguagesControllerTest.php`
+
+## Aktualizacja suite (release 0.255)
+Ostatnio zweryfikowano: 2026-02-12
+
+```text
+OK (130 tests, 303 assertions)
+```
+
+## Aktualizacja suite (release 0.256)
+Ostatnio zweryfikowano: 2026-02-12
+
+```text
+OK (141 tests, 336 assertions)
+```
+
+Nowe testy dodane 2026-02-12:
+- `tests/Unit/Domain/Layouts/LayoutsRepositoryTest.php`
+- `tests/Unit/admin/Controllers/LayoutsControllerTest.php`
+
+Zaktualizowane testy 2026-02-12:
+- `tests/Unit/Domain/Languages/LanguagesRepositoryTest.php` (defaultLanguageId)
+- `tests/Unit/admin/Controllers/ArticlesControllerTest.php` (konstruktor + LayoutsRepository)
+
+## Aktualizacja suite (release 0.257)
+Ostatnio zweryfikowano: 2026-02-12
+
+```text
+OK (150 tests, 372 assertions)
+```
+
+Nowe testy dodane 2026-02-12:
+- `tests/Unit/Domain/Newsletter/NewsletterRepositoryTest.php`
+- `tests/Unit/admin/Controllers/NewsletterControllerTest.php`
+
+## Aktualizacja suite (release 0.258)
+Ostatnio zweryfikowano: 2026-02-12
+
+```text
+OK (150 tests, 372 assertions)
+```
+
+## Aktualizacja suite (release 0.259)
+Ostatnio zweryfikowano: 2026-02-12
+
+```text
+OK (158 tests, 397 assertions)
+```
+
+Nowe testy dodane 2026-02-12:
+- `tests/Unit/Domain/Scontainers/ScontainersRepositoryTest.php`
+- `tests/Unit/admin/Controllers/ScontainersControllerTest.php`
+
+## Aktualizacja suite (release 0.260)
+Ostatnio zweryfikowano: 2026-02-12
+
+```text
+OK (165 tests, 424 assertions)
+```
+
+Nowe testy dodane 2026-02-12:
+- `tests/Unit/Domain/Article/ArticleRepositoryTest.php` (rozszerzenie o testy `restore`, `deletePermanently`, `listArchivedForAdmin`)
+- `tests/Unit/admin/Controllers/ArticlesArchiveControllerTest.php`
+
+## Aktualizacja suite (release 0.261)
+Ostatnio zweryfikowano: 2026-02-13
+
+```text
+OK (176 tests, 439 assertions)
+```
+
+Nowe testy/rozszerzenia 2026-02-13:
+- `tests/Unit/Domain/Article/ArticleRepositoryTest.php` (nowe przypadki dla `pagesSummaryForArticles`, `updateImageAlt`, `markFileToDelete`)
+- `tests/Unit/admin/Controllers/ArticlesControllerTest.php` (nowe kontrakty dla akcji `imageAltChange`, `fileNameChange`, `imageDelete`, `fileDelete`)
+
+## Aktualizacja suite (release 0.261)
+Ostatnio zweryfikowano: 2026-02-13
+
+```text
+OK (178 tests, 443 assertions)
+```
+
+Nowe testy/rozszerzenia 2026-02-13:
+- `tests/Unit/Domain/Article/ArticleRepositoryTest.php` (nowe przypadki dla `saveFilesOrder`)
+
+## Aktualizacja suite (Pages migration)
+Ostatnio zweryfikowano: 2026-02-13
+
+```text
+OK (186 tests, 478 assertions)
+```
+
+Nowe testy dodane 2026-02-13:
+- `tests/Unit/Domain/Pages/PagesRepositoryTest.php`
+- `tests/Unit/admin/Controllers/PagesControllerTest.php`
+
+Zaktualizowane testy 2026-02-13:
+- `tests/Unit/admin/Controllers/ArticlesControllerTest.php` (konstruktor z `Domain\\Pages\\PagesRepository`)
+
+## Aktualizacja suite (Integrations refactor, ver. 0.263)
+Ostatnio zweryfikowano: 2026-02-13
+
+```text
+OK (212 tests, 577 assertions)
+```
+
+Nowe testy dodane 2026-02-13:
+- `tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php` (16 testow: getSettings, getSetting, saveSetting, linkProduct, unlinkProduct, getProductSku, apiloGetAccessToken, invalid provider, settings table mapping)
+- `tests/Unit/admin/Controllers/IntegrationsControllerTest.php` (10 testow: kontrakty metod, return types, brak metod sellasist/baselinker)
+
+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`
+
+## Aktualizacja suite (ShopCoupon refactor, ver. 0.266)
+Ostatnio zweryfikowano: 2026-02-13
+
+```text
+OK (235 tests, 682 assertions)
+```
+
+Nowe testy dodane 2026-02-13:
+- `tests/Unit/Domain/Coupon/CouponRepositoryTest.php` (8 testow: find default/normalize, save insert/update, delete, whitelist sortowania, drzewo kategorii)
+- `tests/Unit/admin/Controllers/ShopCouponControllerTest.php` (5 testow: kontrakty metod, aliasy legacy, DI konstruktora)
+
+Ponowna weryfikacja po poprawkach UI (drzewko + checkboxy): 2026-02-13
+- `OK (235 tests, 682 assertions)`
+
+## Aktualizacja suite (ShopStatuses refactor, ver. 0.267)
+Ostatnio zweryfikowano: 2026-02-14
+
+```text
+OK (254 tests, 736 assertions)
+```
+
+Nowe testy dodane 2026-02-14:
+- `tests/Unit/Domain/ShopStatus/ShopStatusRepositoryTest.php` (9 testow: find z ID=0, find null apilo, save update, save z ID=0, empty apilo sets null, reject negative ID, getApiloStatusId, getByIntegrationStatusId, allStatuses, whitelist sortowania)
+- `tests/Unit/admin/Controllers/ShopStatusesControllerTest.php` (5 testow: kontrakty metod, brak aliasow legacy, return types, DI konstruktora)
+
+## Aktualizacja suite (ShopPaymentMethod refactor, ver. 0.268)
+Ostatnio zweryfikowano: 2026-02-14
+
+```text
+OK (280 tests, 828 assertions)
+```
+
+Nowe testy dodane 2026-02-14:
+- `tests/Unit/Domain/PaymentMethod/PaymentMethodRepositoryTest.php` (14 testow: find invalid/null/normalize, save update/null/non-numeric apilo, listForAdmin whitelist, allActive, allForAdmin, findActiveById, isActive, getApiloPaymentTypeId, forTransport)
+- `tests/Unit/admin/Controllers/ShopPaymentMethodControllerTest.php` (5 testow: kontrakty metod, brak aliasow legacy, return types, DI konstruktora)
+
+## Aktualizacja suite (ShopTransport refactor, ver. 0.269)
+Ostatnio zweryfikowano: 2026-02-14
+
+```text
+OK (300 tests, 895 assertions)
+```
+
+Nowe testy dodane 2026-02-14:
+- `tests/Unit/Domain/Transport/TransportRepositoryTest.php` (14 testow: find invalid/null/normalize/nullables, save insert/update/failure/default reset/switch normalization, listForAdmin whitelist, allActive, getApiloCarrierAccountId, getTransportCost, allForAdmin)
+- `tests/Unit/admin/Controllers/ShopTransportControllerTest.php` (5 testow: kontrakty metod, brak aliasow legacy, return types, DI konstruktora z 2 repo)
+
+## Aktualizacja suite (Apilo sync hardening, ver. 0.270)
+Ostatnio zweryfikowano: 2026-02-14
+
+```text
+OK (300 tests, 895 assertions)
+```
+
+Zmiany testowe 2026-02-14:
+- brak nowych testow; pelna regresja po zmianach sync Apilo (TPAY -> Apilo) przeszla bez bledow
+
+## Aktualizacja suite (ShopAttribute refactor, ver. 0.271)
+Ostatnio zweryfikowano: 2026-02-14
+
+```text
+OK (312 tests, 948 assertions)
+```
+
+Nowe testy dodane 2026-02-14:
+- `tests/Unit/Domain/Attribute/AttributeRepositoryTest.php` (5 testow: domyslne dane cechy, whitelist sortowania/paginacji, zapis wartosci i domyslnej, usuwanie pustych tlumaczen, jezyk domyslny)
+- `tests/Unit/admin/Controllers/ShopAttributeControllerTest.php` (7 testow: kontrakty metod, brak aliasow legacy, return types, DI konstruktora, walidacja `validateValuesRows`)
+
+## Aktualizacja suite (ShopProductSets refactor, ver. 0.272)
+Ostatnio zweryfikowano: 2026-02-15
+
+```text
+OK (324 tests, 1000 assertions)
+```
+
+Nowe testy dodane 2026-02-15:
+- `tests/Unit/Domain/ProductSet/ProductSetRepositoryTest.php` (7 testow: find default/normalize, save insert/update, delete invalid, whitelist sortowania/paginacji, allSets)
+- `tests/Unit/admin/Controllers/ShopProductSetsControllerTest.php` (5 testow: kontrakty metod, aliasy legacy, return types, DI konstruktora)
+
+## Aktualizacja suite (ShopProducer refactor, ver. 0.273)
+Ostatnio zweryfikowano: 2026-02-15
+
+```text
+OK (338 tests, 1063 assertions)
+```
+
+Nowe testy dodane 2026-02-15:
+- `tests/Unit/Domain/Producer/ProducerRepositoryTest.php` (9 testow: find default/normalize, save insert/update, delete invalid/success, whitelist sortowania/paginacji, allProducers, producerProducts)
+- `tests/Unit/admin/Controllers/ShopProducerControllerTest.php` (5 testow: kontrakty metod, aliasy legacy, return types, DI konstruktora)
+
+## Aktualizacja suite (ShopProduct mass_edit, ver. 0.274)
+Ostatnio zweryfikowano: 2026-02-15
+
+```text
+OK (351 tests, 1091 assertions)
+```
+
+Nowe testy dodane 2026-02-15:
+- `tests/Unit/Domain/Product/ProductRepositoryTest.php` (rozszerzenie: `allProductsForMassEdit`, `getProductsByCategory`, `applyDiscountPercent`)
+- `tests/Unit/admin/Controllers/ShopProductControllerTest.php` (7 testow: kontrakty metod, return types, DI konstruktora)
diff --git a/temp/update_build/tmp_0.275/libraries/grid/config.php b/temp/update_build/tmp_0.275/libraries/grid/config.php
new file mode 100644
index 0000000..238b9c9
--- /dev/null
+++ b/temp/update_build/tmp_0.275/libraries/grid/config.php
@@ -0,0 +1,48 @@
+ 'mysql',
+ 'database_name' => $database['name'],
+ 'server' => $database['host'],
+ 'username' => $database['user'],
+ 'password' => $database['password'],
+ 'charset' => 'utf8'
+ );
+
+$mdb = new medoo( [
+ 'database_type' => 'mysql',
+ 'database_name' => $database['name'],
+ 'server' => $database['host'],
+ 'username' => $database['user'],
+ 'password' => $database['password'],
+ 'charset' => 'utf8'
+ ] );
diff --git a/temp/update_build/tmp_0.275/tests/Unit/admin/Controllers/ShopCategoryControllerTest.php b/temp/update_build/tmp_0.275/tests/Unit/admin/Controllers/ShopCategoryControllerTest.php
new file mode 100644
index 0000000..679731c
--- /dev/null
+++ b/temp/update_build/tmp_0.275/tests/Unit/admin/Controllers/ShopCategoryControllerTest.php
@@ -0,0 +1,80 @@
+repository = $this->createMock(CategoryRepository::class);
+ $this->languagesRepository = $this->createMock(LanguagesRepository::class);
+ $this->controller = new ShopCategoryController($this->repository, $this->languagesRepository);
+ }
+
+ public function testConstructorAcceptsDependencies(): void
+ {
+ $controller = new ShopCategoryController($this->repository, $this->languagesRepository);
+ $this->assertInstanceOf(ShopCategoryController::class, $controller);
+ }
+
+ public function testHasExpectedActionMethods(): void
+ {
+ $this->assertTrue(method_exists($this->controller, 'view_list'));
+ $this->assertTrue(method_exists($this->controller, 'list'));
+ $this->assertTrue(method_exists($this->controller, 'category_edit'));
+ $this->assertTrue(method_exists($this->controller, 'edit'));
+ $this->assertTrue(method_exists($this->controller, 'save'));
+ $this->assertTrue(method_exists($this->controller, 'category_delete'));
+ $this->assertTrue(method_exists($this->controller, 'delete'));
+ $this->assertTrue(method_exists($this->controller, 'category_products'));
+ $this->assertTrue(method_exists($this->controller, 'products'));
+ $this->assertTrue(method_exists($this->controller, 'category_url_browser'));
+ $this->assertTrue(method_exists($this->controller, 'save_categories_order'));
+ $this->assertTrue(method_exists($this->controller, 'save_products_order'));
+ $this->assertTrue(method_exists($this->controller, 'cookie_categories'));
+ }
+
+ public function testViewActionsReturnString(): void
+ {
+ $reflection = new \ReflectionClass($this->controller);
+
+ $this->assertEquals('string', (string)$reflection->getMethod('view_list')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('list')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('category_edit')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('edit')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('category_products')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('products')->getReturnType());
+ }
+
+ public function testMutationActionsReturnVoid(): void
+ {
+ $reflection = new \ReflectionClass($this->controller);
+
+ $this->assertEquals('void', (string)$reflection->getMethod('save')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('category_delete')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('delete')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('category_url_browser')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('save_categories_order')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('save_products_order')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('cookie_categories')->getReturnType());
+ }
+
+ public function testConstructorRequiresCategoryAndLanguagesRepositories(): void
+ {
+ $reflection = new \ReflectionClass(ShopCategoryController::class);
+ $constructor = $reflection->getConstructor();
+ $params = $constructor->getParameters();
+
+ $this->assertCount(2, $params);
+ $this->assertEquals('Domain\\Category\\CategoryRepository', $params[0]->getType()->getName());
+ $this->assertEquals('Domain\\Languages\\LanguagesRepository', $params[1]->getType()->getName());
+ }
+}
diff --git a/temp/update_build/update_0.275.zip b/temp/update_build/update_0.275.zip
new file mode 100644
index 0000000..ab9d403
Binary files /dev/null and b/temp/update_build/update_0.275.zip differ
diff --git a/tests/Unit/Domain/Category/CategoryRepositoryTest.php b/tests/Unit/Domain/Category/CategoryRepositoryTest.php
new file mode 100644
index 0000000..2817d5d
--- /dev/null
+++ b/tests/Unit/Domain/Category/CategoryRepositoryTest.php
@@ -0,0 +1,208 @@
+createMock(\medoo::class);
+ $repository = new CategoryRepository($mockDb);
+
+ $types = $repository->sortTypes();
+
+ $this->assertIsArray($types);
+ $this->assertArrayHasKey(0, $types);
+ $this->assertArrayHasKey(6, $types);
+ $this->assertSame('ręczne', $types[4]);
+ }
+
+ public function testCategoryDetailsReturnsDefaultForInvalidId(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $repository = new CategoryRepository($mockDb);
+
+ $result = $repository->categoryDetails(0);
+
+ $this->assertIsArray($result);
+ $this->assertSame(0, (int)$result['id']);
+ $this->assertSame(1, (int)$result['status']);
+ $this->assertNull($result['parent_id']);
+ $this->assertSame([], $result['languages']);
+ }
+
+ public function testCategoryDetailsLoadsTranslations(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+
+ $mockDb->method('get')
+ ->willReturnCallback(function ($table, $columns, $where) {
+ if ($table === 'pp_shop_categories') {
+ return [
+ 'id' => 15,
+ 'status' => '1',
+ 'parent_id' => null,
+ 'sort_type' => 4,
+ 'view_subcategories' => '1',
+ ];
+ }
+ return null;
+ });
+
+ $mockDb->method('select')
+ ->willReturnCallback(function ($table, $columns, $where) {
+ if ($table === 'pp_shop_categories_langs') {
+ return [
+ ['lang_id' => 'pl', 'title' => 'Kategoria PL'],
+ ['lang_id' => 'en', 'title' => 'Category EN'],
+ ];
+ }
+ return [];
+ });
+
+ $repository = new CategoryRepository($mockDb);
+ $result = $repository->categoryDetails(15);
+
+ $this->assertSame(15, (int)$result['id']);
+ $this->assertSame(1, (int)$result['status']);
+ $this->assertSame(1, (int)$result['view_subcategories']);
+ $this->assertSame('Kategoria PL', $result['languages']['pl']['title']);
+ $this->assertSame('Category EN', $result['languages']['en']['title']);
+ }
+
+ public function testSaveCategoriesOrderReturnsFalseForNonArray(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->expects($this->never())->method('update');
+
+ $repository = new CategoryRepository($mockDb);
+
+ $this->assertFalse($repository->saveCategoriesOrder('x'));
+ }
+
+ public function testSaveCategoriesOrderUpdatesOrderAndParent(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+
+ $updates = [];
+ $mockDb->method('update')
+ ->willReturnCallback(function ($table, $row, $where = null) use (&$updates) {
+ $updates[] = [$table, $row, $where];
+ return true;
+ });
+
+ $repository = new CategoryRepository($mockDb);
+
+ $result = $repository->saveCategoriesOrder([
+ ['item_id' => 10, 'parent_id' => null],
+ ['item_id' => 11, 'parent_id' => 10],
+ ['item_id' => 0, 'parent_id' => 11],
+ ]);
+
+ $this->assertTrue($result);
+ $this->assertGreaterThanOrEqual(3, count($updates));
+
+ $this->assertSame('pp_shop_categories', $updates[0][0]);
+ $this->assertSame(['o' => 0], $updates[0][1]);
+
+ $this->assertSame(['o' => 1, 'parent_id' => null], $updates[1][1]);
+ $this->assertSame(['id' => 10], $updates[1][2]);
+
+ $this->assertSame(['o' => 2, 'parent_id' => 10], $updates[2][1]);
+ $this->assertSame(['id' => 11], $updates[2][2]);
+ }
+
+ public function testSaveProductOrderReturnsFalseForInvalidInput(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->expects($this->never())->method('update');
+
+ $repository = new CategoryRepository($mockDb);
+
+ $this->assertFalse($repository->saveProductOrder(0, []));
+ $this->assertFalse($repository->saveProductOrder(5, 'x'));
+ }
+
+ public function testSaveProductOrderUpdatesCategoryProductOrder(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+
+ $updates = [];
+ $mockDb->method('update')
+ ->willReturnCallback(function ($table, $row, $where = null) use (&$updates) {
+ $updates[] = [$table, $row, $where];
+ return true;
+ });
+
+ $repository = new CategoryRepository($mockDb);
+
+ $result = $repository->saveProductOrder(7, [
+ ['item_id' => 101],
+ ['item_id' => 102],
+ ]);
+
+ $this->assertTrue($result);
+ $this->assertGreaterThanOrEqual(3, count($updates));
+
+ $this->assertSame('pp_shop_products_categories', $updates[0][0]);
+ $this->assertSame(['o' => 0], $updates[0][1]);
+ $this->assertSame(['category_id' => 7], $updates[0][2]);
+
+ $this->assertSame(['o' => 1], $updates[1][1]);
+ $this->assertSame(['AND' => ['category_id' => 7, 'product_id' => 101]], $updates[1][2]);
+ }
+
+ public function testCategoryDeleteReturnsFalseWhenHasChildren(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+
+ $mockDb->expects($this->once())
+ ->method('count')
+ ->with('pp_shop_categories', ['parent_id' => 5])
+ ->willReturn(1);
+
+ $mockDb->expects($this->never())->method('delete');
+
+ $repository = new CategoryRepository($mockDb);
+
+ $this->assertFalse($repository->categoryDelete(5));
+ }
+
+ public function testCategoryDeleteReturnsTrueWhenDeleted(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+
+ $mockDb->method('count')->willReturn(0);
+ $mockDb->expects($this->once())
+ ->method('delete')
+ ->with('pp_shop_categories', ['id' => 8])
+ ->willReturn(true);
+
+ $repository = new CategoryRepository($mockDb);
+
+ $this->assertTrue($repository->categoryDelete(8));
+ }
+
+ public function testCategoryTitleReturnsEmptyWhenNotFound(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->method('select')->willReturn([]);
+
+ $repository = new CategoryRepository($mockDb);
+
+ $this->assertSame('', $repository->categoryTitle(10));
+ }
+
+ public function testCategoryTitleReturnsFirstAvailableTitle(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->method('select')
+ ->willReturn(['Kategoria testowa']);
+
+ $repository = new CategoryRepository($mockDb);
+
+ $this->assertSame('Kategoria testowa', $repository->categoryTitle(10));
+ }
+}
diff --git a/tests/Unit/admin/Controllers/ShopCategoryControllerTest.php b/tests/Unit/admin/Controllers/ShopCategoryControllerTest.php
new file mode 100644
index 0000000..679731c
--- /dev/null
+++ b/tests/Unit/admin/Controllers/ShopCategoryControllerTest.php
@@ -0,0 +1,80 @@
+repository = $this->createMock(CategoryRepository::class);
+ $this->languagesRepository = $this->createMock(LanguagesRepository::class);
+ $this->controller = new ShopCategoryController($this->repository, $this->languagesRepository);
+ }
+
+ public function testConstructorAcceptsDependencies(): void
+ {
+ $controller = new ShopCategoryController($this->repository, $this->languagesRepository);
+ $this->assertInstanceOf(ShopCategoryController::class, $controller);
+ }
+
+ public function testHasExpectedActionMethods(): void
+ {
+ $this->assertTrue(method_exists($this->controller, 'view_list'));
+ $this->assertTrue(method_exists($this->controller, 'list'));
+ $this->assertTrue(method_exists($this->controller, 'category_edit'));
+ $this->assertTrue(method_exists($this->controller, 'edit'));
+ $this->assertTrue(method_exists($this->controller, 'save'));
+ $this->assertTrue(method_exists($this->controller, 'category_delete'));
+ $this->assertTrue(method_exists($this->controller, 'delete'));
+ $this->assertTrue(method_exists($this->controller, 'category_products'));
+ $this->assertTrue(method_exists($this->controller, 'products'));
+ $this->assertTrue(method_exists($this->controller, 'category_url_browser'));
+ $this->assertTrue(method_exists($this->controller, 'save_categories_order'));
+ $this->assertTrue(method_exists($this->controller, 'save_products_order'));
+ $this->assertTrue(method_exists($this->controller, 'cookie_categories'));
+ }
+
+ public function testViewActionsReturnString(): void
+ {
+ $reflection = new \ReflectionClass($this->controller);
+
+ $this->assertEquals('string', (string)$reflection->getMethod('view_list')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('list')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('category_edit')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('edit')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('category_products')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('products')->getReturnType());
+ }
+
+ public function testMutationActionsReturnVoid(): void
+ {
+ $reflection = new \ReflectionClass($this->controller);
+
+ $this->assertEquals('void', (string)$reflection->getMethod('save')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('category_delete')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('delete')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('category_url_browser')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('save_categories_order')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('save_products_order')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('cookie_categories')->getReturnType());
+ }
+
+ public function testConstructorRequiresCategoryAndLanguagesRepositories(): void
+ {
+ $reflection = new \ReflectionClass(ShopCategoryController::class);
+ $constructor = $reflection->getConstructor();
+ $params = $constructor->getParameters();
+
+ $this->assertCount(2, $params);
+ $this->assertEquals('Domain\\Category\\CategoryRepository', $params[0]->getType()->getName());
+ $this->assertEquals('Domain\\Languages\\LanguagesRepository', $params[1]->getType()->getName());
+ }
+}