improve photos

This commit is contained in:
O K
2025-11-25 11:00:26 +02:00
parent ff2dcdc0ee
commit 8e7141175a
6 changed files with 1004 additions and 719 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/photo
.llmdump
llmdumper.php

View File

@@ -84,16 +84,14 @@ class AddLivePhoto extends Module
$this->uninstallAdminTab();
}
/**
* 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 . '` (
`id_add_live_photo` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`id_product` INT(11) UNSIGNED NOT NULL,
`image_name` VARCHAR(255) NOT NULL,
`image_type` ENUM("expiry", "packaging") NOT NULL DEFAULT "expiry",
`date_add` DATETIME NOT NULL,
PRIMARY KEY (`id_add_live_photo`),
INDEX `id_product_idx` (`id_product`)
@@ -177,48 +175,51 @@ class AddLivePhoto extends Module
}
$id_product = (int) Tools::getValue('id_product');
if (!$id_product) {
return;
}
if (!$id_product) return;
// Fetch images from the last 4 months
$sql = new DbQuery();
$sql->select('`image_name`');
$sql->from(self::TABLE_NAME);
$sql->where('`id_product` = ' . $id_product);
$sql->where('`date_add` >= DATE_SUB(NOW(), INTERVAL 4 MONTH)');
$sql->orderBy('`date_add` DESC');
// Complex Logic:
// 1. Get 'packaging' photos (Always show, limit to newest 3)
// 2. Get 'expiry' photos (Show only if newer than 3 months)
$sql = "SELECT * FROM `" . _DB_PREFIX_ . self::TABLE_NAME . "`
WHERE `id_product` = " . $id_product . "
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);
if (!$results) {
return;
}
if (!$results) return;
$live_photos = [];
foreach ($results as $row) {
$image_uri = $this->getProductImageUri($id_product, $row['image_name']);
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[] = [
'url' => $image_uri,
// This alt text is crucial for SEO
'alt' => sprintf(
$this->trans('Freshness photo for %s, taken on %s',[], 'Modules.Addlivephoto.Shop'),
$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'),
'type' => $row['image_type'], // 'expiry' or 'packaging'
'date' => $row['date_add'],
'alt' => $alt_text,
];
}
}
if (empty($live_photos)) {
return;
}
if (empty($live_photos)) return;
$this->context->smarty->assign([
'live_photos' => $live_photos,
'module_name' => $this->name,
]);
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
if (Tools::getValue('controller') == 'AdminAddLivePhoto') {
$this->context->controller->addJS($this->_path . 'views/js/admin.js');
$this->context->controller->addCSS($this->_path . 'views/css/admin.css');
// $this->context->controller->addJS($this->_path . 'views/js/admin.js');
// $this->context->controller->addCSS($this->_path . 'views/css/admin.css');
}
}

View File

@@ -1,30 +1,6 @@
<?php
/**
* 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
*
* @property \AddLivePhoto $module
* Admin Controller for AddLivePhoto Module
*/
class AdminAddLivePhotoController extends ModuleAdminController
@@ -32,205 +8,211 @@ class AdminAddLivePhotoController extends ModuleAdminController
public function __construct()
{
$this->bootstrap = true;
// The table is not for a list view, but it's good practice to set it.
$this->table = 'product';
$this->display = 'view'; // Force custom view
parent::__construct();
}
/**
* This is the entry point for the controller page.
* It sets up the main template.
*/
public function initContent()
{
parent::initContent();
// 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
);
// Не викликаємо parent::initContent(), бо нам не потрібен стандартний список
// Але нам потрібен header і footer адмінки
$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()
{
$action = Tools::getValue('action');
try {
switch ($action) {
case 'searchProduct':
$this->ajaxProcessSearchProduct();
$this->processSearchProduct();
break;
case 'uploadImage':
$this->ajaxProcessUploadImage();
$this->processUploadImage();
break;
case 'deleteImage':
$this->ajaxProcessDeleteImage();
$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;
}
/**
* Handles searching for a product by EAN13 barcode or ID.
*/
protected function ajaxProcessSearchProduct()
protected function processSearchProduct()
{
$identifier = Tools::getValue('identifier');
$identifier = trim(Tools::getValue('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;
if (is_numeric($identifier)) {
// Check if it's an EAN or a Product ID
$id_product_by_ean = (int)Db::getInstance()->getValue('
SELECT id_product FROM `' . _DB_PREFIX_ . 'product` WHERE ean13 = \'' . pSQL($identifier) . '\'
');
if ($id_product_by_ean) {
$id_product = $id_product_by_ean;
} else {
// Assume it's a product ID if not found by EAN
// 1. Спробуємо знайти по EAN13
$sql = 'SELECT id_product FROM `' . _DB_PREFIX_ . 'product` WHERE ean13 = "'.pSQL($identifier).'"';
$id_by_ean = Db::getInstance()->getValue($sql);
if ($id_by_ean) {
$id_product = (int)$id_by_ean;
} elseif (is_numeric($identifier)) {
// 2. Якщо це число, пробуємо як ID
$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
$retail_price = Product::getPriceStatic($product->id, true, null, 2, null, false, true);
$discounted_price = Product::getPriceStatic($product->id, true, null, 2, null, true, true);
// Fetch existing live photos for this product
$live_photos = $this->getLivePhotosForProduct($product->id);
$response = [
$this->jsonResponse([
'success' => true,
'data' => [
'id_product' => $product->id,
'name' => $product->name,
'wholesale_price' => $product->wholesale_price,
'retail_price' => $retail_price,
'discounted_price' => ($retail_price !== $discounted_price) ? $discounted_price : null,
'existing_photos' => $live_photos,
];
$this->jsonSuccess($response);
'reference' => $product->reference,
'ean13' => $product->ean13,
'existing_photos' => $existing_photos
]
]);
}
/**
* Handles the image upload process.
*/
protected function ajaxProcessUploadImage()
protected function processUploadImage()
{
$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) {
$this->jsonError($this->trans('Missing product ID or image data.',[], 'Modules.Addlivephoto.Admin'));
if (!$id_product || !$rawImage) {
throw new Exception('Missing ID or Image Data');
}
// Remove the data URI scheme header
list($type, $imageData) = explode(';', $imageData);
list(, $imageData) = explode(',', $imageData);
$imageData = base64_decode($imageData);
if ($imageData === false) {
$this->jsonError($this->trans('Invalid image data.',[], 'Modules.Addlivephoto.Admin'));
if (!in_array($imageType, ['expiry', 'packaging'])) {
$imageType = 'expiry'; // Fallback
}
$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);
if (!$path || !file_put_contents($path . $image_name, $imageData)) {
$this->jsonError($this->trans('Could not save image file. Check permissions for /var/modules/addlivephoto/',[], 'Modules.Addlivephoto.Admin'));
if (!$path) {
throw new Exception('Could not create directory');
}
// Save to database
$success = Db::getInstance()->insert(AddLivePhoto::TABLE_NAME, [
// Save File
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,
'image_name' => pSQL($image_name),
'image_name' => pSQL($filename),
'image_type' => pSQL($imageType),
'date_add' => date('Y-m-d H:i:s'),
]);
if (!$success) {
// Clean up the created file if DB insert fails
@unlink($path . $image_name);
$this->jsonError($this->trans('Could not save image information to the database.',[], 'Modules.Addlivephoto.Admin'));
if (!$res) {
@unlink($path . $filename); // Cleanup
throw new Exception('Database insert error');
}
$new_photo_data = [
'name' => $image_name,
'url' => $this->module->getProductImageUri($id_product, $image_name),
'full_url' => $this->module->getProductImageUri($id_product, $image_name),
];
$photoUrl = $this->module->getProductImageUri($id_product, $filename);
$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
]
]);
}
/**
* Handles deleting a specific image.
*/
protected function ajaxProcessDeleteImage()
protected function processDeleteFreshImage()
{
$id_product = (int)Tools::getValue('id_product');
$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)) {
$this->jsonSuccess(['message' => $this->trans('Image deleted successfully.')]);
$this->jsonResponse(['success' => true, 'message' => 'Deleted']);
} else {
$this->jsonError($this->trans('Failed to delete image.',[], 'Modules.Addlivephoto.Admin'));
throw new Exception('Delete failed');
}
}
/**
* Fetches all live photos for a given product ID.
* @param int $id_product
* @return array
*/
private function getLivePhotosForProduct($id_product)
private function getLivePhotos($id_product)
{
$sql = new DbQuery();
$sql->select('`image_name`');
$sql->from(AddLivePhoto::TABLE_NAME);
$sql->where('`id_product` = ' . (int)$id_product);
$sql->orderBy('`date_add` DESC');
$results = Db::getInstance()->executeS($sql);
$sql->select('*');
$sql->from('add_live_photo');
$sql->where('id_product = ' . (int)$id_product);
$sql->orderBy('date_add DESC');
$res = Db::getInstance()->executeS($sql);
$photos = [];
if ($results) {
foreach ($results as $row) {
if ($res) {
foreach($res as $row) {
$photos[] = [
'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;
}
/** Helper functions for consistent JSON responses */
private function jsonSuccess($data)
private function jsonResponse($data)
{
header('Content-Type: application/json');
echo json_encode(['success' => true, 'data' => $data]);
echo json_encode($data);
exit;
}
}

View File

@@ -1,309 +1,254 @@
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Element References ---
const videoContainer = document.getElementById('alp-video-container');
// 1. Elements
const video = document.getElementById('alp-video');
const canvas = document.getElementById('alp-canvas');
const overlay = document.getElementById('alp-viewfinder-overlay');
const overlayText = document.getElementById('alp-overlay-text');
const cameraSelector = document.getElementById('alp-camera-selector');
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');
const overlayText = document.getElementById('alp-status-text');
const stepScan = document.getElementById('alp-step-scan');
const stepAction = document.getElementById('alp-step-action');
// --- State Management ---
const AppState = {
IDLE: 'idle', // Camera off, welcome message
READY_TO_SCAN: 'ready_to_scan', // Camera on, waiting for tap to scan
SCANNING: 'scanning', // Actively looking for barcode
PRODUCT_FOUND: 'product_found', // Product found, waiting for tap to take photo
UPLOADING: 'uploading' // Photo is being sent to server
};
let currentState = AppState.IDLE;
// 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 barcodeDetector = null;
let isScanning = false;
let currentProductId = null;
const ajaxUrl = window.addLivePhotoAjaxUrl || '';
// --- Initialization ---
if (!('BarcodeDetector' in window)) {
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:
// 2. Initialize
initBarcodeDetector();
startCamera();
break;
case AppState.READY_TO_SCAN:
detectBarcode();
break;
case AppState.PRODUCT_FOUND:
takePhoto();
break;
}
}
function updateUIForState(newState, customText = null) {
currentState = newState;
let textContent = '';
overlay.style.display = 'flex';
// 3. Event Listeners
manualForm.addEventListener('submit', (e) => {
e.preventDefault();
const val = document.getElementById('alp-manual-input').value.trim();
if(val) fetchProduct(val);
});
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;
btnReset.addEventListener('click', resetApp);
btnExpiry.addEventListener('click', () => takePhoto('expiry'));
btnPkg.addEventListener('click', () => takePhoto('packaging'));
// --- Core Functions ---
async function initBarcodeDetector() {
if ('BarcodeDetector' in window) {
// Check supported formats
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";
}
} 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() {
if (currentStream) return;
const constraints = { video: { deviceId: cameraSelector.value ? { exact: cameraSelector.value } : undefined, facingMode: 'environment' } };
try {
const constraints = {
video: {
facingMode: 'environment', // Rear camera
width: { ideal: 1280 },
height: { ideal: 720 }
}
};
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
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) {
console.error('Error accessing camera:', err);
stopCamera(); // Ensure everything is reset
updateUIForState(AppState.IDLE, 'Camera Error. Tap to retry.');
console.error(err);
overlayText.textContent = "Camera Access Denied or Error";
}
}
function stopCamera() {
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop());
currentStream = null;
}
video.srcObject = null;
updateUIForState(AppState.IDLE);
}
async function scanLoop() {
if (!isScanning || !barcodeDetector || currentProductId) return;
async function detectBarcode() {
if (!barcodeDetector || currentState !== AppState.READY_TO_SCAN) return;
updateUIForState(AppState.SCANNING);
try {
const barcodes = await barcodeDetector.detect(video);
if (barcodes.length > 0) {
searchProduct(barcodes[0].rawValue);
} else {
showMessage('No barcode found. Please try again.', true);
updateUIForState(AppState.READY_TO_SCAN);
}
} catch (err) {
console.error('Barcode detection error:', err);
showMessage('Error during barcode detection.', true);
updateUIForState(AppState.READY_TO_SCAN);
const code = barcodes[0].rawValue;
isScanning = false; // Stop scanning
fetchProduct(code);
return;
}
} catch (e) {
// Detection error (common in loop)
}
function takePhoto() {
if (!currentStream || !currentProductId || currentState !== AppState.PRODUCT_FOUND) return;
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);
// Scan every 200ms to save battery
setTimeout(() => requestAnimationFrame(scanLoop), 200);
}
function resetForNextProduct() {
currentProductId = null;
productInfoSection.style.display = 'none';
existingPhotosSection.style.display = 'none';
existingPhotosContainer.innerHTML = '';
updateUIForState(AppState.READY_TO_SCAN);
}
function fetchProduct(identifier) {
overlayText.textContent = "Searching...";
isScanning = false;
// --- AJAX and Helper Functions ---
async function searchProduct(identifier) {
const formData = new FormData();
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);
}
}
const fd = new FormData();
fd.append('action', 'searchProduct');
fd.append('identifier', identifier);
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() {
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 })
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
.then(res => res.json())
.then(result => {
if (result.success) {
showMessage(result.message, false);
button.closest('.photo-thumb').remove();
.then(data => {
if (data.success) {
loadProductView(data.data);
} else {
showMessage(result.message, true);
}
}).catch(err => showMessage('Network error deleting photo.', true));
}
alert(data.message || 'Product not found');
resetApp(); // Go back to scanning
}
})
.catch(err => {
console.error(err);
alert('Network Error');
resetApp();
});
}
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 loadProductView(productData) {
currentProductId = productData.id_product;
productNameEl.textContent = `[${productData.reference}] ${productData.name}`;
renderPhotos(productData.existing_photos);
// Switch View
stepScan.style.display = 'none';
stepAction.style.display = 'block';
}
function renderExistingPhotos(photos, productId) {
existingPhotosContainer.innerHTML = '';
if (photos && photos.length > 0) {
existingPhotosSection.style.display = 'block';
photos.forEach(photo => appendNewPhoto(photo, productId));
function takePhoto(type) {
if (!currentProductId) return;
// Capture frame
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 {
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) {
const thumbDiv = document.createElement('div');
thumbDiv.className = 'photo-thumb';
thumbDiv.innerHTML = `
<a href="${photo.url}" target="_blank">
<img src="${photo.url}" alt="Live photo" loading="lazy" />
</a>
<button class="btn btn-sm btn-danger delete-photo-btn" data-product-id="${productId}" data-image-name="${photo.name}">X</button>
function addPhotoToDom(photo) {
// Remove "No photos" msg if exists
if (photoListEl.querySelector('p')) photoListEl.innerHTML = '';
const div = document.createElement('div');
div.className = 'alp-thumb';
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);
existingPhotosSection.style.display = 'block';
photoListEl.prepend(div);
}
function showMessage(text, isError = false) {
messageArea.textContent = text;
messageArea.className = isError ? 'alert alert-danger' : 'alert alert-info';
messageArea.style.display = 'block';
setTimeout(() => { messageArea.style.display = 'none'; }, 4000); // Message disappears after 4s
function resetApp() {
currentProductId = null;
document.getElementById('alp-manual-input').value = '';
stepAction.style.display = 'none';
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');
}
});
};
});

View File

@@ -1,88 +1,557 @@
{**
This script block passes the unique, secure AJAX URL from the PHP controller to our JavaScript.
The 'javascript' escaper is crucial to prevent encoding issues.
**}
<script type="text/javascript">
var addLivePhotoAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
{* Pass URL to JS *}
<script>
var alpAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
</script>
<div class="panel">
<div class="panel" id="alp-app">
<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 class="container-fluid">
<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 --- *}
<div id="alp-camera-view" class="my-3">
<div id="alp-video-container" class="video-container">
{* The video feed will be attached here by JavaScript *}
<video id="alp-video" autoplay playsinline muted></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 *}
{* 1. CAMERA VIEW *}
<div id="alp-step-scan" class="text-center">
<div class="video-wrapper"
style="position: relative; background: #000; min-height: 300px; margin-bottom: 15px; overflow: hidden;">
<video id="alp-video" autoplay playsinline muted
style="width: 100%; height: 100%; object-fit: cover;"></video>
<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>
{* --- Message Area (for non-critical feedback) --- *}
<div id="alp-message-area" class="alert" style="display: none; text-align: center;"></div>
{* --- 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">
{* Manual Input Fallback *}
<form id="alp-manual-form" class="form-inline" style="margin-top: 10px;">
<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>
<input type="text" id="alp-manual-input" class="form-control" placeholder="EAN13 or Product ID">
</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>
<button type="submit" class="btn btn-primary"><i class="icon-search"></i> Search</button>
</form>
</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>
<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>

View File

@@ -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)}
<div id="addlivephoto-container" class="mt-3">
<h6 class="h6">{l s='Freshness Guaranteed: See Today\'s Stock' d='Modules.Addlivephoto.Shop'}</h6>
<div id="addlivephoto-container" class="mt-3 mb-3">
<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">
{foreach from=$live_photos item=photo name=livephotoloop}
<a href="{$photo.url|escape:'htmlall':'UTF-8'}" class="live-photo-thumb" data-bs-toggle="modal"
data-bs-target="#livePhotoModal" data-photo-index="{$smarty.foreach.livephotoloop.index}"
title="{$photo.title|escape:'htmlall':'UTF-8'}">
<img src="{$photo.url|escape:'htmlall':'UTF-8'}" alt="{$photo.alt|escape:'htmlall':'UTF-8'}" class="img-thumbnail"
width="80" height="80" loading="lazy">
<div class="position-relative">
<a href="{$photo.url|escape:'htmlall':'UTF-8'}" class="live-photo-thumb d-block" data-bs-toggle="modal"
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" width="80" height="80" loading="lazy" style="object-fit: cover;">
</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}
</div>
</div>
{* --- MODAL --- *}
<div class="modal fade" id="livePhotoModal" tabindex="-1" aria-labelledby="livePhotoModalLabel" aria-hidden="true">
{* Modal code remains mostly same, just ensuring script handles it *}
<div class="modal fade" id="livePhotoModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="livePhotoModalLabel">{l s='Live Product Photo' d='Modules.Addlivephoto.Shop'}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="{l s='Close' d='Shop.Theme.Actions'}"></button>
<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>
</div>
<div class="modal-body">
<div class="position-relative">
<img id="livePhotoModalImage" src="" alt="" class="img-fluid w-100">
<button id="livePhotoPrevBtn" class="btn btn-light modal-nav-btn prev">&lt;</button>
<button id="livePhotoNextBtn" class="btn btn-light modal-nav-btn next">&gt;</button>
<div class="modal-body text-center p-0 bg-light">
<img id="livePhotoModalImage" src="" class="img-fluid" style="max-height: 80vh;">
</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 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 class="modal-footer justify-content-between">
<small class="text-muted" id="livePhotoModalDate"></small>
<p id="livePhotoModalCaption" class="mb-0 fw-bold"></p>
</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">
document.addEventListener('DOMContentLoaded', () => {
// Data passed directly from Smarty to JavaScript
// 1. Ініціалізація даних
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');
if (!modalElement) return;
const modalImg = document.getElementById('livePhotoModalImage');
const modalCap = document.getElementById('livePhotoModalCaption');
const modalDate = document.getElementById('livePhotoModalDate');
const modalImage = document.getElementById('livePhotoModalImage');
const modalCaption = document.getElementById('livePhotoModalCaption');
const prevBtn = document.getElementById('livePhotoPrevBtn');
const nextBtn = document.getElementById('livePhotoNextBtn');
// SEO meta tags
const metaUrl = document.getElementById('livePhotoMetaUrl');
const metaDesc = document.getElementById('livePhotoMetaDesc');
const thumbnailLinks = document.querySelectorAll('.live-photo-thumb');
// 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';
// --- ФУНКЦІЯ TREKING (GA4) ---
const trackClick = (photoType) => {
if (typeof gtag === 'function') {
gtag('event', 'select_content', {
'content_type': 'live_photo',
'item_id': productId,
'item_name': productName,
'photo_type': photoType, // 'expiry' або 'packaging'
'event_category': 'Product Engagement',
'event_label': 'Live Photo Click'
});
console.log('GA4 Event sent: ' + photoType);
} else {
console.log('GA4 not loaded');
}
};
// Add click listeners to each thumbnail
thumbnailLinks.forEach(link => {
// --- ОБРОБНИКИ КЛІКІВ ---
document.querySelectorAll('.live-photo-thumb').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
currentIndex = parseInt(e.currentTarget.dataset.photoIndex, 10);
updateModalContent(currentIndex);
});
});
const idx = e.currentTarget.dataset.photoIndex;
// Add click listeners for modal navigation
prevBtn.addEventListener('click', () => {
if (currentIndex > 0) {
currentIndex--;
updateModalContent(currentIndex);
}
});
if (photos[idx]) {
// Оновлення модалки
modalImg.src = photos[idx].url;
modalCap.textContent = photos[idx].alt;
modalDate.textContent = "Дата зйомки/завантаження: " + photos[idx].date;
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();
// ВІДПРАВКА ПОДІЇ
// photos[idx].type ми додали в попередньому кроці (expiry/packaging)
trackClick(photos[idx].type || 'unknown');
}
});
});
</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}.');
} catch (e) {
console.error('addLivePhoto GA4: Error firing click event.', e);
}
});
});
// (Опціонально) Відстеження перегляду самого блоку, якщо він видимий
// Можна реалізувати через IntersectionObserver, але кліку зазвичай достатньо.
});
</script>
{/if}