first commit
This commit is contained in:
102
views/css/admin.css
Normal file
102
views/css/admin.css
Normal 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
309
views/js/admin.js
Normal 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
|
||||
}
|
||||
});
|
||||
88
views/templates/admin/uploader.tpl
Normal file
88
views/templates/admin/uploader.tpl
Normal 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>
|
||||
228
views/templates/hook/displayProductPriceBlock.tpl
Normal file
228
views/templates/hook/displayProductPriceBlock.tpl
Normal 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"><</button>
|
||||
<button id="livePhotoNextBtn" class="btn btn-light modal-nav-btn next">></button>
|
||||
</div>
|
||||
<div class="text-muted text-wrap small mb-0">
|
||||
{l s='Please Note: This is a live photo of a randomly selected package from our current stock to show its freshness. The expiry date on the product you receive will be the same or newer, but the lot number may differ.' d='Modules.Addlivephoto.Shop'}
|
||||
</div>
|
||||
</div>
|
||||
<div 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}
|
||||
Reference in New Issue
Block a user