improve photos
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
/photo
|
/photo
|
||||||
|
.llmdump
|
||||||
|
llmdumper.php
|
||||||
@@ -84,16 +84,14 @@ class AddLivePhoto extends Module
|
|||||||
$this->uninstallAdminTab();
|
$this->uninstallAdminTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function installDb()
|
||||||
* Create the database table for storing image information.
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
protected function installDb()
|
|
||||||
{
|
{
|
||||||
|
// Added image_type column
|
||||||
$sql = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . self::TABLE_NAME . '` (
|
$sql = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . self::TABLE_NAME . '` (
|
||||||
`id_add_live_photo` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id_add_live_photo` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`id_product` INT(11) UNSIGNED NOT NULL,
|
`id_product` INT(11) UNSIGNED NOT NULL,
|
||||||
`image_name` VARCHAR(255) NOT NULL,
|
`image_name` VARCHAR(255) NOT NULL,
|
||||||
|
`image_type` ENUM("expiry", "packaging") NOT NULL DEFAULT "expiry",
|
||||||
`date_add` DATETIME NOT NULL,
|
`date_add` DATETIME NOT NULL,
|
||||||
PRIMARY KEY (`id_add_live_photo`),
|
PRIMARY KEY (`id_add_live_photo`),
|
||||||
INDEX `id_product_idx` (`id_product`)
|
INDEX `id_product_idx` (`id_product`)
|
||||||
@@ -177,48 +175,51 @@ class AddLivePhoto extends Module
|
|||||||
}
|
}
|
||||||
|
|
||||||
$id_product = (int) Tools::getValue('id_product');
|
$id_product = (int) Tools::getValue('id_product');
|
||||||
if (!$id_product) {
|
if (!$id_product) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch images from the last 4 months
|
// Complex Logic:
|
||||||
$sql = new DbQuery();
|
// 1. Get 'packaging' photos (Always show, limit to newest 3)
|
||||||
$sql->select('`image_name`');
|
// 2. Get 'expiry' photos (Show only if newer than 3 months)
|
||||||
$sql->from(self::TABLE_NAME);
|
|
||||||
$sql->where('`id_product` = ' . $id_product);
|
$sql = "SELECT * FROM `" . _DB_PREFIX_ . self::TABLE_NAME . "`
|
||||||
$sql->where('`date_add` >= DATE_SUB(NOW(), INTERVAL 4 MONTH)');
|
WHERE `id_product` = " . $id_product . "
|
||||||
$sql->orderBy('`date_add` DESC');
|
AND (
|
||||||
|
(`image_type` = 'packaging')
|
||||||
|
OR
|
||||||
|
(`image_type` = 'expiry' AND `date_add` >= DATE_SUB(NOW(), INTERVAL 3 MONTH))
|
||||||
|
)
|
||||||
|
ORDER BY `date_add` DESC";
|
||||||
|
|
||||||
$results = Db::getInstance()->executeS($sql);
|
$results = Db::getInstance()->executeS($sql);
|
||||||
|
|
||||||
if (!$results) {
|
if (!$results) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$live_photos = [];
|
$live_photos = [];
|
||||||
foreach ($results as $row) {
|
foreach ($results as $row) {
|
||||||
$image_uri = $this->getProductImageUri($id_product, $row['image_name']);
|
$image_uri = $this->getProductImageUri($id_product, $row['image_name']);
|
||||||
if ($image_uri) {
|
if ($image_uri) {
|
||||||
|
|
||||||
|
// Customize text based on type
|
||||||
|
$is_expiry = ($row['image_type'] === 'expiry');
|
||||||
|
$date_taken = date('Y-m-d', strtotime($row['date_add']));
|
||||||
|
|
||||||
|
$alt_text = $is_expiry
|
||||||
|
? sprintf($this->trans('Expiry date photo for %s, taken on %s',[], 'Modules.Addlivephoto.Shop'), $this->context->smarty->tpl_vars['product']->value['name'], $date_taken)
|
||||||
|
: sprintf($this->trans('Real packaging photo for %s',[], 'Modules.Addlivephoto.Shop'), $this->context->smarty->tpl_vars['product']->value['name']);
|
||||||
|
|
||||||
$live_photos[] = [
|
$live_photos[] = [
|
||||||
'url' => $image_uri,
|
'url' => $image_uri,
|
||||||
// This alt text is crucial for SEO
|
'type' => $row['image_type'], // 'expiry' or 'packaging'
|
||||||
'alt' => sprintf(
|
'date' => $row['date_add'],
|
||||||
$this->trans('Freshness photo for %s, taken on %s',[], 'Modules.Addlivephoto.Shop'),
|
'alt' => $alt_text,
|
||||||
$this->context->smarty->tpl_vars['product']->value['name'],
|
|
||||||
date('Y-m-d') // You can store the date_add and format it here
|
|
||||||
),
|
|
||||||
'title' => $this->trans('Click to see the expiry date photo',[], 'Modules.Addlivephoto.Shop'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($live_photos)) {
|
if (empty($live_photos)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->context->smarty->assign([
|
$this->context->smarty->assign([
|
||||||
'live_photos' => $live_photos,
|
'live_photos' => $live_photos,
|
||||||
'module_name' => $this->name,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->display(__FILE__, 'views/templates/hook/displayProductPriceBlock.tpl');
|
return $this->display(__FILE__, 'views/templates/hook/displayProductPriceBlock.tpl');
|
||||||
@@ -231,8 +232,8 @@ class AddLivePhoto extends Module
|
|||||||
{
|
{
|
||||||
// We only want to load these assets on our specific controller page
|
// We only want to load these assets on our specific controller page
|
||||||
if (Tools::getValue('controller') == 'AdminAddLivePhoto') {
|
if (Tools::getValue('controller') == 'AdminAddLivePhoto') {
|
||||||
$this->context->controller->addJS($this->_path . 'views/js/admin.js');
|
// $this->context->controller->addJS($this->_path . 'views/js/admin.js');
|
||||||
$this->context->controller->addCSS($this->_path . 'views/css/admin.css');
|
// $this->context->controller->addCSS($this->_path . 'views/css/admin.css');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2007-2023 PrestaShop
|
* Admin Controller for AddLivePhoto Module
|
||||||
*
|
|
||||||
* NOTICE OF LICENSE
|
|
||||||
*
|
|
||||||
* This source file is subject to the Academic Free License (AFL 3.0)
|
|
||||||
* that is bundled with this package in the file LICENSE.txt.
|
|
||||||
* It is also available through the world-wide-web at this URL:
|
|
||||||
* http://opensource.org/licenses/afl-3.0.php
|
|
||||||
* 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 http://www.prestashop.com for more information.
|
|
||||||
*
|
|
||||||
* @author Your Name <your@email.com>
|
|
||||||
* @copyright 2007-2023 PrestaShop SA
|
|
||||||
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
|
|
||||||
* International Registered Trademark & Property of PrestaShop SA
|
|
||||||
*
|
|
||||||
* @property \AddLivePhoto $module
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class AdminAddLivePhotoController extends ModuleAdminController
|
class AdminAddLivePhotoController extends ModuleAdminController
|
||||||
@@ -32,205 +8,211 @@ class AdminAddLivePhotoController extends ModuleAdminController
|
|||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->bootstrap = true;
|
$this->bootstrap = true;
|
||||||
// The table is not for a list view, but it's good practice to set it.
|
$this->display = 'view'; // Force custom view
|
||||||
$this->table = 'product';
|
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the entry point for the controller page.
|
|
||||||
* It sets up the main template.
|
|
||||||
*/
|
|
||||||
public function initContent()
|
public function initContent()
|
||||||
{
|
{
|
||||||
parent::initContent();
|
// Не викликаємо parent::initContent(), бо нам не потрібен стандартний список
|
||||||
|
// Але нам потрібен header і footer адмінки
|
||||||
// Pass the ajax URL to the template
|
|
||||||
$ajax_url = $this->context->link->getAdminLink(
|
|
||||||
'AdminAddLivePhoto',
|
|
||||||
true, // Keep the token
|
|
||||||
[], // No route params
|
|
||||||
['ajax' => 1] // Add ajax=1 to the query string
|
|
||||||
);
|
|
||||||
$this->context->smarty->assign([
|
$this->context->smarty->assign([
|
||||||
'ajax_url' => $ajax_url,
|
'content' => $this->renderView(), // Це вставить наш tpl
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// We use a custom template for our camera interface.
|
// Викликаємо батьківський метод для відображення структури адмінки
|
||||||
$this->setTemplate('uploader.tpl');
|
parent::initContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renderView()
|
||||||
|
{
|
||||||
|
$ajax_url = $this->context->link->getAdminLink(
|
||||||
|
'AdminAddLivePhoto',
|
||||||
|
true,
|
||||||
|
[],
|
||||||
|
['ajax' => 1]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->context->smarty->assign([
|
||||||
|
'ajax_url' => $ajax_url,
|
||||||
|
'module_dir' => _MODULE_DIR_ . $this->module->name . '/',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->context->smarty->fetch($this->module->getLocalPath() . 'views/templates/admin/uploader.tpl');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This method is automatically called by PrestaShop when an AJAX request is made to this controller.
|
|
||||||
* We use a 'action' parameter to decide what to do.
|
|
||||||
*/
|
|
||||||
public function ajaxProcess()
|
public function ajaxProcess()
|
||||||
{
|
{
|
||||||
$action = Tools::getValue('action');
|
$action = Tools::getValue('action');
|
||||||
switch ($action) {
|
|
||||||
case 'searchProduct':
|
try {
|
||||||
$this->ajaxProcessSearchProduct();
|
switch ($action) {
|
||||||
break;
|
case 'searchProduct':
|
||||||
case 'uploadImage':
|
$this->processSearchProduct();
|
||||||
$this->ajaxProcessUploadImage();
|
break;
|
||||||
break;
|
case 'uploadImage':
|
||||||
case 'deleteImage':
|
$this->processUploadImage();
|
||||||
$this->ajaxProcessDeleteImage();
|
break;
|
||||||
break;
|
case 'deleteImage':
|
||||||
|
$this->processDeleteFreshImage();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Exception('Unknown action');
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->jsonResponse(['success' => false, 'message' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
// No further processing needed for AJAX
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function processSearchProduct()
|
||||||
* Handles searching for a product by EAN13 barcode or ID.
|
|
||||||
*/
|
|
||||||
protected function ajaxProcessSearchProduct()
|
|
||||||
{
|
{
|
||||||
$identifier = Tools::getValue('identifier');
|
$identifier = trim(Tools::getValue('identifier'));
|
||||||
if (empty($identifier)) {
|
if (empty($identifier)) {
|
||||||
$this->jsonError($this->trans('Identifier cannot be empty.',[], 'Modules.Addlivephoto.Admin'));
|
throw new Exception($this->trans('Please enter a barcode or ID.', [], 'Modules.Addlivephoto.Admin'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$id_product = 0;
|
$id_product = 0;
|
||||||
if (is_numeric($identifier)) {
|
// 1. Спробуємо знайти по EAN13
|
||||||
// Check if it's an EAN or a Product ID
|
$sql = 'SELECT id_product FROM `' . _DB_PREFIX_ . 'product` WHERE ean13 = "'.pSQL($identifier).'"';
|
||||||
$id_product_by_ean = (int)Db::getInstance()->getValue('
|
$id_by_ean = Db::getInstance()->getValue($sql);
|
||||||
SELECT id_product FROM `' . _DB_PREFIX_ . 'product` WHERE ean13 = \'' . pSQL($identifier) . '\'
|
|
||||||
');
|
if ($id_by_ean) {
|
||||||
if ($id_product_by_ean) {
|
$id_product = (int)$id_by_ean;
|
||||||
$id_product = $id_product_by_ean;
|
} elseif (is_numeric($identifier)) {
|
||||||
} else {
|
// 2. Якщо це число, пробуємо як ID
|
||||||
// Assume it's a product ID if not found by EAN
|
$id_product = (int)$identifier;
|
||||||
$id_product = (int)$identifier;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$id_product || !Validate::isLoadedObject($product = new Product($id_product, false, $this->context->language->id))) {
|
$product = new Product($id_product, false, $this->context->language->id);
|
||||||
$this->jsonError($this->trans('Product not found.',[], 'Modules.Addlivephoto.Admin'));
|
|
||||||
|
if (!Validate::isLoadedObject($product)) {
|
||||||
|
throw new Exception($this->trans('Product not found.', [], 'Modules.Addlivephoto.Admin'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get product prices
|
// Отримуємо існуючі фото
|
||||||
$retail_price = Product::getPriceStatic($product->id, true, null, 2, null, false, true);
|
$existing_photos = $this->getLivePhotos($product->id);
|
||||||
$discounted_price = Product::getPriceStatic($product->id, true, null, 2, null, true, true);
|
|
||||||
|
|
||||||
// Fetch existing live photos for this product
|
$this->jsonResponse([
|
||||||
$live_photos = $this->getLivePhotosForProduct($product->id);
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
$response = [
|
'id_product' => $product->id,
|
||||||
'id_product' => $product->id,
|
'name' => $product->name,
|
||||||
'name' => $product->name,
|
'reference' => $product->reference,
|
||||||
'wholesale_price' => $product->wholesale_price,
|
'ean13' => $product->ean13,
|
||||||
'retail_price' => $retail_price,
|
'existing_photos' => $existing_photos
|
||||||
'discounted_price' => ($retail_price !== $discounted_price) ? $discounted_price : null,
|
]
|
||||||
'existing_photos' => $live_photos,
|
]);
|
||||||
];
|
|
||||||
|
|
||||||
$this->jsonSuccess($response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function processUploadImage()
|
||||||
* Handles the image upload process.
|
|
||||||
*/
|
|
||||||
protected function ajaxProcessUploadImage()
|
|
||||||
{
|
{
|
||||||
$id_product = (int)Tools::getValue('id_product');
|
$id_product = (int)Tools::getValue('id_product');
|
||||||
$imageData = Tools::getValue('imageData');
|
$rawImage = Tools::getValue('imageData'); // base64 string
|
||||||
|
$imageType = Tools::getValue('image_type'); // expiry або packaging
|
||||||
|
|
||||||
if (!$id_product || !$imageData) {
|
if (!$id_product || !$rawImage) {
|
||||||
$this->jsonError($this->trans('Missing product ID or image data.',[], 'Modules.Addlivephoto.Admin'));
|
throw new Exception('Missing ID or Image Data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the data URI scheme header
|
if (!in_array($imageType, ['expiry', 'packaging'])) {
|
||||||
list($type, $imageData) = explode(';', $imageData);
|
$imageType = 'expiry'; // Fallback
|
||||||
list(, $imageData) = explode(',', $imageData);
|
|
||||||
$imageData = base64_decode($imageData);
|
|
||||||
|
|
||||||
if ($imageData === false) {
|
|
||||||
$this->jsonError($this->trans('Invalid image data.',[], 'Modules.Addlivephoto.Admin'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$image_name = uniqid() . '.webp';
|
// Clean Base64
|
||||||
|
if (preg_match('/^data:image\/(\w+);base64,/', $rawImage, $type)) {
|
||||||
|
$rawImage = substr($rawImage, strpos($rawImage, ',') + 1);
|
||||||
|
$type = strtolower($type[1]); // jpg, png, webp
|
||||||
|
|
||||||
|
if (!in_array($type, ['jpg', 'jpeg', 'png', 'webp'])) {
|
||||||
|
throw new Exception('Invalid image type');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawImage = base64_decode($rawImage);
|
||||||
|
if ($rawImage === false) {
|
||||||
|
throw new Exception('Base64 decode failed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Exception('Did not match data URI with image data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Filename
|
||||||
|
$filename = uniqid() . '.webp'; // Save as WebP usually
|
||||||
$path = $this->module->getProductImageServerPath($id_product);
|
$path = $this->module->getProductImageServerPath($id_product);
|
||||||
|
|
||||||
if (!$path || !file_put_contents($path . $image_name, $imageData)) {
|
if (!$path) {
|
||||||
$this->jsonError($this->trans('Could not save image file. Check permissions for /var/modules/addlivephoto/',[], 'Modules.Addlivephoto.Admin'));
|
throw new Exception('Could not create directory');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to database
|
// Save File
|
||||||
$success = Db::getInstance()->insert(AddLivePhoto::TABLE_NAME, [
|
if (!file_put_contents($path . $filename, $rawImage)) {
|
||||||
|
throw new Exception('Failed to write file to disk');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to DB
|
||||||
|
$res = Db::getInstance()->insert('add_live_photo', [
|
||||||
'id_product' => $id_product,
|
'id_product' => $id_product,
|
||||||
'image_name' => pSQL($image_name),
|
'image_name' => pSQL($filename),
|
||||||
|
'image_type' => pSQL($imageType),
|
||||||
'date_add' => date('Y-m-d H:i:s'),
|
'date_add' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$success) {
|
if (!$res) {
|
||||||
// Clean up the created file if DB insert fails
|
@unlink($path . $filename); // Cleanup
|
||||||
@unlink($path . $image_name);
|
throw new Exception('Database insert error');
|
||||||
$this->jsonError($this->trans('Could not save image information to the database.',[], 'Modules.Addlivephoto.Admin'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$new_photo_data = [
|
$photoUrl = $this->module->getProductImageUri($id_product, $filename);
|
||||||
'name' => $image_name,
|
|
||||||
'url' => $this->module->getProductImageUri($id_product, $image_name),
|
|
||||||
'full_url' => $this->module->getProductImageUri($id_product, $image_name),
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->jsonSuccess(['message' => $this->trans('Image uploaded successfully!',[], 'Modules.Addlivephoto.Admin'), 'new_photo' => $new_photo_data]);
|
$this->jsonResponse([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Saved successfully!',
|
||||||
|
'photo' => [
|
||||||
|
'name' => $filename,
|
||||||
|
'url' => $photoUrl,
|
||||||
|
'type' => $imageType
|
||||||
|
]
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function processDeleteFreshImage()
|
||||||
* Handles deleting a specific image.
|
|
||||||
*/
|
|
||||||
protected function ajaxProcessDeleteImage()
|
|
||||||
{
|
{
|
||||||
$id_product = (int)Tools::getValue('id_product');
|
$id_product = (int)Tools::getValue('id_product');
|
||||||
$image_name = Tools::getValue('image_name');
|
$image_name = Tools::getValue('image_name');
|
||||||
|
|
||||||
if (!$id_product || !$image_name) {
|
|
||||||
$this->jsonError($this->trans('Missing product ID or image name.',[], 'Modules.Addlivephoto.Admin'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the method from the main module class
|
|
||||||
if ($this->module->deleteProductImage($id_product, $image_name)) {
|
if ($this->module->deleteProductImage($id_product, $image_name)) {
|
||||||
$this->jsonSuccess(['message' => $this->trans('Image deleted successfully.')]);
|
$this->jsonResponse(['success' => true, 'message' => 'Deleted']);
|
||||||
} else {
|
} else {
|
||||||
$this->jsonError($this->trans('Failed to delete image.',[], 'Modules.Addlivephoto.Admin'));
|
throw new Exception('Delete failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function getLivePhotos($id_product)
|
||||||
* Fetches all live photos for a given product ID.
|
|
||||||
* @param int $id_product
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
private function getLivePhotosForProduct($id_product)
|
|
||||||
{
|
{
|
||||||
$sql = new DbQuery();
|
$sql = new DbQuery();
|
||||||
$sql->select('`image_name`');
|
$sql->select('*');
|
||||||
$sql->from(AddLivePhoto::TABLE_NAME);
|
$sql->from('add_live_photo');
|
||||||
$sql->where('`id_product` = ' . (int)$id_product);
|
$sql->where('id_product = ' . (int)$id_product);
|
||||||
$sql->orderBy('`date_add` DESC');
|
$sql->orderBy('date_add DESC');
|
||||||
|
|
||||||
$results = Db::getInstance()->executeS($sql);
|
|
||||||
|
|
||||||
|
$res = Db::getInstance()->executeS($sql);
|
||||||
$photos = [];
|
$photos = [];
|
||||||
if ($results) {
|
if ($res) {
|
||||||
foreach ($results as $row) {
|
foreach($res as $row) {
|
||||||
$photos[] = [
|
$photos[] = [
|
||||||
'name' => $row['image_name'],
|
'name' => $row['image_name'],
|
||||||
'url' => $this->module->getProductImageUri($id_product, $row['image_name']),
|
'type' => isset($row['image_type']) ? $row['image_type'] : 'expiry',
|
||||||
|
'url' => $this->module->getProductImageUri($id_product, $row['image_name'])
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $photos;
|
return $photos;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Helper functions for consistent JSON responses */
|
private function jsonResponse($data)
|
||||||
|
|
||||||
private function jsonSuccess($data)
|
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['success' => true, 'data' => $data]);
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,309 +1,254 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// --- DOM Element References ---
|
// 1. Elements
|
||||||
const videoContainer = document.getElementById('alp-video-container');
|
|
||||||
const video = document.getElementById('alp-video');
|
const video = document.getElementById('alp-video');
|
||||||
const canvas = document.getElementById('alp-canvas');
|
const canvas = document.getElementById('alp-canvas');
|
||||||
const overlay = document.getElementById('alp-viewfinder-overlay');
|
const overlayText = document.getElementById('alp-status-text');
|
||||||
const overlayText = document.getElementById('alp-overlay-text');
|
const stepScan = document.getElementById('alp-step-scan');
|
||||||
const cameraSelector = document.getElementById('alp-camera-selector');
|
const stepAction = document.getElementById('alp-step-action');
|
||||||
const manualInputForm = document.getElementById('alp-manual-form');
|
|
||||||
const productInfoSection = document.getElementById('alp-product-info');
|
|
||||||
const productNameEl = document.getElementById('alp-product-name');
|
|
||||||
const productPricesEl = document.getElementById('alp-product-prices');
|
|
||||||
const existingPhotosSection = document.getElementById('alp-existing-photos');
|
|
||||||
const existingPhotosContainer = document.getElementById('alp-photos-container');
|
|
||||||
const messageArea = document.getElementById('alp-message-area');
|
|
||||||
|
|
||||||
// --- State Management ---
|
// Product Data Elements
|
||||||
const AppState = {
|
const productNameEl = document.getElementById('alp-product-name');
|
||||||
IDLE: 'idle', // Camera off, welcome message
|
const photoListEl = document.getElementById('alp-photo-list');
|
||||||
READY_TO_SCAN: 'ready_to_scan', // Camera on, waiting for tap to scan
|
|
||||||
SCANNING: 'scanning', // Actively looking for barcode
|
// Buttons
|
||||||
PRODUCT_FOUND: 'product_found', // Product found, waiting for tap to take photo
|
const manualForm = document.getElementById('alp-manual-form');
|
||||||
UPLOADING: 'uploading' // Photo is being sent to server
|
const btnExpiry = document.getElementById('btn-snap-expiry');
|
||||||
};
|
const btnPkg = document.getElementById('btn-snap-packaging');
|
||||||
let currentState = AppState.IDLE;
|
const btnReset = document.getElementById('btn-reset');
|
||||||
|
|
||||||
|
// State
|
||||||
let currentStream = null;
|
let currentStream = null;
|
||||||
let barcodeDetector = null;
|
let barcodeDetector = null;
|
||||||
|
let isScanning = false;
|
||||||
let currentProductId = null;
|
let currentProductId = null;
|
||||||
const ajaxUrl = window.addLivePhotoAjaxUrl || '';
|
|
||||||
|
|
||||||
// --- Initialization ---
|
// 2. Initialize
|
||||||
if (!('BarcodeDetector' in window)) {
|
initBarcodeDetector();
|
||||||
showMessage('Barcode Detector API is not supported. Please use manual input.', true);
|
startCamera();
|
||||||
} else {
|
|
||||||
barcodeDetector = new BarcodeDetector({ formats: ['ean_13'] });
|
|
||||||
}
|
|
||||||
if (!navigator.mediaDevices) {
|
|
||||||
showMessage('Camera access is not supported in this browser.', true);
|
|
||||||
} else {
|
|
||||||
populateCameraSelector();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUIForState(AppState.IDLE); // Set initial UI state
|
// 3. Event Listeners
|
||||||
|
manualForm.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = document.getElementById('alp-manual-input').value.trim();
|
||||||
|
if(val) fetchProduct(val);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Event Listeners ---
|
btnReset.addEventListener('click', resetApp);
|
||||||
videoContainer.addEventListener('click', handleViewfinderTap);
|
|
||||||
cameraSelector.addEventListener('change', handleCameraChange);
|
|
||||||
manualInputForm.addEventListener('submit', handleManualSubmit);
|
|
||||||
existingPhotosContainer.addEventListener('click', handleDeleteClick);
|
|
||||||
|
|
||||||
// --- Core Logic ---
|
btnExpiry.addEventListener('click', () => takePhoto('expiry'));
|
||||||
function handleViewfinderTap() {
|
btnPkg.addEventListener('click', () => takePhoto('packaging'));
|
||||||
switch (currentState) {
|
|
||||||
case AppState.IDLE:
|
// --- Core Functions ---
|
||||||
startCamera();
|
|
||||||
break;
|
async function initBarcodeDetector() {
|
||||||
case AppState.READY_TO_SCAN:
|
if ('BarcodeDetector' in window) {
|
||||||
detectBarcode();
|
// Check supported formats
|
||||||
break;
|
const formats = await BarcodeDetector.getSupportedFormats();
|
||||||
case AppState.PRODUCT_FOUND:
|
if (formats.includes('ean_13')) {
|
||||||
takePhoto();
|
barcodeDetector = new BarcodeDetector({ formats: ['ean_13'] });
|
||||||
break;
|
console.log('BarcodeDetector ready');
|
||||||
|
} else {
|
||||||
|
overlayText.textContent = "EAN13 not supported by device";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('BarcodeDetector API not supported in this browser');
|
||||||
|
overlayText.textContent = "Auto-scan not supported. Use manual input.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUIForState(newState, customText = null) {
|
|
||||||
currentState = newState;
|
|
||||||
let textContent = '';
|
|
||||||
overlay.style.display = 'flex';
|
|
||||||
|
|
||||||
switch (newState) {
|
|
||||||
case AppState.IDLE:
|
|
||||||
textContent = "Tap to Start Camera";
|
|
||||||
break;
|
|
||||||
case AppState.READY_TO_SCAN:
|
|
||||||
textContent = "Tap to Scan Barcode";
|
|
||||||
break;
|
|
||||||
case AppState.SCANNING:
|
|
||||||
textContent = `<div class="spinner"></div>`;
|
|
||||||
break;
|
|
||||||
case AppState.PRODUCT_FOUND:
|
|
||||||
textContent = "Tap to Take Picture";
|
|
||||||
break;
|
|
||||||
case AppState.UPLOADING:
|
|
||||||
textContent = "Uploading...";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
overlayText.innerHTML = customText || textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startCamera() {
|
async function startCamera() {
|
||||||
if (currentStream) return;
|
|
||||||
const constraints = { video: { deviceId: cameraSelector.value ? { exact: cameraSelector.value } : undefined, facingMode: 'environment' } };
|
|
||||||
try {
|
try {
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment', // Rear camera
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
};
|
||||||
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
video.srcObject = currentStream;
|
video.srcObject = currentStream;
|
||||||
await video.play();
|
|
||||||
updateUIForState(AppState.READY_TO_SCAN);
|
// Wait for video to be ready
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
video.play();
|
||||||
|
if(barcodeDetector) {
|
||||||
|
isScanning = true;
|
||||||
|
overlayText.textContent = "Scan Barcode...";
|
||||||
|
scanLoop();
|
||||||
|
} else {
|
||||||
|
overlayText.textContent = "Camera Ready (Manual Mode)";
|
||||||
|
}
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error accessing camera:', err);
|
console.error(err);
|
||||||
stopCamera(); // Ensure everything is reset
|
overlayText.textContent = "Camera Access Denied or Error";
|
||||||
updateUIForState(AppState.IDLE, 'Camera Error. Tap to retry.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopCamera() {
|
async function scanLoop() {
|
||||||
if (currentStream) {
|
if (!isScanning || !barcodeDetector || currentProductId) return;
|
||||||
currentStream.getTracks().forEach(track => track.stop());
|
|
||||||
currentStream = null;
|
|
||||||
}
|
|
||||||
video.srcObject = null;
|
|
||||||
updateUIForState(AppState.IDLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectBarcode() {
|
|
||||||
if (!barcodeDetector || currentState !== AppState.READY_TO_SCAN) return;
|
|
||||||
updateUIForState(AppState.SCANNING);
|
|
||||||
try {
|
try {
|
||||||
const barcodes = await barcodeDetector.detect(video);
|
const barcodes = await barcodeDetector.detect(video);
|
||||||
if (barcodes.length > 0) {
|
if (barcodes.length > 0) {
|
||||||
searchProduct(barcodes[0].rawValue);
|
const code = barcodes[0].rawValue;
|
||||||
} else {
|
isScanning = false; // Stop scanning
|
||||||
showMessage('No barcode found. Please try again.', true);
|
fetchProduct(code);
|
||||||
updateUIForState(AppState.READY_TO_SCAN);
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
console.error('Barcode detection error:', err);
|
// Detection error (common in loop)
|
||||||
showMessage('Error during barcode detection.', true);
|
|
||||||
updateUIForState(AppState.READY_TO_SCAN);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scan every 200ms to save battery
|
||||||
|
setTimeout(() => requestAnimationFrame(scanLoop), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
function takePhoto() {
|
function fetchProduct(identifier) {
|
||||||
if (!currentStream || !currentProductId || currentState !== AppState.PRODUCT_FOUND) return;
|
overlayText.textContent = "Searching...";
|
||||||
updateUIForState(AppState.UPLOADING);
|
isScanning = false;
|
||||||
|
|
||||||
const targetWidth = 800, targetHeight = 800;
|
const fd = new FormData();
|
||||||
canvas.width = targetWidth; canvas.height = targetHeight;
|
fd.append('action', 'searchProduct');
|
||||||
const ctx = canvas.getContext('2d');
|
fd.append('identifier', identifier);
|
||||||
const videoWidth = video.videoWidth, videoHeight = video.videoHeight;
|
|
||||||
const size = Math.min(videoWidth, videoHeight);
|
|
||||||
const x = (videoWidth - size) / 2, y = (videoHeight - size) / 2;
|
|
||||||
ctx.drawImage(video, x, y, size, size, 0, 0, targetWidth, targetHeight);
|
|
||||||
const imageData = canvas.toDataURL('image/webp', 0.8);
|
|
||||||
|
|
||||||
uploadImage(imageData);
|
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||||
}
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
function resetForNextProduct() {
|
if (data.success) {
|
||||||
currentProductId = null;
|
loadProductView(data.data);
|
||||||
productInfoSection.style.display = 'none';
|
} else {
|
||||||
existingPhotosSection.style.display = 'none';
|
alert(data.message || 'Product not found');
|
||||||
existingPhotosContainer.innerHTML = '';
|
resetApp(); // Go back to scanning
|
||||||
updateUIForState(AppState.READY_TO_SCAN);
|
}
|
||||||
}
|
})
|
||||||
|
.catch(err => {
|
||||||
// --- AJAX and Helper Functions ---
|
console.error(err);
|
||||||
async function searchProduct(identifier) {
|
alert('Network Error');
|
||||||
const formData = new FormData();
|
resetApp();
|
||||||
formData.append('action', 'searchProduct'); formData.append('identifier', identifier);
|
|
||||||
try {
|
|
||||||
const response = await fetch(ajaxUrl, { method: 'POST', body: formData });
|
|
||||||
const result = await response.json();
|
|
||||||
if (result.success) {
|
|
||||||
const product = result.data;
|
|
||||||
currentProductId = product.id_product;
|
|
||||||
displayProductInfo(product);
|
|
||||||
updateUIForState(AppState.PRODUCT_FOUND);
|
|
||||||
} else {
|
|
||||||
showMessage(result.message, true);
|
|
||||||
updateUIForState(AppState.READY_TO_SCAN);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showMessage('Network error searching for product.', true);
|
|
||||||
updateUIForState(AppState.READY_TO_SCAN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadImage(imageData) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('action', 'uploadImage'); formData.append('id_product', currentProductId); formData.append('imageData', imageData);
|
|
||||||
try {
|
|
||||||
const response = await fetch(ajaxUrl, { method: 'POST', body: formData });
|
|
||||||
const result = await response.json();
|
|
||||||
if (result.success) {
|
|
||||||
showMessage(result.message, false);
|
|
||||||
appendNewPhoto(result.data.new_photo);
|
|
||||||
setTimeout(resetForNextProduct, 1500); // Pause to show success, then reset
|
|
||||||
} else {
|
|
||||||
showMessage(result.message, true);
|
|
||||||
updateUIForState(AppState.PRODUCT_FOUND); // Allow user to try photo again
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showMessage('Network error uploading photo.', true);
|
|
||||||
updateUIForState(AppState.PRODUCT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function populateCameraSelector() { /* (This function can remain from previous versions) */ }
|
|
||||||
function handleCameraChange() { /* (This function can remain from previous versions) */ }
|
|
||||||
function handleManualSubmit(e) { /* (This function can remain from previous versions) */ }
|
|
||||||
function handleDeleteClick(e) { /* (This function can remain from previous versions) */ }
|
|
||||||
function displayProductInfo(product) { /* (This function can remain from previous versions) */ }
|
|
||||||
function appendNewPhoto(photo) { /* (This function can remain from previous versions) */ }
|
|
||||||
function showMessage(text, isError = false) { /* (This function can remain from previous versions) */ }
|
|
||||||
|
|
||||||
// --- Re-pasting the helper functions for completeness ---
|
|
||||||
|
|
||||||
async function populateCameraSelector() {
|
|
||||||
try {
|
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
||||||
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
|
||||||
cameraSelector.innerHTML = '';
|
|
||||||
videoDevices.forEach((device, index) => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = device.deviceId;
|
|
||||||
option.textContent = device.label || `Camera ${index + 1}`;
|
|
||||||
cameraSelector.appendChild(option);
|
|
||||||
});
|
});
|
||||||
const preferredCameraId = localStorage.getItem('addLivePhoto_preferredCameraId');
|
|
||||||
if (preferredCameraId && cameraSelector.querySelector(`option[value="${preferredCameraId}"]`)) {
|
|
||||||
cameraSelector.value = preferredCameraId;
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('Error enumerating devices:', err); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCameraChange() {
|
function loadProductView(productData) {
|
||||||
localStorage.setItem('addLivePhoto_preferredCameraId', cameraSelector.value);
|
currentProductId = productData.id_product;
|
||||||
if (currentStream) { // If camera is active, restart it with the new selection
|
productNameEl.textContent = `[${productData.reference}] ${productData.name}`;
|
||||||
stopCamera();
|
|
||||||
startCamera();
|
renderPhotos(productData.existing_photos);
|
||||||
}
|
|
||||||
|
// Switch View
|
||||||
|
stepScan.style.display = 'none';
|
||||||
|
stepAction.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleManualSubmit(e) {
|
function takePhoto(type) {
|
||||||
e.preventDefault();
|
if (!currentProductId) return;
|
||||||
const identifier = document.getElementById('alp-manual-identifier').value.trim();
|
|
||||||
if (identifier) {
|
// Capture frame
|
||||||
showMessage(`Searching for: ${identifier}...`);
|
const w = video.videoWidth;
|
||||||
searchProduct(identifier);
|
const h = video.videoHeight;
|
||||||
}
|
|
||||||
|
// Crop to square (center)
|
||||||
|
const size = Math.min(w, h);
|
||||||
|
const x = (w - size) / 2;
|
||||||
|
const y = (h - size) / 2;
|
||||||
|
|
||||||
|
canvas.width = 800;
|
||||||
|
canvas.height = 800;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(video, x, y, size, size, 0, 0, 800, 800);
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL('image/webp', 0.8);
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'uploadImage');
|
||||||
|
fd.append('id_product', currentProductId);
|
||||||
|
fd.append('image_type', type);
|
||||||
|
fd.append('imageData', dataUrl);
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
const btn = (type === 'expiry') ? btnExpiry : btnPkg;
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = 'Uploading...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Add new photo to list without reload
|
||||||
|
addPhotoToDom(data.photo);
|
||||||
|
// Flash success message
|
||||||
|
alert(`Saved as ${type}!`);
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => alert('Upload failed'))
|
||||||
|
.finally(() => {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteClick(e) {
|
function renderPhotos(photos) {
|
||||||
if (e.target && e.target.classList.contains('delete-photo-btn')) {
|
photoListEl.innerHTML = '';
|
||||||
const button = e.target;
|
if(photos && photos.length) {
|
||||||
const imageName = button.dataset.imageName;
|
photos.forEach(addPhotoToDom);
|
||||||
const productId = button.dataset.productId;
|
|
||||||
if (confirm(`Are you sure you want to delete this photo?`)) {
|
|
||||||
// Simplified delete without a dedicated function
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('action', 'deleteImage');
|
|
||||||
formData.append('id_product', productId);
|
|
||||||
formData.append('image_name', imageName);
|
|
||||||
fetch(ajaxUrl, { method: 'POST', body: formData })
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(result => {
|
|
||||||
if (result.success) {
|
|
||||||
showMessage(result.message, false);
|
|
||||||
button.closest('.photo-thumb').remove();
|
|
||||||
} else {
|
|
||||||
showMessage(result.message, true);
|
|
||||||
}
|
|
||||||
}).catch(err => showMessage('Network error deleting photo.', true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayProductInfo(product) {
|
|
||||||
productNameEl.textContent = `[ID: ${product.id_product}] ${product.name}`;
|
|
||||||
let pricesHtml = `Wholesale: ${product.wholesale_price} | Sale: ${product.retail_price}`;
|
|
||||||
if (product.discounted_price) {
|
|
||||||
pricesHtml += ` | <strong class="text-danger">Discounted: ${product.discounted_price}</strong>`;
|
|
||||||
}
|
|
||||||
productPricesEl.innerHTML = pricesHtml;
|
|
||||||
renderExistingPhotos(product.existing_photos, product.id_product);
|
|
||||||
productInfoSection.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderExistingPhotos(photos, productId) {
|
|
||||||
existingPhotosContainer.innerHTML = '';
|
|
||||||
if (photos && photos.length > 0) {
|
|
||||||
existingPhotosSection.style.display = 'block';
|
|
||||||
photos.forEach(photo => appendNewPhoto(photo, productId));
|
|
||||||
} else {
|
} else {
|
||||||
existingPhotosSection.style.display = 'none';
|
photoListEl.innerHTML = '<p class="text-muted">No photos yet.</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendNewPhoto(photo, productId = currentProductId) {
|
function addPhotoToDom(photo) {
|
||||||
const thumbDiv = document.createElement('div');
|
// Remove "No photos" msg if exists
|
||||||
thumbDiv.className = 'photo-thumb';
|
if (photoListEl.querySelector('p')) photoListEl.innerHTML = '';
|
||||||
thumbDiv.innerHTML = `
|
|
||||||
<a href="${photo.url}" target="_blank">
|
const div = document.createElement('div');
|
||||||
<img src="${photo.url}" alt="Live photo" loading="lazy" />
|
div.className = 'alp-thumb';
|
||||||
</a>
|
|
||||||
<button class="btn btn-sm btn-danger delete-photo-btn" data-product-id="${productId}" data-image-name="${photo.name}">X</button>
|
const badgeClass = (photo.type === 'expiry') ? 'badge-success' : 'badge-info';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<img src="${photo.url}" target="_blank">
|
||||||
|
<span class="badge ${badgeClass}">${photo.type}</span>
|
||||||
|
<button class="btn-delete" onclick="deletePhoto(${currentProductId}, '${photo.name}', this)">×</button>
|
||||||
`;
|
`;
|
||||||
existingPhotosContainer.prepend(thumbDiv);
|
photoListEl.prepend(div);
|
||||||
existingPhotosSection.style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(text, isError = false) {
|
function resetApp() {
|
||||||
messageArea.textContent = text;
|
currentProductId = null;
|
||||||
messageArea.className = isError ? 'alert alert-danger' : 'alert alert-info';
|
document.getElementById('alp-manual-input').value = '';
|
||||||
messageArea.style.display = 'block';
|
stepAction.style.display = 'none';
|
||||||
setTimeout(() => { messageArea.style.display = 'none'; }, 4000); // Message disappears after 4s
|
stepScan.style.display = 'block';
|
||||||
|
|
||||||
|
if(barcodeDetector) {
|
||||||
|
isScanning = true;
|
||||||
|
overlayText.textContent = "Scan Barcode...";
|
||||||
|
scanLoop();
|
||||||
|
} else {
|
||||||
|
overlayText.textContent = "Camera Ready (Manual Mode)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose delete function globally so onclick in HTML works
|
||||||
|
window.deletePhoto = function(idProduct, imgName, btnEl) {
|
||||||
|
if(!confirm('Delete this photo?')) return;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'deleteImage');
|
||||||
|
fd.append('id_product', idProduct);
|
||||||
|
fd.append('image_name', imgName);
|
||||||
|
|
||||||
|
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if(data.success) {
|
||||||
|
btnEl.closest('.alp-thumb').remove();
|
||||||
|
} else {
|
||||||
|
alert('Error deleting');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
});
|
});
|
||||||
@@ -1,88 +1,557 @@
|
|||||||
{**
|
{* Pass URL to JS *}
|
||||||
This script block passes the unique, secure AJAX URL from the PHP controller to our JavaScript.
|
<script>
|
||||||
The 'javascript' escaper is crucial to prevent encoding issues.
|
var alpAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
|
||||||
**}
|
|
||||||
<script type="text/javascript">
|
|
||||||
var addLivePhotoAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel" id="alp-app">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<i class="icon-camera"></i> {l s='Live Photo Uploader' d='Modules.Addlivephoto.Admin'}
|
<i class="icon-camera"></i> {l s='Live Photo Scanner' d='Modules.Addlivephoto.Admin'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="row">
|
||||||
<div class="row">
|
<div class="col-md-6 col-md-offset-3">
|
||||||
<div class="col-lg-8 col-lg-offset-2">
|
|
||||||
|
|
||||||
{* --- The New Unified Camera Interface --- *}
|
{* 1. CAMERA VIEW *}
|
||||||
<div id="alp-camera-view" class="my-3">
|
<div id="alp-step-scan" class="text-center">
|
||||||
<div id="alp-video-container" class="video-container">
|
<div class="video-wrapper"
|
||||||
{* The video feed will be attached here by JavaScript *}
|
style="position: relative; background: #000; min-height: 300px; margin-bottom: 15px; overflow: hidden;">
|
||||||
<video id="alp-video" autoplay playsinline muted></video>
|
<video id="alp-video" autoplay playsinline muted
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover;"></video>
|
||||||
|
<canvas id="alp-canvas" style="display: none;"></canvas>
|
||||||
|
|
||||||
{* This overlay displays instructions and is the main tap target *}
|
{* Camera Controls Overlay *}
|
||||||
<div id="alp-viewfinder-overlay">
|
<div id="alp-controls"
|
||||||
<div id="alp-overlay-text"></div>
|
style="position: absolute; bottom: 20px; left: 0; width: 100%; display: flex; justify-content: center; gap: 20px; z-index: 10;">
|
||||||
</div>
|
{* Flash Button (Hidden by default until capability detected) *}
|
||||||
|
<button type="button" id="btn-torch" class="btn btn-default btn-circle" style="display:none;">
|
||||||
|
<i class="icon-bolt"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
{* This canvas is used for capturing the frame but is not visible *}
|
{* Zoom Button (Hidden by default) *}
|
||||||
<canvas id="alp-canvas" style="display: none;"></canvas>
|
<button type="button" id="btn-zoom" class="btn btn-default btn-circle" style="display:none;">
|
||||||
|
1x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{* Overlay Message *}
|
||||||
|
<div id="alp-overlay"
|
||||||
|
style="position: absolute; top:0; left:0; width:100%; height:100%; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5); color: #fff; pointer-events: none;">
|
||||||
|
<span id="alp-status-text">Starting Camera...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{* --- Message Area (for non-critical feedback) --- *}
|
{* Manual Input Fallback *}
|
||||||
<div id="alp-message-area" class="alert" style="display: none; text-align: center;"></div>
|
<form id="alp-manual-form" class="form-inline" style="margin-top: 10px;">
|
||||||
|
<div class="form-group">
|
||||||
{* --- Product Information (hidden by default) --- *}
|
<input type="text" id="alp-manual-input" class="form-control" placeholder="EAN13 or Product ID">
|
||||||
<div id="alp-product-info" style="display: none;" class="card mt-4">
|
|
||||||
<div class="card-header bg-success text-white">
|
|
||||||
<h5 class="card-title mb-0">{l s='Product Found' d='Modules.Addlivephoto.Admin'}</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<button type="submit" class="btn btn-primary"><i class="icon-search"></i> Search</button>
|
||||||
<p><strong>{l s='Name:' d='Modules.Addlivephoto.Admin'}</strong> <span id="alp-product-name"></span></p>
|
</form>
|
||||||
<p><strong>{l s='Prices:' d='Modules.Addlivephoto.Admin'}</strong> <span id="alp-product-prices"></span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{* --- Existing Photos (hidden by default) --- *}
|
|
||||||
<div id="alp-existing-photos" style="display: none;" class="card mt-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">{l s='Existing Live Photos' d='Modules.Addlivephoto.Admin'}</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="alp-photos-container" class="d-flex flex-wrap gap-2">
|
|
||||||
{* JavaScript will populate this area *}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{* --- Settings Section (at the bottom, out of the way) --- *}
|
|
||||||
<div class="card mt-4">
|
|
||||||
<div class="card-header">{l s='Camera Settings' d='Modules.Addlivephoto.Admin'}</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="alp-camera-selector" class="control-label">{l s='Select Camera:' d='Modules.Addlivephoto.Admin'}</label>
|
|
||||||
<select id="alp-camera-selector" class="form-control"></select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{* --- Manual Input Section (remains as a fallback) --- *}
|
|
||||||
<div id="alp-manual-input" class="card mt-3">
|
|
||||||
<div class="card-header">{l s='Or Enter Manually' d='Modules.Addlivephoto.Admin'}</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form id="alp-manual-form" class="form-inline">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="alp-manual-identifier" class="mr-2">{l s='Product ID or EAN13 Barcode:' d='Modules.Addlivephoto.Admin'}</label>
|
|
||||||
<input type="text" id="alp-manual-identifier" class="form-control mr-2" placeholder="e.g., 4006381333931">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-default"><i class="icon-search"></i> {l s='Find Product' d='Modules.Addlivephoto.Admin'}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{* 2. PRODUCT ACTIONS (Hidden initially) *}
|
||||||
|
<div id="alp-step-action" style="display: none;">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Product:</strong> <span id="alp-product-name"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center" style="margin-bottom: 20px;">
|
||||||
|
<p class="text-muted">What are you photographing?</p>
|
||||||
|
<div class="btn-group-lg">
|
||||||
|
<button type="button" class="btn btn-success" id="btn-snap-expiry">
|
||||||
|
<i class="icon-calendar"></i> Expiry Date
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-info" id="btn-snap-packaging">
|
||||||
|
<i class="icon-box"></i> Packaging
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<br><br>
|
||||||
|
<button type="button" class="btn btn-default btn-sm" id="btn-reset">
|
||||||
|
<i class="icon-refresh"></i> Scan New Product
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{* Existing Photos List *}
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-heading">Existing Photos</div>
|
||||||
|
<div class="panel-body" id="alp-photo-list" style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
{* Photos injected via JS *}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.video-wrapper {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alp-video {
|
||||||
|
width: 100%;
|
||||||
|
/* Ensure aspect ratio handles mobile screens well */
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Circular Control Buttons */
|
||||||
|
.btn-circle {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-circle:hover,
|
||||||
|
.btn-circle:active,
|
||||||
|
.btn-circle.active {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-circle i {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alp-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alp-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alp-thumb .badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alp-thumb .btn-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(255, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 1. Elements
|
||||||
|
const video = document.getElementById('alp-video');
|
||||||
|
const canvas = document.getElementById('alp-canvas');
|
||||||
|
const overlayText = document.getElementById('alp-status-text');
|
||||||
|
const stepScan = document.getElementById('alp-step-scan');
|
||||||
|
const stepAction = document.getElementById('alp-step-action');
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
const btnTorch = document.getElementById('btn-torch');
|
||||||
|
const btnZoom = document.getElementById('btn-zoom');
|
||||||
|
|
||||||
|
// Product Data Elements
|
||||||
|
const productNameEl = document.getElementById('alp-product-name');
|
||||||
|
const photoListEl = document.getElementById('alp-photo-list');
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
const manualForm = document.getElementById('alp-manual-form');
|
||||||
|
const btnExpiry = document.getElementById('btn-snap-expiry');
|
||||||
|
const btnPkg = document.getElementById('btn-snap-packaging');
|
||||||
|
const btnReset = document.getElementById('btn-reset');
|
||||||
|
|
||||||
|
// State
|
||||||
|
let currentStream = null;
|
||||||
|
let videoTrack = null; // Store track for zoom/torch
|
||||||
|
let barcodeDetector = null;
|
||||||
|
let isScanning = false;
|
||||||
|
let currentProductId = null;
|
||||||
|
|
||||||
|
// Camera Features State
|
||||||
|
let zoomLevel = 1;
|
||||||
|
let torchState = false;
|
||||||
|
let capabilities = {};
|
||||||
|
|
||||||
|
// 2. Initialize
|
||||||
|
initBarcodeDetector();
|
||||||
|
startCamera();
|
||||||
|
|
||||||
|
// 3. Event Listeners
|
||||||
|
manualForm.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = document.getElementById('alp-manual-input').value.trim();
|
||||||
|
if (val) fetchProduct(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
btnReset.addEventListener('click', resetApp);
|
||||||
|
|
||||||
|
btnExpiry.addEventListener('click', () => takePhoto('expiry'));
|
||||||
|
btnPkg.addEventListener('click', () => takePhoto('packaging'));
|
||||||
|
|
||||||
|
// Zoom Toggle
|
||||||
|
btnZoom.addEventListener('click', () => {
|
||||||
|
if (!videoTrack) return;
|
||||||
|
|
||||||
|
// Toggle between 1 and 2 (or max zoom)
|
||||||
|
zoomLevel = (zoomLevel === 1) ? 2 : 1;
|
||||||
|
|
||||||
|
// Check max zoom
|
||||||
|
if (capabilities.zoom) {
|
||||||
|
zoomLevel = Math.min(zoomLevel, capabilities.zoom.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
videoTrack.applyConstraints({
|
||||||
|
advanced: [{ zoom: zoomLevel }]
|
||||||
|
});
|
||||||
|
btnZoom.textContent = zoomLevel + 'x';
|
||||||
|
btnZoom.classList.toggle('active', zoomLevel > 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Zoom failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Torch Toggle
|
||||||
|
btnTorch.addEventListener('click', () => {
|
||||||
|
if (!videoTrack) return;
|
||||||
|
|
||||||
|
torchState = !torchState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
videoTrack.applyConstraints({
|
||||||
|
advanced: [{ torch: torchState }]
|
||||||
|
});
|
||||||
|
btnTorch.classList.toggle('active', torchState);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Torch failed', err);
|
||||||
|
// Fallback if failed
|
||||||
|
torchState = !torchState;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Core Functions ---
|
||||||
|
|
||||||
|
async function initBarcodeDetector() {
|
||||||
|
if ('BarcodeDetector' in window) {
|
||||||
|
try {
|
||||||
|
const formats = await BarcodeDetector.getSupportedFormats();
|
||||||
|
if (formats.includes('ean_13')) {
|
||||||
|
barcodeDetector = new BarcodeDetector({ formats: ['ean_13'] });
|
||||||
|
console.log('BarcodeDetector ready');
|
||||||
|
} else {
|
||||||
|
overlayText.textContent = "EAN13 not supported by device hardware";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('BarcodeDetector error', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('BarcodeDetector API not supported');
|
||||||
|
overlayText.textContent = "Auto-scan not supported. Use manual search.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCamera() {
|
||||||
|
try {
|
||||||
|
// Try high res for better barcode scanning
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: { ideal: 'environment' },
|
||||||
|
width: { ideal: 1920 },
|
||||||
|
height: { ideal: 1080 },
|
||||||
|
// Zoom/Torch are usually "advanced" constraints, applied later
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
video.srcObject = currentStream;
|
||||||
|
|
||||||
|
// Get the video track to control Zoom/Torch
|
||||||
|
videoTrack = currentStream.getVideoTracks()[0];
|
||||||
|
|
||||||
|
// Check Capabilities (Zoom/Torch)
|
||||||
|
if (videoTrack.getCapabilities) {
|
||||||
|
capabilities = videoTrack.getCapabilities();
|
||||||
|
|
||||||
|
// Enable Torch Button if supported
|
||||||
|
if (capabilities.torch) {
|
||||||
|
btnTorch.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable Zoom Button if supported
|
||||||
|
if (capabilities.zoom) {
|
||||||
|
btnZoom.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for video to be ready
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
video.play();
|
||||||
|
document.getElementById('alp-overlay').style.display =
|
||||||
|
'none'; // Hide "Starting..." overlay
|
||||||
|
|
||||||
|
if (barcodeDetector) {
|
||||||
|
isScanning = true;
|
||||||
|
// Add a visual scanning line or text
|
||||||
|
const scanOverlay = document.createElement('div');
|
||||||
|
scanOverlay.id = 'scan-line';
|
||||||
|
scanOverlay.style.cssText =
|
||||||
|
'position:absolute; top:50%; left:10%; right:10%; height:2px; background:red; box-shadow:0 0 4px red; opacity:0.7;';
|
||||||
|
// Check if already exists
|
||||||
|
if (!document.getElementById('scan-line')) {
|
||||||
|
document.querySelector('.video-wrapper').appendChild(scanOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
scanLoop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
overlayText.textContent = "Camera Access Denied. Check permissions.";
|
||||||
|
document.getElementById('alp-overlay').style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanLoop() {
|
||||||
|
if (!isScanning || !barcodeDetector || currentProductId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const barcodes = await barcodeDetector.detect(video);
|
||||||
|
if (barcodes.length > 0) {
|
||||||
|
const code = barcodes[0].rawValue;
|
||||||
|
console.log("Barcode Detected:", code);
|
||||||
|
playSound(); // Optional feedback
|
||||||
|
|
||||||
|
isScanning = false; // Stop scanning immediately
|
||||||
|
fetchProduct(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Detection error (common while moving camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan loop (optimized)
|
||||||
|
if (isScanning) {
|
||||||
|
requestAnimationFrame(scanLoop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSound() {
|
||||||
|
// Simple beep
|
||||||
|
const context = new(window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
oscillator.type = "sine";
|
||||||
|
oscillator.frequency.value = 800;
|
||||||
|
oscillator.connect(context.destination);
|
||||||
|
oscillator.start();
|
||||||
|
setTimeout(() => oscillator.stop(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchProduct(identifier) {
|
||||||
|
// Remove scan line if exists
|
||||||
|
const line = document.getElementById('scan-line');
|
||||||
|
if (line) line.remove();
|
||||||
|
|
||||||
|
const btnSubmit = manualForm.querySelector('button');
|
||||||
|
const originalText = btnSubmit.innerHTML;
|
||||||
|
btnSubmit.disabled = true;
|
||||||
|
btnSubmit.innerHTML = 'Searching...';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'searchProduct');
|
||||||
|
fd.append('identifier', identifier);
|
||||||
|
|
||||||
|
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
loadProductView(data.data);
|
||||||
|
} else {
|
||||||
|
alert(data.message || 'Product not found');
|
||||||
|
resetApp(); // Go back to scanning
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
alert('Network Error');
|
||||||
|
resetApp();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
btnSubmit.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{literal}
|
||||||
|
function loadProductView(productData) {
|
||||||
|
currentProductId = productData.id_product;
|
||||||
|
// Smarty ignores the curly braces and ${} inside the literal tags
|
||||||
|
productNameEl.textContent = `[${productData.reference || ''}] ${productData.name}`;
|
||||||
|
|
||||||
|
renderPhotos(productData.existing_photos);
|
||||||
|
|
||||||
|
// Switch View
|
||||||
|
stepScan.style.display = 'none';
|
||||||
|
stepAction.style.display = 'block';
|
||||||
|
}
|
||||||
|
{/literal}
|
||||||
|
|
||||||
|
function takePhoto(type) {
|
||||||
|
if (!currentProductId) return;
|
||||||
|
|
||||||
|
// Use canvas to capture high-res frame
|
||||||
|
// Set canvas to video dimension for full resolution
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Convert to WebP (0.8 quality)
|
||||||
|
const dataUrl = canvas.toDataURL('image/webp', 0.8);
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'uploadImage');
|
||||||
|
fd.append('id_product', currentProductId);
|
||||||
|
fd.append('image_type', type);
|
||||||
|
fd.append('imageData', dataUrl);
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
const btn = (type === 'expiry') ? btnExpiry : btnPkg;
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="icon-refresh icon-spin"></i> Saving...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
addPhotoToDom(data.photo);
|
||||||
|
// Provide haptic feedback if available
|
||||||
|
if (navigator.vibrate) navigator.vibrate(200);
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => alert('Upload failed'))
|
||||||
|
.finally(() => {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhotos(photos) {
|
||||||
|
photoListEl.innerHTML = '';
|
||||||
|
if (photos && photos.length) {
|
||||||
|
photos.forEach(addPhotoToDom);
|
||||||
|
} else {
|
||||||
|
photoListEl.innerHTML = '<p class="text-muted" style="width:100%">No photos yet.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPhotoToDom(photo) {
|
||||||
|
// Remove "No photos" msg if exists
|
||||||
|
const emptyMsg = photoListEl.querySelector('p');
|
||||||
|
if (emptyMsg) emptyMsg.remove();
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'alp-thumb';
|
||||||
|
|
||||||
|
const badgeClass = (photo.type === 'expiry') ? 'badge-success' : 'badge-info';
|
||||||
|
{literal}
|
||||||
|
div.innerHTML = `
|
||||||
|
<a href="${photo.url}" target="_blank">
|
||||||
|
<img src="${photo.url}">
|
||||||
|
</a>
|
||||||
|
<span class="badge ${badgeClass}">${photo.type}</span>
|
||||||
|
<button class="btn-delete" onclick="deletePhoto(${currentProductId}, '${photo.name}', this)">×</button>
|
||||||
|
`;
|
||||||
|
{/literal}
|
||||||
|
|
||||||
|
photoListEl.prepend(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetApp() {
|
||||||
|
currentProductId = null;
|
||||||
|
document.getElementById('alp-manual-input').value = '';
|
||||||
|
stepAction.style.display = 'none';
|
||||||
|
stepScan.style.display = 'block';
|
||||||
|
|
||||||
|
// Reset controls
|
||||||
|
zoomLevel = 1;
|
||||||
|
if (videoTrack) {
|
||||||
|
try { videoTrack.applyConstraints({ advanced: [{ zoom: 1 }] }); } catch (e) {}
|
||||||
|
btnZoom.textContent = '1x';
|
||||||
|
btnZoom.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (barcodeDetector) {
|
||||||
|
isScanning = true;
|
||||||
|
// Re-add scan line
|
||||||
|
const scanOverlay = document.createElement('div');
|
||||||
|
scanOverlay.id = 'scan-line';
|
||||||
|
scanOverlay.style.cssText =
|
||||||
|
'position:absolute; top:50%; left:10%; right:10%; height:2px; background:red; box-shadow:0 0 4px red; opacity:0.7;';
|
||||||
|
if (!document.getElementById('scan-line')) {
|
||||||
|
document.querySelector('.video-wrapper').appendChild(scanOverlay);
|
||||||
|
}
|
||||||
|
scanLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose delete function globally
|
||||||
|
window.deletePhoto = function(idProduct, imgName, btnEl) {
|
||||||
|
if (!confirm('Delete this photo?')) return;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'deleteImage');
|
||||||
|
fd.append('id_product', idProduct);
|
||||||
|
fd.append('image_name', imgName);
|
||||||
|
|
||||||
|
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
btnEl.closest('.alp-thumb').remove();
|
||||||
|
} else {
|
||||||
|
alert('Error deleting');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,228 +1,114 @@
|
|||||||
{*
|
|
||||||
* 2007-2023 PrestaShop
|
|
||||||
*
|
|
||||||
* NOTICE OF LICENSE
|
|
||||||
*
|
|
||||||
* This source file is subject to the Academic Free License (AFL 3.0)
|
|
||||||
* that is bundled with this package in the file LICENSE.txt.
|
|
||||||
* It is also available through the world-wide-web at this URL:
|
|
||||||
* http://opensource.org/licenses/afl-3.0.php
|
|
||||||
* 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 http://www.prestashop.com for more information.
|
|
||||||
*
|
|
||||||
* @author Your Name <your@email.com>
|
|
||||||
* @copyright 2007-2023 PrestaShop SA
|
|
||||||
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
|
|
||||||
* International Registered Trademark & Property of PrestaShop SA
|
|
||||||
*}
|
|
||||||
|
|
||||||
{if isset($live_photos) && !empty($live_photos)}
|
{if isset($live_photos) && !empty($live_photos)}
|
||||||
<div id="addlivephoto-container" class="mt-3">
|
<div id="addlivephoto-container" class="mt-3 mb-3">
|
||||||
<h6 class="h6">{l s='Freshness Guaranteed: See Today\'s Stock' d='Modules.Addlivephoto.Shop'}</h6>
|
<h6 class="h6 text-uppercase text-muted mb-2" style="font-size: 0.8rem; letter-spacing: 0.05em;">
|
||||||
|
<i class="material-icons" style="font-size: 1rem; vertical-align: text-bottom;">verified</i>
|
||||||
|
{l s='Live Warehouse Photos' d='Modules.Addlivephoto.Shop'}
|
||||||
|
</h6>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
{foreach from=$live_photos item=photo name=livephotoloop}
|
{foreach from=$live_photos item=photo name=livephotoloop}
|
||||||
<a href="{$photo.url|escape:'htmlall':'UTF-8'}" class="live-photo-thumb" data-bs-toggle="modal"
|
<div class="position-relative">
|
||||||
data-bs-target="#livePhotoModal" data-photo-index="{$smarty.foreach.livephotoloop.index}"
|
<a href="{$photo.url|escape:'htmlall':'UTF-8'}" class="live-photo-thumb d-block" data-bs-toggle="modal"
|
||||||
title="{$photo.title|escape:'htmlall':'UTF-8'}">
|
data-bs-target="#livePhotoModal" data-photo-index="{$smarty.foreach.livephotoloop.index}">
|
||||||
<img src="{$photo.url|escape:'htmlall':'UTF-8'}" alt="{$photo.alt|escape:'htmlall':'UTF-8'}" class="img-thumbnail"
|
<img src="{$photo.url|escape:'htmlall':'UTF-8'}" alt="{$photo.alt|escape:'htmlall':'UTF-8'}"
|
||||||
width="80" height="80" loading="lazy">
|
class="img-thumbnail" width="80" height="80" loading="lazy" style="object-fit: cover;">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{* BADGES *}
|
||||||
|
{if $photo.type == 'expiry'}
|
||||||
|
<span class="badge bg-success position-absolute bottom-0 start-0 w-100 rounded-0 rounded-bottom"
|
||||||
|
style="font-size: 0.6rem;">
|
||||||
|
{l s='Expiry Date' d='Modules.Addlivephoto.Shop'}
|
||||||
|
</span>
|
||||||
|
{else}
|
||||||
|
<span class="badge bg-info position-absolute bottom-0 start-0 w-100 rounded-0 rounded-bottom"
|
||||||
|
style="font-size: 0.6rem;">
|
||||||
|
{l s='Packaging' d='Modules.Addlivephoto.Shop'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{* SCHEMA.ORG METADATA FOR GOOGLE (Hidden but readable by bots) *}
|
||||||
|
<div style="display:none;" itemprop="image" itemscope itemtype="https://schema.org/ImageObject">
|
||||||
|
<meta itemprop="contentUrl" content="{$photo.url|escape:'htmlall':'UTF-8'}" />
|
||||||
|
<meta itemprop="uploadDate" content="{$photo.date|escape:'htmlall':'UTF-8'}" />
|
||||||
|
<meta itemprop="description" content="{$photo.alt|escape:'htmlall':'UTF-8'}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/foreach}
|
{/foreach}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{* --- MODAL --- *}
|
{* Modal code remains mostly same, just ensuring script handles it *}
|
||||||
<div class="modal fade" id="livePhotoModal" tabindex="-1" aria-labelledby="livePhotoModalLabel" aria-hidden="true">
|
<div class="modal fade" id="livePhotoModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="livePhotoModalLabel">{l s='Live Product Photo' d='Modules.Addlivephoto.Shop'}</h5>
|
<h5 class="modal-title">{l s='Live Stock Photo' d='Modules.Addlivephoto.Shop'}</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
aria-label="{l s='Close' d='Shop.Theme.Actions'}"></button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body text-center p-0 bg-light">
|
||||||
<div class="position-relative">
|
<img id="livePhotoModalImage" src="" class="img-fluid" style="max-height: 80vh;">
|
||||||
<img id="livePhotoModalImage" src="" alt="" class="img-fluid w-100">
|
|
||||||
<button id="livePhotoPrevBtn" class="btn btn-light modal-nav-btn prev"><</button>
|
|
||||||
<button id="livePhotoNextBtn" class="btn btn-light modal-nav-btn next">></button>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted text-wrap small mb-0">
|
|
||||||
{l s='Please Note: This is a live photo of a randomly selected package from our current stock to show its freshness. The expiry date on the product you receive will be the same or newer, but the lot number may differ.' d='Modules.Addlivephoto.Shop'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer justify-content-start">
|
<div class="modal-footer justify-content-between">
|
||||||
|
<small class="text-muted" id="livePhotoModalDate"></small>
|
||||||
{* This caption is visible to the user and good for accessibility *}
|
<p id="livePhotoModalCaption" class="mb-0 fw-bold"></p>
|
||||||
<p id="livePhotoModalCaption" class="text-muted text-wrap small mb-0"></p>
|
|
||||||
|
|
||||||
{*
|
|
||||||
This hidden block provides rich metadata for SEO and AI crawlers (e.g., Google Images).
|
|
||||||
It uses schema.org microdata to describe the image.
|
|
||||||
*}
|
|
||||||
<div class="visually-hidden" itemprop="image" itemscope itemtype="https://schema.org/ImageObject">
|
|
||||||
<meta itemprop="contentUrl" id="livePhotoMetaUrl" content="">
|
|
||||||
<meta itemprop="description" id="livePhotoMetaDesc" content="">
|
|
||||||
<span itemprop="author" itemscope itemtype="https://schema.org/Organization">
|
|
||||||
<meta itemprop="name" content="{$shop.name|escape:'htmlall':'UTF-8'}">
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{* --- STYLES AND SCRIPTS --- *}
|
|
||||||
<style>
|
|
||||||
.live-photo-thumb img {
|
|
||||||
object-fit: cover;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-photo-thumb:hover img {
|
|
||||||
transform: scale(1.05);
|
|
||||||
border-color: var(--bs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-nav-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background-color: rgba(255, 255, 255, 0.7);
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-nav-btn.prev {
|
|
||||||
left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-nav-btn.next {
|
|
||||||
right: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Data passed directly from Smarty to JavaScript
|
// 1. Ініціалізація даних
|
||||||
const photos = {$live_photos|json_encode nofilter};
|
const photos = {$live_photos|json_encode nofilter};
|
||||||
let currentIndex = 0;
|
// Отримуємо дані про товар з Smarty (PrestaShop зазвичай має змінну $product)
|
||||||
|
const productId = '{$product.id_product|default:0}';
|
||||||
|
const productName = '{$product.name|escape:"javascript"}';
|
||||||
|
|
||||||
|
// Елементи модального вікна
|
||||||
const modalElement = document.getElementById('livePhotoModal');
|
const modalElement = document.getElementById('livePhotoModal');
|
||||||
if (!modalElement) return;
|
const modalImg = document.getElementById('livePhotoModalImage');
|
||||||
|
const modalCap = document.getElementById('livePhotoModalCaption');
|
||||||
|
const modalDate = document.getElementById('livePhotoModalDate');
|
||||||
|
|
||||||
const modalImage = document.getElementById('livePhotoModalImage');
|
// --- ФУНКЦІЯ TREKING (GA4) ---
|
||||||
const modalCaption = document.getElementById('livePhotoModalCaption');
|
const trackClick = (photoType) => {
|
||||||
const prevBtn = document.getElementById('livePhotoPrevBtn');
|
if (typeof gtag === 'function') {
|
||||||
const nextBtn = document.getElementById('livePhotoNextBtn');
|
gtag('event', 'select_content', {
|
||||||
|
'content_type': 'live_photo',
|
||||||
// SEO meta tags
|
'item_id': productId,
|
||||||
const metaUrl = document.getElementById('livePhotoMetaUrl');
|
'item_name': productName,
|
||||||
const metaDesc = document.getElementById('livePhotoMetaDesc');
|
'photo_type': photoType, // 'expiry' або 'packaging'
|
||||||
|
'event_category': 'Product Engagement',
|
||||||
const thumbnailLinks = document.querySelectorAll('.live-photo-thumb');
|
'event_label': 'Live Photo Click'
|
||||||
|
|
||||||
// Function to update the modal's content based on the current index
|
|
||||||
const updateModalContent = (index) => {
|
|
||||||
if (!photos[index]) return;
|
|
||||||
|
|
||||||
const photo = photos[index];
|
|
||||||
modalImage.src = photo.url;
|
|
||||||
modalImage.alt = photo.alt;
|
|
||||||
modalCaption.textContent = photo.alt; // Use the descriptive alt text as a caption
|
|
||||||
|
|
||||||
// Update hidden SEO metadata
|
|
||||||
metaUrl.setAttribute('content', photo.url);
|
|
||||||
metaDesc.setAttribute('content', photo.alt);
|
|
||||||
|
|
||||||
// Show/hide navigation buttons
|
|
||||||
prevBtn.style.display = (index === 0) ? 'none' : 'block';
|
|
||||||
nextBtn.style.display = (index === photos.length - 1) ? 'none' : 'block';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add click listeners to each thumbnail
|
|
||||||
thumbnailLinks.forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
currentIndex = parseInt(e.currentTarget.dataset.photoIndex, 10);
|
|
||||||
updateModalContent(currentIndex);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add click listeners for modal navigation
|
|
||||||
prevBtn.addEventListener('click', () => {
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
currentIndex--;
|
|
||||||
updateModalContent(currentIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nextBtn.addEventListener('click', () => {
|
|
||||||
if (currentIndex < photos.length - 1) {
|
|
||||||
currentIndex++;
|
|
||||||
updateModalContent(currentIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add keyboard navigation for accessibility
|
|
||||||
modalElement.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
prevBtn.click();
|
|
||||||
} else if (e.key === 'ArrowRight') {
|
|
||||||
nextBtn.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<script type="text/javascript">
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Check if the gtag function is available to avoid errors
|
|
||||||
if (typeof gtag !== 'function') {
|
|
||||||
console.log('addLivePhoto GA4: gtag function not found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 1. Event for viewing the thumbnails ---
|
|
||||||
// This event is sent once the thumbnails are rendered on the page.
|
|
||||||
try {
|
|
||||||
gtag('event', 'view_live_photo_thumbnail', {
|
|
||||||
'event_category': 'product_page_engagement',
|
|
||||||
'event_label': '{$product.name|escape:'javascript':'UTF-8'}',
|
|
||||||
'product_id': '{$product.id|escape:'javascript':'UTF-8'}',
|
|
||||||
'photo_count': {$live_photos|count}
|
|
||||||
});
|
|
||||||
console.log('addLivePhoto GA4: Fired event "view_live_photo_thumbnail" for product ID {$product.id}.');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('addLivePhoto GA4: Error firing view event.', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 2. Event for clicking a thumbnail ---
|
|
||||||
const thumbnailLinks = document.querySelectorAll('#addlivephoto-container .live-photo-thumb');
|
|
||||||
|
|
||||||
thumbnailLinks.forEach(link => {
|
|
||||||
link.addEventListener('click', () => {
|
|
||||||
try {
|
|
||||||
gtag('event', 'click_live_photo', {
|
|
||||||
'event_category': 'product_page_engagement',
|
|
||||||
'event_label': '{$product.name|escape:'javascript':'UTF-8'}',
|
|
||||||
'product_id': '{$product.id|escape:'javascript':'UTF-8'}',
|
|
||||||
'photo_count': {$live_photos|count}
|
|
||||||
});
|
});
|
||||||
console.log('addLivePhoto GA4: Fired event "click_live_photo" for product ID {$product.id}.');
|
console.log('GA4 Event sent: ' + photoType);
|
||||||
} catch (e) {
|
} else {
|
||||||
console.error('addLivePhoto GA4: Error firing click event.', e);
|
console.log('GA4 not loaded');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- ОБРОБНИКИ КЛІКІВ ---
|
||||||
|
document.querySelectorAll('.live-photo-thumb').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = e.currentTarget.dataset.photoIndex;
|
||||||
|
|
||||||
|
if (photos[idx]) {
|
||||||
|
// Оновлення модалки
|
||||||
|
modalImg.src = photos[idx].url;
|
||||||
|
modalCap.textContent = photos[idx].alt;
|
||||||
|
modalDate.textContent = "Дата зйомки/завантаження: " + photos[idx].date;
|
||||||
|
|
||||||
|
// ВІДПРАВКА ПОДІЇ
|
||||||
|
// photos[idx].type ми додали в попередньому кроці (expiry/packaging)
|
||||||
|
trackClick(photos[idx].type || 'unknown');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// (Опціонально) Відстеження перегляду самого блоку, якщо він видимий
|
||||||
|
// Можна реалізувати через IntersectionObserver, але кліку зазвичай достатньо.
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{/if}
|
{/if}
|
||||||
Reference in New Issue
Block a user