Add product carousel module with template and database structure
- Created `pp_carousel.tpl` for rendering product carousel with Swiper integration. - Added `plan.md` detailing module architecture, database schema, and implementation steps. - Initialized log files for development and production environments.
This commit is contained in:
12
modules/pp_carousel/config.xml
Normal file
12
modules/pp_carousel/config.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<module>
|
||||
<name>pp_carousel</name>
|
||||
<displayName><![CDATA[Project-Pro Karuzela Produktów]]></displayName>
|
||||
<version><![CDATA[1.0.0]]></version>
|
||||
<description><![CDATA[Wyświetla konfigurowalne karuzele produktów w dowolnych hookach.]]></description>
|
||||
<author><![CDATA[Project-Pro]]></author>
|
||||
<tab><![CDATA[front_office_features]]></tab>
|
||||
<is_configurable>1</is_configurable>
|
||||
<need_instance>0</need_instance>
|
||||
<limited_countries></limited_countries>
|
||||
</module>
|
||||
16
modules/pp_carousel/index.php
Normal file
16
modules/pp_carousel/index.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* Project-Pro Karuzela Produktów
|
||||
*
|
||||
* @author Project-Pro <kontakt@project-pro.pl>
|
||||
* @copyright Project-Pro
|
||||
* @license https://www.project-pro.pl
|
||||
*/
|
||||
|
||||
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
|
||||
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Cache-Control: post-check=0, pre-check=0', false);
|
||||
header('Pragma: no-cache');
|
||||
header('Location: ../');
|
||||
exit;
|
||||
762
modules/pp_carousel/pp_carousel.php
Normal file
762
modules/pp_carousel/pp_carousel.php
Normal file
@@ -0,0 +1,762 @@
|
||||
<?php
|
||||
if (!defined('_PS_VERSION_')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
use PrestaShop\PrestaShop\Adapter\Image\ImageRetriever;
|
||||
use PrestaShop\PrestaShop\Adapter\Product\PriceFormatter;
|
||||
use PrestaShop\PrestaShop\Adapter\Product\ProductColorsRetriever;
|
||||
use PrestaShop\PrestaShop\Core\Product\ProductListingPresenter;
|
||||
|
||||
class Pp_Carousel extends Module
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->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();
|
||||
|
||||
$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'] = isset($carousel['hook_name']) ? $carousel['hook_name'] : 'displayHome';
|
||||
$helper->fields_value['custom_hook'] = '';
|
||||
$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 '
|
||||
<script>
|
||||
(function() {
|
||||
function toggleSourceFields() {
|
||||
var val = document.querySelector("[name=source_type]").value;
|
||||
var catRow = document.querySelector(".pp-field-category");
|
||||
var manualRow = document.querySelector(".pp-field-manual");
|
||||
if (catRow) catRow.style.display = (val === "category") ? "" : "none";
|
||||
if (manualRow) manualRow.style.display = (val === "manual") ? "" : "none";
|
||||
}
|
||||
var sel = document.querySelector("[name=source_type]");
|
||||
if (sel) {
|
||||
sel.addEventListener("change", toggleSourceFields);
|
||||
toggleSourceFields();
|
||||
}
|
||||
})();
|
||||
</script>';
|
||||
}
|
||||
|
||||
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 = Hook::getIdByName($hookName);
|
||||
if (!$idHook) {
|
||||
$db = Db::getInstance();
|
||||
$db->insert('hook', [
|
||||
'name' => pSQL($hookName),
|
||||
'title' => pSQL($hookName),
|
||||
]);
|
||||
}
|
||||
if (!$this->isRegisteredInHook($hookName)) {
|
||||
$this->registerHook($hookName);
|
||||
}
|
||||
}
|
||||
|
||||
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, 'hookDisplay') === 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;
|
||||
}
|
||||
}
|
||||
27
modules/pp_carousel/sql/install.sql
Normal file
27
modules/pp_carousel/sql/install.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS `PREFIX_pp_carousel` (
|
||||
`id_carousel` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`hook_name` VARCHAR(128) NOT NULL DEFAULT 'displayHome',
|
||||
`source_type` VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||
`id_category` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`product_ids` TEXT,
|
||||
`limit_products` INT(11) UNSIGNED NOT NULL DEFAULT 12,
|
||||
`button_url` VARCHAR(512) DEFAULT '',
|
||||
`position` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
|
||||
`id_shop` INT(11) UNSIGNED NOT NULL DEFAULT 1,
|
||||
`date_add` DATETIME NOT NULL,
|
||||
`date_upd` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id_carousel`),
|
||||
KEY `hook_name` (`hook_name`),
|
||||
KEY `active` (`active`)
|
||||
) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `PREFIX_pp_carousel_lang` (
|
||||
`id_carousel` INT(11) UNSIGNED NOT NULL,
|
||||
`id_lang` INT(11) UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(255) DEFAULT '',
|
||||
`subtitle` VARCHAR(255) DEFAULT '',
|
||||
`button_label` VARCHAR(255) DEFAULT '',
|
||||
`price_suffix` VARCHAR(64) DEFAULT '',
|
||||
PRIMARY KEY (`id_carousel`, `id_lang`)
|
||||
) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8mb4;
|
||||
2
modules/pp_carousel/sql/uninstall.sql
Normal file
2
modules/pp_carousel/sql/uninstall.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS `PREFIX_pp_carousel_lang`;
|
||||
DROP TABLE IF EXISTS `PREFIX_pp_carousel`;
|
||||
179
modules/pp_carousel/views/css/pp_carousel.css
Normal file
179
modules/pp_carousel/views/css/pp_carousel.css
Normal file
@@ -0,0 +1,179 @@
|
||||
.pp-carousel {
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.pp-carousel__header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.pp-carousel__title {
|
||||
font-size: 42px;
|
||||
line-height: 1.1;
|
||||
margin: 0 0 6px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pp-carousel__subtitle {
|
||||
font-size: 44px;
|
||||
line-height: 1.1;
|
||||
font-weight: 300;
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.pp-carousel__slider {
|
||||
position: relative;
|
||||
padding: 10px 0 0 0;
|
||||
}
|
||||
|
||||
.pp-carousel__card {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pp-carousel__image {
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background: #f6f6f6;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pp-carousel__image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pp-carousel__label {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: .02em;
|
||||
text-transform: capitalize;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pp-carousel__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 2px 0 2px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.pp-carousel__name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pp-carousel__name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pp-carousel__price {
|
||||
font-size: 16px;
|
||||
opacity: .7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pp-carousel__priceSuffix {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.pp-carousel__footer {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.pp-carousel__more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
opacity: .75;
|
||||
font-size: 16px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.pp-carousel__more:before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
.pp-carousel__more:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Navigation arrows */
|
||||
.pp-carousel__nav .pp-carousel__prev,
|
||||
.pp-carousel__nav .pp-carousel__next {
|
||||
position: absolute;
|
||||
top: 45%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
opacity: .6;
|
||||
z-index: 3;
|
||||
transition: opacity .2s;
|
||||
}
|
||||
|
||||
.pp-carousel__nav .pp-carousel__prev:hover,
|
||||
.pp-carousel__nav .pp-carousel__next:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pp-carousel__nav .pp-carousel__prev { left: -10px; }
|
||||
.pp-carousel__nav .pp-carousel__next { right: -10px; }
|
||||
|
||||
.pp-carousel__nav .pp-carousel__prev:after,
|
||||
.pp-carousel__nav .pp-carousel__next:after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.pp-carousel__nav .pp-carousel__prev:after {
|
||||
transform: translate(-50%, -50%) rotate(135deg);
|
||||
}
|
||||
|
||||
.pp-carousel__nav .pp-carousel__next:after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.pp-carousel__nav .swiper-button-disabled {
|
||||
opacity: .2;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 992px) {
|
||||
.pp-carousel__title { font-size: 34px; }
|
||||
.pp-carousel__subtitle { font-size: 34px; }
|
||||
.pp-carousel__nav .pp-carousel__prev { left: 0; }
|
||||
.pp-carousel__nav .pp-carousel__next { right: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.pp-carousel__title { font-size: 26px; }
|
||||
.pp-carousel__subtitle { font-size: 26px; }
|
||||
.pp-carousel__name { font-size: 16px; }
|
||||
}
|
||||
23
modules/pp_carousel/views/js/pp_carousel.js
Normal file
23
modules/pp_carousel/views/js/pp_carousel.js
Normal file
@@ -0,0 +1,23 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof Swiper === 'undefined') return;
|
||||
|
||||
document.querySelectorAll('.pp-carousel__slider.swiper').forEach(function (el) {
|
||||
var section = el.closest('.pp-carousel');
|
||||
if (!section) return;
|
||||
|
||||
new Swiper(el, {
|
||||
slidesPerView: 3,
|
||||
spaceBetween: 26,
|
||||
loop: false,
|
||||
navigation: {
|
||||
nextEl: section.querySelector('.pp-carousel__next'),
|
||||
prevEl: section.querySelector('.pp-carousel__prev')
|
||||
},
|
||||
breakpoints: {
|
||||
0: { slidesPerView: 1.15, spaceBetween: 16 },
|
||||
576: { slidesPerView: 2, spaceBetween: 18 },
|
||||
992: { slidesPerView: 3, spaceBetween: 26 }
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
13
modules/pp_carousel/views/lib/swiper/swiper-bundle.min.css
vendored
Normal file
13
modules/pp_carousel/views/lib/swiper/swiper-bundle.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7661
modules/pp_carousel/views/lib/swiper/swiper-bundle.min.js
vendored
Normal file
7661
modules/pp_carousel/views/lib/swiper/swiper-bundle.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
67
modules/pp_carousel/views/templates/hook/pp_carousel.tpl
Normal file
67
modules/pp_carousel/views/templates/hook/pp_carousel.tpl
Normal file
@@ -0,0 +1,67 @@
|
||||
<section class="pp-carousel" id="pp-carousel-{$ppc_id}">
|
||||
<div class="pp-carousel__header">
|
||||
{if $ppc_title}
|
||||
<h2 class="pp-carousel__title">{$ppc_title|escape:'htmlall':'UTF-8'}</h2>
|
||||
{/if}
|
||||
{if $ppc_subtitle}
|
||||
<div class="pp-carousel__subtitle">{$ppc_subtitle|escape:'htmlall':'UTF-8'}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{if $ppc_products|count > 0}
|
||||
<div class="pp-carousel__slider swiper">
|
||||
<div class="swiper-wrapper">
|
||||
{foreach from=$ppc_products item=product}
|
||||
<div class="swiper-slide">
|
||||
<article class="pp-carousel__card">
|
||||
<a class="pp-carousel__image" href="{$product.url}" title="{$product.name|escape:'htmlall':'UTF-8'}">
|
||||
{if isset($product.cover.bySize.home_default.url)}
|
||||
<img src="{$product.cover.bySize.home_default.url}"
|
||||
alt="{$product.name|escape:'htmlall':'UTF-8'}"
|
||||
loading="lazy"
|
||||
width="300" height="300">
|
||||
{elseif isset($product.cover.large.url)}
|
||||
<img src="{$product.cover.large.url}"
|
||||
alt="{$product.name|escape:'htmlall':'UTF-8'}"
|
||||
loading="lazy">
|
||||
{/if}
|
||||
|
||||
{if isset($product.category_name) && $product.category_name}
|
||||
<span class="pp-carousel__label">{$product.category_name|escape:'htmlall':'UTF-8'}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<div class="pp-carousel__meta">
|
||||
<a class="pp-carousel__name" href="{$product.url}">
|
||||
{$product.name|escape:'htmlall':'UTF-8'}
|
||||
</a>
|
||||
|
||||
{if isset($product.price) && $product.price}
|
||||
<div class="pp-carousel__price">
|
||||
{$product.price}
|
||||
{if $ppc_price_suffix}
|
||||
<span class="pp-carousel__priceSuffix">{$ppc_price_suffix|escape:'htmlall':'UTF-8'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{/foreach}
|
||||
</div>
|
||||
|
||||
<div class="pp-carousel__nav">
|
||||
<div class="pp-carousel__prev" aria-label="Poprzedni"></div>
|
||||
<div class="pp-carousel__next" aria-label="Następny"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{if $ppc_button_label && $ppc_button_url}
|
||||
<div class="pp-carousel__footer">
|
||||
<a class="pp-carousel__more" href="{$ppc_button_url|escape:'htmlall':'UTF-8'}">
|
||||
{$ppc_button_label|escape:'htmlall':'UTF-8'}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
Reference in New Issue
Block a user