diff --git a/.phpunit.result.cache b/.phpunit.result.cache
index 80a3900..1d17c8a 100644
--- a/.phpunit.result.cache
+++ b/.phpunit.result.cache
@@ -1 +1 @@
-{"version":1,"defects":{"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveCreatesNewArticle":3,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveUpdatesExistingArticle":3,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveTranslationsInsertsForNewArticle":4,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveTranslationsUpsertsForExistingArticle":4,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSavePagesForNewArticle":4,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveDeletesMarkedImagesOnUpdate":4},"times":{"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetQuantityReturnsCorrectValue":0.001,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetQuantityReturnsNullWhenProductNotFound":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testFindReturnsProductData":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testUpdateQuantitySuccess":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetPriceReturnsRegularPrice":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetPriceReturnsPromoPrice":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetPriceReturnsRegularWhenPromoIsHigher":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetPriceReturnsNullWhenNotFound":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetNameReturnsProductName":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetNameReturnsNullWhenNotFound":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetQuantityReturnsInteger":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testUnarchiveUpdatesProductAndChildren":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testArchiveUpdatesProductAndChildren":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testUnarchiveReturnsBool":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testArchiveReturnsBool":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testConstructorAcceptsRepository":0.002,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testHasListMethod":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testHasUnarchiveMethod":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testListMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testUnarchiveMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testConstructorRequiresProductRepository":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testFindReturnsBannerWithTranslations":0.004,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testFindReturnsNullWhenNotFound":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testDeleteReturnsTrue":0.002,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testSaveInsertsNewBanner":0,"Tests\\Unit\\Domain\\Cache\\CacheRepositoryTest::testClearCacheWithRedis":0.002,"Tests\\Unit\\Domain\\Cache\\CacheRepositoryTest::testClearCacheRedisUnavailable":0,"Tests\\Unit\\Domain\\Cache\\CacheRepositoryTest::testClearCacheWithoutRedis":0,"Tests\\Unit\\Domain\\Cache\\CacheRepositoryTest::testClearCacheReturnStructure":0,"Tests\\Unit\\Domain\\Settings\\SettingsRepositoryTest::testCanBeInstantiated":0.001,"Tests\\Unit\\Domain\\Settings\\SettingsRepositoryTest::testHasSaveSettingsMethod":0,"Tests\\Unit\\Domain\\Settings\\SettingsRepositoryTest::testHasGetSettingsMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testConstructorAcceptsRepository":0.001,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testHasClearCacheMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testHasClearCacheAjaxMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testHasSaveMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testHasViewMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testIsNotAbstract":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testActionMethodReturnTypes":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testCanCreateController":0.002,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testHasListMethod":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testListMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testHasEditMethod":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testEditMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testConstructorAcceptsRepository":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testConstructorRequiresArticleRepository":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testFindReturnsArticleWithRelations":0.004,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testFindReturnsNullWhenArticleDoesNotExist":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testDeleteNonassignedFilesDeletesDbRows":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testDeleteNonassignedImagesDeletesDbRows":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveCreatesNewArticle":0.001,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveReturnsZeroWhenInsertFails":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveUpdatesExistingArticle":0.001,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveTranslationsInsertsForNewArticle":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveTranslationsUpsertsForExistingArticle":0.001,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSavePagesForNewArticle":0.001,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveDeletesMarkedImagesOnUpdate":0.001,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testArchiveSetsStatusToMinusOne":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testArchiveReturnsFalseWhenUpdateFails":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveGalleryOrderUpdatesImageOrder":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveGalleryOrderSkipsEmptyValues":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testHasBrowseListMethod":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testHasGalleryOrderSaveMethod":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testBrowseListMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testGalleryOrderSaveMethodReturnType":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testListForAdminWhitelistsSortAndDirection":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testListForAdminUsesBoundParamsForTitleFilter":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testSaveWithLegacyFormat":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testSaveUpdatesExistingTranslationsByBannerAndLang":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testListForAdminIncludesThumbnailSrc":0.001}}
\ No newline at end of file
+{"version":1,"defects":{"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveCreatesNewArticle":3,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveUpdatesExistingArticle":3,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveTranslationsInsertsForNewArticle":4,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveTranslationsUpsertsForExistingArticle":4,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSavePagesForNewArticle":4,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveDeletesMarkedImagesOnUpdate":4},"times":{"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetQuantityReturnsCorrectValue":0.001,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetQuantityReturnsNullWhenProductNotFound":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testFindReturnsProductData":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testUpdateQuantitySuccess":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetPriceReturnsRegularPrice":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetPriceReturnsPromoPrice":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetPriceReturnsRegularWhenPromoIsHigher":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetPriceReturnsNullWhenNotFound":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetNameReturnsProductName":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetNameReturnsNullWhenNotFound":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testGetQuantityReturnsInteger":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testUnarchiveUpdatesProductAndChildren":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testArchiveUpdatesProductAndChildren":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testUnarchiveReturnsBool":0,"Tests\\Unit\\Domain\\Product\\ProductRepositoryTest::testArchiveReturnsBool":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testConstructorAcceptsRepository":0.001,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testHasListMethod":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testHasUnarchiveMethod":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testListMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testUnarchiveMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ProductArchiveControllerTest::testConstructorRequiresProductRepository":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testFindReturnsBannerWithTranslations":0.001,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testFindReturnsNullWhenNotFound":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testDeleteReturnsTrue":0.002,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testSaveInsertsNewBanner":0,"Tests\\Unit\\Domain\\Cache\\CacheRepositoryTest::testClearCacheWithRedis":0.001,"Tests\\Unit\\Domain\\Cache\\CacheRepositoryTest::testClearCacheRedisUnavailable":0,"Tests\\Unit\\Domain\\Cache\\CacheRepositoryTest::testClearCacheWithoutRedis":0,"Tests\\Unit\\Domain\\Cache\\CacheRepositoryTest::testClearCacheReturnStructure":0,"Tests\\Unit\\Domain\\Settings\\SettingsRepositoryTest::testCanBeInstantiated":0,"Tests\\Unit\\Domain\\Settings\\SettingsRepositoryTest::testHasSaveSettingsMethod":0,"Tests\\Unit\\Domain\\Settings\\SettingsRepositoryTest::testHasGetSettingsMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testConstructorAcceptsRepository":0.001,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testHasClearCacheMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testHasClearCacheAjaxMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testHasSaveMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testHasViewMethod":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testIsNotAbstract":0,"Tests\\Unit\\admin\\Controllers\\SettingsControllerTest::testActionMethodReturnTypes":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testCanCreateController":0.002,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testHasListMethod":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testListMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testHasEditMethod":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testEditMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testConstructorAcceptsRepository":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testConstructorRequiresArticleRepository":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testFindReturnsArticleWithRelations":0.004,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testFindReturnsNullWhenArticleDoesNotExist":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testDeleteNonassignedFilesDeletesDbRows":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testDeleteNonassignedImagesDeletesDbRows":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveCreatesNewArticle":0.001,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveReturnsZeroWhenInsertFails":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveUpdatesExistingArticle":0.001,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveTranslationsInsertsForNewArticle":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveTranslationsUpsertsForExistingArticle":0.001,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSavePagesForNewArticle":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveDeletesMarkedImagesOnUpdate":0.001,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testArchiveSetsStatusToMinusOne":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testArchiveReturnsFalseWhenUpdateFails":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveGalleryOrderUpdatesImageOrder":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testSaveGalleryOrderSkipsEmptyValues":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testHasBrowseListMethod":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testHasGalleryOrderSaveMethod":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testBrowseListMethodReturnType":0,"Tests\\Unit\\admin\\Controllers\\ArticlesControllerTest::testGalleryOrderSaveMethodReturnType":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testListForAdminWhitelistsSortAndDirection":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testListForAdminUsesBoundParamsForTitleFilter":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testSaveWithLegacyFormat":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testSaveUpdatesExistingTranslationsByBannerAndLang":0,"Tests\\Unit\\Domain\\Banner\\BannerRepositoryTest::testListForAdminIncludesThumbnailSrc":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testRestoreSetsStatusToZero":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testDeletePermanentlyRemovesArticleAndRelations":0,"Tests\\Unit\\Domain\\Article\\ArticleRepositoryTest::testListArchivedForAdminWhitelistsSortAndDirection":0,"Tests\\Unit\\Domain\\Dictionaries\\DictionariesRepositoryTest::testFindReturnsUnitWithTranslations":0.001,"Tests\\Unit\\Domain\\Dictionaries\\DictionariesRepositoryTest::testFindReturnsNullWhenUnitNotFound":0,"Tests\\Unit\\Domain\\Dictionaries\\DictionariesRepositoryTest::testSaveInsertsNewUnitAndTranslationsForStringLanguageId":0,"Tests\\Unit\\Domain\\Dictionaries\\DictionariesRepositoryTest::testDeleteRemovesUnitAndTranslations":0,"Tests\\Unit\\Domain\\Dictionaries\\DictionariesRepositoryTest::testGetUnitNameByIdReturnsTextFromDatabase":0,"Tests\\Unit\\Domain\\Dictionaries\\DictionariesRepositoryTest::testGetUnitNameByIdSupportsStringLanguageId":0,"Tests\\Unit\\Domain\\Dictionaries\\DictionariesRepositoryTest::testAllUnitsReturnsArrayIndexedById":0,"Tests\\Unit\\Domain\\Languages\\LanguagesRepositoryTest::testLanguageDetailsReturnsArrayOrNull":0.001,"Tests\\Unit\\Domain\\Languages\\LanguagesRepositoryTest::testLanguagesListReturnsArray":0,"Tests\\Unit\\Domain\\Languages\\LanguagesRepositoryTest::testSaveLanguageRejectsInvalidLanguageId":0,"Tests\\Unit\\Domain\\Languages\\LanguagesRepositoryTest::testSaveTranslationInsertsNewTranslationAndReturnsId":0,"Tests\\Unit\\Domain\\Languages\\LanguagesRepositoryTest::testDeleteTranslationReturnsBoolean":0,"Tests\\Unit\\Domain\\Languages\\LanguagesRepositoryTest::testListForAdminReturnsItemsAndTotal":0,"Tests\\Unit\\Domain\\Languages\\LanguagesRepositoryTest::testDefaultLanguageIdReturnsLanguageWithStartFlag":0,"Tests\\Unit\\Domain\\Languages\\LanguagesRepositoryTest::testDefaultLanguageIdFallsBackToFirstLanguageOrPl":0,"Tests\\Unit\\Domain\\Layouts\\LayoutsRepositoryTest::testFindReturnsLayoutWithRelations":0.001,"Tests\\Unit\\Domain\\Layouts\\LayoutsRepositoryTest::testDeleteReturnsFalseWhenOnlyOneLayoutExists":0,"Tests\\Unit\\Domain\\Layouts\\LayoutsRepositoryTest::testFindReturnsDefaultLayoutWhenRecordDoesNotExist":0,"Tests\\Unit\\Domain\\Layouts\\LayoutsRepositoryTest::testSaveInsertsNewLayoutAndReturnsId":0,"Tests\\Unit\\Domain\\Layouts\\LayoutsRepositoryTest::testListAllReturnsArray":0,"Tests\\Unit\\Domain\\Newsletter\\NewsletterRepositoryTest::testTemplateDetailsReturnsNullForInvalidId":0.002,"Tests\\Unit\\Domain\\Newsletter\\NewsletterRepositoryTest::testTemplateDetailsReturnsArray":0,"Tests\\Unit\\Domain\\Newsletter\\NewsletterRepositoryTest::testSaveSettingsUpdatesHeaderAndFooter":0,"Tests\\Unit\\Domain\\Newsletter\\NewsletterRepositoryTest::testDeleteTemplateReturnsFalseForAdminTemplate":0,"Tests\\Unit\\Domain\\Newsletter\\NewsletterRepositoryTest::testTemplateByNameReturnsText":0,"Tests\\Unit\\Domain\\Scontainers\\ScontainersRepositoryTest::testFindReturnsDefaultContainerForInvalidId":0.001,"Tests\\Unit\\Domain\\Scontainers\\ScontainersRepositoryTest::testDeleteReturnsFalseForInvalidId":0,"Tests\\Unit\\Domain\\Scontainers\\ScontainersRepositoryTest::testFindReturnsContainerWithTranslations":0,"Tests\\Unit\\Domain\\Scontainers\\ScontainersRepositoryTest::testDetailsForLanguageReturnsNullForInvalidData":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testFindReturnsUserWhenExists":0.001,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testFindReturnsNullWhenNotFound":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testCheckLoginReturnsErrorWhenLoginIsTaken":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testCheckLoginReturnsOkWhenAvailable":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testSaveReturnsErrorForTooShortPasswordOnCreate":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testSaveReturnsErrorForMismatchedPasswordsOnCreate":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testSaveCreatesUserWithNormalizedSwitches":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testSaveUpdatesExistingUserWithPassword":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testSaveUpdatesExistingUserWithoutPassword":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testSaveReturnsErrorForTooShortPasswordOnUpdate":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testSaveReturnsErrorForMismatchedPasswordsOnUpdate":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testDeleteReturnsTrue":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testDeleteReturnsFalseOnFailure":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testDetailsReturnsUserByLogin":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testDetailsReturnsNullWhenNotFound":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testLogonReturnsSuccessForValidCredentials":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testLogonReturnsZeroForNonexistentUser":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testLogonReturnsNegativeOneForBlockedUser":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testVerifyTwofaCodeReturnsFalseForNonexistentUser":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testVerifyTwofaCodeReturnsFalseAfterMaxAttempts":0.078,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testVerifyTwofaCodeReturnsFalseForExpiredCode":0.079,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testVerifyTwofaCodeReturnsTrueForValidCode":0.158,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testSendTwofaCodeReturnsFalseWhen2FADisabled":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testSendTwofaCodeReturnsFalseForInvalidEmail":0.001,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testUpdateByIdCallsDbUpdate":0,"Tests\\Unit\\Domain\\User\\UserRepositoryTest::testListForAdminReturnsItemsAndTotal":0,"Tests\\Unit\\admin\\Controllers\\ArticlesArchiveControllerTest::testConstructorAcceptsRepository":0.002,"Tests\\Unit\\admin\\Controllers\\ArticlesArchiveControllerTest::testHasMainActionMethods":0,"Tests\\Unit\\admin\\Controllers\\ArticlesArchiveControllerTest::testActionMethodReturnTypes":0,"Tests\\Unit\\admin\\Controllers\\ArticlesArchiveControllerTest::testConstructorRequiresArticleRepository":0,"Tests\\Unit\\admin\\Controllers\\DictionariesControllerTest::testConstructorAcceptsRepository":0.002,"Tests\\Unit\\admin\\Controllers\\DictionariesControllerTest::testHasListMethod":0,"Tests\\Unit\\admin\\Controllers\\DictionariesControllerTest::testHasEditMethod":0,"Tests\\Unit\\admin\\Controllers\\DictionariesControllerTest::testHasSaveMethod":0,"Tests\\Unit\\admin\\Controllers\\DictionariesControllerTest::testHasDeleteMethod":0,"Tests\\Unit\\admin\\Controllers\\DictionariesControllerTest::testActionMethodReturnTypes":0,"Tests\\Unit\\admin\\Controllers\\DictionariesControllerTest::testConstructorRequiresDictionariesRepository":0,"Tests\\Unit\\admin\\Controllers\\LanguagesControllerTest::testConstructorAcceptsRepository":0.001,"Tests\\Unit\\admin\\Controllers\\LanguagesControllerTest::testHasMainActionMethods":0,"Tests\\Unit\\admin\\Controllers\\LanguagesControllerTest::testActionMethodReturnTypes":0,"Tests\\Unit\\admin\\Controllers\\LanguagesControllerTest::testConstructorRequiresLanguagesRepository":0,"Tests\\Unit\\admin\\Controllers\\LayoutsControllerTest::testConstructorAcceptsRepository":0.001,"Tests\\Unit\\admin\\Controllers\\LayoutsControllerTest::testHasMainActionMethods":0,"Tests\\Unit\\admin\\Controllers\\LayoutsControllerTest::testActionMethodReturnTypes":0,"Tests\\Unit\\admin\\Controllers\\LayoutsControllerTest::testConstructorRequiresLayoutsRepository":0,"Tests\\Unit\\admin\\Controllers\\NewsletterControllerTest::testConstructorAcceptsDependencies":0.003,"Tests\\Unit\\admin\\Controllers\\NewsletterControllerTest::testHasMainActionMethods":0,"Tests\\Unit\\admin\\Controllers\\NewsletterControllerTest::testActionMethodReturnTypes":0,"Tests\\Unit\\admin\\Controllers\\NewsletterControllerTest::testConstructorRequiresRepositoryAndRenderer":0,"Tests\\Unit\\admin\\Controllers\\ScontainersControllerTest::testConstructorAcceptsDependencies":0.001,"Tests\\Unit\\admin\\Controllers\\ScontainersControllerTest::testHasMainActionMethods":0,"Tests\\Unit\\admin\\Controllers\\ScontainersControllerTest::testActionMethodReturnTypes":0,"Tests\\Unit\\admin\\Controllers\\ScontainersControllerTest::testConstructorRequiresRepositoryAndLanguagesRepository":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testConstructorAcceptsRepository":0.002,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testHasViewListMethod":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testHasUserEditMethod":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testHasUserSaveMethod":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testHasUserDeleteMethod":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testHasTwofaMethod":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testHasLoginFormMethod":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testActionMethodReturnTypes":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testConstructorRequiresUserRepository":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testNormalizeUserReturnsDefaultsForNull":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testNormalizeUserCastsTypes":0,"Tests\\Unit\\admin\\Controllers\\UsersControllerTest::testNormalizeUserHandlesPartialData":0}}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 0f57855..730c8be 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,3 +24,8 @@ Przed rozpoczęciem implementacji sprawdź aktualną zawartość:
- `TESTING.md`
To ma pomóc zachować spójność zmian i dokumentacji.
+
+
+## INNE
+
+Przejdźmy teraz do refaktoringu wszystkiego co związane z https://shoppro.project-dc.pl/admin/articles_archive/, nowe widoki, klasy (usuwanie starych), poprawa routingu, przeszukanie innych klas pod względem zależności. Zapisz plan a później realizuj krok po kroku.
\ No newline at end of file
diff --git a/admin/templates/scontainers/container-edit.php b/admin/templates/scontainers/container-edit.php
index e74e80c..40bf9e9 100644
--- a/admin/templates/scontainers/container-edit.php
+++ b/admin/templates/scontainers/container-edit.php
@@ -1,123 +1 @@
-
-
-
-global $db;
-
-ob_start();
-?>
-
-
-
-
-
-
- if ( is_array( $this -> languages ) ): foreach ( $this -> languages as $lg ):?>
- if ( $lg['status'] ):?>
- - = $lg['name'];?>
- endif;?>
- endforeach; endif;?>
-
-
- if ( is_array( $this -> languages ) ): foreach ( $this -> languages as $lg ):?>
- if ( $lg['status'] ):?>
-
- = \Html::input(
- array(
- 'label' => 'Tytuł',
- 'name' => 'title[' . $lg['id'] . ']',
- 'id' => 'title_' . $lg['id'],
- 'value' => $this -> container['languages'][ $lg['id'] ]['title'],
- 'inline' => true
- )
- );?>
- = \Html::textarea(
- array(
- 'label' => 'Treść',
- 'name' => 'text[' . $lg['id'] . ']',
- 'id' => 'text_' . $lg['id'],
- 'value' => $this -> container['languages'][ $lg['id'] ]['text'],
- 'inline' => true
- )
- );?>
-
-
- endif;?>
- endforeach; endif;?>
-
-
-
-
-
- = \Html::input_switch(
- array(
- 'label' => 'Aktywny',
- 'name' => 'status',
- 'checked' => $this -> container['status'] == 1 or !$this -> container['id'] ? true : false
- )
- );?>
- = \Html::input_switch(
- array(
- 'label' => 'Pokaż tytuł',
- 'name' => 'show_title',
- 'checked' => $this -> container['show_title'] == 1 ? true : false
- )
- );?>
-
-
-
-
-$out = ob_get_clean();
-
-$grid = new \gridEdit;
-$grid -> id = 'container-edit';
-$grid -> gdb_opt = $gdb;
-$grid -> include_plugins = true;
-$grid -> title = 'Edycja kontenera statycznego';
-$grid -> fields = [
- [
- 'db' => 'id',
- 'type' => 'hidden',
- 'value' => $this -> container['id']
- ]
- ];
-$grid -> actions = [
- 'save' => [ 'url' => '/admin/scontainers/container_save/', 'back_url' => '/admin/scontainers/view_list/' ],
- 'cancel' => [ 'url' => '/admin/scontainers/view_list/' ]
- ];
-$grid -> external_code = $out;
-$grid -> persist_edit = true;
-$grid -> id_param = 'id';
-
-echo $grid -> draw();
-?>
-
-
\ No newline at end of file
+= \Tpl::view('components/form-edit', ['form' => $this->form]); ?>
diff --git a/admin/templates/scontainers/containers-list.php b/admin/templates/scontainers/containers-list.php
index 0573daa..d2ecb80 100644
--- a/admin/templates/scontainers/containers-list.php
+++ b/admin/templates/scontainers/containers-list.php
@@ -1,79 +1 @@
- gdb_opt = $gdb;
-$grid -> sql = 'SELECT *'
- . 'FROM ( '
- . 'SELECT '
- . 'id, status, '
- . '( SELECT title FROM pp_scontainers_langs AS psl, pp_langs AS pl WHERE lang_id = pl.id AND container_id = ps.id AND title != \'\' ORDER BY o ASC LIMIT 1 ) AS title '
- . 'FROM '
- . 'pp_scontainers AS ps '
- . ') AS q1 '
- . 'WHERE '
- . '1=1 [where] '
- . 'ORDER BY '
- . '[order_p1] [order_p2]';
-$grid -> sql_count = 'SELECT '
- . 'COUNT(0) FROM ( '
- . 'SELECT '
- . 'id, status, '
- . '( SELECT title FROM pp_scontainers_langs AS psl, pp_langs AS pl WHERE lang_id = pl.id AND container_id = ps.id AND title != \'\' ORDER BY o ASC LIMIT 1 ) AS title '
- . 'FROM '
- . 'pp_scontainers AS ps '
- . ') AS q1 '
- . 'WHERE '
- . '1=1 [where] ';
-$grid -> debug = true;
-$grid -> order = [ 'column' => 'id', 'type' => 'DESC' ];
-$grid -> search = [
- [ 'name' => 'Tytuł', 'db' => 'title', 'type' => 'text' ],
- [ 'name' => 'Aktywny', 'db' => 'status', 'type' => 'select', 'replace' => [ 'array' => [ 0 => 'nie', 1 => 'tak' ] ] ]
- ];
-$grid -> columns_view = [
- [
- 'name' => 'Lp.',
- 'th' => [ 'class' => 'g-lp' ],
- 'td' => [ 'class' => 'g-center' ],
- 'autoincrement' => true
- ],
- [
- 'name' => 'Tytuł',
- 'db' => 'title',
- 'php' => 'echo "[title]";',
- 'sort' => true
- ],
- [
- 'name' => 'Kod',
- 'php' => 'echo "[KONTENER:[id]]";'
- ],
- [
- 'name' => 'Aktywny',
- 'db' => 'status',
- 'replace' => [ 'array' => [ 0 => 'nie', 1 => 'tak' ] ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 150px;' ],
- 'td' => [ 'class' => 'g-center' ]
- ],
- [
- 'name' => 'Edytuj',
- 'action' => [ 'type' => 'edit', 'url' => '/admin/scontainers/container_edit/id=[id]' ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 70px;' ],
- 'td' => [ 'class' => 'g-center' ]
- ],
- [
- 'name' => 'Usuń',
- 'action' => [ 'type' => 'delete', 'url' => '/admin/scontainers/container_delete/id=[id]' ],
- 'th' => [ 'class' => 'g-center', 'style' => 'width: 70px;' ],
- 'td' => [ 'class' => 'g-center' ]
- ]
- ];
-$grid -> buttons = [
- [
- 'label' => 'Dodaj kontener',
- 'url' => '/admin/scontainers/container_edit/',
- 'icon' => 'fa-plus-circle',
- 'class' => 'btn-success'
- ]
- ];
-echo $grid -> draw();
\ No newline at end of file
+= \Tpl::view('components/table-list', ['list' => $this->viewModel]); ?>
diff --git a/autoload/Domain/Scontainers/ScontainersRepository.php b/autoload/Domain/Scontainers/ScontainersRepository.php
new file mode 100644
index 0000000..bc94b33
--- /dev/null
+++ b/autoload/Domain/Scontainers/ScontainersRepository.php
@@ -0,0 +1,311 @@
+db = $db;
+ }
+
+ /**
+ * @return array{items: array>, total: int}
+ */
+ public function listForAdmin(
+ array $filters,
+ string $sortColumn = 'id',
+ string $sortDir = 'DESC',
+ int $page = 1,
+ int $perPage = 15
+ ): array {
+ $allowedSortColumns = [
+ 'id' => 'q1.id',
+ 'title' => 'q1.title',
+ 'status' => 'q1.status',
+ ];
+
+ $sortSql = $allowedSortColumns[$sortColumn] ?? 'q1.id';
+ $sortDir = strtoupper(trim($sortDir)) === 'ASC' ? 'ASC' : 'DESC';
+ $page = max(1, $page);
+ $perPage = min(self::MAX_PER_PAGE, max(1, $perPage));
+ $offset = ($page - 1) * $perPage;
+
+ $where = ['1 = 1'];
+ $params = [];
+
+ $title = trim((string)($filters['title'] ?? ''));
+ if ($title !== '') {
+ if (strlen($title) > 255) {
+ $title = substr($title, 0, 255);
+ }
+ $where[] = 'q1.title LIKE :title';
+ $params[':title'] = '%' . $title . '%';
+ }
+
+ $status = trim((string)($filters['status'] ?? ''));
+ if ($status === '0' || $status === '1') {
+ $where[] = 'q1.status = :status';
+ $params[':status'] = (int)$status;
+ }
+
+ $whereSql = implode(' AND ', $where);
+ $baseSelect = $this->baseListSelect();
+
+ $sqlCount = "
+ SELECT COUNT(0)
+ FROM ({$baseSelect}) AS q1
+ WHERE {$whereSql}
+ ";
+
+ $stmtCount = $this->db->query($sqlCount, $params);
+ $countRows = $stmtCount ? $stmtCount->fetchAll() : [];
+ $total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0;
+
+ $sql = "
+ SELECT q1.*
+ FROM ({$baseSelect}) AS q1
+ WHERE {$whereSql}
+ ORDER BY {$sortSql} {$sortDir}, q1.id DESC
+ LIMIT {$perPage} OFFSET {$offset}
+ ";
+
+ $stmt = $this->db->query($sql, $params);
+ $items = $stmt ? $stmt->fetchAll() : [];
+
+ return [
+ 'items' => is_array($items) ? $items : [],
+ 'total' => $total,
+ ];
+ }
+
+ public function find(int $containerId): array
+ {
+ if ($containerId <= 0) {
+ return $this->defaultContainer();
+ }
+
+ $container = $this->db->get('pp_scontainers', '*', ['id' => $containerId]);
+ if (!is_array($container)) {
+ return $this->defaultContainer();
+ }
+
+ $container['languages'] = $this->translationsMap($containerId);
+ return $container;
+ }
+
+ public function detailsForLanguage(int $containerId, string $langId): ?array
+ {
+ if ($containerId <= 0 || trim($langId) === '') {
+ return null;
+ }
+
+ $container = $this->db->get('pp_scontainers', '*', ['id' => $containerId]);
+ if (!is_array($container)) {
+ return null;
+ }
+
+ $translation = $this->db->get('pp_scontainers_langs', '*', [
+ 'AND' => [
+ 'container_id' => $containerId,
+ 'lang_id' => $langId,
+ ],
+ ]);
+
+ $container['languages'] = is_array($translation) ? $translation : [
+ 'lang_id' => $langId,
+ 'title' => '',
+ 'text' => '',
+ ];
+
+ return $container;
+ }
+
+ public function save(array $data): ?int
+ {
+ $containerId = (int)($data['id'] ?? 0);
+ $status = $this->toSwitchValue($data['status'] ?? 0);
+ $showTitle = $this->toSwitchValue($data['show_title'] ?? 0);
+ $translations = $this->extractTranslations($data);
+
+ if ($containerId <= 0) {
+ $this->db->insert('pp_scontainers', [
+ 'status' => $status,
+ 'show_title' => $showTitle,
+ ]);
+ $containerId = (int)$this->db->id();
+ if ($containerId <= 0) {
+ return null;
+ }
+ } else {
+ $this->db->update('pp_scontainers', [
+ 'status' => $status,
+ 'show_title' => $showTitle,
+ ], [
+ 'id' => $containerId,
+ ]);
+ }
+
+ foreach ($translations as $langId => $row) {
+ $translationId = $this->db->get('pp_scontainers_langs', 'id', [
+ 'AND' => [
+ 'container_id' => $containerId,
+ 'lang_id' => $langId,
+ ],
+ ]);
+
+ if ($translationId) {
+ $this->db->update('pp_scontainers_langs', [
+ 'title' => (string)($row['title'] ?? ''),
+ 'text' => (string)($row['text'] ?? ''),
+ ], [
+ 'id' => (int)$translationId,
+ ]);
+ } else {
+ $this->db->insert('pp_scontainers_langs', [
+ 'container_id' => $containerId,
+ 'lang_id' => $langId,
+ 'title' => (string)($row['title'] ?? ''),
+ 'text' => (string)($row['text'] ?? ''),
+ ]);
+ }
+ }
+
+ \S::delete_dir('../temp/');
+ $this->clearFrontCache($containerId);
+
+ return $containerId;
+ }
+
+ public function delete(int $containerId): bool
+ {
+ if ($containerId <= 0) {
+ return false;
+ }
+
+ $result = (bool)$this->db->delete('pp_scontainers', ['id' => $containerId]);
+ if ($result) {
+ $this->clearFrontCache($containerId);
+ }
+
+ return $result;
+ }
+
+ private function baseListSelect(): string
+ {
+ return "
+ SELECT
+ ps.id,
+ ps.status,
+ (
+ SELECT psl.title
+ FROM pp_scontainers_langs AS psl
+ JOIN pp_langs AS pl ON psl.lang_id = pl.id
+ WHERE psl.container_id = ps.id
+ AND psl.title <> ''
+ ORDER BY pl.o ASC
+ LIMIT 1
+ ) AS title
+ FROM pp_scontainers AS ps
+ ";
+ }
+
+ private function clearFrontCache(int $containerId): void
+ {
+ if ($containerId <= 0 || !class_exists('\CacheHandler')) {
+ return;
+ }
+
+ $cacheHandler = new \CacheHandler();
+ $cacheKey = '\front\factory\Scontainers::scontainer_details:' . $containerId;
+ $cacheHandler->delete($cacheKey);
+ }
+
+ /**
+ * @return array>
+ */
+ private function translationsMap(int $containerId): array
+ {
+ $rows = $this->db->select('pp_scontainers_langs', '*', ['container_id' => $containerId]);
+ if (!is_array($rows)) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($rows as $row) {
+ $langId = (string)($row['lang_id'] ?? '');
+ if ($langId !== '') {
+ $result[$langId] = $row;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return array>
+ */
+ private function extractTranslations(array $data): array
+ {
+ $translations = [];
+
+ if (isset($data['translations']) && is_array($data['translations'])) {
+ foreach ($data['translations'] as $langId => $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+
+ $safeLangId = trim((string)$langId);
+ if ($safeLangId === '') {
+ continue;
+ }
+
+ $translations[$safeLangId] = [
+ 'title' => (string)($row['title'] ?? ''),
+ 'text' => (string)($row['text'] ?? ''),
+ ];
+ }
+ }
+
+ $legacyTitles = isset($data['title']) && is_array($data['title']) ? $data['title'] : [];
+ $legacyTexts = isset($data['text']) && is_array($data['text']) ? $data['text'] : [];
+
+ foreach ($legacyTitles as $langId => $title) {
+ $safeLangId = trim((string)$langId);
+ if ($safeLangId === '') {
+ continue;
+ }
+
+ if (!isset($translations[$safeLangId])) {
+ $translations[$safeLangId] = [
+ 'title' => '',
+ 'text' => '',
+ ];
+ }
+
+ $translations[$safeLangId]['title'] = (string)$title;
+ $translations[$safeLangId]['text'] = (string)($legacyTexts[$safeLangId] ?? '');
+ }
+
+ return $translations;
+ }
+
+ private function toSwitchValue($value): int
+ {
+ return ($value === 'on' || $value === 1 || $value === '1' || $value === true) ? 1 : 0;
+ }
+
+ private function defaultContainer(): array
+ {
+ return [
+ 'id' => 0,
+ 'status' => 1,
+ 'show_title' => 0,
+ 'languages' => [],
+ ];
+ }
+}
+
diff --git a/autoload/admin/Controllers/ScontainersController.php b/autoload/admin/Controllers/ScontainersController.php
new file mode 100644
index 0000000..083dfbf
--- /dev/null
+++ b/autoload/admin/Controllers/ScontainersController.php
@@ -0,0 +1,297 @@
+repository = $repository;
+ $this->languagesRepository = $languagesRepository;
+ $this->formHandler = new FormRequestHandler();
+ }
+
+ public function list(): string
+ {
+ $sortableColumns = ['id', 'title', 'status'];
+ $filterDefinitions = [
+ [
+ 'key' => 'title',
+ 'label' => 'Tytul',
+ 'type' => 'text',
+ ],
+ [
+ 'key' => 'status',
+ 'label' => 'Aktywny',
+ 'type' => 'select',
+ 'options' => [
+ '' => '- aktywny -',
+ '1' => 'tak',
+ '0' => 'nie',
+ ],
+ ],
+ ];
+
+ $listRequest = \admin\Support\TableListRequestFactory::fromRequest(
+ $filterDefinitions,
+ $sortableColumns,
+ 'id'
+ );
+
+ $sortDir = $listRequest['sortDir'];
+ if (trim((string)\S::get('sort')) === '') {
+ $sortDir = 'DESC';
+ }
+
+ $result = $this->repository->listForAdmin(
+ $listRequest['filters'],
+ $listRequest['sortColumn'],
+ $sortDir,
+ $listRequest['page'],
+ $listRequest['perPage']
+ );
+
+ $rows = [];
+ $lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1;
+ foreach ($result['items'] as $item) {
+ $id = (int)($item['id'] ?? 0);
+ $title = trim((string)($item['title'] ?? ''));
+
+ $rows[] = [
+ 'lp' => $lp++ . '.',
+ 'title' => '' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '',
+ 'code' => '[KONTENER:' . $id . ']',
+ 'status' => ((int)($item['status'] ?? 0) === 1) ? 'tak' : 'nie',
+ '_actions' => [
+ [
+ 'label' => 'Edytuj',
+ 'url' => '/admin/scontainers/container_edit/id=' . $id,
+ 'class' => 'btn btn-xs btn-primary',
+ ],
+ [
+ 'label' => 'Usun',
+ 'url' => '/admin/scontainers/container_delete/id=' . $id,
+ 'class' => 'btn btn-xs btn-danger',
+ 'confirm' => 'Na pewno chcesz usunac wybrany kontener?',
+ ],
+ ],
+ ];
+ }
+
+ $total = (int)$result['total'];
+ $totalPages = max(1, (int)ceil($total / $listRequest['perPage']));
+
+ $viewModel = new PaginatedTableViewModel(
+ [
+ ['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false],
+ ['key' => 'title', 'sort_key' => 'title', 'label' => 'Tytul', 'sortable' => true, 'raw' => true],
+ ['key' => 'code', 'label' => 'Kod', 'sortable' => false],
+ ['key' => 'status', 'sort_key' => 'status', 'label' => 'Aktywny', 'class' => 'text-center', 'sortable' => true, 'raw' => true],
+ ],
+ $rows,
+ $listRequest['viewFilters'],
+ [
+ 'column' => $listRequest['sortColumn'],
+ 'dir' => $sortDir,
+ ],
+ [
+ 'page' => $listRequest['page'],
+ 'per_page' => $listRequest['perPage'],
+ 'total' => $total,
+ 'total_pages' => $totalPages,
+ ],
+ array_merge($listRequest['queryFilters'], [
+ 'sort' => $listRequest['sortColumn'],
+ 'dir' => $sortDir,
+ 'per_page' => $listRequest['perPage'],
+ ]),
+ $listRequest['perPageOptions'],
+ $sortableColumns,
+ '/admin/scontainers/view_list/',
+ 'Brak danych w tabeli.',
+ '/admin/scontainers/container_edit/',
+ 'Dodaj kontener'
+ );
+
+ return \Tpl::view('scontainers/containers-list', [
+ 'viewModel' => $viewModel,
+ ]);
+ }
+
+ public function view_list(): string
+ {
+ return $this->list();
+ }
+
+ public function edit(): string
+ {
+ $container = $this->repository->find((int)\S::get('id'));
+ $languages = $this->languagesRepository->languagesList();
+ $validationErrors = $_SESSION['form_errors'][$this->formId()] ?? null;
+ if ($validationErrors) {
+ unset($_SESSION['form_errors'][$this->formId()]);
+ }
+
+ return \Tpl::view('scontainers/container-edit', [
+ 'form' => $this->buildFormViewModel($container, $languages, $validationErrors),
+ ]);
+ }
+
+ public function container_edit(): string
+ {
+ return $this->edit();
+ }
+
+ public function save(): void
+ {
+ $legacyValues = \S::get('values');
+ if ($legacyValues) {
+ $values = json_decode((string)$legacyValues, true);
+ $response = ['status' => 'error', 'msg' => 'Podczas zapisywania kontenera wystapil blad.'];
+
+ if (is_array($values)) {
+ $savedId = $this->repository->save($values);
+ if (!empty($savedId)) {
+ $response = ['status' => 'ok', 'msg' => 'Kontener zostal zapisany.', 'id' => $savedId];
+ }
+ }
+
+ echo json_encode($response);
+ exit;
+ }
+
+ $container = $this->repository->find((int)\S::get('id'));
+ $languages = $this->languagesRepository->languagesList();
+ $form = $this->buildFormViewModel($container, $languages);
+
+ $result = $this->formHandler->handleSubmit($form, $_POST);
+ if (!$result['success']) {
+ $_SESSION['form_errors'][$this->formId()] = $result['errors'];
+ echo json_encode(['success' => false, 'errors' => $result['errors']]);
+ exit;
+ }
+
+ $data = $result['data'];
+ $savedId = $this->repository->save([
+ 'id' => (int)($data['id'] ?? 0),
+ 'status' => $data['status'] ?? 0,
+ 'show_title' => $data['show_title'] ?? 0,
+ 'translations' => $data['translations'] ?? [],
+ ]);
+
+ if ($savedId) {
+ echo json_encode([
+ 'success' => true,
+ 'id' => $savedId,
+ 'message' => 'Kontener zostal zapisany.',
+ ]);
+ exit;
+ }
+
+ echo json_encode([
+ 'success' => false,
+ 'errors' => ['general' => 'Podczas zapisywania kontenera wystapil blad.'],
+ ]);
+ exit;
+ }
+
+ public function container_save(): void
+ {
+ $this->save();
+ }
+
+ public function delete(): void
+ {
+ if ($this->repository->delete((int)\S::get('id'))) {
+ \S::alert('Kontener zostal usuniety.');
+ }
+
+ header('Location: /admin/scontainers/view_list/');
+ exit;
+ }
+
+ public function container_delete(): void
+ {
+ $this->delete();
+ }
+
+ private function buildFormViewModel(array $container, array $languages, ?array $errors = null): FormEditViewModel
+ {
+ $id = (int)($container['id'] ?? 0);
+ $isNew = $id <= 0;
+
+ $data = [
+ 'id' => $id,
+ 'status' => (int)($container['status'] ?? 1),
+ 'show_title' => (int)($container['show_title'] ?? 0),
+ 'languages' => is_array($container['languages'] ?? null) ? $container['languages'] : [],
+ ];
+
+ $fields = [
+ FormField::hidden('id', $id),
+ FormField::langSection('translations', 'content', [
+ FormField::text('title', [
+ 'label' => 'Tytul',
+ ]),
+ FormField::editor('text', [
+ 'label' => 'Tresc',
+ 'height' => 300,
+ ]),
+ ]),
+ FormField::switch('status', [
+ 'label' => 'Aktywny',
+ 'tab' => 'settings',
+ 'value' => true,
+ ]),
+ FormField::switch('show_title', [
+ 'label' => 'Pokaz tytul',
+ 'tab' => 'settings',
+ ]),
+ ];
+
+ $tabs = [
+ new FormTab('content', 'Tresc', 'fa-file'),
+ new FormTab('settings', 'Ustawienia', 'fa-wrench'),
+ ];
+
+ $actionUrl = '/admin/scontainers/container_save/' . ($isNew ? '' : ('id=' . $id));
+ $actions = [
+ FormAction::save($actionUrl, '/admin/scontainers/view_list/'),
+ FormAction::cancel('/admin/scontainers/view_list/'),
+ ];
+
+ return new FormEditViewModel(
+ $this->formId(),
+ 'Edycja kontenera statycznego',
+ $data,
+ $fields,
+ $tabs,
+ $actions,
+ 'POST',
+ $actionUrl,
+ '/admin/scontainers/view_list/',
+ true,
+ [],
+ $languages,
+ $errors
+ );
+ }
+
+ private function formId(): string
+ {
+ return 'scontainers-container-edit';
+ }
+}
+
diff --git a/autoload/admin/controls/class.Pages.php b/autoload/admin/controls/class.Pages.php
index afd9d64..80ae118 100644
--- a/autoload/admin/controls/class.Pages.php
+++ b/autoload/admin/controls/class.Pages.php
@@ -66,11 +66,28 @@ class Pages
'parent_id' => \S::get( 'pid' ),
'menu_id' => \S::get( 'menu_id' ),
'menus' => \admin\factory\Pages::menu_lists(),
- 'layouts' => \admin\factory\Layouts::layouts_list(),
+ 'layouts' => self::layouts_for_page_edit( $GLOBALS['mdb'] ),
'languages' => ( new \Domain\Languages\LanguagesRepository( $GLOBALS['mdb'] ) )->languagesList()
] );
}
+ private static function layouts_for_page_edit( $db )
+ {
+ if ( class_exists( '\Domain\Layouts\LayoutsRepository' ) )
+ {
+ $rows = ( new \Domain\Layouts\LayoutsRepository( $db ) ) -> listAll();
+ return is_array( $rows ) ? $rows : [];
+ }
+
+ if ( class_exists( '\admin\factory\Layouts' ) )
+ {
+ $rows = \admin\factory\Layouts::layouts_list();
+ return is_array( $rows ) ? $rows : [];
+ }
+
+ return [];
+ }
+
public static function menu_save()
{
$response = [ 'status' => 'error', 'msg' => 'Podczas zapisywania menu wystąpił błąd. Proszę spróbować ponownie.' ];
diff --git a/autoload/admin/controls/class.Scontainers.php b/autoload/admin/controls/class.Scontainers.php
deleted file mode 100644
index d06e63e..0000000
--- a/autoload/admin/controls/class.Scontainers.php
+++ /dev/null
@@ -1,40 +0,0 @@
- 'error', 'msg' => 'Podczas zapisywania kontenera wystąpił błąd. Proszę spróbować ponownie.' ];
- $values = json_decode( \S::get( 'values' ), true );
-
- if ( $id = \admin\factory\Scontainers::container_save( $values['id'], $values['title'], $values['text'], $values['status'], $values['show_title'] ) )
- $response = [ 'status' => 'ok', 'msg' => 'Kontener został zapisany.', 'id' => $id ];
-
- echo json_encode( $response );
- exit;
- }
-
- public static function container_edit()
- {
- return \admin\view\Scontainers::container_edit(
- \admin\factory\Scontainers::container_details(
- \S::get( 'id' )
- ),
- ( new \Domain\Languages\LanguagesRepository( $GLOBALS['mdb'] ) )->languagesList()
- );
- }
-
- public static function view_list()
- {
- return \admin\view\Scontainers::containers_list();
- }
-}
diff --git a/autoload/admin/controls/class.ShopProduct.php b/autoload/admin/controls/class.ShopProduct.php
index d0181bc..6e1cb97 100644
--- a/autoload/admin/controls/class.ShopProduct.php
+++ b/autoload/admin/controls/class.ShopProduct.php
@@ -244,7 +244,7 @@ class ShopProduct
'product' => \admin\factory\ShopProduct::product_details( (int) \S::get( 'id' ) ),
'languages' => ( new \Domain\Languages\LanguagesRepository( $GLOBALS['mdb'] ) )->languagesList(),
'categories' => \admin\factory\ShopCategory::subcategories( null ),
- 'layouts' => \admin\factory\Layouts::layouts_list(),
+ 'layouts' => self::layouts_for_product_edit( $mdb ),
'products' => \admin\factory\ShopProduct::products_list(),
'dlang' => \front\factory\Languages::default_language(),
'sets' => \shop\ProductSet::sets_list(),
@@ -254,6 +254,23 @@ class ShopProduct
] );
}
+ 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 : [];
+ }
+
+ if ( class_exists( '\admin\factory\Layouts' ) )
+ {
+ $rows = \admin\factory\Layouts::layouts_list();
+ return is_array( $rows ) ? $rows : [];
+ }
+
+ return [];
+ }
+
// ajax_load_products ARCHIVE
static public function ajax_load_products_archive()
{
diff --git a/autoload/admin/factory/class.Scontainers.php b/autoload/admin/factory/class.Scontainers.php
index 438a87e..3f1d819 100644
--- a/autoload/admin/factory/class.Scontainers.php
+++ b/autoload/admin/factory/class.Scontainers.php
@@ -3,86 +3,30 @@ namespace admin\factory;
class Scontainers
{
- public static function container_delete( $container_id )
+ private static function repository(): \Domain\Scontainers\ScontainersRepository
{
global $mdb;
- return $mdb -> delete( 'pp_scontainers', [ 'id' => (int)$container_id ] );
+ return new \Domain\Scontainers\ScontainersRepository($mdb);
}
- public static function container_save( $container_id, $title, $text, $status, $show_title )
+ public static function container_delete($container_id)
{
- global $mdb;
-
- if ( !$container_id )
- {
- $mdb -> insert( 'pp_scontainers', [
- 'status' => $status == 'on' ? 1 : 0,
- 'show_title' => $show_title == 'on' ? 1 : 0
- ] );
-
- $id = $mdb -> id();
-
- if ( $id )
- {
- foreach ( $title as $key => $val )
- {
- $mdb -> insert( 'pp_scontainers_langs', [
- 'container_id' => (int)$id,
- 'lang_id' => $key,
- 'title' => $title[$key],
- 'text' => $text[$key]
- ] );
- }
-
- \S::delete_dir( '../temp/' );
-
- return $id;
- }
- }
- else
- {
- $mdb -> update( 'pp_scontainers', [
- 'status' => $status == 'on' ? 1 : 0,
- 'show_title' => $show_title == 'on' ? 1 : 0
- ], [
- 'id' => (int)$container_id
- ] );
-
- foreach ( $title as $key => $val )
- {
- if ( $translation_id = $mdb -> get( 'pp_scontainers_langs', 'id', [ 'AND' => [ 'container_id' => $container_id, 'lang_id' => $key ] ] ) )
- $mdb -> update( 'pp_scontainers_langs', [
- 'lang_id' => $key,
- 'title' => $title[$key],
- 'text' => $text[$key]
- ], [
- 'id' => $translation_id
- ] );
- else
- $mdb -> insert( 'pp_scontainers_langs', [
- 'container_id' => (int)$container_id,
- 'lang_id' => $key,
- 'title' => $title[$key],
- 'text' => $text[$key]
- ] );
- }
-
- \S::delete_dir( '../temp/' );
-
- return $container_id;
- }
+ return self::repository()->delete((int)$container_id);
}
- public static function container_details( $container_id )
+ public static function container_save($container_id, $title, $text, $status, $show_title)
{
- global $mdb;
-
- $container = $mdb -> get( 'pp_scontainers', '*', [ 'id' => (int)$container_id ] );
-
- $results = $mdb -> select( 'pp_scontainers_langs', '*', [ 'container_id' => (int)$container_id ] );
- if ( is_array( $results ) ) foreach ( $results as $row )
- $container['languages'][ $row['lang_id'] ] = $row;
-
- return $container;
+ return self::repository()->save([
+ 'id' => (int)$container_id,
+ 'title' => is_array($title) ? $title : [],
+ 'text' => is_array($text) ? $text : [],
+ 'status' => $status,
+ 'show_title' => $show_title,
+ ]);
}
-}
+
+ public static function container_details($container_id)
+ {
+ return self::repository()->find((int)$container_id);
+ }
+}
\ No newline at end of file
diff --git a/autoload/admin/view/class.Scontainers.php b/autoload/admin/view/class.Scontainers.php
deleted file mode 100644
index 486edb7..0000000
--- a/autoload/admin/view/class.Scontainers.php
+++ /dev/null
@@ -1,20 +0,0 @@
- container = $container;
- $tpl -> languages = $languages;
- return $tpl -> render( 'scontainers/container-edit' );
- }
-
- public static function containers_list()
- {
- $tpl = new \Tpl;
- return $tpl -> render( 'scontainers/containers-list' );
- }
-}
diff --git a/autoload/front/factory/class.Scontainers.php b/autoload/front/factory/class.Scontainers.php
index 91340fe..c3a7c1f 100644
--- a/autoload/front/factory/class.Scontainers.php
+++ b/autoload/front/factory/class.Scontainers.php
@@ -3,7 +3,7 @@ namespace front\factory;
class Scontainers
{
- public static function scontainer_details( $scontainer_id )
+ public static function scontainer_details($scontainer_id)
{
global $mdb, $lang;
@@ -11,21 +11,29 @@ class Scontainers
$cacheKey = "\front\factory\Scontainers::scontainer_details:$scontainer_id";
$objectData = $cacheHandler->get($cacheKey);
-
- if ( !$objectData )
- {
- $scontainer = $mdb -> get( 'pp_scontainers', '*', [ 'id' => (int)$scontainer_id ] );
- $results = $mdb -> select( 'pp_scontainers_langs', '*', [ 'AND' => [ 'container_id' => (int)$scontainer_id, 'lang_id' => $lang[0] ] ] );
- if ( is_array( $results ) ) foreach ( $results as $row )
- $scontainer['languages'] = $row;
-
- $cacheHandler -> set( $cacheKey, $scontainer );
- }
- else
- {
+ if ($objectData) {
return unserialize($objectData);
}
+ $repository = new \Domain\Scontainers\ScontainersRepository($mdb);
+ $langId = (string)($lang[0] ?? 'pl');
+ $scontainer = $repository->detailsForLanguage((int)$scontainer_id, $langId);
+
+ if (!is_array($scontainer)) {
+ $scontainer = [
+ 'id' => (int)$scontainer_id,
+ 'status' => 0,
+ 'show_title' => 0,
+ 'languages' => [
+ 'lang_id' => $langId,
+ 'title' => '',
+ 'text' => '',
+ ],
+ ];
+ }
+
+ $cacheHandler->set($cacheKey, $scontainer);
+
return $scontainer;
}
-}
+}
\ No newline at end of file
diff --git a/tests/Unit/Domain/Scontainers/ScontainersRepositoryTest.php b/tests/Unit/Domain/Scontainers/ScontainersRepositoryTest.php
new file mode 100644
index 0000000..5348e52
--- /dev/null
+++ b/tests/Unit/Domain/Scontainers/ScontainersRepositoryTest.php
@@ -0,0 +1,63 @@
+createMock(\medoo::class);
+ $repository = new ScontainersRepository($mockDb);
+
+ $container = $repository->find(0);
+
+ $this->assertIsArray($container);
+ $this->assertSame(0, (int)$container['id']);
+ $this->assertSame(1, (int)$container['status']);
+ }
+
+ public function testDeleteReturnsFalseForInvalidId(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $repository = new ScontainersRepository($mockDb);
+
+ $this->assertFalse($repository->delete(0));
+ }
+
+ public function testFindReturnsContainerWithTranslations(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $mockDb->expects($this->once())
+ ->method('get')
+ ->with('pp_scontainers', '*', ['id' => 7])
+ ->willReturn(['id' => 7, 'status' => 1, 'show_title' => 1]);
+
+ $mockDb->expects($this->once())
+ ->method('select')
+ ->with('pp_scontainers_langs', '*', ['container_id' => 7])
+ ->willReturn([
+ ['lang_id' => 'pl', 'title' => 'Tytul PL', 'text' => 'Tekst PL'],
+ ['lang_id' => 'en', 'title' => 'Title EN', 'text' => 'Text EN'],
+ ]);
+
+ $repository = new ScontainersRepository($mockDb);
+ $container = $repository->find(7);
+
+ $this->assertSame(7, (int)$container['id']);
+ $this->assertArrayHasKey('languages', $container);
+ $this->assertArrayHasKey('pl', $container['languages']);
+ $this->assertArrayHasKey('en', $container['languages']);
+ }
+
+ public function testDetailsForLanguageReturnsNullForInvalidData(): void
+ {
+ $mockDb = $this->createMock(\medoo::class);
+ $repository = new ScontainersRepository($mockDb);
+
+ $this->assertNull($repository->detailsForLanguage(0, 'pl'));
+ $this->assertNull($repository->detailsForLanguage(1, ''));
+ }
+}
+
diff --git a/tests/Unit/admin/Controllers/ScontainersControllerTest.php b/tests/Unit/admin/Controllers/ScontainersControllerTest.php
new file mode 100644
index 0000000..49cb2e5
--- /dev/null
+++ b/tests/Unit/admin/Controllers/ScontainersControllerTest.php
@@ -0,0 +1,59 @@
+repository = $this->createMock(ScontainersRepository::class);
+ $this->languagesRepository = $this->createMock(LanguagesRepository::class);
+ $this->controller = new ScontainersController($this->repository, $this->languagesRepository);
+ }
+
+ public function testConstructorAcceptsDependencies(): void
+ {
+ $controller = new ScontainersController($this->repository, $this->languagesRepository);
+ $this->assertInstanceOf(ScontainersController::class, $controller);
+ }
+
+ public function testHasMainActionMethods(): void
+ {
+ $this->assertTrue(method_exists($this->controller, 'list'));
+ $this->assertTrue(method_exists($this->controller, 'view_list'));
+ $this->assertTrue(method_exists($this->controller, 'edit'));
+ $this->assertTrue(method_exists($this->controller, 'save'));
+ $this->assertTrue(method_exists($this->controller, 'delete'));
+ }
+
+ public function testActionMethodReturnTypes(): void
+ {
+ $reflection = new \ReflectionClass($this->controller);
+
+ $this->assertEquals('string', (string)$reflection->getMethod('list')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('view_list')->getReturnType());
+ $this->assertEquals('string', (string)$reflection->getMethod('edit')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('save')->getReturnType());
+ $this->assertEquals('void', (string)$reflection->getMethod('delete')->getReturnType());
+ }
+
+ public function testConstructorRequiresRepositoryAndLanguagesRepository(): void
+ {
+ $reflection = new \ReflectionClass(ScontainersController::class);
+ $constructor = $reflection->getConstructor();
+ $params = $constructor->getParameters();
+
+ $this->assertCount(2, $params);
+ $this->assertEquals('Domain\Scontainers\ScontainersRepository', $params[0]->getType()->getName());
+ $this->assertEquals('Domain\Languages\LanguagesRepository', $params[1]->getType()->getName());
+ }
+}
+
diff --git a/updates/0.20/ver_0.259_files.txt b/updates/0.20/ver_0.259_files.txt
new file mode 100644
index 0000000..ac3f2b8
--- /dev/null
+++ b/updates/0.20/ver_0.259_files.txt
@@ -0,0 +1,2 @@
+F: ../autoload/admin/controls/class.Scontainers.php
+F: ../autoload/admin/view/class.Scontainers.php