first commit

This commit is contained in:
O K
2025-09-07 12:50:02 +03:00
commit ba8c1e6957
10 changed files with 843 additions and 0 deletions

191
README.MD Normal file
View 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.*
![Admin Interface Demo](demo1.jpg)
#### Front-End Countdown Timer
*A clear and attractive timer displayed on the product page.*
![Front-End Interface Demo](demo2.jpg)
### ⚙️ 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).

BIN
demo1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
demo2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

472
productcountdown.php Normal file
View 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
View 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
View 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);
});
}

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

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