first commit
This commit is contained in:
191
README.MD
Normal file
191
README.MD
Normal file
@@ -0,0 +1,191 @@
|
||||
<p align="center">
|
||||
<img src="logo.png" width="128" alt="Product Countdown Module Logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Product Discount Countdown for PrestaShop</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/PrestaShop-1.7.8%2B-blue.svg?logo=prestashop" alt="PrestaShop Version">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green.svg" alt="License: MIT">
|
||||
<a href="https://secure.wayforpay.com/donate/dd579282b23b4">
|
||||
<img src="https://img.shields.io/badge/Donate-WayForPay-orange.svg" alt="Donate via WayForPay">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A free, feature-rich PrestaShop module that displays a real-time countdown timer for products with specific prices (discounts), creating a sense of urgency to boost your sales.
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<p align="center">
|
||||
<strong><a href="#-english">English</a></strong> | <strong><a href="#-українська">Українська</a></strong> | <strong><a href="#-русский">Русский</a></strong>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
## 🇬🇧 English <a name="-english"></a>
|
||||
|
||||
Boost your store's conversion rate by visually highlighting limited-time offers. This module adds a customizable countdown timer directly on the product page for any item with an expiring specific price, encouraging customers to make a purchase before the deal is gone.
|
||||
|
||||
### ✨ Key Features
|
||||
|
||||
* **Real-Time Countdown:** Displays days, hours, minutes, and seconds remaining for a special offer.
|
||||
* **Highly Customizable:**
|
||||
* Set a **display threshold** (e.g., only show the timer if the offer ends in less than 30 days).
|
||||
* Choose the **display position** (after price, near add to cart, etc.).
|
||||
* Customize all **front-end text**.
|
||||
* Select custom **background and text colors** for the badge to match your theme.
|
||||
* Option to display the name of the Catalog Price Rule (e.g., "Black Friday Sale").
|
||||
* Add your own styles with a **Custom CSS** field.
|
||||
* **AJAX Compatible:** The timer correctly reloads when a customer changes product combinations (attributes).
|
||||
* **Smart Logic:**
|
||||
* Automatically detects the customer's country or uses the store's default.
|
||||
* Choose what happens when the timer expires: hide, reload the page, or show a message.
|
||||
* **Modern & Translatable:** Built using PrestaShop's modern translation system.
|
||||
* **Lightweight:** Clean code ensures minimal impact on your site's performance.
|
||||
|
||||
### 📸 Screenshots
|
||||
|
||||
#### Admin Configuration Interface
|
||||
*Manage all settings from a clean and intuitive back-office panel.*
|
||||

|
||||
|
||||
#### Front-End Countdown Timer
|
||||
*A clear and attractive timer displayed on the product page.*
|
||||

|
||||
|
||||
### ⚙️ Compatibility
|
||||
|
||||
* **PrestaShop:** Version 1.7.8 or newer.
|
||||
|
||||
### 🛠️ Installation
|
||||
|
||||
1. Download the latest release `.zip` file from the [releases page](https://github.com/panariga/productcountdown/releases).
|
||||
2. In your PrestaShop Back Office, navigate to **Modules > Module Manager**.
|
||||
3. Click on **"Upload a module"** and select the `.zip` file you downloaded.
|
||||
4. After installation, click **"Configure"** to set up the module.
|
||||
|
||||
### ❤️ Support & Contribution
|
||||
|
||||
This module is completely free and developed in my spare time. If you find it useful and it helps your business, please consider showing your appreciation with a small donation. Your support helps cover the costs of development and motivates me to continue improving this module and creating new ones.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://secure.wayforpay.com/donate/dd579282b23b4" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://img.shields.io/badge/Donate-WayForPay-orange.svg" alt="Donate" style="height: 50px !important;width: 180px !important;" >
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Found a bug or have a feature request? Please [open an issue](https://github.com/panariga/productcountdown/issues) on GitHub.
|
||||
|
||||
### 📄 License
|
||||
|
||||
This module is released under the [MIT License](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## 🇺🇦 Українська <a name="-українська"></a>
|
||||
|
||||
Збільшуйте конверсію вашого магазину, візуально виділяючи обмежені в часі пропозиції. Цей модуль додає таймер зворотного відліку на сторінку товару для будь-якого продукту з акційною ціною, що закінчується, спонукаючи клієнтів зробити покупку, доки пропозиція діє.
|
||||
|
||||
### ✨ Ключові можливості
|
||||
|
||||
* **Таймер у реальному часі:** Показує дні, години, хвилини та секунди, що залишилися до кінця акції.
|
||||
* **Гнучке налаштування:**
|
||||
* Встановіть **поріг відображення** (наприклад, показувати таймер, лише якщо акція закінчується менш ніж за 30 днів).
|
||||
* Виберіть **позицію для відображення** (після ціни, біля кнопки "Додати в кошик" тощо).
|
||||
* Налаштуйте всі **тексти, що бачить користувач**.
|
||||
* Виберіть **колір фону та тексту** для таймера, щоб він пасував до вашої теми.
|
||||
* Можливість показувати назву правила каталогу цін (напр., "Чорна п'ятниця").
|
||||
* Додайте власні стилі за допомогою поля **Custom CSS**.
|
||||
* **Сумісність з AJAX:** Таймер коректно перезавантажується, коли клієнт змінює комбінації товару (атрибути).
|
||||
* **Розумна логіка:**
|
||||
* Автоматично визначає країну клієнта або використовує країну магазину за замовчуванням.
|
||||
* Виберіть дію після закінчення таймера: приховати, перезавантажити сторінку або показати повідомлення.
|
||||
* **Сучасний та перекладний:** Створений з використанням сучасної системи перекладів PrestaShop.
|
||||
* **Легкий:** Чистий код забезпечує мінімальний вплив на продуктивність вашого сайту.
|
||||
|
||||
### 📸 Скріншоти
|
||||
|
||||
*(Дивіться скріншоти в англійській секції вище)*
|
||||
|
||||
### ⚙️ Сумісність
|
||||
|
||||
* **PrestaShop:** Версія 1.7.8 або новіша.
|
||||
|
||||
### 🛠️ Встановлення
|
||||
|
||||
1. Завантажте останню версію `.zip` файлу зі [сторінки релізів](https://github.com/panariga/productcountdown/releases).
|
||||
2. У вашій адмін-панелі PrestaShop перейдіть до **Модулі > Менеджер модулів**.
|
||||
3. Натисніть **"Завантажити модуль"** та виберіть завантажений `.zip` файл.
|
||||
4. Після встановлення натисніть **"Налаштувати"**, щоб сконфігурувати модуль.
|
||||
|
||||
### ❤️ Підтримка та внесок
|
||||
|
||||
Цей модуль є повністю безкоштовним і розроблений у мій вільний час. Якщо він вам подобається і допомагає вашому бізнесу, будь ласка, підтримайте його невеликим внеском. Ваша підтримка допомагає покрити витрати на розробку та мотивує мене продовжувати вдосконалювати цей модуль та створювати нові.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://secure.wayforpay.com/donate/dd579282b23b4" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://img.shields.io/badge/Donate-WayForPay-orange.svg" alt="Підтримати" style="height: 50px !important;width: 180px !important;" >
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Знайшли помилку або маєте ідею? Будь ласка, [створіть issue](https://github.com/panariga/productcountdown/issues) на GitHub.
|
||||
|
||||
### 📄 Ліцензія
|
||||
|
||||
Цей модуль випущено під [ліцензією MIT](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## 🇷🇺 Русский <a name="-русский"></a>
|
||||
|
||||
Увеличивайте конверсию вашего магазина, визуально выделяя ограниченные по времени предложения. Этот модуль добавляет таймер обратного отсчета на страницу товара для любого продукта с истекающей специальной ценой, побуждая клиентов совершить покупку, пока предложение действует.
|
||||
|
||||
### ✨ Ключевые возможности
|
||||
|
||||
* **Таймер в реальном времени:** Показывает дни, часы, минуты и секунды, оставшиеся до конца акции.
|
||||
* **Гибкая настройка:**
|
||||
* Установите **порог отображения** (например, показывать таймер, только если акция заканчивается менее чем через 30 дней).
|
||||
* Выберите **позицию для отображения** (после цены, возле кнопки "Добавить в корзину" и т.д.).
|
||||
* Настройте все **тексты, которые видит пользователь**.
|
||||
* Выберите **цвет фона и текста** для таймера, чтобы он соответствовал вашей теме.
|
||||
* Возможность отображать название правила каталога цен (напр., "Черная пятница").
|
||||
* Добавьте собственные стили с помощью поля **Custom CSS**.
|
||||
* **Совместимость с AJAX:** Таймер корректно перезагружается, когда клиент меняет комбинации товара (атрибуты).
|
||||
* **Умная логика:**
|
||||
* Автоматически определяет страну клиента или использует страну магазина по умолчанию.
|
||||
* Выберите действие после окончания таймера: скрыть, перезагрузить страницу или показать сообщение.
|
||||
* **Современный и переводимый:** Создан с использованием современной системы переводов PrestaShop.
|
||||
* **Легковесный:** Чистый код обеспечивает минимальное влияние на производительность вашего сайта.
|
||||
|
||||
### 📸 Скриншоты
|
||||
|
||||
*(Смотрите скриншоты в английской секции выше)*
|
||||
|
||||
### ⚙️ Совместимость
|
||||
|
||||
* **PrestaShop:** Версия 1.7.8 или новее.
|
||||
|
||||
### 🛠️ Установка
|
||||
|
||||
1. Скачайте последнюю версию `.zip` файла со [страницы релизов](https://github.com/panariga/productcountdown/releases).
|
||||
2. В вашей админ-панели PrestaShop перейдите в **Модули > Менеджер модулей**.
|
||||
3. Нажмите **"Загрузить модуль"** и выберите скачанный `.zip` файл.
|
||||
4. После установки нажмите **"Настроить"**, чтобы сконфигурировать модуль.
|
||||
|
||||
### ❤️ Поддержка и вклад
|
||||
|
||||
Этот модуль является полностью бесплатным и разработан в мое свободное время. Если он вам нравится и помогает вашему бизнесу, пожалуйста, поддержите его небольшим пожертвованием. Ваша поддержка помогает покрыть расходы на разработку и мотивирует меня продолжать улучшать этот модуль и создавать новые.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://secure.wayforpay.com/donate/dd579282b23b4" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://img.shields.io/badge/Donate-WayForPay-orange.svg" alt="Поддержать" style="height: 50px !important;width: 180px !important;" >
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Нашли ошибку или есть идея? Пожалуйста, [создайте issue](https://github.com/panariga/productcountdown/issues) на GitHub.
|
||||
|
||||
### 📄 Лицензия
|
||||
|
||||
Этот модуль выпущен под [лицензией MIT](LICENSE).
|
||||
472
productcountdown.php
Normal file
472
productcountdown.php
Normal file
@@ -0,0 +1,472 @@
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Serializer\Encoder\JsonEncode;
|
||||
|
||||
if (!defined('_PS_VERSION_')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class ProductCountdown extends Module
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->name = 'productcountdown';
|
||||
$this->tab = 'front_office_features';
|
||||
$this->version = '1.0.0';
|
||||
$this->author = 'Panariga';
|
||||
$this->need_instance = 0;
|
||||
$this->ps_versions_compliancy = ['min' => '1.7.8', 'max' => _PS_VERSION_];
|
||||
$this->bootstrap = true;
|
||||
|
||||
parent::__construct();
|
||||
|
||||
$this->displayName = $this->trans('Product Discount Countdown', [], 'Modules.Productcountdown.Admin');
|
||||
$this->description = $this->trans('Displays a countdown for products with a limited-time discount.', [], 'Modules.Productcountdown.Admin');
|
||||
}
|
||||
|
||||
public function isUsingNewTranslationSystem()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function install()
|
||||
{
|
||||
if (!parent::install()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set default configuration values
|
||||
Configuration::updateValue('PDC_ENABLED', 1);
|
||||
Configuration::updateValue('PDC_MAX_DAYS', 30);
|
||||
// Default hook is 'displayProductPriceBlock', but we register all of them.
|
||||
Configuration::updateValue('PDC_HOOK_POSITION', 'displayProductPriceBlock');
|
||||
Configuration::updateValue('PDC_PREFIX_TEXT', ['en' => 'Special offer ends in:', 'fr' => 'Offre spéciale se termine dans :']);
|
||||
Configuration::updateValue('PDC_SHOW_NAME', 0);
|
||||
Configuration::updateValue('PDC_ON_EXPIRE', 'hide');
|
||||
Configuration::updateValue('PDC_EXPIRED_TEXT', ['en' => 'This special offer has expired.']);
|
||||
Configuration::updateValue('PDC_BADGE_BG_COLOR', '#D9534F'); // A nice default red
|
||||
Configuration::updateValue('PDC_BADGE_TEXT_COLOR', '#FFFFFF'); // White text
|
||||
Configuration::updateValue('PDC_CUSTOM_CSS', '');
|
||||
|
||||
// Register ALL possible hooks. The logic to display will be inside each hook method.
|
||||
return $this->registerHook('displayHeader')
|
||||
&& $this->registerHook('displayProductPriceBlock')
|
||||
&& $this->registerHook('displayProductActions')
|
||||
&& $this->registerHook('displayProductAdditionalInfo');
|
||||
}
|
||||
|
||||
// ... uninstall() remains the same ...
|
||||
public function uninstall()
|
||||
{
|
||||
// Delete all configuration values
|
||||
$configKeys = [
|
||||
'PDC_ENABLED',
|
||||
'PDC_MAX_DAYS',
|
||||
'PDC_HOOK_POSITION',
|
||||
'PDC_PREFIX_TEXT',
|
||||
'PDC_SHOW_NAME',
|
||||
'PDC_ON_EXPIRE',
|
||||
'PDC_EXPIRED_TEXT',
|
||||
'PDC_CUSTOM_CSS',
|
||||
|
||||
'PDC_BADGE_BG_COLOR',
|
||||
'PDC_BADGE_TEXT_COLOR'
|
||||
];
|
||||
foreach ($configKeys as $key) {
|
||||
Configuration::deleteByName($key);
|
||||
}
|
||||
|
||||
return parent::uninstall();
|
||||
}
|
||||
|
||||
public function getContent()
|
||||
{
|
||||
$output = '';
|
||||
if (Tools::isSubmit('submit' . $this->name)) {
|
||||
$this->postProcess();
|
||||
$output .= $this->displayConfirmation($this->trans('Settings updated', [], 'Admin.Notifications.Success'));
|
||||
}
|
||||
// Generate the HTML for the form and the support panel
|
||||
$formHtml = $this->renderForm();
|
||||
$supportPanelHtml = $this->renderSupportPanel();
|
||||
|
||||
// Combine them into a two-column layout using Bootstrap's grid system
|
||||
$finalHtml = '
|
||||
<div class="row">
|
||||
<div class="col-lg-9">' . $formHtml . '</div>
|
||||
<div class="col-lg-3">' . $supportPanelHtml . '</div>
|
||||
</div>
|
||||
';
|
||||
|
||||
return $output . $finalHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* SIMPLIFIED postProcess() method
|
||||
*/
|
||||
protected function postProcess()
|
||||
{
|
||||
$form_values = $this->getConfigFormValues();
|
||||
$languages = Language::getLanguages(false);
|
||||
|
||||
foreach (array_keys($form_values) as $key) {
|
||||
if (in_array($key, ['PDC_PREFIX_TEXT', 'PDC_EXPIRED_TEXT'])) {
|
||||
$values = [];
|
||||
foreach ($languages as $lang) {
|
||||
$values[$lang['id_lang']] = Tools::getValue($key . '_' . $lang['id_lang']);
|
||||
}
|
||||
Configuration::updateValue($key, $values);
|
||||
} else {
|
||||
// The complex hook registration logic is no longer needed here.
|
||||
// We just save the value.
|
||||
Configuration::updateValue($key, Tools::getValue($key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... renderForm(), getSettingsForm(), getStylingForm(), getConfigFormValues() remain the same ...
|
||||
|
||||
|
||||
// --- HOOKS AND FRONT OFFICE LOGIC ---
|
||||
|
||||
/**
|
||||
* NEW: Dedicated hook method for displayProductPriceBlock
|
||||
* This correctly handles the 'type' parameter.
|
||||
*/
|
||||
public function hookDisplayProductPriceBlock($params)
|
||||
{
|
||||
// 1. Check if this is the hook selected by the user in the configuration.
|
||||
if (Configuration::get('PDC_HOOK_POSITION') === 'displayProductPriceBlock') {
|
||||
// 2. Check for the specific 'type' to prevent multiple displays.
|
||||
if (isset($params['type']) && $params['type'] === 'after_price') {
|
||||
return $this->renderCountdown($params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NEW: Dedicated hook method for displayProductActions
|
||||
*/
|
||||
public function hookDisplayProductActions($params)
|
||||
{
|
||||
if (Configuration::get('PDC_HOOK_POSITION') === 'displayProductActions') {
|
||||
return $this->renderCountdown($params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NEW: Dedicated hook method for displayProductAdditionalInfo
|
||||
*/
|
||||
public function hookDisplayProductAdditionalInfo($params)
|
||||
{
|
||||
if (Configuration::get('PDC_HOOK_POSITION') === 'displayProductAdditionalInfo') {
|
||||
return $this->renderCountdown($params);
|
||||
}
|
||||
}
|
||||
|
||||
// ... hookDisplayHeader(), renderCountdown(), and getActiveDiscount() remain the same ...
|
||||
public function hookDisplayHeader()
|
||||
{
|
||||
if ($this->context->controller instanceof ProductController) {
|
||||
$this->context->controller->registerJavascript(
|
||||
'module-productcountdown-js',
|
||||
'modules/' . $this->name . '/views/js/front.js',
|
||||
['position' => 'bottom', 'priority' => 150]
|
||||
);
|
||||
$this->context->controller->registerStylesheet(
|
||||
'module-productcountdown-css',
|
||||
'modules/' . $this->name . '/views/css/front.css',
|
||||
['media' => 'all', 'priority' => 150]
|
||||
);
|
||||
|
||||
$customCss = Configuration::get('PDC_CUSTOM_CSS');
|
||||
if (!empty($customCss)) {
|
||||
Media::addInlineStyle($customCss);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function renderCountdown($params)
|
||||
{
|
||||
if (!(bool)Configuration::get('PDC_ENABLED')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$product = isset($params['product']) ? $params['product'] : null;
|
||||
if (!$product || !is_object($product) || !isset($product->id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activeDiscount = $this->getActiveDiscount($product);
|
||||
|
||||
if (!$activeDiscount) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activeDiscountTime = strtotime($activeDiscount['to']);
|
||||
$maxDays = (int)Configuration::get('PDC_MAX_DAYS', null, null, null, 30);
|
||||
$maxTime = ($maxDays > 0) ? (time() + 60 * 60 * 24 * $maxDays) : 0;
|
||||
|
||||
if ($activeDiscountTime > time() && ($maxTime === 0 || $activeDiscountTime < $maxTime)) {
|
||||
|
||||
$discountName = null;
|
||||
if ((bool)Configuration::get('PDC_SHOW_NAME') && (int)$activeDiscount['id_specific_price_rule'] > 0) {
|
||||
$rule = new SpecificPriceRule($activeDiscount['id_specific_price_rule']);
|
||||
if (Validate::isLoadedObject($rule)) {
|
||||
$discountName = $rule->name[$this->context->language->id] ?? current($rule->name);
|
||||
}
|
||||
}
|
||||
|
||||
$prefixText = Configuration::get('PDC_PREFIX_TEXT', $this->context->language->id, null, null, 'Special offer ends in:');
|
||||
$expiredText = Configuration::get('PDC_EXPIRED_TEXT', $this->context->language->id, null, null, 'This special offer has expired.');
|
||||
|
||||
$this->context->smarty->assign([
|
||||
'countdown_timestamp' => $activeDiscountTime,
|
||||
'countdown_prefix' => $prefixText,
|
||||
'countdown_discount_name' => $discountName,
|
||||
'countdown_on_expire_action' => Configuration::get('PDC_ON_EXPIRE', null, null, null, 'hide'),
|
||||
'countdown_expired_text' => $expiredText,
|
||||
// NEW: Assign colors to Smarty variables
|
||||
'countdown_bg_color' => Configuration::get('PDC_BADGE_BG_COLOR'),
|
||||
'countdown_text_color' => Configuration::get('PDC_BADGE_TEXT_COLOR'),
|
||||
]);
|
||||
|
||||
return $this->display(__FILE__, 'views/templates/hook/countdown.tpl');
|
||||
}
|
||||
}
|
||||
|
||||
public function getActiveDiscount($product)
|
||||
{
|
||||
$id_country = (int)($this->context->country->id ?? Configuration::get('PS_COUNTRY_DEFAULT'));
|
||||
|
||||
return SpecificPrice::getSpecificPrice(
|
||||
(int)$product->id,
|
||||
$this->context->shop->id,
|
||||
$this->context->currency->id,
|
||||
$id_country,
|
||||
$this->context->customer->id_default_group,
|
||||
1,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Builds the configuration form using HelperForm.
|
||||
*/
|
||||
public function renderForm()
|
||||
{
|
||||
$helper = new HelperForm();
|
||||
$helper->show_toolbar = false;
|
||||
$helper->table = $this->table;
|
||||
$helper->module = $this;
|
||||
$helper->default_form_language = $this->context->language->id;
|
||||
$helper->allow_employee_form_lang = Configuration::get('PS_BO_ALLOW_EMPLOYEE_FORM_LANG', 0);
|
||||
$helper->identifier = $this->identifier;
|
||||
$helper->submit_action = 'submit' . $this->name;
|
||||
$helper->currentIndex = $this->context->link->getAdminLink('AdminModules', false)
|
||||
. '&configure=' . $this->name . '&tab_module=' . $this->tab . '&module_name=' . $this->name;
|
||||
$helper->token = Tools::getAdminTokenLite('AdminModules');
|
||||
|
||||
$helper->tpl_vars = [
|
||||
'fields_value' => $this->getConfigFormValues(),
|
||||
'languages' => $this->context->controller->getLanguages(),
|
||||
'id_language' => $this->context->language->id,
|
||||
];
|
||||
|
||||
return $helper->generateForm([$this->getSettingsForm(), $this->getStylingForm()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the "Settings" section of the form.
|
||||
*/
|
||||
protected function getSettingsForm()
|
||||
{
|
||||
$hookOptions = [
|
||||
['id_option' => 'displayProductPriceBlock', 'name' => $this->trans('After Price (Recommended)', [], 'Modules.Productcountdown.Admin')],
|
||||
['id_option' => 'displayProductActions', 'name' => $this->trans('Product Actions (Near Add to Cart)', [], 'Modules.Productcountdown.Admin')],
|
||||
['id_option' => 'displayProductAdditionalInfo', 'name' => $this->trans('Product Additional Info (Tabs)', [], 'Modules.Productcountdown.Admin')],
|
||||
];
|
||||
|
||||
$expireOptions = [
|
||||
['id_option' => 'hide', 'name' => $this->trans('Hide the countdown timer', [], 'Modules.Productcountdown.Admin')],
|
||||
['id_option' => 'reload', 'name' => $this->trans('Reload the page', [], 'Modules.Productcountdown.Admin')],
|
||||
['id_option' => 'message', 'name' => $this->trans('Show an "Expired" message', [], 'Modules.Productcountdown.Admin')],
|
||||
];
|
||||
|
||||
return [
|
||||
'form' => [
|
||||
'legend' => [
|
||||
'title' => $this->trans('General Settings', [], 'Modules.Productcountdown.Admin'),
|
||||
'icon' => 'icon-cogs',
|
||||
],
|
||||
'input' => [
|
||||
[
|
||||
'type' => 'switch',
|
||||
'label' => $this->trans('Enable Countdown', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_ENABLED',
|
||||
'is_bool' => true,
|
||||
'desc' => $this->trans('Globally enable or disable the countdown timer.', [], 'Modules.Productcountdown.Admin'),
|
||||
'values' => [['id' => 'active_on', 'value' => 1, 'label' => $this->trans('Enabled', [], 'Admin.Global')], ['id' => 'active_off', 'value' => 0, 'label' => $this->trans('Disabled', [], 'Admin.Global')]],
|
||||
],
|
||||
[
|
||||
'type' => 'text',
|
||||
'label' => $this->trans('Display Threshold', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_MAX_DAYS',
|
||||
'class' => 'fixed-width-sm',
|
||||
'suffix' => $this->trans('days', [], 'Modules.Productcountdown.Admin'),
|
||||
'desc' => $this->trans('Only show the timer if the discount expires within this many days. Set to 0 to always show.', [], 'Modules.Productcountdown.Admin'),
|
||||
],
|
||||
[
|
||||
'type' => 'select',
|
||||
'label' => $this->trans('Display Position (Hook)', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_HOOK_POSITION',
|
||||
'options' => ['query' => $hookOptions, 'id' => 'id_option', 'name' => 'name'],
|
||||
'desc' => $this->trans('Choose where the countdown timer should appear.', [], 'Modules.Productcountdown.Admin'),
|
||||
],
|
||||
[
|
||||
'type' => 'text',
|
||||
'label' => $this->trans('Countdown Prefix Text', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_PREFIX_TEXT',
|
||||
'lang' => true,
|
||||
'desc' => $this->trans('This text appears before the timer (e.g., "Offer ends in:").', [], 'Modules.Productcountdown.Admin'),
|
||||
],
|
||||
[
|
||||
'type' => 'switch',
|
||||
'label' => $this->trans('Display Discount Name', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_SHOW_NAME',
|
||||
'is_bool' => true,
|
||||
'desc' => $this->trans('If available, show the name of the Catalog Price Rule.', [], 'Modules.Productcountdown.Admin'),
|
||||
'values' => [['id' => 'active_on', 'value' => 1, 'label' => $this->trans('Yes', [], 'Admin.Global')], ['id' => 'active_off', 'value' => 0, 'label' => $this->trans('No', [], 'Admin.Global')]],
|
||||
],
|
||||
[
|
||||
'type' => 'select',
|
||||
'label' => $this->trans('Action on Expiry', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_ON_EXPIRE',
|
||||
'options' => ['query' => $expireOptions, 'id' => 'id_option', 'name' => 'name'],
|
||||
'desc' => $this->trans('What should happen when the timer reaches zero?', [], 'Modules.Productcountdown.Admin'),
|
||||
],
|
||||
[
|
||||
'type' => 'text',
|
||||
'label' => $this->trans('Expired Message Text', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_EXPIRED_TEXT',
|
||||
'lang' => true,
|
||||
'desc' => $this->trans('This message is shown only if "Action on Expiry" is set to "Show an Expired message".', [], 'Modules.Productcountdown.Admin'),
|
||||
],
|
||||
],
|
||||
'submit' => [
|
||||
'title' => $this->trans('Save', [], 'Admin.Actions'),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the support and donation panel for the configuration page.
|
||||
*/
|
||||
protected function renderSupportPanel()
|
||||
{
|
||||
// Define your donation link here
|
||||
$donationLink = 'https://secure.wayforpay.com/donate/dd579282b23b4'; // <--- CHANGE THIS LINK
|
||||
|
||||
$this->context->smarty->assign([
|
||||
'panel_title' => $this->trans('Enjoying this module?', [], 'Modules.Productcountdown.Admin'),
|
||||
'panel_body_text_1' => $this->trans('This module is offered for free on GitHub.', [], 'Modules.Productcountdown.Admin'),
|
||||
'panel_body_text_2' => $this->trans('If it helps your business, please consider supporting its future development by making a small donation.', [], 'Modules.Productcountdown.Admin'),
|
||||
'button_text' => $this->trans('Buy me a donut', [], 'Modules.Productcountdown.Admin'),
|
||||
'donation_link' => $donationLink,
|
||||
]);
|
||||
|
||||
return $this->display(__FILE__, 'views/templates/admin/support_panel.tpl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the "Styling" section of the form.
|
||||
*/
|
||||
protected function getStylingForm()
|
||||
{
|
||||
return [
|
||||
'form' => [
|
||||
'legend' => [
|
||||
'title' => $this->trans('Styling', [], 'Modules.Productcountdown.Admin'),
|
||||
'icon' => 'icon-paint-brush',
|
||||
],
|
||||
'input' => [
|
||||
// NEW: Color pickers
|
||||
[
|
||||
'type' => 'color',
|
||||
'label' => $this->trans('Badge Background Color', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_BADGE_BG_COLOR',
|
||||
'desc' => $this->trans('Choose the background color for the countdown badge.', [], 'Modules.Productcountdown.Admin'),
|
||||
],
|
||||
[
|
||||
'type' => 'color',
|
||||
'label' => $this->trans('Badge Text Color', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_BADGE_TEXT_COLOR',
|
||||
'desc' => $this->trans('Choose the text color for the countdown badge.', [], 'Modules.Productcountdown.Admin'),
|
||||
],
|
||||
// END NEW
|
||||
[
|
||||
'type' => 'textarea',
|
||||
'label' => $this->trans('Custom CSS', [], 'Modules.Productcountdown.Admin'),
|
||||
'name' => 'PDC_CUSTOM_CSS',
|
||||
'desc' => $this->trans('Add your custom CSS rules here. The main container has the class ".product-countdown-container".', [], 'Modules.Productcountdown.Admin'),
|
||||
],
|
||||
],
|
||||
'submit' => [
|
||||
'title' => $this->trans('Save', [], 'Admin.Actions'),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves current configuration values to populate the form.
|
||||
*/
|
||||
protected function getConfigFormValues()
|
||||
{
|
||||
$languages = Language::getLanguages(false);
|
||||
$fields = [];
|
||||
|
||||
$fields['PDC_ENABLED'] = Configuration::get('PDC_ENABLED');
|
||||
$fields['PDC_MAX_DAYS'] = Configuration::get('PDC_MAX_DAYS');
|
||||
$fields['PDC_HOOK_POSITION'] = Configuration::get('PDC_HOOK_POSITION');
|
||||
$fields['PDC_SHOW_NAME'] = Configuration::get('PDC_SHOW_NAME');
|
||||
$fields['PDC_ON_EXPIRE'] = Configuration::get('PDC_ON_EXPIRE');
|
||||
$fields['PDC_CUSTOM_CSS'] = Configuration::get('PDC_CUSTOM_CSS');
|
||||
$fields['PDC_BADGE_BG_COLOR'] = Configuration::get('PDC_BADGE_BG_COLOR');
|
||||
$fields['PDC_BADGE_TEXT_COLOR'] = Configuration::get('PDC_BADGE_TEXT_COLOR');
|
||||
|
||||
foreach ($languages as $lang) {
|
||||
$fields['PDC_PREFIX_TEXT'][$lang['id_lang']] = Tools::getValue(
|
||||
'PDC_PREFIX_TEXT_' . $lang['id_lang'],
|
||||
Configuration::get('PDC_PREFIX_TEXT', $lang['id_lang'])
|
||||
);
|
||||
$fields['PDC_EXPIRED_TEXT'][$lang['id_lang']] = Tools::getValue(
|
||||
'PDC_EXPIRED_TEXT_' . $lang['id_lang'],
|
||||
Configuration::get('PDC_EXPIRED_TEXT', $lang['id_lang'])
|
||||
);
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
// --- HOOKS AND FRONT OFFICE LOGIC ---
|
||||
|
||||
public function __call($method, $args)
|
||||
{
|
||||
if (strpos($method, 'hook') === 0) {
|
||||
$configuredHook = Configuration::get('PDC_HOOK_POSITION', null, null, null, 'displayProductPriceBlock');
|
||||
if (strtolower($method) === 'hook' . strtolower($configuredHook)) {
|
||||
echo '<script>' . json_encode($method) . '</script>';
|
||||
|
||||
return $this->renderCountdown($args[0]);
|
||||
}
|
||||
}
|
||||
return parent::__call($method, $args);
|
||||
}
|
||||
}
|
||||
27
views/css/front.css
Normal file
27
views/css/front.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.product-countdown-container {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.product-countdown-container .countdown-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* MODIFIED: Changed selector from .badge to .countdown-badge */
|
||||
.product-countdown-container .countdown-wrapper .countdown-badge {
|
||||
padding: 0.5em 0.75em;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
/* Colors are now handled by inline styles, so they are not needed here */
|
||||
}
|
||||
|
||||
.product-countdown-container .countdown-timer {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
min-width: 120px;
|
||||
text-align: left;
|
||||
}
|
||||
117
views/js/front.js
Normal file
117
views/js/front.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Product Discount Countdown Module
|
||||
*
|
||||
* This script initializes countdown timers on the product page.
|
||||
* It handles the initial page load and the dynamic updates that occur
|
||||
* when a customer changes a product combination (attribute).
|
||||
*/
|
||||
|
||||
// A global array to keep track of our active timer intervals.
|
||||
// This is crucial for cleanup when the product block is updated via AJAX.
|
||||
let productCountdownIntervals = [];
|
||||
|
||||
/**
|
||||
* Scans the DOM for countdown containers and initializes them.
|
||||
* This function is designed to be called on page load and after AJAX updates.
|
||||
*/
|
||||
function initializeProductCountdowns() {
|
||||
// 1. Clear any previously running timers.
|
||||
// This prevents multiple timers from running after a product combination change.
|
||||
productCountdownIntervals.forEach(intervalId => clearInterval(intervalId));
|
||||
productCountdownIntervals = [];
|
||||
|
||||
// 2. Find all countdown containers on the page that need to be processed.
|
||||
const countdownContainers = document.querySelectorAll('.product-countdown-container');
|
||||
|
||||
countdownContainers.forEach(container => {
|
||||
// Check if this specific container has already been initialized to avoid race conditions.
|
||||
if (container.dataset.initialized === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerElement = container.querySelector('.countdown-timer');
|
||||
if (!timerElement) return;
|
||||
|
||||
// Mark as initialized
|
||||
container.dataset.initialized = 'true';
|
||||
|
||||
const targetTimestamp = parseInt(container.dataset.timestamp, 10);
|
||||
const onExpireAction = container.dataset.onExpire;
|
||||
const expiredText = container.dataset.expiredText;
|
||||
|
||||
// Get translated units from hidden spans
|
||||
const units = {
|
||||
day: container.querySelector('[data-unit="day"]').textContent,
|
||||
days: container.querySelector('[data-unit="days"]').textContent,
|
||||
hr: container.querySelector('[data-unit="hr"]').textContent,
|
||||
min: container.querySelector('[data-unit="min"]').textContent,
|
||||
sec: container.querySelector('[data-unit="sec"]').textContent
|
||||
};
|
||||
|
||||
function updateTimer() {
|
||||
const now = Math.floor(new Date().getTime() / 1000);
|
||||
const diff = targetTimestamp - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
clearInterval(interval);
|
||||
handleExpiry();
|
||||
return;
|
||||
}
|
||||
|
||||
const d = Math.floor(diff / (60 * 60 * 24));
|
||||
const h = Math.floor((diff % (60 * 60 * 24)) / (60 * 60));
|
||||
const m = Math.floor((diff % (60 * 60)) / 60);
|
||||
const s = Math.floor(diff % 60);
|
||||
|
||||
const parts = [];
|
||||
if (d > 0) {
|
||||
parts.push(`${d} ${d > 1 ? units.days : units.day}`);
|
||||
}
|
||||
// Always show hours, minutes, and seconds for a better countdown experience
|
||||
parts.push(`${String(h).padStart(2, '0')}${units.hr}`);
|
||||
parts.push(`${String(m).padStart(2, '0')}${units.min}`);
|
||||
parts.push(`${String(s).padStart(2, '0')}${units.sec}`);
|
||||
|
||||
timerElement.textContent = parts.join(' ');
|
||||
}
|
||||
|
||||
function handleExpiry() {
|
||||
switch (onExpireAction) {
|
||||
case 'reload':
|
||||
location.reload();
|
||||
break;
|
||||
case 'message':
|
||||
container.querySelector('.countdown-wrapper').innerHTML = `<span class="badge badge-secondary">${expiredText}</span>`;
|
||||
break;
|
||||
case 'hide':
|
||||
default:
|
||||
container.style.display = 'none';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const interval = setInterval(updateTimer, 1000);
|
||||
// Add the new interval ID to our global array for tracking.
|
||||
productCountdownIntervals.push(interval);
|
||||
updateTimer(); // Initial call to display the timer immediately
|
||||
});
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
|
||||
// 1. Run the initializer on the initial page load.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeProductCountdowns();
|
||||
});
|
||||
|
||||
// 2. Run the initializer whenever PrestaShop updates the product block via AJAX.
|
||||
// The `prestashop` object is globally available in modern themes.
|
||||
if (typeof prestashop !== 'undefined') {
|
||||
prestashop.on('updateProduct', (data) => {
|
||||
// We use a small timeout to ensure the DOM has been fully updated by PrestaShop's scripts
|
||||
// before our script runs and looks for the new container. 100ms is usually safe.
|
||||
setTimeout(() => {
|
||||
initializeProductCountdowns();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
12
views/templates/admin/support_panel.tpl
Normal file
12
views/templates/admin/support_panel.tpl
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<i class="icon-heart"></i> {$panel_title}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>{$panel_body_text_1}</p>
|
||||
<p>{$panel_body_text_2}</p>
|
||||
<a href="{$donation_link}" target="_blank" rel="noopener noreferrer" class="btn btn-success btn-lg btn-block">
|
||||
<i class="icon-rocket"></i> {$button_text}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
24
views/templates/hook/countdown.tpl
Normal file
24
views/templates/hook/countdown.tpl
Normal file
@@ -0,0 +1,24 @@
|
||||
<div class="product-countdown-container" id="product-countdown-{$product.id}" data-timestamp="{$countdown_timestamp}"
|
||||
data-on-expire="{$countdown_on_expire_action}"
|
||||
data-expired-text="{l s=$countdown_expired_text d='Modules.Productcountdown.Shop'}">
|
||||
|
||||
{if !empty($countdown_discount_name)}
|
||||
<p class="countdown-name">{$countdown_discount_name|escape:'htmlall':'UTF-8'}</p>
|
||||
{/if}
|
||||
|
||||
<div class="countdown-wrapper h5">
|
||||
{* MODIFIED: Added style attribute and changed class *}
|
||||
<span class="badge countdown-badge"
|
||||
style="background-color: {$countdown_bg_color|escape:'html':'UTF-8'}; color: {$countdown_text_color|escape:'html':'UTF-8'}; border-color: {$countdown_bg_color|escape:'html':'UTF-8'};">
|
||||
{l s=$countdown_prefix d='Modules.Productcountdown.Shop'}
|
||||
<span class="countdown-timer"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{* These hidden spans provide translated units for the javascript, no changes here *}
|
||||
<span class="d-none" data-unit="day">{l s='day' d='Modules.Productcountdown.Shop'}</span>
|
||||
<span class="d-none" data-unit="days">{l s='days' d='Modules.Productcountdown.Shop'}</span>
|
||||
<span class="d-none" data-unit="hr">{l s='hr' d='Modules.Productcountdown.Shop'}</span>
|
||||
<span class="d-none" data-unit="min">{l s='min' d='Modules.Productcountdown.Shop'}</span>
|
||||
<span class="d-none" data-unit="sec">{l s='sec' d='Modules.Productcountdown.Shop'}</span>
|
||||
</div>
|
||||
Reference in New Issue
Block a user