first commit

This commit is contained in:
O K
2025-11-24 17:26:43 +02:00
commit ff2dcdc0ee
9 changed files with 1334 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/photo

324
addlivephoto.php Normal file
View File

@@ -0,0 +1,324 @@
<?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 Panariga
* @copyright 2025
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
* International Registered Trademark & Property of PrestaShop SA
*/
if (!defined('_PS_VERSION_')) {
exit;
}
class AddLivePhoto extends Module
{
const TABLE_NAME = 'add_live_photo';
const IMG_DIR_VAR_PATH = _PS_ROOT_DIR_ . '/modules/addlivephoto/photo/';
const IMG_DIR_MODULE_PATH = _PS_MODULE_DIR_ . 'addlivephoto/photo/'; // Kept for URI generation context
public function __construct()
{
$this->name = 'addlivephoto';
$this->tab = 'front_office_features';
$this->version = '1.0.0';
$this->author = 'Panariga';
$this->need_instance = 0;
$this->bootstrap = true;
parent::__construct();
$this->displayName = $this->trans('Add Live Product Photos',[], 'Modules.Addlivephoto.Admin');
$this->description = $this->trans('Allows admin to add live photos of product details like expiry dates directly from their phone. Displays fresh images on the product page.',[], 'Modules.Addlivephoto.Admin');
$this->ps_versions_compliancy = array('min' => '8.0.0', 'max' => _PS_VERSION_);
}
/**
* Module installation process.
* @return bool
*/
public function install()
{
if (
!parent::install() ||
!$this->registerHook('displayProductPriceBlock') ||
!$this->registerHook('actionAdminControllerSetMedia') ||
!$this->installDb() ||
!$this->installAdminTab() ||
!$this->createImageDirectories()
) {
return false;
}
return true;
}
/**
* Module uninstallation process.
* @return bool
*/
public function uninstall()
{
// Note: For safety, we are not deleting the /var/modules/addlivephoto directory
// with user-uploaded images by default. You can add a configuration option for this.
return parent::uninstall() &&
$this->uninstallDb() &&
$this->uninstallAdminTab();
}
/**
* Create the database table for storing image information.
* @return bool
*/
protected function installDb()
{
$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,
`date_add` DATETIME NOT NULL,
PRIMARY KEY (`id_add_live_photo`),
INDEX `id_product_idx` (`id_product`)
) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;';
return Db::getInstance()->execute($sql);
}
/**
* Drop the database table.
* @return bool
*/
protected function uninstallDb()
{
// return Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . self::TABLE_NAME . '`');
}
/**
* Install the link to our Admin Controller in the Quick Access menu.
* @return bool
*/
protected function installAdminTab()
{
$tab = new Tab();
$tab->active = 1;
$tab->class_name = 'AdminAddLivePhoto';
$tab->name = array();
foreach (Language::getLanguages(true) as $lang) {
$tab->name[$lang['id_lang']] = $this->trans('Live Photo Uploader',[], 'Modules.Addlivephoto.Admin');
}
$tab->id_parent = (int) Tab::getIdFromClassName('IMPROVE');
$tab->module = $this->name;
return $tab->add();
}
/**
* Remove the Admin Controller link.
* @return bool
*/
protected function uninstallAdminTab()
{
$id_tab = (int) Tab::getIdFromClassName('AdminAddLivePhoto');
if ($id_tab) {
$tab = new Tab($id_tab);
return $tab->delete();
}
return true;
}
/**
* Create directories for storing images.
* @return bool
*/
protected function createImageDirectories()
{
if (!is_dir(self::IMG_DIR_VAR_PATH)) {
// Create directory recursively with write permissions
if (!mkdir(self::IMG_DIR_VAR_PATH, 0775, true)) {
$this->_errors[] = $this->trans('Could not create image directory: ',[], 'Modules.Addlivephoto.Admin') . self::IMG_DIR_VAR_PATH;
return false;
}
}
// Add an index.php file for security
if (!file_exists(self::IMG_DIR_VAR_PATH . 'index.php')) {
@copy(_PS_MODULE_DIR_.$this->name.'/views/index.php', self::IMG_DIR_VAR_PATH . 'index.php');
}
return true;
}
/**
* Hook to display content on the product page.
* @param array $params
* @return string|void
*/
public function hookDisplayProductPriceBlock($params)
{
if (!isset($params['type']) || $params['type'] !== 'after_price') {
return;
}
$id_product = (int) Tools::getValue('id_product');
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');
$results = Db::getInstance()->executeS($sql);
if (!$results) {
return;
}
$live_photos = [];
foreach ($results as $row) {
$image_uri = $this->getProductImageUri($id_product, $row['image_name']);
if ($image_uri) {
$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'),
];
}
}
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');
}
/**
* Hook to add CSS/JS to the admin controller page.
*/
public function hookActionAdminControllerSetMedia()
{
// 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');
}
}
/**
* Gets the full server path to a product's image directory, creating it if necessary.
* Follows the PrestaShop pattern (e.g., /1/2/3/ for ID 123).
*
* @param int $id_product
* @return string|false The path to the directory or false on failure.
*/
public function getProductImageServerPath($id_product)
{
if (!is_numeric($id_product)) {
return false;
}
$path = self::IMG_DIR_VAR_PATH . implode('/', str_split((string)$id_product)) . '/';
if (!is_dir($path)) {
if (!mkdir($path, 0775, true)) {
return false;
}
// Add an index.php file for security
if (!file_exists($path . 'index.php')) {
@copy(_PS_MODULE_DIR_.$this->name.'/views/index.php', $path . 'index.php');
}
}
return $path;
}
/**
* Gets the public URI for a specific product image.
*
* @param int $id_product
* @param string $image_name
* @return string|false The public URI or false if file does not exist.
*/
public function getProductImageUri($id_product, $image_name)
{
if (!is_numeric($id_product) || empty($image_name)) {
return false;
}
$path_parts = str_split((string)$id_product);
$image_path = implode('/', $path_parts) . '/' . $image_name;
$server_path_check = self::IMG_DIR_VAR_PATH . $image_path;
// We check if the file actually exists before returning a URI
if (!file_exists($server_path_check)) {
return false;
}
return $this->context->link->getBaseLink() . 'modules/addlivephoto/photo/' . $image_path;
}
/**
* Deletes a live photo record and its corresponding file.
* @param int $id_product
* @param string $image_name
* @return bool
*/
public function deleteProductImage($id_product, $image_name)
{
if (!is_numeric($id_product) || empty($image_name)) {
return false;
}
// Delete from database
$deleted_from_db = Db::getInstance()->delete(
self::TABLE_NAME,
'`id_product` = ' . (int)$id_product . ' AND `image_name` = \'' . pSQL($image_name) . '\''
);
// Delete file from server
$file_path = $this->getProductImageServerPath($id_product) . $image_name;
$deleted_from_disk = false;
if (file_exists($file_path) && is_writable($file_path)) {
$deleted_from_disk = unlink($file_path);
}
// Return true if both operations were successful or if the file was already gone but DB entry was removed
return $deleted_from_db && ($deleted_from_disk || !file_exists($file_path));
}
public function isUsingNewTranslationSystem()
{
return true;
}
}

11
config_uk.xml Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<module>
<name>addlivephoto</name>
<displayName><![CDATA[Add Live Product Photos]]></displayName>
<version><![CDATA[1.0.0]]></version>
<description><![CDATA[Allows admin to add live photos of product details like expiry dates directly from their phone. Displays fresh images on the product page.]]></description>
<author><![CDATA[Panariga]]></author>
<tab><![CDATA[front_office_features]]></tab>
<is_configurable>0</is_configurable>
<need_instance>0</need_instance>
</module>

View File

@@ -0,0 +1,236 @@
<?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
*/
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';
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
);
$this->context->smarty->assign([
'ajax_url' => $ajax_url,
]);
// We use a custom template for our camera interface.
$this->setTemplate('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');
switch ($action) {
case 'searchProduct':
$this->ajaxProcessSearchProduct();
break;
case 'uploadImage':
$this->ajaxProcessUploadImage();
break;
case 'deleteImage':
$this->ajaxProcessDeleteImage();
break;
}
// No further processing needed for AJAX
exit;
}
/**
* Handles searching for a product by EAN13 barcode or ID.
*/
protected function ajaxProcessSearchProduct()
{
$identifier = Tools::getValue('identifier');
if (empty($identifier)) {
$this->jsonError($this->trans('Identifier cannot be empty.',[], '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
$id_product = (int)$identifier;
}
}
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'));
}
// 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 = [
'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);
}
/**
* Handles the image upload process.
*/
protected function ajaxProcessUploadImage()
{
$id_product = (int)Tools::getValue('id_product');
$imageData = Tools::getValue('imageData');
if (!$id_product || !$imageData) {
$this->jsonError($this->trans('Missing product ID or image data.',[], 'Modules.Addlivephoto.Admin'));
}
// 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'));
}
$image_name = uniqid() . '.webp';
$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'));
}
// Save to database
$success = Db::getInstance()->insert(AddLivePhoto::TABLE_NAME, [
'id_product' => $id_product,
'image_name' => pSQL($image_name),
'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'));
}
$new_photo_data = [
'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]);
}
/**
* Handles deleting a specific image.
*/
protected function ajaxProcessDeleteImage()
{
$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.')]);
} else {
$this->jsonError($this->trans('Failed to delete image.',[], 'Modules.Addlivephoto.Admin'));
}
}
/**
* Fetches all live photos for a given product ID.
* @param int $id_product
* @return array
*/
private function getLivePhotosForProduct($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);
$photos = [];
if ($results) {
foreach ($results as $row) {
$photos[] = [
'name' => $row['image_name'],
'url' => $this->module->getProductImageUri($id_product, $row['image_name']),
];
}
}
return $photos;
}
/** Helper functions for consistent JSON responses */
private function jsonSuccess($data)
{
header('Content-Type: application/json');
echo json_encode(['success' => true, 'data' => $data]);
}
}

35
index.php Normal file
View File

@@ -0,0 +1,35 @@
<?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 PrestaShop SA <contact@prestashop.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
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Location: ../');
exit;

102
views/css/admin.css Normal file
View File

@@ -0,0 +1,102 @@
/* --- Video and Camera Controls --- */
.video-container {
position: relative;
background: #2c2c2c;
/* Darker background */
border: 2px solid #ddd;
border-radius: 4px;
overflow: hidden;
width: 100%;
max-width: 640px;
max-height: 60vh;
/* A bit more height is ok now */
margin: 0 auto;
aspect-ratio: 1 / 1;
cursor: pointer;
/* Indicate it's clickable */
}
#alp-video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
/* --- The New Viewfinder Overlay --- */
#alp-viewfinder-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
background-color: rgba(0, 0, 0, 0.5);
color: white;
font-size: 1.5rem;
font-weight: bold;
padding: 20px;
transition: background-color 0.3s ease;
/* Prevent text selection on rapid taps */
-webkit-user-select: none;
/* Safari */
-ms-user-select: none;
/* IE 10+ */
user-select: none;
/* Standard syntax */
}
#alp-viewfinder-overlay:hover {
background-color: rgba(0, 0, 0, 0.3);
}
/* Spinner for loading states */
.spinner {
border: 8px solid #f3f3f3;
border-top: 8px solid #3498db;
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#alp-photos-container {
gap: 1rem;
}
.photo-thumb {
position: relative;
width: 100px;
height: 100px;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
}
.photo-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-thumb .delete-photo-btn {
position: absolute;
top: 2px;
right: 2px;
line-height: 1;
padding: 2px 5px;
font-size: 12px;
}

309
views/js/admin.js Normal file
View File

@@ -0,0 +1,309 @@
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Element References ---
const videoContainer = document.getElementById('alp-video-container');
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');
// --- 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;
let currentStream = null;
let barcodeDetector = null;
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:
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';
switch (newState) {
case AppState.IDLE:
textContent = "Tap to Start Camera";
break;
case AppState.READY_TO_SCAN:
textContent = "Tap to Scan Barcode";
break;
case AppState.SCANNING:
textContent = `<div class="spinner"></div>`;
break;
case AppState.PRODUCT_FOUND:
textContent = "Tap to Take Picture";
break;
case AppState.UPLOADING:
textContent = "Uploading...";
break;
}
overlayText.innerHTML = customText || textContent;
}
async function startCamera() {
if (currentStream) return;
const constraints = { video: { deviceId: cameraSelector.value ? { exact: cameraSelector.value } : undefined, facingMode: 'environment' } };
try {
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
video.srcObject = currentStream;
await video.play();
updateUIForState(AppState.READY_TO_SCAN);
} catch (err) {
console.error('Error accessing camera:', err);
stopCamera(); // Ensure everything is reset
updateUIForState(AppState.IDLE, 'Camera Error. Tap to retry.');
}
}
function stopCamera() {
if (currentStream) {
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 {
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);
}
}
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);
}
function resetForNextProduct() {
currentProductId = null;
productInfoSection.style.display = 'none';
existingPhotosSection.style.display = 'none';
existingPhotosContainer.innerHTML = '';
updateUIForState(AppState.READY_TO_SCAN);
}
// --- 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);
}
}
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 })
.then(res => res.json())
.then(result => {
if (result.success) {
showMessage(result.message, false);
button.closest('.photo-thumb').remove();
} else {
showMessage(result.message, true);
}
}).catch(err => showMessage('Network error deleting photo.', true));
}
}
}
function displayProductInfo(product) {
productNameEl.textContent = `[ID: ${product.id_product}] ${product.name}`;
let pricesHtml = `Wholesale: ${product.wholesale_price} | Sale: ${product.retail_price}`;
if (product.discounted_price) {
pricesHtml += ` | <strong class="text-danger">Discounted: ${product.discounted_price}</strong>`;
}
productPricesEl.innerHTML = pricesHtml;
renderExistingPhotos(product.existing_photos, product.id_product);
productInfoSection.style.display = 'block';
}
function renderExistingPhotos(photos, productId) {
existingPhotosContainer.innerHTML = '';
if (photos && photos.length > 0) {
existingPhotosSection.style.display = 'block';
photos.forEach(photo => appendNewPhoto(photo, productId));
} else {
existingPhotosSection.style.display = 'none';
}
}
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>
`;
existingPhotosContainer.prepend(thumbDiv);
existingPhotosSection.style.display = 'block';
}
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
}
});

View File

@@ -0,0 +1,88 @@
{**
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'}';
</script>
<div class="panel">
<div class="panel-heading">
<i class="icon-camera"></i> {l s='Live Photo Uploader' d='Modules.Addlivephoto.Admin'}
</div>
<div class="container-fluid">
<div class="row">
<div class="col-lg-8 col-lg-offset-2">
{* --- 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 *}
<canvas id="alp-canvas" style="display: none;"></canvas>
</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">
<div class="form-group">
<label for="alp-camera-selector" class="control-label">{l s='Select Camera:' d='Modules.Addlivephoto.Admin'}</label>
<select id="alp-camera-selector" class="form-control"></select>
</div>
</div>
</div>
{* --- Manual Input Section (remains as a fallback) --- *}
<div id="alp-manual-input" class="card mt-3">
<div class="card-header">{l s='Or Enter Manually' d='Modules.Addlivephoto.Admin'}</div>
<div class="card-body">
<form id="alp-manual-form" class="form-inline">
<div class="form-group">
<label for="alp-manual-identifier" class="mr-2">{l s='Product ID or EAN13 Barcode:' d='Modules.Addlivephoto.Admin'}</label>
<input type="text" id="alp-manual-identifier" class="form-control mr-2" placeholder="e.g., 4006381333931">
</div>
<button type="submit" class="btn btn-default"><i class="icon-search"></i> {l s='Find Product' d='Modules.Addlivephoto.Admin'}</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,228 @@
{*
* 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 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">
</a>
{/foreach}
</div>
</div>
{* --- MODAL --- *}
<div class="modal fade" id="livePhotoModal" tabindex="-1" aria-labelledby="livePhotoModalLabel" 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>
</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>
<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>
</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
const photos = {$live_photos|json_encode nofilter};
let currentIndex = 0;
const modalElement = document.getElementById('livePhotoModal');
if (!modalElement) return;
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';
};
// Add click listeners to each thumbnail
thumbnailLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
currentIndex = parseInt(e.currentTarget.dataset.photoIndex, 10);
updateModalContent(currentIndex);
});
});
// Add click listeners for modal navigation
prevBtn.addEventListener('click', () => {
if (currentIndex > 0) {
currentIndex--;
updateModalContent(currentIndex);
}
});
nextBtn.addEventListener('click', () => {
if (currentIndex < photos.length - 1) {
currentIndex++;
updateModalContent(currentIndex);
}
});
// Add keyboard navigation for accessibility
modalElement.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
prevBtn.click();
} else if (e.key === 'ArrowRight') {
nextBtn.click();
}
});
});
</script>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', () => {
// Check if the gtag function is available to avoid errors
if (typeof gtag !== 'function') {
console.log('addLivePhoto GA4: gtag function not found.');
return;
}
// --- 1. Event for viewing the thumbnails ---
// This event is sent once the thumbnails are rendered on the page.
try {
gtag('event', 'view_live_photo_thumbnail', {
'event_category': 'product_page_engagement',
'event_label': '{$product.name|escape:'javascript':'UTF-8'}',
'product_id': '{$product.id|escape:'javascript':'UTF-8'}',
'photo_count': {$live_photos|count}
});
console.log('addLivePhoto GA4: Fired event "view_live_photo_thumbnail" for product ID {$product.id}.');
} catch (e) {
console.error('addLivePhoto GA4: Error firing view event.', e);
}
// --- 2. Event for clicking a thumbnail ---
const thumbnailLinks = document.querySelectorAll('#addlivephoto-container .live-photo-thumb');
thumbnailLinks.forEach(link => {
link.addEventListener('click', () => {
try {
gtag('event', 'click_live_photo', {
'event_category': 'product_page_engagement',
'event_label': '{$product.name|escape:'javascript':'UTF-8'}',
'product_id': '{$product.id|escape:'javascript':'UTF-8'}',
'photo_count': {$live_photos|count}
});
console.log('addLivePhoto GA4: Fired event "click_live_photo" for product ID {$product.id}.');
} catch (e) {
console.error('addLivePhoto GA4: Error firing click event.', e);
}
});
});
});
</script>
{/if}