diff --git a/.gitignore b/.gitignore index 57872d0..a2957a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /vendor/ +/views/img/banners/* +!/views/img/banners/.gitkeep diff --git a/src/Config/Config.php b/src/Config/Config.php index 49a3bfc..c3264a8 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -11,16 +11,21 @@ class Config ]; const BLOCK_TYPES = [ - 'html' => 'HTML', - 'faq' => 'FAQ Accordion', + 'html' => 'HTML', + 'faq' => 'FAQ Accordion', + 'banner' => 'Banner', ]; - const ADMIN_CONTROLLERS = [ - 'AdminCustomHtmlParent', - 'AdminCustomHtmlOverview', - 'AdminCustomHtmlBlockOverview', + const BANNER_IMAGE_ALLOWED_EXTENSIONS = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', ]; + const BANNER_IMAGE_MAX_SIZE = 5242880; + // Order matters: the parent tab must be registered before its children so // Tab::getIdFromClassName('AdminCustomHtmlParent') resolves during install. const TABS = [ diff --git a/src/Controller/CustomHtmlBlockController.php b/src/Controller/CustomHtmlBlockController.php index 0e493b8..f7e8750 100644 --- a/src/Controller/CustomHtmlBlockController.php +++ b/src/Controller/CustomHtmlBlockController.php @@ -95,9 +95,13 @@ class CustomHtmlBlockController extends FrameworkBundleAdminController $this->addFlash('error', $this->getErrorMessageForException($e, [])); } + $block = new CustomHtmlBlock($idCustomHtmlBlock); + $currentImageUrl = Validate::isLoadedObject($block) ? CustomHtmlBlock::getBannerImageUrl($block->image) : ''; + return $this->render('@Modules/ws_customhtml/views/templates/admin/form/customhtmlblock_edit.html.twig', [ 'customHtmlBlockForm' => $form->createView(), 'idCustomHtmlBlock' => $idCustomHtmlBlock, + 'currentImageUrl' => $currentImageUrl, ]); } diff --git a/src/Form/DataHandler/CustomHtmlBlockDataHandler.php b/src/Form/DataHandler/CustomHtmlBlockDataHandler.php index 6e4af5d..5737af0 100644 --- a/src/Form/DataHandler/CustomHtmlBlockDataHandler.php +++ b/src/Form/DataHandler/CustomHtmlBlockDataHandler.php @@ -3,9 +3,12 @@ namespace Websmid\CustomHtml\Form\DataHandler; use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\DataHandler\FormDataHandlerInterface; +use Websmid\CustomHtml\Config\Config; use Websmid\CustomHtml\Model\CustomHtmlBlock; use Websmid\CustomHtml\Model\CustomHtmlFaq; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Db; +use Exception; use Validate; final class CustomHtmlBlockDataHandler implements FormDataHandlerInterface @@ -21,6 +24,10 @@ final class CustomHtmlBlockDataHandler implements FormDataHandlerInterface $block->show_title = (int) $formData['show_title']; $block->custom_class = (string) ($formData['custom_class'] ?? ''); $block->content = (string) $formData['content']; + $block->description = (string) ($formData['description'] ?? ''); + $block->extra_description = (string) ($formData['extra_description'] ?? ''); + $block->link = (string) ($formData['link'] ?? ''); + $block->image = $this->saveUploadedImage($formData['image'] ?? null, ''); $block->active = (int) $formData['active']; $block->ordering = $this->getNextOrdering($idGroup); @@ -47,6 +54,10 @@ final class CustomHtmlBlockDataHandler implements FormDataHandlerInterface $block->show_title = (int) $formData['show_title']; $block->custom_class = (string) ($formData['custom_class'] ?? ''); $block->content = (string) $formData['content']; + $block->description = (string) ($formData['description'] ?? ''); + $block->extra_description = (string) ($formData['extra_description'] ?? ''); + $block->link = (string) ($formData['link'] ?? ''); + $block->image = $this->saveUploadedImage($formData['image'] ?? null, $block->image); $block->active = (int) $formData['active']; if (!$block->update()) { @@ -58,6 +69,49 @@ final class CustomHtmlBlockDataHandler implements FormDataHandlerInterface return true; } + /** + * Validates and stores an uploaded banner image, replacing the previous + * one if any. Never trusts the client-supplied filename or MIME header — + * the real MIME type is detected server-side and a random filename is + * generated, so nothing from the upload itself ends up on disk. + */ + private function saveUploadedImage(?UploadedFile $file, string $currentFilename): string + { + if (null === $file) { + return $currentFilename; + } + + if (!$file->isValid()) { + throw new Exception('The uploaded image could not be read.'); + } + + if ($file->getSize() > Config::BANNER_IMAGE_MAX_SIZE) { + throw new Exception('The uploaded image is too large (max 5 MB).'); + } + + $mimeType = $file->getMimeType(); + $extension = array_search($mimeType, Config::BANNER_IMAGE_ALLOWED_EXTENSIONS, true); + + if (false === $extension) { + throw new Exception('The uploaded file is not a supported image type (jpg, png, gif, webp).'); + } + + $targetDir = CustomHtmlBlock::getBannerImageDir(); + + if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) { + throw new Exception('Unable to create the banner image directory.'); + } + + $newFilename = bin2hex(random_bytes(16)) . '.' . $extension; + $file->move($targetDir, $newFilename); + + if (!empty($currentFilename) && is_file($targetDir . $currentFilename)) { + unlink($targetDir . $currentFilename); + } + + return $newFilename; + } + private function getNextOrdering(int $idGroup): int { $maxOrdering = Db::getInstance()->getValue( diff --git a/src/Form/DataProvider/CustomHtmlBlockDataProvider.php b/src/Form/DataProvider/CustomHtmlBlockDataProvider.php index 974e700..14b99ee 100644 --- a/src/Form/DataProvider/CustomHtmlBlockDataProvider.php +++ b/src/Form/DataProvider/CustomHtmlBlockDataProvider.php @@ -24,6 +24,10 @@ final class CustomHtmlBlockDataProvider implements FormDataProviderInterface 'show_title' => (bool) $block->show_title, 'custom_class' => $block->custom_class, 'content' => $block->content, + 'image' => null, + 'description' => $block->description, + 'extra_description' => $block->extra_description, + 'link' => $block->link, 'faqs' => $this->loadFaqs((int) $id), 'active' => (bool) $block->active, ]; @@ -38,6 +42,10 @@ final class CustomHtmlBlockDataProvider implements FormDataProviderInterface 'show_title' => false, 'custom_class' => '', 'content' => '', + 'image' => null, + 'description' => '', + 'extra_description' => '', + 'link' => '', 'faqs' => [], 'active' => true, ]; diff --git a/src/Form/Type/CustomHtmlBlockType.php b/src/Form/Type/CustomHtmlBlockType.php index d9907c3..b8b4298 100644 --- a/src/Form/Type/CustomHtmlBlockType.php +++ b/src/Form/Type/CustomHtmlBlockType.php @@ -6,6 +6,7 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use PrestaShop\PrestaShop\Core\Form\ConfigurableFormChoiceProviderInterface; @@ -35,23 +36,20 @@ class CustomHtmlBlockType extends AbstractType 'choices' => $this->groupChoiceProvider->getChoices(), 'required' => true, ]) - ->add('title', TextType::class, [ - 'label' => $this->translator->trans('Title', [], 'Modules.Wscustomhtml.Admin'), - 'required' => true, - ]) ->add('type', ChoiceType::class, [ 'label' => $this->translator->trans('Block type', [], 'Modules.Wscustomhtml.Admin'), 'choices' => array_flip(Config::BLOCK_TYPES), 'required' => true, ]) + ->add('title', TextType::class, [ + 'label' => $this->translator->trans('Title', [], 'Modules.Wscustomhtml.Admin'), + 'required' => true, + 'row_attr' => ['class' => 'col-8'], + ]) ->add('show_title', SwitchType::class, [ 'label' => $this->translator->trans('Show title', [], 'Modules.Wscustomhtml.Admin'), 'required' => false, - ]) - ->add('custom_class', TextType::class, [ - 'label' => $this->translator->trans('Custom classes', [], 'Modules.Wscustomhtml.Admin'), - 'required' => false, - 'attr' => ['placeholder' => 'col-md-6'], + 'row_attr' => ['class' => 'col-3'], ]) ->add('content', TextareaType::class, [ 'label' => $this->translator->trans('Content', [], 'Modules.Wscustomhtml.Admin'), @@ -59,6 +57,11 @@ class CustomHtmlBlockType extends AbstractType 'attr' => ['class' => 'autoload_rte'], 'row_attr' => ['class' => 'customhtmlblock-type-field customhtmlblock-type-html'], ]) + ->add('image', FileType::class, [ + 'label' => $this->translator->trans('Banner image', [], 'Modules.Wscustomhtml.Admin'), + 'required' => false, + 'row_attr' => ['class' => 'customhtmlblock-type-field customhtmlblock-type-banner'], + ]) ->add('faqs', CollectionType::class, [ 'entry_type' => CustomHtmlFaqType::class, 'allow_add' => true, @@ -67,6 +70,27 @@ class CustomHtmlBlockType extends AbstractType 'prototype' => true, 'required' => false, ]) + ->add('description', TextareaType::class, [ + 'label' => $this->translator->trans('Description', [], 'Modules.Wscustomhtml.Admin'), + 'required' => false, + 'row_attr' => ['class' => 'customhtmlblock-type-field customhtmlblock-type-banner'], + ]) + ->add('extra_description', TextareaType::class, [ + 'label' => $this->translator->trans('Extra description', [], 'Modules.Wscustomhtml.Admin'), + 'required' => false, + 'row_attr' => ['class' => 'customhtmlblock-type-field customhtmlblock-type-banner'], + ]) + ->add('link', TextType::class, [ + 'label' => $this->translator->trans('Link', [], 'Modules.Wscustomhtml.Admin'), + 'required' => false, + 'attr' => ['placeholder' => 'https://example.com or /internal-page'], + 'row_attr' => ['class' => 'customhtmlblock-type-field customhtmlblock-type-banner'], + ]) + ->add('custom_class', TextType::class, [ + 'label' => $this->translator->trans('Custom classes', [], 'Modules.Wscustomhtml.Admin'), + 'required' => false, + 'attr' => ['placeholder' => 'col-md-6'], + ]) ->add('active', SwitchType::class, [ 'label' => $this->translator->trans('Active', [], 'Modules.Wscustomhtml.Admin'), 'required' => false, diff --git a/src/Model/CustomHtmlBlock.php b/src/Model/CustomHtmlBlock.php index a33ddc2..e549999 100644 --- a/src/Model/CustomHtmlBlock.php +++ b/src/Model/CustomHtmlBlock.php @@ -25,6 +25,18 @@ class CustomHtmlBlock extends ObjectModel /** @var string */ public $custom_class = ''; + /** @var string */ + public $description = ''; + + /** @var string */ + public $extra_description = ''; + + /** @var string */ + public $link = ''; + + /** @var string */ + public $image = ''; + /** @var int */ public $active = 1; @@ -49,6 +61,10 @@ class CustomHtmlBlock extends ObjectModel 'type' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => true, 'size' => 32], 'content' => ['type' => self::TYPE_HTML, 'validate' => 'isCleanHtml'], 'custom_class' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => false, 'size' => 128], + 'description' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => false, 'size' => 1024], + 'extra_description' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => false, 'size' => 1024], + 'link' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => false, 'size' => 512], + 'image' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => false, 'size' => 255], 'active' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'], 'show_title' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'], 'ordering' => ['type' => self::TYPE_INT, 'validate' => 'isInt'], @@ -57,6 +73,20 @@ class CustomHtmlBlock extends ObjectModel ], ]; + public static function getBannerImageDir(): string + { + return _PS_MODULE_DIR_ . 'ws_customhtml/views/img/banners/'; + } + + public static function getBannerImageUrl(string $filename): string + { + if (empty($filename)) { + return ''; + } + + return _MODULE_DIR_ . 'ws_customhtml/views/img/banners/' . $filename; + } + public static function getActiveByGroupIds(array $groupIds): array { $groupIds = array_map('intval', $groupIds); @@ -66,7 +96,8 @@ class CustomHtmlBlock extends ObjectModel } $result = Db::getInstance()->executeS( - 'SELECT `id_ws_customhtml`, `id_ws_customhtml_group`, `title`, `type`, `content`, `custom_class`, `active`, `show_title`, `ordering` + 'SELECT `id_ws_customhtml`, `id_ws_customhtml_group`, `title`, `type`, `content`, `custom_class`, + `description`, `extra_description`, `link`, `image`, `active`, `show_title`, `ordering` FROM `' . _DB_PREFIX_ . 'ws_customhtml` WHERE `id_ws_customhtml_group` IN (' . implode(',', $groupIds) . ') AND `active` = 1 diff --git a/src/Module/AdminHooks.php b/src/Module/AdminHooks.php index e691fba..bcc3097 100644 --- a/src/Module/AdminHooks.php +++ b/src/Module/AdminHooks.php @@ -3,6 +3,7 @@ namespace Websmid\CustomHtml\Module; use Websmid\CustomHtml\Config\Config; +use PrestaShop\PrestaShop\Adapter\SymfonyContainer; use PrestaShop\PrestaShop\Core\Grid\Data\GridData; use PrestaShop\PrestaShop\Core\Grid\Record\RecordCollection; use Tools; @@ -21,9 +22,10 @@ trait AdminHooks public function hookActionAdminControllerSetMedia(): void { - $controllerName = $this->context->controller->controller_name ?? ''; + $request = SymfonyContainer::getInstance()->get('request_stack')->getCurrentRequest(); + $routeName = $request ? (string) $request->attributes->get('_route') : ''; - if (!in_array($controllerName, Config::ADMIN_CONTROLLERS, true)) { + if (0 !== strpos($routeName, 'customhtml')) { return; } diff --git a/src/Module/DisplayHooks.php b/src/Module/DisplayHooks.php index b9b5337..f1eb00e 100644 --- a/src/Module/DisplayHooks.php +++ b/src/Module/DisplayHooks.php @@ -64,6 +64,7 @@ trait DisplayHooks $blocksByGroup = CustomHtmlBlock::getActiveByGroupIds(array_column($groups, 'id_ws_customhtml_group')); $blocksByGroup = $this->attachFaqsToBlocks($blocksByGroup); + $blocksByGroup = $this->attachBannerImageUrls($blocksByGroup); $groups = array_map(static function (array $group) use ($blocksByGroup) { $group['blocks'] = $blocksByGroup[(int) $group['id_ws_customhtml_group']] ?? []; @@ -88,4 +89,17 @@ trait DisplayHooks }, $blocks); }, $blocksByGroup); } + + private function attachBannerImageUrls(array $blocksByGroup): array + { + return array_map(static function (array $blocks) { + return array_map(static function (array $block) { + if ($block['type'] === 'banner') { + $block['image_url'] = CustomHtmlBlock::getBannerImageUrl($block['image']); + } + + return $block; + }, $blocks); + }, $blocksByGroup); + } } diff --git a/src/Module/Install.php b/src/Module/Install.php index 089bdd4..f817864 100644 --- a/src/Module/Install.php +++ b/src/Module/Install.php @@ -65,6 +65,10 @@ trait Install `type` VARCHAR(32) NOT NULL DEFAULT \'html\', `content` LONGTEXT, `custom_class` VARCHAR(128) NOT NULL DEFAULT \'\', + `description` TEXT, + `extra_description` TEXT, + `link` VARCHAR(512) NOT NULL DEFAULT \'\', + `image` VARCHAR(255) NOT NULL DEFAULT \'\', `active` TINYINT(1) NOT NULL DEFAULT 1, `show_title` TINYINT(1) NOT NULL DEFAULT 0, `ordering` INT(11) NOT NULL DEFAULT 0, diff --git a/upgrade/upgrade-1.6.0.php b/upgrade/upgrade-1.6.0.php new file mode 100644 index 0000000..49afd6b --- /dev/null +++ b/upgrade/upgrade-1.6.0.php @@ -0,0 +1,34 @@ + 'TEXT', + 'extra_description' => 'TEXT', + 'link' => 'VARCHAR(512) NOT NULL DEFAULT \'\'', + 'template' => 'VARCHAR(32) NOT NULL DEFAULT \'image_left\'', + 'image' => 'VARCHAR(255) NOT NULL DEFAULT \'\'', + ]; + + foreach ($columnsToAdd as $column => $definition) { + $existing = Db::getInstance()->executeS( + 'SHOW COLUMNS FROM `' . _DB_PREFIX_ . 'ws_customhtml` LIKE \'' . $column . '\'' + ); + + if (!empty($existing)) { + continue; + } + + if (!Db::getInstance()->execute( + 'ALTER TABLE `' . _DB_PREFIX_ . 'ws_customhtml` ADD COLUMN `' . $column . '` ' . $definition + )) { + return false; + } + } + + return true; +} diff --git a/upgrade/upgrade-1.7.0.php b/upgrade/upgrade-1.7.0.php new file mode 100644 index 0000000..abe2cf6 --- /dev/null +++ b/upgrade/upgrade-1.7.0.php @@ -0,0 +1,20 @@ +executeS( + 'SHOW COLUMNS FROM `' . _DB_PREFIX_ . 'ws_customhtml` LIKE \'template\'' + ); + + if (empty($existing)) { + return true; + } + + return Db::getInstance()->execute( + 'ALTER TABLE `' . _DB_PREFIX_ . 'ws_customhtml` DROP COLUMN `template`' + ); +} diff --git a/views/css/admin/grid.css b/views/css/admin/grid.css index fbca35b..e802213 100644 --- a/views/css/admin/grid.css +++ b/views/css/admin/grid.css @@ -9,3 +9,30 @@ td.action-type .btn-group-action { align-items: center; justify-content: center; } + +@media (min-width: 544px) { + .form-horizontal + .form-group.row + > .form-control-label:first-of-type:not(.label-on-top) { + flex: 0 0 auto !important; + max-width: unset !important; + text-align: left !important; + } + form[name="custom_html_block"] .card-body { + padding: 25px 50px !important; + } + form[name="custom_html_block"] .card-header, + form[name="custom_html_block"] .card-footer { + padding: 20px 50px !important; + } +} + +@media (min-width: 1500px) { + form[name="custom_html_block"] .card-body { + padding: 25px 300px !important; + } + form[name="custom_html_block"] .card-header, + form[name="custom_html_block"] .card-footer { + padding: 20px 300px !important; + } +} diff --git a/views/css/front/customhtml.css b/views/css/front/customhtml.css index 7c5acd1..94bd04c 100644 --- a/views/css/front/customhtml.css +++ b/views/css/front/customhtml.css @@ -2,3 +2,16 @@ max-width: 100%; height: auto; } + +.customhtml-banner-content { + padding: 1rem 0; +} + +.customhtml-banner-title { + margin-top: 0; +} + +.customhtml-banner-description, +.customhtml-banner-extra-description { + margin-bottom: 1rem; +} diff --git a/views/img/banners/.gitkeep b/views/img/banners/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/views/templates/admin/form/blocks/block_form.html.twig b/views/templates/admin/form/blocks/block_form.html.twig index edc354d..fb8e4ef 100644 --- a/views/templates/admin/form/blocks/block_form.html.twig +++ b/views/templates/admin/form/blocks/block_form.html.twig @@ -17,12 +17,23 @@