Add Custom Feature Tab module with database integration and AJAX support

- Created main module file `customfeaturetab.php` to manage product tabs based on feature values.
- Implemented database installation and uninstallation methods to create necessary tables.
- Added admin controller files for handling redirects and admin functionalities.
- Introduced AJAX functionality in `admin.js` for dynamic feature value selection based on selected features.
- Included temporary query script for testing feature values.
- Added language support for the module with Polish translations.
- Created necessary view files and JavaScript files for module functionality.
- Added logo image for the module.
This commit is contained in:
2026-02-23 23:24:48 +01:00
parent 721594b124
commit 2a98067d9e
16 changed files with 664 additions and 15 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(head:*)",
"Bash(cp:*)",
"Bash(cd:*)",
"Bash(curl:*)"
]
}
}

3
.vscode/ftp-kr.json vendored
View File

@@ -12,6 +12,7 @@
"ignoreRemoteModification": true,
"ignore": [
".git",
"/.vscode"
"/.vscode",
"/.claude"
]
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* @author Project-Pro <https://www.project-pro.pl>
* @copyright Project-Pro
* @license Proprietary - paid license
*/
if (!defined('_PS_VERSION_')) {
exit;
}
class CustomFeatureTabRule extends ObjectModel
{
public $id_feature;
public $id_feature_value;
public $position;
public $active;
public $title;
public $content;
public $date_add;
public $date_upd;
public static $definition = array(
'table' => 'custom_feature_tab',
'primary' => 'id_custom_feature_tab',
'multilang' => true,
'fields' => array(
'id_feature' => array(
'type' => self::TYPE_INT,
'validate' => 'isUnsignedId',
'required' => true,
),
'id_feature_value' => array(
'type' => self::TYPE_INT,
'validate' => 'isUnsignedId',
'required' => true,
),
'position' => array(
'type' => self::TYPE_INT,
'validate' => 'isUnsignedInt',
),
'active' => array(
'type' => self::TYPE_BOOL,
'validate' => 'isBool',
),
'date_add' => array(
'type' => self::TYPE_DATE,
'validate' => 'isDate',
),
'date_upd' => array(
'type' => self::TYPE_DATE,
'validate' => 'isDate',
),
// Lang fields
'title' => array(
'type' => self::TYPE_STRING,
'lang' => true,
'validate' => 'isGenericName',
'required' => true,
'size' => 255,
),
'content' => array(
'type' => self::TYPE_HTML,
'lang' => true,
'validate' => 'isCleanHtml',
),
),
);
/**
* Get active rules matching product features.
*
* @param array $productFeatures Array from Product::getFeaturesStatic
* @param int $idLang Language ID
* @return array
*/
public static function getMatchingRules(array $productFeatures, $idLang)
{
if (empty($productFeatures)) {
return array();
}
$conditions = array();
foreach ($productFeatures as $feat) {
$conditions[] = '(' . (int) $feat['id_feature'] . ', ' . (int) $feat['id_feature_value'] . ')';
}
$sql = 'SELECT cft.*, cftl.`title`, cftl.`content`
FROM `' . _DB_PREFIX_ . 'custom_feature_tab` cft
LEFT JOIN `' . _DB_PREFIX_ . 'custom_feature_tab_lang` cftl
ON cft.`id_custom_feature_tab` = cftl.`id_custom_feature_tab`
AND cftl.`id_lang` = ' . (int) $idLang . '
WHERE cft.`active` = 1
AND (cft.`id_feature`, cft.`id_feature_value`) IN (' . implode(',', $conditions) . ')
ORDER BY cft.`position` ASC, cft.`id_custom_feature_tab` ASC';
return Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($sql);
}
}

View File

@@ -0,0 +1,8 @@
<?php
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;

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<module>
<name>customfeaturetab</name>
<displayName><![CDATA[Custom Feature Tab]]></displayName>
<version><![CDATA[1.0.0]]></version>
<description><![CDATA[Dodaje dodatkowe zakładki na karcie produktu w zależności od przypisanych cech produktu.]]></description>
<author><![CDATA[Project-Pro]]></author>
<tab>front_office_features</tab>
<is_configurable>1</is_configurable>
<need_instance>0</need_instance>
<limited_countries></limited_countries>
</module>

View File

@@ -0,0 +1,246 @@
<?php
/**
* Admin controller for Custom Feature Tab rules.
*
* @author Project-Pro <https://www.project-pro.pl>
* @copyright Project-Pro
* @license Proprietary - paid license
*/
if (!defined('_PS_VERSION_')) {
exit;
}
require_once _PS_MODULE_DIR_ . 'customfeaturetab/classes/CustomFeatureTabRule.php';
class AdminCustomFeatureTabController extends ModuleAdminController
{
public function __construct()
{
$this->table = 'custom_feature_tab';
$this->className = 'CustomFeatureTabRule';
$this->identifier = 'id_custom_feature_tab';
$this->lang = true;
$this->bootstrap = true;
$this->addRowAction('edit');
$this->addRowAction('delete');
$this->position_identifier = 'position';
parent::__construct();
$this->bulk_actions = array(
'delete' => array(
'text' => $this->l('Delete selected'),
'confirm' => $this->l('Delete selected items?'),
'icon' => 'icon-trash',
),
);
$this->fields_list = array(
'id_custom_feature_tab' => array(
'title' => $this->l('ID'),
'align' => 'center',
'class' => 'fixed-width-xs',
),
'feature_name' => array(
'title' => $this->l('Cecha'),
'filter_key' => 'fl!name',
),
'feature_value_name' => array(
'title' => $this->l('Wartość cechy'),
'filter_key' => 'fvl!value',
),
'title' => array(
'title' => $this->l('Tytuł zakładki'),
'filter_key' => 'b!title',
),
'position' => array(
'title' => $this->l('Pozycja'),
'align' => 'center',
'class' => 'fixed-width-xs',
'position' => 'position',
),
'active' => array(
'title' => $this->l('Aktywna'),
'active' => 'status',
'type' => 'bool',
'align' => 'center',
'class' => 'fixed-width-sm',
),
);
}
/**
* Override getList to JOIN feature/feature_value names.
*/
public function getList($id_lang, $order_by = null, $order_way = null, $start = 0, $limit = null, $id_lang_shop = false)
{
$this->_select = 'fl.`name` AS `feature_name`, fvl.`value` AS `feature_value_name`';
$this->_join = '
LEFT JOIN `' . _DB_PREFIX_ . 'feature_lang` fl
ON (a.`id_feature` = fl.`id_feature` AND fl.`id_lang` = ' . (int) $id_lang . ')
LEFT JOIN `' . _DB_PREFIX_ . 'feature_value_lang` fvl
ON (a.`id_feature_value` = fvl.`id_feature_value` AND fvl.`id_lang` = ' . (int) $id_lang . ')';
parent::getList($id_lang, $order_by, $order_way, $start, $limit, $id_lang_shop);
}
/**
* Build the add/edit form.
*/
public function renderForm()
{
$features = Feature::getFeatures($this->context->language->id);
$featureOptions = array();
foreach ($features as $feature) {
$featureOptions[] = array(
'id' => $feature['id_feature'],
'name' => $feature['name'],
);
}
// For edit mode, get values of the currently selected feature
$featureValueOptions = array();
$selectedFeatureValue = 0;
if ($this->object && $this->object->id) {
$selectedFeatureValue = (int) $this->object->id_feature_value;
$sql = 'SELECT fv.`id_feature_value`, fvl.`value`
FROM `' . _DB_PREFIX_ . 'feature_value` fv
LEFT JOIN `' . _DB_PREFIX_ . 'feature_value_lang` fvl
ON fv.`id_feature_value` = fvl.`id_feature_value`
AND fvl.`id_lang` = ' . (int) $this->context->language->id . '
WHERE fv.`id_feature` = ' . (int) $this->object->id_feature . '
ORDER BY fvl.`value` ASC';
$values = Db::getInstance()->executeS($sql);
if ($values) {
foreach ($values as $val) {
$featureValueOptions[] = array(
'id' => $val['id_feature_value'],
'name' => $val['value'],
);
}
}
}
$this->fields_form = array(
'legend' => array(
'title' => $this->l('Reguła karty cechy'),
'icon' => 'icon-cogs',
),
'input' => array(
array(
'type' => 'select',
'label' => $this->l('Cecha'),
'name' => 'id_feature',
'required' => true,
'options' => array(
'query' => $featureOptions,
'id' => 'id',
'name' => 'name',
),
),
array(
'type' => 'select',
'label' => $this->l('Wartość cechy'),
'name' => 'id_feature_value',
'required' => true,
'options' => array(
'query' => $featureValueOptions,
'id' => 'id',
'name' => 'name',
),
'desc' => $this->l('Wartości zostaną załadowane po wyborze cechy.'),
),
array(
'type' => 'text',
'label' => $this->l('Tytuł zakładki'),
'name' => 'title',
'lang' => true,
'required' => true,
'size' => 255,
),
array(
'type' => 'textarea',
'label' => $this->l('Treść'),
'name' => 'content',
'lang' => true,
'autoload_rte' => true,
'rows' => 10,
'cols' => 100,
),
array(
'type' => 'text',
'label' => $this->l('Pozycja'),
'name' => 'position',
'class' => 'fixed-width-xs',
),
array(
'type' => 'switch',
'label' => $this->l('Aktywna'),
'name' => 'active',
'is_bool' => true,
'values' => array(
array('id' => 'active_on', 'value' => 1, 'label' => $this->l('Tak')),
array('id' => 'active_off', 'value' => 0, 'label' => $this->l('Nie')),
),
),
),
'submit' => array(
'title' => $this->l('Zapisz'),
),
);
return parent::renderForm();
}
/**
* Add JS for AJAX dropdown and pass the selected value.
*/
public function setMedia($isNewTheme = false)
{
parent::setMedia($isNewTheme);
if (in_array($this->display, array('add', 'edit'))) {
$this->addJS(_PS_MODULE_DIR_ . 'customfeaturetab/views/js/admin.js');
// Pass the preselected feature value to JS for edit mode
if ($this->object && $this->object->id) {
Media::addJsDef(array(
'customfeaturetab_selected_value' => (int) $this->object->id_feature_value,
));
}
}
}
/**
* AJAX endpoint: get feature values for a given feature.
*/
public function ajaxProcessGetFeatureValues()
{
$idFeature = (int) Tools::getValue('id_feature');
$idLang = (int) $this->context->language->id;
// Include ALL values (predefined + custom) for the feature
$sql = 'SELECT fv.`id_feature_value`, fvl.`value`
FROM `' . _DB_PREFIX_ . 'feature_value` fv
LEFT JOIN `' . _DB_PREFIX_ . 'feature_value_lang` fvl
ON fv.`id_feature_value` = fvl.`id_feature_value`
AND fvl.`id_lang` = ' . (int) $idLang . '
WHERE fv.`id_feature` = ' . (int) $idFeature . '
ORDER BY fvl.`value` ASC';
$values = Db::getInstance()->executeS($sql);
$result = array();
if ($values) {
foreach ($values as $val) {
$result[] = array(
'id_feature_value' => $val['id_feature_value'],
'value' => $val['value'],
);
}
}
header('Content-Type: application/json');
die(json_encode($result));
}
}

View File

@@ -0,0 +1,8 @@
<?php
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;

View File

@@ -0,0 +1,8 @@
<?php
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;

View File

@@ -0,0 +1,148 @@
<?php
/**
* Custom Feature Tab - Adds product tabs based on feature values.
*
* @author Project-Pro <https://www.project-pro.pl>
* @copyright Project-Pro
* @license Proprietary - paid license
*/
if (!defined('_PS_VERSION_')) {
exit;
}
require_once dirname(__FILE__) . '/classes/CustomFeatureTabRule.php';
class CustomFeatureTab extends Module
{
public function __construct()
{
$this->name = 'customfeaturetab';
$this->tab = 'front_office_features';
$this->version = '1.0.0';
$this->author = 'Project-Pro';
$this->need_instance = 0;
$this->bootstrap = true;
parent::__construct();
$this->displayName = $this->l('Karty cech produktu');
$this->description = $this->l('Dodaje dodatkowe zakładki na karcie produktu w zależności od przypisanych cech.');
$this->ps_versions_compliancy = array('min' => '1.7.0.0', 'max' => _PS_VERSION_);
}
public function install()
{
return parent::install()
&& $this->installDb()
&& $this->installTab()
&& $this->registerHook('displayProductExtraContent');
}
public function uninstall()
{
return $this->uninstallDb()
&& $this->uninstallTab()
&& parent::uninstall();
}
private function installDb()
{
$sql = array();
$sql[] = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'custom_feature_tab` (
`id_custom_feature_tab` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`id_feature` INT(11) UNSIGNED NOT NULL,
`id_feature_value` INT(11) UNSIGNED NOT NULL,
`position` INT(11) UNSIGNED NOT NULL DEFAULT 0,
`active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
`date_add` DATETIME NOT NULL,
`date_upd` DATETIME NOT NULL,
PRIMARY KEY (`id_custom_feature_tab`),
INDEX `idx_feature_value` (`id_feature`, `id_feature_value`)
) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;';
$sql[] = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'custom_feature_tab_lang` (
`id_custom_feature_tab` INT(11) UNSIGNED NOT NULL,
`id_lang` INT(11) UNSIGNED NOT NULL,
`title` VARCHAR(255) NOT NULL,
`content` TEXT,
PRIMARY KEY (`id_custom_feature_tab`, `id_lang`)
) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;';
foreach ($sql as $query) {
if (!Db::getInstance()->execute($query)) {
return false;
}
}
return true;
}
private function uninstallDb()
{
return Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'custom_feature_tab_lang`')
&& Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'custom_feature_tab`');
}
private function installTab()
{
$tab = new Tab();
$tab->class_name = 'AdminCustomFeatureTab';
$tab->module = $this->name;
$tab->id_parent = (int) Tab::getIdFromClassName('AdminCatalog');
$tab->icon = 'description';
$languages = Language::getLanguages(false);
foreach ($languages as $lang) {
$tab->name[$lang['id_lang']] = 'Karty cech produktu';
}
return $tab->add();
}
private function uninstallTab()
{
$idTab = (int) Tab::getIdFromClassName('AdminCustomFeatureTab');
if ($idTab) {
$tab = new Tab($idTab);
return $tab->delete();
}
return true;
}
public function getContent()
{
Tools::redirectAdmin($this->context->link->getAdminLink('AdminCustomFeatureTab'));
}
/**
* Hook: displayProductExtraContent
* Returns extra tabs for products that match feature rules.
*/
public function hookDisplayProductExtraContent($params)
{
$product = $params['product'];
$idLang = (int) $this->context->language->id;
$productFeatures = Product::getFeaturesStatic((int) $product->id);
if (empty($productFeatures)) {
return array();
}
$rules = CustomFeatureTabRule::getMatchingRules($productFeatures, $idLang);
if (empty($rules)) {
return array();
}
$tabs = array();
foreach ($rules as $rule) {
$extraContent = new PrestaShop\PrestaShop\Core\Product\ProductExtraContent();
$extraContent->setTitle($rule['title']);
$extraContent->setContent($rule['content']);
$tabs[] = $extraContent;
}
return $tabs;
}
}

View File

@@ -0,0 +1,23 @@
<?php
// Temporary query script - DELETE AFTER USE
include_once dirname(__FILE__) . '/../../config/config.inc.php';
$sql = "SELECT p.id_product, pl.name AS product_name, fl.name AS feature_name, fvl.value AS feature_value
FROM ps_feature_product fp
JOIN ps_feature_lang fl ON fp.id_feature = fl.id_feature AND fl.id_lang = 1
JOIN ps_feature_value_lang fvl ON fp.id_feature_value = fvl.id_feature_value AND fvl.id_lang = 1
JOIN ps_product p ON fp.id_product = p.id_product
JOIN ps_product_lang pl ON p.id_product = pl.id_product AND pl.id_lang = 1
WHERE fl.name = 'Seria' AND fvl.value = 'Simon 10'
ORDER BY p.id_product
LIMIT 20";
header('Content-Type: text/plain; charset=utf-8');
$results = Db::getInstance()->executeS($sql);
if ($results) {
foreach ($results as $row) {
echo $row['id_product'] . ' | ' . $row['product_name'] . ' | ' . $row['feature_name'] . ' -> ' . $row['feature_value'] . "\n";
}
} else {
echo "Brak wynikow\n";
}

View File

@@ -0,0 +1,14 @@
<?php
/**
* @author InterBlue
* @copyright InterBlue
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
*/
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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -0,0 +1,8 @@
<?php
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;

View File

@@ -0,0 +1,56 @@
/**
* AJAX dependent dropdown for feature values in admin form.
*/
$(document).ready(function () {
var $featureSelect = $('select[name="id_feature"]');
var $valueSelect = $('select[name="id_feature_value"]');
if (!$featureSelect.length || !$valueSelect.length) {
return;
}
// Build AJAX base URL from current page URL
var baseUrl = window.location.href.split('#')[0];
// Strip existing query noise and keep controller + token
var preselectedValue = $valueSelect.data('selected') || $valueSelect.val();
$featureSelect.on('change', function () {
var idFeature = $(this).val();
if (!idFeature) {
$valueSelect.empty().append('<option value="">--</option>');
return;
}
// Use the current page URL, append ajax params
var ajaxUrl = baseUrl + '&ajax=1&action=getFeatureValues&id_feature=' + idFeature;
$.ajax({
url: ajaxUrl,
type: 'GET',
dataType: 'json',
success: function (data) {
$valueSelect.empty();
if (data && data.length) {
$.each(data, function (i, item) {
var selected = (item.id_feature_value == preselectedValue) ? ' selected' : '';
$valueSelect.append(
'<option value="' + item.id_feature_value + '"' + selected + '>' +
item.value +
'</option>'
);
});
preselectedValue = null;
} else {
$valueSelect.append('<option value="">--</option>');
}
},
error: function (xhr, status, error) {
console.error('[customfeaturetab] AJAX error:', status, error);
}
});
});
if ($featureSelect.val()) {
$featureSelect.trigger('change');
}
});

View File

@@ -0,0 +1,8 @@
<?php
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;

View File

@@ -231,14 +231,6 @@
aria-controls="product-details"
{if !$product.description} aria-selected="true"{/if}>{l s='Product Details' d='Shop.Theme.Catalog'}</a>
</li>
<li class="nav-item">
<a
class="nav-link"
data-toggle="tab"
href="#attachments"
role="tab"
aria-controls="attachments">{l s='Attachments' d='Shop.Theme.Catalog'}</a>
</li>
{foreach from=$product.extraContent item=extra key=extraKey}
<li class="nav-item">
<a
@@ -249,6 +241,14 @@
aria-controls="extra-{$extraKey}">{$extra.title}</a>
</li>
{/foreach}
<li class="nav-item">
<a
class="nav-link"
data-toggle="tab"
href="#attachments"
role="tab"
aria-controls="attachments">{l s='Attachments' d='Shop.Theme.Catalog'}</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#ekomiprc">
Opinie
@@ -268,6 +268,12 @@
{include file='catalog/_partials/product-details.tpl'}
{/block}
{foreach from=$product.extraContent item=extra key=extraKey}
<div class="tab-pane fade in {$extra.attr.class}" id="extra-{$extraKey}" role="tabpanel" {foreach $extra.attr as $key => $val} {$key}="{$val}"{/foreach}>
{$extra.content nofilter}
</div>
{/foreach}
{block name='product_attachments'}
<div class="tab-pane fade in" id="attachments" role="tabpanel">
<section class="product-attachments">
@@ -298,12 +304,6 @@
</div>
{/block}
{/block}
{foreach from=$product.extraContent item=extra key=extraKey}
<div class="tab-pane fade in {$extra.attr.class}" id="extra-{$extraKey}" role="tabpanel" {foreach $extra.attr as $key => $val} {$key}="{$val}"{/foreach}>
{$extra.content nofilter}
</div>
{/foreach}
{hook h='displayProductTabContent'}
</div>
</div>