name = 'pp_carousel'; $this->tab = 'front_office_features'; $this->version = '1.0.0'; $this->author = 'Project-Pro'; $this->author_uri = 'https://www.project-pro.pl'; $this->need_instance = 0; $this->bootstrap = true; parent::__construct(); $this->displayName = $this->l('Project-Pro Karuzela Produktów'); $this->description = $this->l('Wyświetla konfigurowalne karuzele produktów w dowolnych hookach.'); $this->ps_versions_compliancy = ['min' => '1.7.0.0', 'max' => _PS_VERSION_]; } public function install() { return $this->executeSqlFile('install') && parent::install() && $this->registerHook('displayHeader') && $this->registerHook('displayHome') && $this->registerHook('displayFooterBefore') && $this->registerHook('displayTopColumn') && $this->registerHook('displayLeftColumn') && $this->registerHook('displayRightColumn') && $this->registerHook('displayFooter'); } public function uninstall() { return $this->executeSqlFile('uninstall') && parent::uninstall(); } private function executeSqlFile($filename) { $path = dirname(__FILE__) . '/sql/' . $filename . '.sql'; if (!file_exists($path)) { return false; } $sql = file_get_contents($path); $sql = str_replace('PREFIX_', _DB_PREFIX_, $sql); $sql = str_replace('ENGINE_TYPE', _MYSQL_ENGINE_, $sql); $queries = preg_split('/;\s*[\r\n]+/', $sql); foreach ($queries as $query) { $query = trim($query); if (!empty($query)) { if (!Db::getInstance()->execute($query)) { return false; } } } return true; } // ─── ADMIN PANEL ──────────────────────────────────────────── public function getContent() { $output = ''; if (Tools::isSubmit('deletepp_carousel')) { $output .= $this->deleteCarousel((int) Tools::getValue('id_carousel')); } if (Tools::isSubmit('statuspp_carousel')) { $output .= $this->toggleCarouselStatus((int) Tools::getValue('id_carousel')); } if (Tools::isSubmit('submitPpCarousel')) { $output .= $this->saveCarousel(); } if (Tools::isSubmit('addpp_carousel') || Tools::isSubmit('updatepp_carousel') || Tools::getValue('id_carousel')) { return $output . $this->renderForm((int) Tools::getValue('id_carousel')); } return $output . $this->renderList(); } private function renderList() { $fieldsList = [ 'id_carousel' => ['title' => 'ID', 'align' => 'center', 'class' => 'fixed-width-xs'], 'title' => ['title' => $this->l('Tytuł')], 'hook_name' => ['title' => $this->l('Hook')], 'source_type' => ['title' => $this->l('Źródło')], 'limit_products' => ['title' => $this->l('Limit'), 'align' => 'center', 'class' => 'fixed-width-xs'], 'active' => ['title' => $this->l('Aktywna'), 'active' => 'status', 'align' => 'center', 'class' => 'fixed-width-sm', 'type' => 'bool'], ]; $helper = new HelperList(); $helper->shopLinkType = ''; $helper->simple_header = false; $helper->actions = ['edit', 'delete']; $helper->identifier = 'id_carousel'; $helper->show_toolbar = true; $helper->title = $this->l('Karuzele produktów'); $helper->table = $this->name; $helper->token = Tools::getAdminTokenLite('AdminModules'); $helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name; $helper->toolbar_btn['new'] = [ 'href' => AdminController::$currentIndex . '&configure=' . $this->name . '&add' . $this->name . '&token=' . Tools::getAdminTokenLite('AdminModules'), 'desc' => $this->l('Dodaj karuzelę'), ]; return $helper->generateList($this->getCarouselList(), $fieldsList); } private function getCarouselList() { $idLang = (int) $this->context->language->id; $sql = 'SELECT c.*, cl.title FROM `' . _DB_PREFIX_ . 'pp_carousel` c LEFT JOIN `' . _DB_PREFIX_ . 'pp_carousel_lang` cl ON c.id_carousel = cl.id_carousel AND cl.id_lang = ' . $idLang . ' WHERE c.id_shop = ' . (int) $this->context->shop->id . ' ORDER BY c.position ASC, c.id_carousel ASC'; return Db::getInstance()->executeS($sql) ?: []; } private function renderForm($idCarousel = 0) { $carousel = $idCarousel ? $this->getCarousel($idCarousel) : []; $defaultLang = (int) Configuration::get('PS_LANG_DEFAULT'); $langs = Language::getLanguages(false); $categories = $this->flattenCategories(Category::getCategories($defaultLang, true, false)); $hookOptions = $this->getAvailableHooks(); $predefinedHookIds = array_column($hookOptions, 'id'); // Detect if saved hook_name is a custom hook $savedHookName = isset($carousel['hook_name']) ? $carousel['hook_name'] : 'displayHome'; $isCustomHook = !empty($savedHookName) && !in_array($savedHookName, $predefinedHookIds); $sourceOptions = [ ['id' => 'new', 'name' => $this->l('Nowości')], ['id' => 'bestseller', 'name' => $this->l('Bestsellery')], ['id' => 'category', 'name' => $this->l('Produkty z kategorii')], ['id' => 'manual', 'name' => $this->l('Ręczne ID produktów')], ]; $fieldsForm = [ 'form' => [ 'legend' => [ 'title' => $idCarousel ? $this->l('Edytuj karuzelę') : $this->l('Dodaj karuzelę'), 'icon' => 'icon-cogs', ], 'input' => [ [ 'type' => 'hidden', 'name' => 'id_carousel', ], [ 'type' => 'select', 'label' => $this->l('Hook (miejsce wyświetlania)'), 'name' => 'hook_name', 'options' => ['query' => $hookOptions, 'id' => 'id', 'name' => 'name'], 'desc' => $this->l('Wybierz hook lub wpisz niestandardowy poniżej.'), ], [ 'type' => 'text', 'label' => $this->l('Niestandardowy hook'), 'name' => 'custom_hook', 'desc' => $this->l('Jeśli wypełnione, zostanie użyte zamiast wybranego powyżej. Hook zostanie utworzony automatycznie.'), ], [ 'type' => 'select', 'label' => $this->l('Źródło produktów'), 'name' => 'source_type', 'options' => ['query' => $sourceOptions, 'id' => 'id', 'name' => 'name'], 'id' => 'source_type_select', ], [ 'type' => 'select', 'label' => $this->l('Kategoria'), 'name' => 'id_category', 'options' => ['query' => $categories, 'id' => 'id', 'name' => 'name'], 'desc' => $this->l('Widoczne gdy źródło = "Produkty z kategorii".'), 'form_group_class' => 'pp-field-category', ], [ 'type' => 'textarea', 'label' => $this->l('ID produktów (ręczne)'), 'name' => 'product_ids', 'desc' => $this->l('ID produktów rozdzielone przecinkami, np. 12,45,67. Widoczne gdy źródło = "Ręczne ID".'), 'form_group_class' => 'pp-field-manual', ], [ 'type' => 'text', 'label' => $this->l('Limit produktów'), 'name' => 'limit_products', 'class' => 'fixed-width-sm', ], [ 'type' => 'text', 'label' => $this->l('Tytuł'), 'name' => 'title', 'lang' => true, ], [ 'type' => 'text', 'label' => $this->l('Podtytuł'), 'name' => 'subtitle', 'lang' => true, ], [ 'type' => 'text', 'label' => $this->l('Tekst przycisku'), 'name' => 'button_label', 'lang' => true, ], [ 'type' => 'text', 'label' => $this->l('URL przycisku'), 'name' => 'button_url', 'desc' => $this->l('Pozostaw puste, aby linkować do kategorii automatycznie.'), ], [ 'type' => 'text', 'label' => $this->l('Sufiks ceny'), 'name' => 'price_suffix', 'lang' => true, 'desc' => $this->l('Np. /m²'), ], [ 'type' => 'text', 'label' => $this->l('Pozycja'), 'name' => 'position', 'class' => 'fixed-width-sm', ], [ 'type' => 'switch', 'label' => $this->l('Aktywna'), 'name' => 'active', 'values' => [ ['id' => 'active_on', 'value' => 1, 'label' => $this->l('Tak')], ['id' => 'active_off', 'value' => 0, 'label' => $this->l('Nie')], ], ], ], 'submit' => [ 'title' => $this->l('Zapisz'), 'name' => 'submitPpCarousel', ], ], ]; $helper = new HelperForm(); $helper->show_toolbar = false; $helper->table = $this->table; $helper->module = $this; $helper->default_form_language = $defaultLang; $helper->allow_employee_form_lang = (int) Configuration::get('PS_BO_ALLOW_EMPLOYEE_FORM_LANG'); $helper->identifier = 'id_carousel'; $helper->submit_action = 'submitPpCarousel'; $helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name; $helper->token = Tools::getAdminTokenLite('AdminModules'); $helper->languages = $this->context->controller->getLanguages(); $helper->id_language = (int) $this->context->language->id; // Fill form values $helper->fields_value['id_carousel'] = $idCarousel; $helper->fields_value['hook_name'] = $isCustomHook ? 'displayHome' : $savedHookName; $helper->fields_value['custom_hook'] = $isCustomHook ? $savedHookName : ''; $helper->fields_value['source_type'] = isset($carousel['source_type']) ? $carousel['source_type'] : 'new'; $helper->fields_value['id_category'] = isset($carousel['id_category']) ? (int) $carousel['id_category'] : 0; $helper->fields_value['product_ids'] = isset($carousel['product_ids']) ? $carousel['product_ids'] : ''; $helper->fields_value['limit_products'] = isset($carousel['limit_products']) ? (int) $carousel['limit_products'] : 12; $helper->fields_value['button_url'] = isset($carousel['button_url']) ? $carousel['button_url'] : ''; $helper->fields_value['position'] = isset($carousel['position']) ? (int) $carousel['position'] : 0; $helper->fields_value['active'] = isset($carousel['active']) ? (int) $carousel['active'] : 1; foreach ($langs as $lang) { $id = (int) $lang['id_lang']; $langData = $idCarousel ? $this->getCarouselLang($idCarousel, $id) : []; $helper->fields_value['title'][$id] = isset($langData['title']) ? $langData['title'] : ''; $helper->fields_value['subtitle'][$id] = isset($langData['subtitle']) ? $langData['subtitle'] : ''; $helper->fields_value['button_label'][$id] = isset($langData['button_label']) ? $langData['button_label'] : ''; $helper->fields_value['price_suffix'][$id] = isset($langData['price_suffix']) ? $langData['price_suffix'] : ''; } $formHtml = $helper->generateForm([$fieldsForm]); // Inject JS for conditional field visibility $formHtml .= $this->getAdminFormJs(); return $formHtml; } private function getAdminFormJs() { return ' '; } private function saveCarousel() { $idCarousel = (int) Tools::getValue('id_carousel'); $hookName = trim(Tools::getValue('custom_hook')); if (empty($hookName)) { $hookName = trim(Tools::getValue('hook_name')); } if (empty($hookName)) { $hookName = 'displayHome'; } $sourceType = Tools::getValue('source_type'); $idCategory = (int) Tools::getValue('id_category'); $productIds = trim(Tools::getValue('product_ids')); $limitProducts = (int) Tools::getValue('limit_products'); $buttonUrl = trim(Tools::getValue('button_url')); $position = (int) Tools::getValue('position'); $active = (int) Tools::getValue('active'); if ($limitProducts <= 0) { $limitProducts = 12; } // Sanitize manual product IDs if ($sourceType === 'manual' && $productIds) { $ids = array_filter(array_map('intval', explode(',', $productIds))); $productIds = implode(',', $ids); } $now = date('Y-m-d H:i:s'); $db = Db::getInstance(); if ($idCarousel > 0) { $db->update('pp_carousel', [ 'hook_name' => pSQL($hookName), 'source_type' => pSQL($sourceType), 'id_category' => $idCategory, 'product_ids' => pSQL($productIds), 'limit_products' => $limitProducts, 'button_url' => pSQL($buttonUrl), 'position' => $position, 'active' => $active, 'date_upd' => $now, ], 'id_carousel = ' . $idCarousel); } else { $db->insert('pp_carousel', [ 'hook_name' => pSQL($hookName), 'source_type' => pSQL($sourceType), 'id_category' => $idCategory, 'product_ids' => pSQL($productIds), 'limit_products' => $limitProducts, 'button_url' => pSQL($buttonUrl), 'position' => $position, 'active' => $active, 'id_shop' => (int) $this->context->shop->id, 'date_add' => $now, 'date_upd' => $now, ]); $idCarousel = (int) $db->Insert_ID(); } // Save lang fields $langs = Language::getLanguages(false); foreach ($langs as $lang) { $id = (int) $lang['id_lang']; $title = Tools::substr(trim(Tools::getValue('title_' . $id)), 0, 255); $subtitle = Tools::substr(trim(Tools::getValue('subtitle_' . $id)), 0, 255); $buttonLabel = Tools::substr(trim(Tools::getValue('button_label_' . $id)), 0, 255); $priceSuffix = Tools::substr(trim(Tools::getValue('price_suffix_' . $id)), 0, 64); $exists = $db->getValue( 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'pp_carousel_lang` WHERE id_carousel = ' . $idCarousel . ' AND id_lang = ' . $id ); $langData = [ 'title' => pSQL($title), 'subtitle' => pSQL($subtitle), 'button_label' => pSQL($buttonLabel), 'price_suffix' => pSQL($priceSuffix), ]; if ($exists) { $db->update('pp_carousel_lang', $langData, 'id_carousel = ' . $idCarousel . ' AND id_lang = ' . $id); } else { $langData['id_carousel'] = $idCarousel; $langData['id_lang'] = $id; $db->insert('pp_carousel_lang', $langData); } } // Register custom hook if needed $this->ensureHookRegistered($hookName); return $this->displayConfirmation($this->l('Karuzela została zapisana.')); } private function deleteCarousel($idCarousel) { if ($idCarousel <= 0) { return ''; } $db = Db::getInstance(); $db->delete('pp_carousel_lang', 'id_carousel = ' . $idCarousel); $db->delete('pp_carousel', 'id_carousel = ' . $idCarousel); return $this->displayConfirmation($this->l('Karuzela została usunięta.')); } private function toggleCarouselStatus($idCarousel) { if ($idCarousel <= 0) { return ''; } $current = (int) Db::getInstance()->getValue( 'SELECT active FROM `' . _DB_PREFIX_ . 'pp_carousel` WHERE id_carousel = ' . $idCarousel ); Db::getInstance()->update('pp_carousel', [ 'active' => $current ? 0 : 1, ], 'id_carousel = ' . $idCarousel); return $this->displayConfirmation($this->l('Status karuzeli został zmieniony.')); } private function getCarousel($idCarousel) { return Db::getInstance()->getRow( 'SELECT * FROM `' . _DB_PREFIX_ . 'pp_carousel` WHERE id_carousel = ' . (int) $idCarousel ); } private function getCarouselLang($idCarousel, $idLang) { return Db::getInstance()->getRow( 'SELECT * FROM `' . _DB_PREFIX_ . 'pp_carousel_lang` WHERE id_carousel = ' . (int) $idCarousel . ' AND id_lang = ' . (int) $idLang ) ?: []; } private function getAvailableHooks() { $hooks = [ 'displayHome', 'displayTopColumn', 'displayFooterBefore', 'displayFooter', 'displayLeftColumn', 'displayRightColumn', 'displayOrderConfirmation2', 'displayCrossSellingShoppingCart', ]; $options = []; foreach ($hooks as $h) { $options[] = ['id' => $h, 'name' => $h]; } return $options; } private function flattenCategories($tree, $depth = 0, &$out = []) { foreach ($tree as $node) { if (!isset($node['id_category'], $node['name'])) { continue; } $prefix = str_repeat('— ', max(0, $depth)); $out[] = [ 'id' => (int) $node['id_category'], 'name' => $prefix . $node['name'], ]; if (!empty($node['children'])) { $this->flattenCategories($node['children'], $depth + 1, $out); } } return $out; } private function ensureHookRegistered($hookName) { $idHook = (int) Hook::getIdByName($hookName); if (!$idHook) { Db::getInstance()->insert('hook', [ 'name' => pSQL($hookName), 'title' => pSQL($hookName), ]); } if (!$this->isRegisteredInHook($hookName)) { $this->registerHook($hookName); } // Clear PrestaShop hook cache if (Cache::isStored('hook_module_list')) { Cache::clean('hook_module_list'); } } public function isRegisteredInHook($hookName) { $idHook = (int) Hook::getIdByName($hookName); if (!$idHook) { return false; } $count = (int) Db::getInstance()->getValue( 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'hook_module` WHERE id_hook = ' . $idHook . ' AND id_module = ' . (int) $this->id ); return $count > 0; } // ─── FRONT HOOKS ──────────────────────────────────────────── public function hookDisplayHeader() { $this->context->controller->registerStylesheet( 'pp_carousel_swiper_css', 'modules/' . $this->name . '/views/lib/swiper/swiper-bundle.min.css', ['media' => 'all', 'priority' => 150] ); $this->context->controller->registerStylesheet( 'pp_carousel_css', 'modules/' . $this->name . '/views/css/pp_carousel.css', ['media' => 'all', 'priority' => 151] ); $this->context->controller->registerJavascript( 'pp_carousel_swiper_js', 'modules/' . $this->name . '/views/lib/swiper/swiper-bundle.min.js', ['position' => 'bottom', 'priority' => 150] ); $this->context->controller->registerJavascript( 'pp_carousel_js', 'modules/' . $this->name . '/views/js/pp_carousel.js', ['position' => 'bottom', 'priority' => 151] ); } public function hookDisplayHome($params) { return $this->renderCarouselsForHook('displayHome'); } public function hookDisplayTopColumn($params) { return $this->renderCarouselsForHook('displayTopColumn'); } public function hookDisplayFooterBefore($params) { return $this->renderCarouselsForHook('displayFooterBefore'); } public function hookDisplayFooter($params) { return $this->renderCarouselsForHook('displayFooter'); } public function hookDisplayLeftColumn($params) { return $this->renderCarouselsForHook('displayLeftColumn'); } public function hookDisplayRightColumn($params) { return $this->renderCarouselsForHook('displayRightColumn'); } /** * Catch-all: render carousels for any hook not explicitly defined above. */ public function __call($method, $args) { if (strpos($method, 'hook') === 0) { $hookName = lcfirst(substr($method, 4)); return $this->renderCarouselsForHook($hookName); } return ''; } // ─── RENDERING ────────────────────────────────────────────── private function renderCarouselsForHook($hookName) { $carousels = Db::getInstance()->executeS( 'SELECT c.*, cl.title, cl.subtitle, cl.button_label, cl.price_suffix FROM `' . _DB_PREFIX_ . 'pp_carousel` c LEFT JOIN `' . _DB_PREFIX_ . 'pp_carousel_lang` cl ON c.id_carousel = cl.id_carousel AND cl.id_lang = ' . (int) $this->context->language->id . ' WHERE c.hook_name = "' . pSQL($hookName) . '" AND c.active = 1 AND c.id_shop = ' . (int) $this->context->shop->id . ' ORDER BY c.position ASC, c.id_carousel ASC' ); if (empty($carousels)) { return ''; } $html = ''; foreach ($carousels as $carousel) { $products = $this->getProductsByCarousel($carousel); if (empty($products)) { continue; } $buttonUrl = trim($carousel['button_url']); if (empty($buttonUrl) && $carousel['source_type'] === 'category' && $carousel['id_category'] > 0) { $cat = new Category((int) $carousel['id_category'], (int) $this->context->language->id); if (Validate::isLoadedObject($cat)) { $buttonUrl = $this->context->link->getCategoryLink($cat); } } $this->context->smarty->assign([ 'ppc_id' => (int) $carousel['id_carousel'], 'ppc_title' => $carousel['title'] ?: '', 'ppc_subtitle' => $carousel['subtitle'] ?: '', 'ppc_button_label' => $carousel['button_label'] ?: '', 'ppc_button_url' => $buttonUrl, 'ppc_price_suffix' => $carousel['price_suffix'] ?: '', 'ppc_products' => $products, ]); $html .= $this->fetch('module:' . $this->name . '/views/templates/hook/pp_carousel.tpl'); } return $html; } // ─── PRODUCT SOURCES ──────────────────────────────────────── private function getProductsByCarousel($carousel) { $limit = (int) $carousel['limit_products']; if ($limit <= 0) { $limit = 12; } switch ($carousel['source_type']) { case 'new': return $this->getNewProducts($limit); case 'bestseller': return $this->getBestsellers($limit); case 'category': return $this->getCategoryProducts((int) $carousel['id_category'], $limit); case 'manual': return $this->getManualProducts($carousel['product_ids'], $limit); default: return []; } } private function getNewProducts($limit) { $idLang = (int) $this->context->language->id; $raw = Product::getNewProducts($idLang, 0, $limit); return is_array($raw) ? $this->presentProducts($raw) : []; } private function getBestsellers($limit) { $idLang = (int) $this->context->language->id; $raw = ProductSale::getBestSales($idLang, 0, $limit); return is_array($raw) ? $this->presentProducts($raw) : []; } private function getCategoryProducts($idCategory, $limit) { if ($idCategory <= 0) { return []; } $idLang = (int) $this->context->language->id; $category = new Category($idCategory, $idLang); if (!Validate::isLoadedObject($category)) { return []; } $raw = $category->getProducts($idLang, 1, $limit, 'position', 'asc'); return is_array($raw) ? $this->presentProducts($raw) : []; } private function getManualProducts($productIdsStr, $limit) { if (empty($productIdsStr)) { return []; } $ids = array_filter(array_map('intval', explode(',', $productIdsStr))); if (empty($ids)) { return []; } $idLang = (int) $this->context->language->id; $idShop = (int) $this->context->shop->id; $sql = 'SELECT p.*, pl.`name`, pl.`description_short`, pl.`link_rewrite`, cl.`name` AS category_default, cl.`link_rewrite` AS category_link_rewrite, i.`id_image`, il.`legend`, m.`name` AS manufacturer_name, p.`id_category_default` FROM `' . _DB_PREFIX_ . 'product` p LEFT JOIN `' . _DB_PREFIX_ . 'product_lang` pl ON p.id_product = pl.id_product AND pl.id_lang = ' . $idLang . ' AND pl.id_shop = ' . $idShop . ' LEFT JOIN `' . _DB_PREFIX_ . 'category_lang` cl ON p.id_category_default = cl.id_category AND cl.id_lang = ' . $idLang . ' AND cl.id_shop = ' . $idShop . ' LEFT JOIN `' . _DB_PREFIX_ . 'image` i ON p.id_product = i.id_product AND i.cover = 1 LEFT JOIN `' . _DB_PREFIX_ . 'image_lang` il ON i.id_image = il.id_image AND il.id_lang = ' . $idLang . ' LEFT JOIN `' . _DB_PREFIX_ . 'manufacturer` m ON p.id_manufacturer = m.id_manufacturer LEFT JOIN `' . _DB_PREFIX_ . 'product_shop` ps ON p.id_product = ps.id_product AND ps.id_shop = ' . $idShop . ' WHERE p.id_product IN (' . implode(',', $ids) . ') AND ps.active = 1 LIMIT ' . (int) $limit; $raw = Db::getInstance()->executeS($sql); return is_array($raw) ? $this->presentProducts($raw) : []; } private function presentProducts(array $rawProducts) { $assembler = new \ProductAssembler($this->context); $presenterFactory = new \ProductPresenterFactory($this->context); $presentationSettings = $presenterFactory->getPresentationSettings(); $presenter = $presenterFactory->getPresenter(); $products = []; foreach ($rawProducts as $raw) { try { $assembled = $assembler->assembleProduct($raw); $products[] = $presenter->present( $presentationSettings, $assembled, $this->context->language ); } catch (Exception $e) { continue; } } return $products; } }