Add new module

This commit is contained in:
Roman Pyrih
2026-01-13 15:18:38 +01:00
parent 8e99578ae9
commit f703bc23a4
13 changed files with 8314 additions and 0 deletions

BIN
modules/.DS_Store vendored Normal file

Binary file not shown.

BIN
modules/appagebuilder/classes/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,37 @@
<?php
class CustomDevSliderSlide extends ObjectModel
{
public $id_customdevslider_slide;
public $image;
public $link;
public $position;
public $active;
public $title; // multilang
public $text; // multilang
public static $definition = [
'table' => 'customdevslider_slide',
'primary' => 'id_customdevslider_slide',
'multilang' => true,
'fields' => [
'image' => ['type' => self::TYPE_STRING, 'validate' => 'isFileName', 'size' => 255],
'link' => ['type' => self::TYPE_STRING, 'validate' => 'isUrlOrEmpty', 'size' => 255],
'position' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
'active' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'title' => ['type' => self::TYPE_STRING, 'lang' => true, 'validate' => 'isCleanHtml', 'size' => 255],
'text' => ['type' => self::TYPE_HTML, 'lang' => true, 'validate' => 'isCleanHtml'],
],
];
public static function isUrlOrEmpty($url)
{
if ($url === null || $url === '') {
return true;
}
return Validate::isUrl($url);
}
}

View File

@@ -0,0 +1,152 @@
<?php
require_once _PS_MODULE_DIR_.'customdevslider/classes/CustomDevSliderSlide.php';
class AdminCustomDevSliderController extends ModuleAdminController
{
public function __construct()
{
$this->bootstrap = true;
$this->table = 'customdevslider_slide';
$this->className = 'CustomDevSliderSlide';
$this->identifier = 'id_customdevslider_slide';
$this->lang = true;
parent::__construct();
$this->_defaultOrderBy = 'position';
$this->_defaultOrderWay = 'ASC';
$this->fields_list = [
'id_customdevslider_slide' => ['title' => 'ID', 'class' => 'fixed-width-xs'],
'title' => ['title' => $this->l('Title')],
'link' => ['title' => $this->l('Link')],
'position' => ['title' => $this->l('Position'), 'class' => 'fixed-width-sm'],
'active' => ['title' => $this->l('Active'), 'type' => 'bool', 'active' => 'status', 'class' => 'fixed-width-sm'],
];
$this->addRowAction('edit');
$this->addRowAction('delete');
// Кнопка "Додати"
$this->bulk_actions = [];
}
public function renderForm()
{
/** @var CustomDevSliderSlide $obj */
$obj = $this->object;
$imagePreview = '';
if ($obj && !empty($obj->image)) {
$imageUrl = __PS_BASE_URI__.'modules/customdevslider/uploads/'.$obj->image;
$imagePreview = '<div style="margin-top:10px;"><img src="'.$imageUrl.'" style="max-width:220px;height:auto;border:1px solid #ddd;padding:5px;"></div>';
}
$this->fields_form = [
'legend' => ['title' => $this->l('Slide')],
'input' => [
[
'type' => 'file',
'label' => $this->l('Image'),
'name' => 'image_file',
'desc' => $this->l('Allowed: jpg, jpeg, png, webp, gif.').$imagePreview,
],
[
'type' => 'text',
'label' => $this->l('Link'),
'name' => 'link',
'desc' => $this->l('Optional URL (https://...)'),
],
[
'type' => 'text',
'label' => $this->l('Title'),
'name' => 'title',
'lang' => true,
],
[
'type' => 'textarea',
'label' => $this->l('Text'),
'name' => 'text',
'lang' => true,
'autoload_rte' => true,
],
[
'type' => 'text',
'label' => $this->l('Position'),
'name' => 'position',
'class' => 'fixed-width-sm',
'desc' => $this->l('Sorting order (0..n). Lower goes first.'),
],
[
'type' => 'switch',
'label' => $this->l('Active'),
'name' => 'active',
'is_bool' => true,
'values' => [
['id' => 'active_on', 'value' => 1, 'label' => $this->l('Enabled')],
['id' => 'active_off', 'value' => 0, 'label' => $this->l('Disabled')],
],
],
],
'submit' => ['title' => $this->l('Save')],
];
return parent::renderForm();
}
public function processAdd()
{
$this->handleImageUpload();
return parent::processAdd();
}
public function processUpdate()
{
$this->handleImageUpload();
return parent::processUpdate();
}
private function handleImageUpload()
{
if (!isset($_FILES['image_file']) || empty($_FILES['image_file']['name'])) {
return;
}
$file = $_FILES['image_file'];
if (!empty($file['error'])) {
$this->errors[] = $this->l('Image upload error.');
return;
}
$ext = Tools::strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
if (!in_array($ext, $allowed, true)) {
$this->errors[] = $this->l('Invalid image format.');
return;
}
$uploadDir = _PS_MODULE_DIR_.'customdevslider/uploads/';
if (!is_dir($uploadDir)) {
if (!@mkdir($uploadDir, 0755, true)) {
$this->errors[] = $this->l('Cannot create uploads directory.');
return;
}
}
// Нормальне безпечне ім'я
$safeName = sha1(uniqid('customdevslider_', true)).'.'.$ext;
$dest = $uploadDir.$safeName;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
$this->errors[] = $this->l('Image upload failed.');
return;
}
// Записуємо ім'я файлу в поле image
$_POST['image'] = $safeName;
}
}

View File

@@ -0,0 +1 @@
.hp-c-slider{padding:0 !important}.hp-c-slider>.ApColumn{padding:0 !important}div[id^=customdevslider-] .customdevslider-swiper__container{position:relative}div[id^=customdevslider-] .customdevslider-swiper__container .swiper-wrapper{position:absolute;inset:0}div[id^=customdevslider-] .customdevslider-swiper__container .swiper-wrapper .swiper-slide .customdevslider-swiper__image{height:100%;-o-object-fit:cover;object-fit:cover;-o-object-position:left center;object-position:left center}div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content{position:relative;margin:50px 0 50px auto;row-gap:40px;min-height:548px;width:50%;height:100%;background:rgba(120,120,120,.65);padding:24px;color:#f2f2f2;z-index:1;display:flex;flex-direction:column}@media(max-width: 1300px){div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content{width:60%}}@media(max-width: 900px){div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content{width:100%;margin:0;padding:50px;background-color:rgba(34,34,34,.85);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}}div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content .customdevslider-swiper__text{flex:1 1 auto;overflow:hidden;font-size:20px;line-height:1.3}div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content .customdevslider-swiper__pagination{display:flex;flex-direction:row;gap:20px;justify-content:space-between;flex:0 0 auto;margin-top:auto;position:static !important;text-align:center}@media(max-width: 700px){div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content .customdevslider-swiper__pagination{display:flex;flex-direction:row;flex-wrap:wrap}}div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content .customdevslider-swiper__pagination .swiper-pagination-bullet{width:auto;height:auto;border-radius:0;background:rgba(0,0,0,0);opacity:1;margin:0 !important;padding:0;color:#fff;font-size:26px;letter-spacing:1px;text-transform:uppercase;font-weight:500;display:inline-block;cursor:pointer;min-width:-moz-max-content;min-width:max-content}@media(max-width: 700px){div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content .customdevslider-swiper__pagination .swiper-pagination-bullet{flex:40%}}@media(max-width: 460px){div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content .customdevslider-swiper__pagination .swiper-pagination-bullet{font-size:18px}}div[id^=customdevslider-] .customdevslider-swiper__container .customdevslider-swiper__content .customdevslider-swiper__pagination .swiper-pagination-bullet.swiper-pagination-bullet-active{color:#e60000}/*# sourceMappingURL=front.css.map */

View File

@@ -0,0 +1 @@
{"version":3,"sources":["front.scss"],"names":[],"mappings":"AAAA,aACC,oBAAA,CAEA,uBACC,oBAAA,CAID,6DACC,iBAAA,CAEA,6EACC,iBAAA,CACA,OAAA,CAGC,0HACC,WAAA,CACA,mBAAA,CAAA,gBAAA,CACA,8BAAA,CAAA,2BAAA,CAIH,8FACC,iBAAA,CACA,uBAAA,CACA,YAAA,CACA,gBAAA,CAKA,SAAA,CAEA,WAAA,CACA,gCAAA,CACA,YAAA,CACA,aAAA,CACA,SAAA,CAEA,YAAA,CACA,qBAAA,CAEA,0BApBD,8FAqBE,SAAA,CAAA,CAGD,yBAxBD,8FAyBE,UAAA,CACA,QAAA,CACA,YAAA,CACA,mCAAA,CACA,iCAAA,CAAA,yBAAA,CAAA,CAGD,4HACC,aAAA,CACA,eAAA,CACA,cAAA,CACA,eAAA,CAGD,kIACC,YAAA,CACA,kBAAA,CACA,QAAA,CACA,6BAAA,CAEA,aAAA,CACA,eAAA,CACA,0BAAA,CACA,iBAAA,CAEA,yBAXD,kIAYE,YAAA,CACA,kBAAA,CACA,cAAA,CAAA,CAGD,4JACC,UAAA,CACA,WAAA,CACA,eAAA,CACA,wBAAA,CACA,SAAA,CACA,mBAAA,CACA,SAAA,CACA,UAAA,CACA,cAAA,CACA,kBAAA,CACA,wBAAA,CACA,eAAA,CACA,oBAAA,CACA,cAAA,CAEA,0BAAA,CAAA,qBAAA,CAEA,yBAlBD,4JAmBE,QAAA,CAAA,CAED,yBArBD,4JAsBE,cAAA,CAAA,CAED,4LACC,aAAA","file":"front.css"}

View File

@@ -0,0 +1,111 @@
.hp-c-slider {
padding: 0 !important;
> .ApColumn {
padding: 0 !important;
}
}
div[id^='customdevslider-'] {
.customdevslider-swiper__container {
position: relative;
.swiper-wrapper {
position: absolute;
inset: 0;
.swiper-slide {
.customdevslider-swiper__image {
height: 100%;
object-fit: cover;
object-position: left center;
}
}
}
.customdevslider-swiper__content {
position: relative;
margin: 50px 0 50px auto;
row-gap: 40px;
min-height: 548px;
// position: absolute;
// right: 5%;
// top: 10%;
width: 50%;
// height: 80%;
height: 100%;
background: rgba(120, 120, 120, 0.65);
padding: 24px;
color: #f2f2f2;
z-index: 1;
display: flex;
flex-direction: column;
@media (max-width: 1300px) {
width: 60%;
}
@media (max-width: 900px) {
width: 100%;
margin: 0;
padding: 50px;
background-color: rgb(34 34 34 / 85%);
backdrop-filter: blur(3px);
}
.customdevslider-swiper__text {
flex: 1 1 auto;
overflow: hidden;
font-size: 20px;
line-height: 1.3;
}
.customdevslider-swiper__pagination {
display: flex;
flex-direction: row;
gap: 20px;
justify-content: space-between;
flex: 0 0 auto;
margin-top: auto;
position: static !important;
text-align: center;
@media (max-width: 700px) {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.swiper-pagination-bullet {
width: auto;
height: auto;
border-radius: 0;
background: transparent;
opacity: 1;
margin: 0 !important;
padding: 0;
color: #ffffff;
font-size: 26px;
letter-spacing: 1px;
text-transform: uppercase;
font-weight: 500;
display: inline-block;
cursor: pointer;
min-width: max-content;
@media (max-width: 700px) {
flex: 40%;
}
@media (max-width: 460px) {
font-size: 18px;
}
&.swiper-pagination-bullet-active {
color: #e60000;
}
}
}
}
}
}

View File

@@ -0,0 +1,195 @@
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
class CustomDevSlider extends Module implements \PrestaShop\PrestaShop\Core\Module\WidgetInterface
{
public function __construct()
{
$this->name = 'customdevslider';
$this->tab = 'front_office_features';
$this->version = '1.0.0';
$this->author = 'CustomDev';
$this->need_instance = 0;
$this->bootstrap = true;
parent::__construct();
$this->displayName = $this->l('Custom Dev Slider');
$this->description = $this->l('Custom slider with dynamic slides (image, title, text, link).');
}
public function install()
{
return parent::install()
&& $this->installDb()
&& $this->installTab()
&& $this->registerHook('displayHome')
&& $this->registerHook('displayTop')
&& $this->registerHook('header');
}
public function uninstall()
{
return $this->uninstallTab()
&& $this->uninstallDb()
&& parent::uninstall();
}
public function hookDisplayTop($params)
{
return $this->renderWidget('displayTop', []);
}
public function hookDisplayHome($params)
{
return $this->renderWidget('displayHome', []);
}
public function getContent()
{
Tools::redirectAdmin($this->context->link->getAdminLink('AdminCustomDevSlider'));
}
public function hookHeader()
{
// Swiper (локально)
$this->context->controller->registerStylesheet(
'customdevslider-swiper',
'modules/'.$this->name.'/plugins/swiperjs/swiper.min.css',
['media' => 'all', 'priority' => 140]
);
// Твій CSS (після swiper)
$this->context->controller->registerStylesheet(
'customdevslider-front',
'modules/'.$this->name.'/css/front.css',
['media' => 'all', 'priority' => 150]
);
// Swiper JS (перед твоїм JS)
$this->context->controller->registerJavascript(
'customdevslider-swiper',
'modules/'.$this->name.'/plugins/swiperjs/swiper.min.js',
['position' => 'bottom', 'priority' => 140]
);
// Твій JS ініціалізації
$this->context->controller->registerJavascript(
'customdevslider-front',
'modules/'.$this->name.'/js/front.js',
['position' => 'bottom', 'priority' => 150]
);
}
// WidgetInterface
public function renderWidget($hookName, array $configuration)
{
$this->smarty->assign($this->getWidgetVariables($hookName, $configuration));
return $this->fetch('module:'.$this->name.'/views/templates/hook/slider.tpl');
}
public function getWidgetVariables($hookName, array $configuration)
{
$idLang = (int)$this->context->language->id;
$slides = Db::getInstance()->executeS('
SELECT s.*, sl.title, sl.text
FROM `'._DB_PREFIX_.'customdevslider_slide` s
LEFT JOIN `'._DB_PREFIX_.'customdevslider_slide_lang` sl
ON (s.id_customdevslider_slide = sl.id_customdevslider_slide AND sl.id_lang = '.$idLang.')
WHERE s.active = 1
ORDER BY s.position ASC
');
foreach ($slides as &$s) {
$s['image_url'] = $s['image']
? $this->context->link->getMediaLink(_MODULE_DIR_.$this->name.'/uploads/'.$s['image'])
: null;
}
unset($s);
// UID щоб кілька віджетів не конфліктували
$uid = Tools::passwdGen(10, 'NO_NUMERIC');
return [
'customdevslider_slides' => $slides,
'customdevslider_uid' => $uid,
];
}
private function installDb()
{
$sql = [];
$sql[] = 'CREATE TABLE IF NOT EXISTS `'._DB_PREFIX_.'customdevslider_slide` (
`id_customdevslider_slide` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`image` VARCHAR(255) DEFAULT NULL,
`link` VARCHAR(255) DEFAULT NULL,
`position` INT UNSIGNED NOT NULL DEFAULT 0,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`date_add` DATETIME NULL,
`date_upd` DATETIME NULL,
PRIMARY KEY (`id_customdevslider_slide`)
) ENGINE='._MYSQL_ENGINE_.' DEFAULT CHARSET=utf8;';
$sql[] = 'CREATE TABLE IF NOT EXISTS `'._DB_PREFIX_.'customdevslider_slide_lang` (
`id_customdevslider_slide` INT UNSIGNED NOT NULL,
`id_lang` INT UNSIGNED NOT NULL,
`title` VARCHAR(255) DEFAULT NULL,
`text` TEXT DEFAULT NULL,
PRIMARY KEY (`id_customdevslider_slide`, `id_lang`)
) ENGINE='._MYSQL_ENGINE_.' DEFAULT CHARSET=utf8;';
foreach ($sql as $q) {
if (!Db::getInstance()->execute($q)) {
return false;
}
}
return true;
}
private function uninstallDb()
{
$sql = [];
$sql[] = 'DROP TABLE IF EXISTS `'._DB_PREFIX_.'customdevslider_slide_lang`';
$sql[] = 'DROP TABLE IF EXISTS `'._DB_PREFIX_.'customdevslider_slide`';
foreach ($sql as $q) {
if (!Db::getInstance()->execute($q)) {
return false;
}
}
return true;
}
private function installTab()
{
$idParent = (int)Tab::getIdFromClassName('AdminParentModulesSf');
$tab = new Tab();
$tab->active = 1;
$tab->class_name = 'AdminCustomDevSlider';
$tab->module = $this->name;
$tab->id_parent = $idParent;
$tab->name = [];
foreach (Language::getLanguages(true) as $lang) {
$tab->name[(int)$lang['id_lang']] = 'Custom Dev Slider';
}
return (bool)$tab->add();
}
private function uninstallTab()
{
$idTab = (int)Tab::getIdFromClassName('AdminCustomDevSlider');
if ($idTab) {
$tab = new Tab($idTab);
return (bool)$tab->delete();
}
return true;
}
}

View File

@@ -0,0 +1,98 @@
$(function () {
var $roots = $('.customdevslider-swiper')
if (!$roots.length) return
$roots.each(function () {
var $root = $(this)
if ($root.data('inited') === 1) return
$root.data('inited', 1)
var $container = $root.find('.customdevslider-swiper__container').first()
var $paginationEl = $root
.find('.customdevslider-swiper__pagination')
.first()
var $textBox = $root.find('.customdevslider-swiper__text').first()
if (
!$container.length ||
!$paginationEl.length ||
typeof Swiper === 'undefined'
)
return
var titles = []
try {
titles = JSON.parse($root.attr('data-titles') || '[]')
} catch (e) {
titles = []
}
function setHtmlByIndex(idx) {
var $slide = $container.find('.swiper-slide').eq(idx)
var html =
$slide.find('.customdevslider-swiper__slide-html').first().html() || ''
$textBox.html(html)
}
var swiper = new Swiper($container[0], {
loop: true,
speed: 600,
autoplay: {
delay: 5000,
},
// fade
effect: 'fade',
fadeEffect: { crossFade: true },
pagination: {
el: $paginationEl[0],
clickable: true,
renderBullet: function (index, className) {
var t = (titles[index] || '').toString().trim()
if (!t) t = 'Slide ' + (index + 1)
return '<span class="' + className + '">' + t + '</span>'
},
},
on: {
init: function () {
setMaxTextHeight()
setHtmlByIndex(this.realIndex)
},
slideChange: function () {
setHtmlByIndex(this.realIndex)
},
resize: function () {
setMaxTextHeight()
},
},
})
setHtmlByIndex(0)
})
function setMaxTextHeight() {
var maxHeight = 0
var originalHtml = $textBox.html()
$container.find('.swiper-slide').each(function () {
var html = $(this)
.find('.customdevslider-swiper__slide-html')
.first()
.html()
if (!html) return
$textBox.html(html)
$textBox.css('height', 'auto')
var h = $textBox.outerHeight(true)
if (h > maxHeight) maxHeight = h
})
$textBox.html(originalHtml)
$textBox.height(maxHeight)
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
{if $customdevslider_slides|@count > 0}
{assign var="titles" value=[]}
{foreach from=$customdevslider_slides item=s}
{$titles[] = ($s.title|default:'')|strip_tags}
{/foreach}
<div class="customdevslider-swiper"
id="customdevslider-{$customdevslider_uid|escape:'htmlall':'UTF-8'}"
data-titles='{$titles|@json_encode|escape:'htmlall':'UTF-8'}'>
<div class="swiper customdevslider-swiper__container">
<div class="swiper-wrapper">
{foreach from=$customdevslider_slides item=s}
<div class="swiper-slide customdevslider-swiper__slide">
{if $s.link}
<a class="customdevslider-swiper__link" href="{$s.link|escape:'htmlall':'UTF-8'}">
{/if}
{if $s.image_url}
<img class="customdevslider-swiper__image"
src="{$s.image_url|escape:'htmlall':'UTF-8'}"
alt="{$s.title|escape:'htmlall':'UTF-8'}">
{/if}
{if $s.link}
</a>
{/if}
{* ВАЖЛИВО: зберігаємо HTML структуру тексту *}
<div class="customdevslider-swiper__slide-html" style="display:none;">
{$s.text nofilter}
</div>
</div>
{/foreach}
</div>
{* ОДИН overlay content на весь слайдер *}
<div class="customdevslider-swiper__content">
<div class="customdevslider-swiper__text"></div>
<div class="swiper-pagination customdevslider-swiper__pagination"></div>
</div>
</div>
</div>
{/if}