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:
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal 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
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
126
.serena/project.yml
Normal file
126
.serena/project.yml
Normal 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
4
.vscode/ftp-kr.json
vendored
@@ -12,6 +12,8 @@
|
||||
"ignoreRemoteModification": true,
|
||||
"ignore": [
|
||||
".git",
|
||||
"/.vscode"
|
||||
"/.vscode",
|
||||
"/.claude",
|
||||
"/.serena"
|
||||
]
|
||||
}
|
||||
@@ -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
28
index.php
Normal 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();
|
||||
2447
modules/cookiesplus/cookiesplus.php
Normal file
2447
modules/cookiesplus/cookiesplus.php
Normal file
File diff suppressed because it is too large
Load Diff
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>
|
||||
196
plan.md
Normal file
196
plan.md
Normal 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
0
var/logs/dev.log
Normal file
0
var/logs/prod.log
Normal file
0
var/logs/prod.log
Normal file
Reference in New Issue
Block a user