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:
2026-02-25 09:23:54 +01:00
parent e579d0a597
commit e888c81aef
20 changed files with 11577 additions and 2 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"mcp__serena__activate_project",
"mcp__serena__list_dir",
"mcp__serena__read_file",
"mcp__serena__find_file"
]
}
}

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

126
.serena/project.yml Normal file
View File

@@ -0,0 +1,126 @@
# the name by which the project can be referenced within Serena
project_name: "newwalls.pl"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- php
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:

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

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

View File

@@ -26,7 +26,10 @@
/* Debug only */
if (!defined('_PS_MODE_DEV_')) {
define('_PS_MODE_DEV_', false);
if ( $_SERVER['REMOTE_ADDR'] == '91.189.216.43' )
define('_PS_MODE_DEV_', false);
else
define('_PS_MODE_DEV_', false);
}
/* Compatibility warning */
define('_PS_DISPLAY_COMPATIBILITY_WARNING_', false);

28
index.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
require dirname(__FILE__).'/config/config.inc.php';
Dispatcher::getInstance()->dispatch();

File diff suppressed because it is too large Load Diff

View 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>

View 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;

View 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;
}
}

View 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;

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS `PREFIX_pp_carousel_lang`;
DROP TABLE IF EXISTS `PREFIX_pp_carousel`;

View 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; }
}

View 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 }
}
});
});
});

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,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>

196
plan.md Normal file
View File

@@ -0,0 +1,196 @@
# Plan: Moduł Project-Pro Karuzela Produktów
## Analiza screenshota
Karuzela wyświetla produkty jako karty z: dużym zdjęciem, etykietą kategorii (label w lewym górnym rogu), nazwą produktu, ceną z sufiksem `/m2`. Układ 3 kolumny na desktop, slider Swiper.
## Kluczowe decyzje architektoniczne
**Dlaczego tabela SQL zamiast Configuration?**
Moduł wymaga *dowolnej liczby* karuzeli — Configuration przechowuje pary klucz-wartość, nie nadaje się do list rekordów o zmiennej długości. Dedykowana tabela `pp_carousel` to jedyne poprawne rozwiązanie.
**Dlaczego dynamiczne hooki?**
Użytkownik chce przypisywać karuzele do hooków, w tym niestandardowych. Moduł rejestruje się na `displayHeader` + catch-all `actionDispatcher`, a wyświetlanie realizuje poprzez `Hook::exec()` z warunkiem `WHERE hook_name = ?` w bazie. Niestandardowe hooki dodawane są przez `Hook::getIdByName()` / ręczne INSERT.
## Struktura plików modułu
```
modules/pp_carousel/
├── pp_carousel.php # Główna klasa modułu
├── config.xml
├── logo.png
├── sql/
│ ├── install.sql # CREATE TABLE
│ └── uninstall.sql # DROP TABLE
├── views/
│ ├── css/
│ │ └── pp_carousel.css # Style karuzeli (front)
│ ├── js/
│ │ └── pp_carousel.js # Inicjalizacja Swiper (front)
│ ├── lib/
│ │ └── swiper/ # swiper-bundle.min.js/css (kopiujemy z project_pro_news)
│ └── templates/
│ ├── hook/
│ │ └── pp_carousel.tpl # Szablon frontu karuzeli
│ └── admin/
│ ├── list.tpl # Lista karuzeli w BO
│ └── form.tpl # Formularz dodawania/edycji karuzeli
```
## Tabela bazy danych: `ps_pp_carousel`
| Kolumna | Typ | Opis |
|---------|-----|------|
| `id_carousel` | INT AUTO_INCREMENT PK | ID karuzeli |
| `hook_name` | VARCHAR(128) | Nazwa hooka (np. `displayHome`, custom) |
| `source_type` | ENUM('new','bestseller','category','manual') | Źródło produktów |
| `id_category` | INT DEFAULT 0 | ID kategorii (gdy source_type=category) |
| `product_ids` | TEXT | Ręczne ID produktów rozdzielone przecinkami |
| `limit_products` | INT DEFAULT 12 | Limit produktów |
| `title` | VARCHAR(255) | Tytuł karuzeli |
| `subtitle` | VARCHAR(255) | Podtytuł |
| `button_label` | VARCHAR(255) | Tekst przycisku |
| `button_url` | VARCHAR(512) | URL przycisku |
| `price_suffix` | VARCHAR(64) | Sufiks ceny (np. `/m²`) |
| `position` | INT DEFAULT 0 | Kolejność sortowania |
| `active` | TINYINT(1) DEFAULT 1 | Aktywna/nieaktywna |
| `id_shop` | INT DEFAULT 1 | Multishop |
| `date_add` | DATETIME | Data utworzenia |
| `date_upd` | DATETIME | Data modyfikacji |
> Wielojęzyczność: pola `title`, `subtitle`, `button_label`, `price_suffix` — dodatkowa tabela `ps_pp_carousel_lang` z kolumnami `id_carousel`, `id_lang`, `title`, `subtitle`, `button_label`, `price_suffix`.
## Implementacja krok po kroku
### Krok 1: Plik główny `pp_carousel.php` — szkielet
- Klasa `Pp_Carousel extends Module`
- Konstruktor: name=`pp_carousel`, author=`Project-Pro`, version=`1.0.0`, displayName=`Project-Pro Karuzela Produktów`
- `install()`: wykonuje SQL, rejestruje hooki: `displayHeader`, `displayHome`, `displayBackOfficeHeader`
- `uninstall()`: DROP tabeli, usunięcie konfiguracji
- Metoda `getContent()` → panel admin z listą karuzeli + formularz CRUD
### Krok 2: SQL install/uninstall
**install.sql:**
```sql
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;
```
### Krok 3: Panel administracyjny (`getContent()`)
Widok listy (domyślny):
- Tabela HelperList: ID | Tytuł | Hook | Źródło | Limit | Aktywna | Akcje (edytuj/usuń)
- Przycisk "Dodaj karuzelę"
Widok formularza (dodaj/edytuj):
- **Hook** — select z listą dostępnych hooków + pole tekstowe "Niestandardowy hook" (jeśli wpisany, tworzony jest nowy hook w `ps_hook`)
- **Źródło produktów** — select: Nowości / Bestsellery / Kategoria / Ręczne ID
- **Kategoria** — select z drzewem kategorii (widoczny gdy source_type=category)
- **Ręczne ID produktów** — textarea, oddzielone przecinkami (widoczny gdy source_type=manual)
- **Limit produktów** — input number
- **Tytuł** — input text (wielojęzyczny)
- **Podtytuł** — input text (wielojęzyczny)
- **Tekst przycisku** — input text (wielojęzyczny)
- **URL przycisku** — input text
- **Sufiks ceny** — input text (wielojęzyczny), np. `/m²`
- **Aktywna** — switch tak/nie
### Krok 4: Pobieranie produktów — metoda `getProductsByCarousel($carousel)`
Switch po `source_type`:
- `new``Product::getNewProducts($idLang, 0, $limit)` (wzorzec z `ps_newproducts`)
- `bestseller``ProductSale::getBestSales($idLang, 0, $limit)` (wzorzec z `ps_bestsellers`)
- `category``Category::getProducts($idCategory, $idLang, 1, $limit)` (wzorzec z `project_pro_news`)
- `manual``SELECT` po `id_product IN (...)`, potem prezentacja
Wszystkie wyniki przechodzą przez `ProductAssembler` + `ProductListingPresenter` (identycznie jak w istniejących modułach).
### Krok 5: Renderowanie hooków
Mechanizm: moduł rejestruje się na najczęstsze hooki (`displayHome`, `displayFooterBefore`, `displayLeftColumn` itp.). W każdym hooku:
```php
public function hookDisplayHome($params) {
return $this->renderCarouselsForHook('displayHome');
}
```
Metoda `renderCarouselsForHook($hookName)`:
1. SELECT z `ps_pp_carousel` WHERE `hook_name` = $hookName AND `active` = 1 ORDER BY `position`
2. Dla każdej karuzeli: pobierz produkty, przypisz do Smarty, fetch szablonu
3. Zwróć połączony HTML
Niestandardowe hooki: przy zapisie karuzeli z nowym hookiem → `$this->registerHook($hookName)` + ewentualnie INSERT do `ps_hook`.
### Krok 6: Szablon frontu `pp_carousel.tpl`
Wzorowany na `project_pro_news.tpl` — ten sam layout Swiper:
- Section z unikalnym `id="pp-carousel-{$carousel.id}"`
- Nagłówek: tytuł + podtytuł
- Swiper container z kartami produktów
- Karta: zdjęcie (home_default), etykieta kategorii, nazwa, cena + sufiks
- Nawigacja prev/next
- Przycisk "Zobacz więcej"
### Krok 7: CSS + JS
**CSS** — bazowany na `project_pro_news.css`, dostosowany do nowego namespace `.pp-carousel`.
**JS** — inicjalizacja wielu instancji Swipera:
```js
document.querySelectorAll('.pp-carousel__slider').forEach(function(el) {
new Swiper(el, { /* config */ });
});
```
### Krok 8: Rejestracja assetów
`hookDisplayHeader()` — rejestracja CSS/JS (swiper + pp_carousel) identycznie jak w `project_pro_news`.
## Hooki do rejestracji (install)
Obowiązkowe:
- `displayHeader` (assety CSS/JS)
- `displayHome` (główna pozycja)
- `displayBackOfficeHeader` (admin CSS/JS jeśli potrzebne)
Dodatkowe (rejestrowane dynamicznie przy tworzeniu karuzeli z niestandardowym hookiem):
- `displayFooterBefore`, `displayTopColumn`, `displayLeftColumn`, `displayFooter` itd.
- Dowolne niestandardowe hooki
## Kolejność wdrożenia
1. **Pliki SQL** — tabele `pp_carousel` + `pp_carousel_lang`
2. **pp_carousel.php** — konstruktor, install/uninstall, hookDisplayHeader
3. **Panel admin** — getContent() z listą + formularzem CRUD
4. **Logika produktów** — getProductsByCarousel() z 4 źródłami
5. **Rendering hooków** — renderCarouselsForHook() + rejestracja dynamiczna
6. **Szablon .tpl** — karta produktu zgodna ze screenshotem
7. **CSS/JS** — style + Swiper init (wieloinstancyjny)
8. **Testowanie** — dodanie karuzeli w BO, weryfikacja na froncie

0
var/logs/dev.log Normal file
View File

0
var/logs/prod.log Normal file
View File