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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the database table for storing image information.
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
protected function installDb()
|
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');
|
||||||
|
|
||||||
|
try {
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
case 'searchProduct':
|
case 'searchProduct':
|
||||||
$this->ajaxProcessSearchProduct();
|
$this->processSearchProduct();
|
||||||
break;
|
break;
|
||||||
case 'uploadImage':
|
case 'uploadImage':
|
||||||
$this->ajaxProcessUploadImage();
|
$this->processUploadImage();
|
||||||
break;
|
break;
|
||||||
case 'deleteImage':
|
case 'deleteImage':
|
||||||
$this->ajaxProcessDeleteImage();
|
$this->processDeleteFreshImage();
|
||||||
break;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$product = new Product($id_product, false, $this->context->language->id);
|
||||||
|
|
||||||
|
if (!Validate::isLoadedObject($product)) {
|
||||||
|
throw new Exception($this->trans('Product not found.', [], 'Modules.Addlivephoto.Admin'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$id_product || !Validate::isLoadedObject($product = new Product($id_product, false, $this->context->language->id))) {
|
// Отримуємо існуючі фото
|
||||||
$this->jsonError($this->trans('Product not found.',[], 'Modules.Addlivephoto.Admin'));
|
$existing_photos = $this->getLivePhotos($product->id);
|
||||||
}
|
|
||||||
|
|
||||||
// Get product prices
|
$this->jsonResponse([
|
||||||
$retail_price = Product::getPriceStatic($product->id, true, null, 2, null, false, true);
|
'success' => true,
|
||||||
$discounted_price = Product::getPriceStatic($product->id, true, null, 2, null, true, true);
|
'data' => [
|
||||||
|
|
||||||
// Fetch existing live photos for this product
|
|
||||||
$live_photos = $this->getLivePhotosForProduct($product->id);
|
|
||||||
|
|
||||||
$response = [
|
|
||||||
'id_product' => $product->id,
|
'id_product' => $product->id,
|
||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
'wholesale_price' => $product->wholesale_price,
|
'reference' => $product->reference,
|
||||||
'retail_price' => $retail_price,
|
'ean13' => $product->ean13,
|
||||||
'discounted_price' => ($retail_price !== $discounted_price) ? $discounted_price : null,
|
'existing_photos' => $existing_photos
|
||||||
'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);
|
|
||||||
} 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
|
|
||||||
|
|
||||||
// --- Event Listeners ---
|
|
||||||
videoContainer.addEventListener('click', handleViewfinderTap);
|
|
||||||
cameraSelector.addEventListener('change', handleCameraChange);
|
|
||||||
manualInputForm.addEventListener('submit', handleManualSubmit);
|
|
||||||
existingPhotosContainer.addEventListener('click', handleDeleteClick);
|
|
||||||
|
|
||||||
// --- Core Logic ---
|
|
||||||
function handleViewfinderTap() {
|
|
||||||
switch (currentState) {
|
|
||||||
case AppState.IDLE:
|
|
||||||
startCamera();
|
startCamera();
|
||||||
break;
|
|
||||||
case AppState.READY_TO_SCAN:
|
|
||||||
detectBarcode();
|
|
||||||
break;
|
|
||||||
case AppState.PRODUCT_FOUND:
|
|
||||||
takePhoto();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUIForState(newState, customText = null) {
|
// 3. Event Listeners
|
||||||
currentState = newState;
|
manualForm.addEventListener('submit', (e) => {
|
||||||
let textContent = '';
|
e.preventDefault();
|
||||||
overlay.style.display = 'flex';
|
const val = document.getElementById('alp-manual-input').value.trim();
|
||||||
|
if(val) fetchProduct(val);
|
||||||
|
});
|
||||||
|
|
||||||
switch (newState) {
|
btnReset.addEventListener('click', resetApp);
|
||||||
case AppState.IDLE:
|
|
||||||
textContent = "Tap to Start Camera";
|
btnExpiry.addEventListener('click', () => takePhoto('expiry'));
|
||||||
break;
|
btnPkg.addEventListener('click', () => takePhoto('packaging'));
|
||||||
case AppState.READY_TO_SCAN:
|
|
||||||
textContent = "Tap to Scan Barcode";
|
// --- Core Functions ---
|
||||||
break;
|
|
||||||
case AppState.SCANNING:
|
async function initBarcodeDetector() {
|
||||||
textContent = `<div class="spinner"></div>`;
|
if ('BarcodeDetector' in window) {
|
||||||
break;
|
// Check supported formats
|
||||||
case AppState.PRODUCT_FOUND:
|
const formats = await BarcodeDetector.getSupportedFormats();
|
||||||
textContent = "Tap to Take Picture";
|
if (formats.includes('ean_13')) {
|
||||||
break;
|
barcodeDetector = new BarcodeDetector({ formats: ['ean_13'] });
|
||||||
case AppState.UPLOADING:
|
console.log('BarcodeDetector ready');
|
||||||
textContent = "Uploading...";
|
} else {
|
||||||
break;
|
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.";
|
||||||
}
|
}
|
||||||
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) {
|
|
||||||
console.error('Barcode detection error:', err);
|
|
||||||
showMessage('Error during barcode detection.', true);
|
|
||||||
updateUIForState(AppState.READY_TO_SCAN);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Detection error (common in loop)
|
||||||
}
|
}
|
||||||
|
|
||||||
function takePhoto() {
|
// Scan every 200ms to save battery
|
||||||
if (!currentStream || !currentProductId || currentState !== AppState.PRODUCT_FOUND) return;
|
setTimeout(() => requestAnimationFrame(scanLoop), 200);
|
||||||
updateUIForState(AppState.UPLOADING);
|
|
||||||
|
|
||||||
const targetWidth = 800, targetHeight = 800;
|
|
||||||
canvas.width = targetWidth; canvas.height = targetHeight;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForNextProduct() {
|
function fetchProduct(identifier) {
|
||||||
currentProductId = null;
|
overlayText.textContent = "Searching...";
|
||||||
productInfoSection.style.display = 'none';
|
isScanning = false;
|
||||||
existingPhotosSection.style.display = 'none';
|
|
||||||
existingPhotosContainer.innerHTML = '';
|
|
||||||
updateUIForState(AppState.READY_TO_SCAN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- AJAX and Helper Functions ---
|
const fd = new FormData();
|
||||||
async function searchProduct(identifier) {
|
fd.append('action', 'searchProduct');
|
||||||
const formData = new FormData();
|
fd.append('identifier', identifier);
|
||||||
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) {
|
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||||
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() {
|
|
||||||
localStorage.setItem('addLivePhoto_preferredCameraId', cameraSelector.value);
|
|
||||||
if (currentStream) { // If camera is active, restart it with the new selection
|
|
||||||
stopCamera();
|
|
||||||
startCamera();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleManualSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const identifier = document.getElementById('alp-manual-identifier').value.trim();
|
|
||||||
if (identifier) {
|
|
||||||
showMessage(`Searching for: ${identifier}...`);
|
|
||||||
searchProduct(identifier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteClick(e) {
|
|
||||||
if (e.target && e.target.classList.contains('delete-photo-btn')) {
|
|
||||||
const button = e.target;
|
|
||||||
const imageName = button.dataset.imageName;
|
|
||||||
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(res => res.json())
|
||||||
.then(result => {
|
.then(data => {
|
||||||
if (result.success) {
|
if (data.success) {
|
||||||
showMessage(result.message, false);
|
loadProductView(data.data);
|
||||||
button.closest('.photo-thumb').remove();
|
|
||||||
} else {
|
} else {
|
||||||
showMessage(result.message, true);
|
alert(data.message || 'Product not found');
|
||||||
}
|
resetApp(); // Go back to scanning
|
||||||
}).catch(err => showMessage('Network error deleting photo.', true));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
alert('Network Error');
|
||||||
|
resetApp();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayProductInfo(product) {
|
function loadProductView(productData) {
|
||||||
productNameEl.textContent = `[ID: ${product.id_product}] ${product.name}`;
|
currentProductId = productData.id_product;
|
||||||
let pricesHtml = `Wholesale: ${product.wholesale_price} | Sale: ${product.retail_price}`;
|
productNameEl.textContent = `[${productData.reference}] ${productData.name}`;
|
||||||
if (product.discounted_price) {
|
|
||||||
pricesHtml += ` | <strong class="text-danger">Discounted: ${product.discounted_price}</strong>`;
|
renderPhotos(productData.existing_photos);
|
||||||
}
|
|
||||||
productPricesEl.innerHTML = pricesHtml;
|
// Switch View
|
||||||
renderExistingPhotos(product.existing_photos, product.id_product);
|
stepScan.style.display = 'none';
|
||||||
productInfoSection.style.display = 'block';
|
stepAction.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderExistingPhotos(photos, productId) {
|
function takePhoto(type) {
|
||||||
existingPhotosContainer.innerHTML = '';
|
if (!currentProductId) return;
|
||||||
if (photos && photos.length > 0) {
|
|
||||||
existingPhotosSection.style.display = 'block';
|
// Capture frame
|
||||||
photos.forEach(photo => appendNewPhoto(photo, productId));
|
const w = video.videoWidth;
|
||||||
|
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 {
|
} else {
|
||||||
existingPhotosSection.style.display = 'none';
|
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">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-lg-8 col-lg-offset-2">
|
<div class="col-md-6 col-md-offset-3">
|
||||||
|
|
||||||
{* --- 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>
|
||||||
{* This overlay displays instructions and is the main tap target *}
|
|
||||||
<div id="alp-viewfinder-overlay">
|
|
||||||
<div id="alp-overlay-text"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{* This canvas is used for capturing the frame but is not visible *}
|
|
||||||
<canvas id="alp-canvas" style="display: none;"></canvas>
|
<canvas id="alp-canvas" style="display: none;"></canvas>
|
||||||
|
|
||||||
|
{* Camera Controls Overlay *}
|
||||||
|
<div id="alp-controls"
|
||||||
|
style="position: absolute; bottom: 20px; left: 0; width: 100%; display: flex; justify-content: center; gap: 20px; z-index: 10;">
|
||||||
|
{* 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>
|
||||||
|
|
||||||
|
{* Zoom Button (Hidden by default) *}
|
||||||
|
<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;">
|
||||||
|
|
||||||
{* --- Product Information (hidden by default) --- *}
|
|
||||||
<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 class="card-body">
|
|
||||||
<p><strong>{l s='Name:' d='Modules.Addlivephoto.Admin'}</strong> <span id="alp-product-name"></span></p>
|
|
||||||
<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">
|
<div class="form-group">
|
||||||
<label for="alp-camera-selector" class="control-label">{l s='Select Camera:' d='Modules.Addlivephoto.Admin'}</label>
|
<input type="text" id="alp-manual-input" class="form-control" placeholder="EAN13 or Product ID">
|
||||||
<select id="alp-camera-selector" class="form-control"></select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button type="submit" class="btn btn-primary"><i class="icon-search"></i> Search</button>
|
||||||
</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>
|
</form>
|
||||||
</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>
|
</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>
|
||||||
<div class="text-muted text-wrap small mb-0">
|
<div class="modal-footer justify-content-between">
|
||||||
{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'}
|
<small class="text-muted" id="livePhotoModalDate"></small>
|
||||||
</div>
|
<p id="livePhotoModalCaption" class="mb-0 fw-bold"></p>
|
||||||
</div>
|
|
||||||
<div class="modal-footer justify-content-start">
|
|
||||||
|
|
||||||
{* This caption is visible to the user and good for accessibility *}
|
|
||||||
<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
|
console.log('GA4 Event sent: ' + photoType);
|
||||||
const updateModalContent = (index) => {
|
} else {
|
||||||
if (!photos[index]) return;
|
console.log('GA4 not loaded');
|
||||||
|
}
|
||||||
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 => {
|
document.querySelectorAll('.live-photo-thumb').forEach(link => {
|
||||||
link.addEventListener('click', (e) => {
|
link.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
currentIndex = parseInt(e.currentTarget.dataset.photoIndex, 10);
|
const idx = e.currentTarget.dataset.photoIndex;
|
||||||
updateModalContent(currentIndex);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add click listeners for modal navigation
|
if (photos[idx]) {
|
||||||
prevBtn.addEventListener('click', () => {
|
// Оновлення модалки
|
||||||
if (currentIndex > 0) {
|
modalImg.src = photos[idx].url;
|
||||||
currentIndex--;
|
modalCap.textContent = photos[idx].alt;
|
||||||
updateModalContent(currentIndex);
|
modalDate.textContent = "Дата зйомки/завантаження: " + photos[idx].date;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nextBtn.addEventListener('click', () => {
|
// ВІДПРАВКА ПОДІЇ
|
||||||
if (currentIndex < photos.length - 1) {
|
// photos[idx].type ми додали в попередньому кроці (expiry/packaging)
|
||||||
currentIndex++;
|
trackClick(photos[idx].type || 'unknown');
|
||||||
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.
|
// Можна реалізувати через IntersectionObserver, але кліку зазвичай достатньо.
|
||||||
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}.');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('addLivePhoto GA4: Error firing click event.', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{/if}
|
{/if}
|
||||||
Reference in New Issue
Block a user