From ff2dcdc0ee06d19062a7d2ced29920e89d970131 Mon Sep 17 00:00:00 2001 From: O K Date: Mon, 24 Nov 2025 17:26:43 +0200 Subject: [PATCH] first commit --- .gitignore | 1 + addlivephoto.php | 324 ++++++++++++++++++ config_uk.xml | 11 + .../admin/AdminAddLivePhotoController.php | 236 +++++++++++++ index.php | 35 ++ views/css/admin.css | 102 ++++++ views/js/admin.js | 309 +++++++++++++++++ views/templates/admin/uploader.tpl | 88 +++++ .../hook/displayProductPriceBlock.tpl | 228 ++++++++++++ 9 files changed, 1334 insertions(+) create mode 100644 .gitignore create mode 100644 addlivephoto.php create mode 100644 config_uk.xml create mode 100644 controllers/admin/AdminAddLivePhotoController.php create mode 100644 index.php create mode 100644 views/css/admin.css create mode 100644 views/js/admin.js create mode 100644 views/templates/admin/uploader.tpl create mode 100644 views/templates/hook/displayProductPriceBlock.tpl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b537fe --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/photo \ No newline at end of file diff --git a/addlivephoto.php b/addlivephoto.php new file mode 100644 index 0000000..a65e7e0 --- /dev/null +++ b/addlivephoto.php @@ -0,0 +1,324 @@ +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; + } +} \ No newline at end of file diff --git a/config_uk.xml b/config_uk.xml new file mode 100644 index 0000000..d4345a0 --- /dev/null +++ b/config_uk.xml @@ -0,0 +1,11 @@ + + + addlivephoto + + + + + + 0 + 0 + \ No newline at end of file diff --git a/controllers/admin/AdminAddLivePhotoController.php b/controllers/admin/AdminAddLivePhotoController.php new file mode 100644 index 0000000..6de9ff7 --- /dev/null +++ b/controllers/admin/AdminAddLivePhotoController.php @@ -0,0 +1,236 @@ + + * @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]); + } +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..0dab2ff --- /dev/null +++ b/index.php @@ -0,0 +1,35 @@ + +* @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; \ No newline at end of file diff --git a/views/css/admin.css b/views/css/admin.css new file mode 100644 index 0000000..4d8e8c6 --- /dev/null +++ b/views/css/admin.css @@ -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; +} \ No newline at end of file diff --git a/views/js/admin.js b/views/js/admin.js new file mode 100644 index 0000000..e62a416 --- /dev/null +++ b/views/js/admin.js @@ -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 = `
`; + 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 += ` | Discounted: ${product.discounted_price}`; + } + 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 = ` + + Live photo + + + `; + 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 + } +}); \ No newline at end of file diff --git a/views/templates/admin/uploader.tpl b/views/templates/admin/uploader.tpl new file mode 100644 index 0000000..fcecd76 --- /dev/null +++ b/views/templates/admin/uploader.tpl @@ -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. +**} + + +
+
+ {l s='Live Photo Uploader' d='Modules.Addlivephoto.Admin'} +
+ +
+
+
+ + {* --- The New Unified Camera Interface --- *} +
+
+ {* The video feed will be attached here by JavaScript *} + + + {* This overlay displays instructions and is the main tap target *} +
+
+
+ + {* This canvas is used for capturing the frame but is not visible *} + +
+
+ + {* --- Message Area (for non-critical feedback) --- *} + + + {* --- Product Information (hidden by default) --- *} + + + {* --- Existing Photos (hidden by default) --- *} + + + {* --- Settings Section (at the bottom, out of the way) --- *} +
+
{l s='Camera Settings' d='Modules.Addlivephoto.Admin'}
+
+
+ + +
+
+
+ + {* --- Manual Input Section (remains as a fallback) --- *} +
+
{l s='Or Enter Manually' d='Modules.Addlivephoto.Admin'}
+
+
+
+ + +
+ +
+
+
+ +
+
+
+
\ No newline at end of file diff --git a/views/templates/hook/displayProductPriceBlock.tpl b/views/templates/hook/displayProductPriceBlock.tpl new file mode 100644 index 0000000..15b8664 --- /dev/null +++ b/views/templates/hook/displayProductPriceBlock.tpl @@ -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 +* @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)} +
+
{l s='Freshness Guaranteed: See Today\'s Stock' d='Modules.Addlivephoto.Shop'}
+ +
+ {foreach from=$live_photos item=photo name=livephotoloop} + + {$photo.alt|escape:'htmlall':'UTF-8'} + + {/foreach} +
+ +
+ + {* --- MODAL --- *} + + + {* --- STYLES AND SCRIPTS --- *} + + + + +{/if} \ No newline at end of file