improve photos
This commit is contained in:
@@ -1,309 +1,254 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- DOM Element References ---
|
||||
const videoContainer = document.getElementById('alp-video-container');
|
||||
// 1. Elements
|
||||
const video = document.getElementById('alp-video');
|
||||
const canvas = document.getElementById('alp-canvas');
|
||||
const overlay = document.getElementById('alp-viewfinder-overlay');
|
||||
const overlayText = document.getElementById('alp-overlay-text');
|
||||
const cameraSelector = document.getElementById('alp-camera-selector');
|
||||
const manualInputForm = document.getElementById('alp-manual-form');
|
||||
const productInfoSection = document.getElementById('alp-product-info');
|
||||
const overlayText = document.getElementById('alp-status-text');
|
||||
const stepScan = document.getElementById('alp-step-scan');
|
||||
const stepAction = document.getElementById('alp-step-action');
|
||||
|
||||
// Product Data Elements
|
||||
const productNameEl = document.getElementById('alp-product-name');
|
||||
const productPricesEl = document.getElementById('alp-product-prices');
|
||||
const existingPhotosSection = document.getElementById('alp-existing-photos');
|
||||
const existingPhotosContainer = document.getElementById('alp-photos-container');
|
||||
const messageArea = document.getElementById('alp-message-area');
|
||||
const photoListEl = document.getElementById('alp-photo-list');
|
||||
|
||||
// Buttons
|
||||
const manualForm = document.getElementById('alp-manual-form');
|
||||
const btnExpiry = document.getElementById('btn-snap-expiry');
|
||||
const btnPkg = document.getElementById('btn-snap-packaging');
|
||||
const btnReset = document.getElementById('btn-reset');
|
||||
|
||||
// --- State 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;
|
||||
// State
|
||||
let currentStream = null;
|
||||
let barcodeDetector = null;
|
||||
let isScanning = false;
|
||||
let currentProductId = null;
|
||||
const ajaxUrl = window.addLivePhotoAjaxUrl || '';
|
||||
|
||||
// --- Initialization ---
|
||||
if (!('BarcodeDetector' in window)) {
|
||||
showMessage('Barcode Detector API is not supported. Please use manual input.', true);
|
||||
} else {
|
||||
barcodeDetector = new BarcodeDetector({ formats: ['ean_13'] });
|
||||
}
|
||||
if (!navigator.mediaDevices) {
|
||||
showMessage('Camera access is not supported in this browser.', true);
|
||||
} else {
|
||||
populateCameraSelector();
|
||||
}
|
||||
// 2. Initialize
|
||||
initBarcodeDetector();
|
||||
startCamera();
|
||||
|
||||
// 3. Event Listeners
|
||||
manualForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const val = document.getElementById('alp-manual-input').value.trim();
|
||||
if(val) fetchProduct(val);
|
||||
});
|
||||
|
||||
btnReset.addEventListener('click', resetApp);
|
||||
|
||||
updateUIForState(AppState.IDLE); // Set initial UI state
|
||||
btnExpiry.addEventListener('click', () => takePhoto('expiry'));
|
||||
btnPkg.addEventListener('click', () => takePhoto('packaging'));
|
||||
|
||||
// --- Event Listeners ---
|
||||
videoContainer.addEventListener('click', handleViewfinderTap);
|
||||
cameraSelector.addEventListener('change', handleCameraChange);
|
||||
manualInputForm.addEventListener('submit', handleManualSubmit);
|
||||
existingPhotosContainer.addEventListener('click', handleDeleteClick);
|
||||
// --- Core Functions ---
|
||||
|
||||
// --- Core Logic ---
|
||||
function handleViewfinderTap() {
|
||||
switch (currentState) {
|
||||
case AppState.IDLE:
|
||||
startCamera();
|
||||
break;
|
||||
case AppState.READY_TO_SCAN:
|
||||
detectBarcode();
|
||||
break;
|
||||
case AppState.PRODUCT_FOUND:
|
||||
takePhoto();
|
||||
break;
|
||||
async function initBarcodeDetector() {
|
||||
if ('BarcodeDetector' in window) {
|
||||
// Check supported formats
|
||||
const formats = await BarcodeDetector.getSupportedFormats();
|
||||
if (formats.includes('ean_13')) {
|
||||
barcodeDetector = new BarcodeDetector({ formats: ['ean_13'] });
|
||||
console.log('BarcodeDetector ready');
|
||||
} else {
|
||||
overlayText.textContent = "EAN13 not supported by device";
|
||||
}
|
||||
} else {
|
||||
console.warn('BarcodeDetector API not supported in this browser');
|
||||
overlayText.textContent = "Auto-scan not supported. Use manual input.";
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const constraints = {
|
||||
video: {
|
||||
facingMode: 'environment', // Rear camera
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
}
|
||||
};
|
||||
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
video.srcObject = currentStream;
|
||||
await video.play();
|
||||
updateUIForState(AppState.READY_TO_SCAN);
|
||||
|
||||
// Wait for video to be ready
|
||||
video.onloadedmetadata = () => {
|
||||
video.play();
|
||||
if(barcodeDetector) {
|
||||
isScanning = true;
|
||||
overlayText.textContent = "Scan Barcode...";
|
||||
scanLoop();
|
||||
} else {
|
||||
overlayText.textContent = "Camera Ready (Manual Mode)";
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error accessing camera:', err);
|
||||
stopCamera(); // Ensure everything is reset
|
||||
updateUIForState(AppState.IDLE, 'Camera Error. Tap to retry.');
|
||||
console.error(err);
|
||||
overlayText.textContent = "Camera Access Denied or Error";
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (currentStream) {
|
||||
currentStream.getTracks().forEach(track => track.stop());
|
||||
currentStream = null;
|
||||
}
|
||||
video.srcObject = null;
|
||||
updateUIForState(AppState.IDLE);
|
||||
}
|
||||
async function scanLoop() {
|
||||
if (!isScanning || !barcodeDetector || currentProductId) return;
|
||||
|
||||
async function detectBarcode() {
|
||||
if (!barcodeDetector || currentState !== AppState.READY_TO_SCAN) return;
|
||||
updateUIForState(AppState.SCANNING);
|
||||
try {
|
||||
const barcodes = await barcodeDetector.detect(video);
|
||||
if (barcodes.length > 0) {
|
||||
searchProduct(barcodes[0].rawValue);
|
||||
} else {
|
||||
showMessage('No barcode found. Please try again.', true);
|
||||
updateUIForState(AppState.READY_TO_SCAN);
|
||||
const code = barcodes[0].rawValue;
|
||||
isScanning = false; // Stop scanning
|
||||
fetchProduct(code);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Barcode detection error:', err);
|
||||
showMessage('Error during barcode detection.', true);
|
||||
updateUIForState(AppState.READY_TO_SCAN);
|
||||
} catch (e) {
|
||||
// Detection error (common in loop)
|
||||
}
|
||||
}
|
||||
|
||||
function takePhoto() {
|
||||
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);
|
||||
// Scan every 200ms to save battery
|
||||
setTimeout(() => requestAnimationFrame(scanLoop), 200);
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
}
|
||||
function fetchProduct(identifier) {
|
||||
overlayText.textContent = "Searching...";
|
||||
isScanning = false;
|
||||
|
||||
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) */ }
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'searchProduct');
|
||||
fd.append('identifier', identifier);
|
||||
|
||||
// --- 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);
|
||||
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadProductView(data.data);
|
||||
} else {
|
||||
alert(data.message || 'Product not found');
|
||||
resetApp(); // Go back to scanning
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
alert('Network Error');
|
||||
resetApp();
|
||||
});
|
||||
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 loadProductView(productData) {
|
||||
currentProductId = productData.id_product;
|
||||
productNameEl.textContent = `[${productData.reference}] ${productData.name}`;
|
||||
|
||||
renderPhotos(productData.existing_photos);
|
||||
|
||||
// Switch View
|
||||
stepScan.style.display = 'none';
|
||||
stepAction.style.display = 'block';
|
||||
}
|
||||
|
||||
function 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 takePhoto(type) {
|
||||
if (!currentProductId) return;
|
||||
|
||||
// Capture frame
|
||||
const w = video.videoWidth;
|
||||
const h = video.videoHeight;
|
||||
|
||||
// Crop to square (center)
|
||||
const size = Math.min(w, h);
|
||||
const x = (w - size) / 2;
|
||||
const y = (h - size) / 2;
|
||||
|
||||
canvas.width = 800;
|
||||
canvas.height = 800;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, x, y, size, size, 0, 0, 800, 800);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/webp', 0.8);
|
||||
|
||||
// Upload
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'uploadImage');
|
||||
fd.append('id_product', currentProductId);
|
||||
fd.append('image_type', type);
|
||||
fd.append('imageData', dataUrl);
|
||||
|
||||
// Visual feedback
|
||||
const btn = (type === 'expiry') ? btnExpiry : btnPkg;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = 'Uploading...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Add new photo to list without reload
|
||||
addPhotoToDom(data.photo);
|
||||
// Flash success message
|
||||
alert(`Saved as ${type}!`);
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Upload failed'))
|
||||
.finally(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
function renderPhotos(photos) {
|
||||
photoListEl.innerHTML = '';
|
||||
if(photos && photos.length) {
|
||||
photos.forEach(addPhotoToDom);
|
||||
} else {
|
||||
existingPhotosSection.style.display = 'none';
|
||||
photoListEl.innerHTML = '<p class="text-muted">No photos yet.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function appendNewPhoto(photo, productId = currentProductId) {
|
||||
const thumbDiv = document.createElement('div');
|
||||
thumbDiv.className = 'photo-thumb';
|
||||
thumbDiv.innerHTML = `
|
||||
<a href="${photo.url}" target="_blank">
|
||||
<img src="${photo.url}" alt="Live photo" loading="lazy" />
|
||||
</a>
|
||||
<button class="btn btn-sm btn-danger delete-photo-btn" data-product-id="${productId}" data-image-name="${photo.name}">X</button>
|
||||
function addPhotoToDom(photo) {
|
||||
// Remove "No photos" msg if exists
|
||||
if (photoListEl.querySelector('p')) photoListEl.innerHTML = '';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'alp-thumb';
|
||||
|
||||
const badgeClass = (photo.type === 'expiry') ? 'badge-success' : 'badge-info';
|
||||
|
||||
div.innerHTML = `
|
||||
<img src="${photo.url}" target="_blank">
|
||||
<span class="badge ${badgeClass}">${photo.type}</span>
|
||||
<button class="btn-delete" onclick="deletePhoto(${currentProductId}, '${photo.name}', this)">×</button>
|
||||
`;
|
||||
existingPhotosContainer.prepend(thumbDiv);
|
||||
existingPhotosSection.style.display = 'block';
|
||||
photoListEl.prepend(div);
|
||||
}
|
||||
|
||||
function showMessage(text, isError = false) {
|
||||
messageArea.textContent = text;
|
||||
messageArea.className = isError ? 'alert alert-danger' : 'alert alert-info';
|
||||
messageArea.style.display = 'block';
|
||||
setTimeout(() => { messageArea.style.display = 'none'; }, 4000); // Message disappears after 4s
|
||||
function resetApp() {
|
||||
currentProductId = null;
|
||||
document.getElementById('alp-manual-input').value = '';
|
||||
stepAction.style.display = 'none';
|
||||
stepScan.style.display = 'block';
|
||||
|
||||
if(barcodeDetector) {
|
||||
isScanning = true;
|
||||
overlayText.textContent = "Scan Barcode...";
|
||||
scanLoop();
|
||||
} else {
|
||||
overlayText.textContent = "Camera Ready (Manual Mode)";
|
||||
}
|
||||
}
|
||||
|
||||
// Expose delete function globally so onclick in HTML works
|
||||
window.deletePhoto = function(idProduct, imgName, btnEl) {
|
||||
if(!confirm('Delete this photo?')) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'deleteImage');
|
||||
fd.append('id_product', idProduct);
|
||||
fd.append('image_name', imgName);
|
||||
|
||||
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
btnEl.closest('.alp-thumb').remove();
|
||||
} else {
|
||||
alert('Error deleting');
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -1,88 +1,557 @@
|
||||
{**
|
||||
This script block passes the unique, secure AJAX URL from the PHP controller to our JavaScript.
|
||||
The 'javascript' escaper is crucial to prevent encoding issues.
|
||||
**}
|
||||
<script type="text/javascript">
|
||||
var addLivePhotoAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
|
||||
{* Pass URL to JS *}
|
||||
<script>
|
||||
var alpAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel" id="alp-app">
|
||||
<div class="panel-heading">
|
||||
<i class="icon-camera"></i> {l s='Live Photo Uploader' d='Modules.Addlivephoto.Admin'}
|
||||
<i class="icon-camera"></i> {l s='Live Photo Scanner' d='Modules.Addlivephoto.Admin'}
|
||||
</div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 col-lg-offset-2">
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
|
||||
{* --- The New Unified Camera Interface --- *}
|
||||
<div id="alp-camera-view" class="my-3">
|
||||
<div id="alp-video-container" class="video-container">
|
||||
{* The video feed will be attached here by JavaScript *}
|
||||
<video id="alp-video" autoplay playsinline muted></video>
|
||||
{* 1. CAMERA VIEW *}
|
||||
<div id="alp-step-scan" class="text-center">
|
||||
<div class="video-wrapper"
|
||||
style="position: relative; background: #000; min-height: 300px; margin-bottom: 15px; overflow: hidden;">
|
||||
<video id="alp-video" autoplay playsinline muted
|
||||
style="width: 100%; height: 100%; object-fit: cover;"></video>
|
||||
<canvas id="alp-canvas" style="display: none;"></canvas>
|
||||
|
||||
{* This overlay displays instructions and is the main tap target *}
|
||||
<div id="alp-viewfinder-overlay">
|
||||
<div id="alp-overlay-text"></div>
|
||||
</div>
|
||||
{* Camera Controls Overlay *}
|
||||
<div id="alp-controls"
|
||||
style="position: absolute; bottom: 20px; left: 0; width: 100%; display: flex; justify-content: center; gap: 20px; z-index: 10;">
|
||||
{* Flash Button (Hidden by default until capability detected) *}
|
||||
<button type="button" id="btn-torch" class="btn btn-default btn-circle" style="display:none;">
|
||||
<i class="icon-bolt"></i>
|
||||
</button>
|
||||
|
||||
{* This canvas is used for capturing the frame but is not visible *}
|
||||
<canvas id="alp-canvas" style="display: none;"></canvas>
|
||||
{* Zoom Button (Hidden by default) *}
|
||||
<button type="button" id="btn-zoom" class="btn btn-default btn-circle" style="display:none;">
|
||||
1x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{* Overlay Message *}
|
||||
<div id="alp-overlay"
|
||||
style="position: absolute; top:0; left:0; width:100%; height:100%; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5); color: #fff; pointer-events: none;">
|
||||
<span id="alp-status-text">Starting Camera...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* --- Message Area (for non-critical feedback) --- *}
|
||||
<div id="alp-message-area" class="alert" style="display: none; text-align: center;"></div>
|
||||
|
||||
{* --- Product Information (hidden by default) --- *}
|
||||
<div id="alp-product-info" style="display: none;" class="card mt-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="card-title mb-0">{l s='Product Found' d='Modules.Addlivephoto.Admin'}</h5>
|
||||
{* Manual Input Fallback *}
|
||||
<form id="alp-manual-form" class="form-inline" style="margin-top: 10px;">
|
||||
<div class="form-group">
|
||||
<input type="text" id="alp-manual-input" class="form-control" placeholder="EAN13 or Product ID">
|
||||
</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>
|
||||
|
||||
<button type="submit" class="btn btn-primary"><i class="icon-search"></i> Search</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{* 2. PRODUCT ACTIONS (Hidden initially) *}
|
||||
<div id="alp-step-action" style="display: none;">
|
||||
<div class="alert alert-info">
|
||||
<strong>Product:</strong> <span id="alp-product-name"></span>
|
||||
</div>
|
||||
|
||||
<div class="text-center" style="margin-bottom: 20px;">
|
||||
<p class="text-muted">What are you photographing?</p>
|
||||
<div class="btn-group-lg">
|
||||
<button type="button" class="btn btn-success" id="btn-snap-expiry">
|
||||
<i class="icon-calendar"></i> Expiry Date
|
||||
</button>
|
||||
<button type="button" class="btn btn-info" id="btn-snap-packaging">
|
||||
<i class="icon-box"></i> Packaging
|
||||
</button>
|
||||
</div>
|
||||
<br><br>
|
||||
<button type="button" class="btn btn-default btn-sm" id="btn-reset">
|
||||
<i class="icon-refresh"></i> Scan New Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{* Existing Photos List *}
|
||||
<div class="panel">
|
||||
<div class="panel-heading">Existing Photos</div>
|
||||
<div class="panel-body" id="alp-photo-list" style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
{* Photos injected via JS *}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<style>
|
||||
.video-wrapper {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
#alp-video {
|
||||
width: 100%;
|
||||
/* Ensure aspect ratio handles mobile screens well */
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
/* Circular Control Buttons */
|
||||
.btn-circle {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-circle:hover,
|
||||
.btn-circle:active,
|
||||
.btn-circle.active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-circle i {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.alp-thumb {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.alp-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.alp-thumb .badge {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
border-radius: 0;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.alp-thumb .btn-delete {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(255, 0, 0, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 1. Elements
|
||||
const video = document.getElementById('alp-video');
|
||||
const canvas = document.getElementById('alp-canvas');
|
||||
const overlayText = document.getElementById('alp-status-text');
|
||||
const stepScan = document.getElementById('alp-step-scan');
|
||||
const stepAction = document.getElementById('alp-step-action');
|
||||
|
||||
// Controls
|
||||
const btnTorch = document.getElementById('btn-torch');
|
||||
const btnZoom = document.getElementById('btn-zoom');
|
||||
|
||||
// Product Data Elements
|
||||
const productNameEl = document.getElementById('alp-product-name');
|
||||
const photoListEl = document.getElementById('alp-photo-list');
|
||||
|
||||
// Buttons
|
||||
const manualForm = document.getElementById('alp-manual-form');
|
||||
const btnExpiry = document.getElementById('btn-snap-expiry');
|
||||
const btnPkg = document.getElementById('btn-snap-packaging');
|
||||
const btnReset = document.getElementById('btn-reset');
|
||||
|
||||
// State
|
||||
let currentStream = null;
|
||||
let videoTrack = null; // Store track for zoom/torch
|
||||
let barcodeDetector = null;
|
||||
let isScanning = false;
|
||||
let currentProductId = null;
|
||||
|
||||
// Camera Features State
|
||||
let zoomLevel = 1;
|
||||
let torchState = false;
|
||||
let capabilities = {};
|
||||
|
||||
// 2. Initialize
|
||||
initBarcodeDetector();
|
||||
startCamera();
|
||||
|
||||
// 3. Event Listeners
|
||||
manualForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const val = document.getElementById('alp-manual-input').value.trim();
|
||||
if (val) fetchProduct(val);
|
||||
});
|
||||
|
||||
btnReset.addEventListener('click', resetApp);
|
||||
|
||||
btnExpiry.addEventListener('click', () => takePhoto('expiry'));
|
||||
btnPkg.addEventListener('click', () => takePhoto('packaging'));
|
||||
|
||||
// Zoom Toggle
|
||||
btnZoom.addEventListener('click', () => {
|
||||
if (!videoTrack) return;
|
||||
|
||||
// Toggle between 1 and 2 (or max zoom)
|
||||
zoomLevel = (zoomLevel === 1) ? 2 : 1;
|
||||
|
||||
// Check max zoom
|
||||
if (capabilities.zoom) {
|
||||
zoomLevel = Math.min(zoomLevel, capabilities.zoom.max);
|
||||
}
|
||||
|
||||
try {
|
||||
videoTrack.applyConstraints({
|
||||
advanced: [{ zoom: zoomLevel }]
|
||||
});
|
||||
btnZoom.textContent = zoomLevel + 'x';
|
||||
btnZoom.classList.toggle('active', zoomLevel > 1);
|
||||
} catch (err) {
|
||||
console.error('Zoom failed', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Torch Toggle
|
||||
btnTorch.addEventListener('click', () => {
|
||||
if (!videoTrack) return;
|
||||
|
||||
torchState = !torchState;
|
||||
|
||||
try {
|
||||
videoTrack.applyConstraints({
|
||||
advanced: [{ torch: torchState }]
|
||||
});
|
||||
btnTorch.classList.toggle('active', torchState);
|
||||
} catch (err) {
|
||||
console.error('Torch failed', err);
|
||||
// Fallback if failed
|
||||
torchState = !torchState;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Core Functions ---
|
||||
|
||||
async function initBarcodeDetector() {
|
||||
if ('BarcodeDetector' in window) {
|
||||
try {
|
||||
const formats = await BarcodeDetector.getSupportedFormats();
|
||||
if (formats.includes('ean_13')) {
|
||||
barcodeDetector = new BarcodeDetector({ formats: ['ean_13'] });
|
||||
console.log('BarcodeDetector ready');
|
||||
} else {
|
||||
overlayText.textContent = "EAN13 not supported by device hardware";
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('BarcodeDetector error', e);
|
||||
}
|
||||
} else {
|
||||
console.warn('BarcodeDetector API not supported');
|
||||
overlayText.textContent = "Auto-scan not supported. Use manual search.";
|
||||
}
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
try {
|
||||
// Try high res for better barcode scanning
|
||||
const constraints = {
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
// Zoom/Torch are usually "advanced" constraints, applied later
|
||||
}
|
||||
};
|
||||
|
||||
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
video.srcObject = currentStream;
|
||||
|
||||
// Get the video track to control Zoom/Torch
|
||||
videoTrack = currentStream.getVideoTracks()[0];
|
||||
|
||||
// Check Capabilities (Zoom/Torch)
|
||||
if (videoTrack.getCapabilities) {
|
||||
capabilities = videoTrack.getCapabilities();
|
||||
|
||||
// Enable Torch Button if supported
|
||||
if (capabilities.torch) {
|
||||
btnTorch.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Enable Zoom Button if supported
|
||||
if (capabilities.zoom) {
|
||||
btnZoom.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for video to be ready
|
||||
video.onloadedmetadata = () => {
|
||||
video.play();
|
||||
document.getElementById('alp-overlay').style.display =
|
||||
'none'; // Hide "Starting..." overlay
|
||||
|
||||
if (barcodeDetector) {
|
||||
isScanning = true;
|
||||
// Add a visual scanning line or text
|
||||
const scanOverlay = document.createElement('div');
|
||||
scanOverlay.id = 'scan-line';
|
||||
scanOverlay.style.cssText =
|
||||
'position:absolute; top:50%; left:10%; right:10%; height:2px; background:red; box-shadow:0 0 4px red; opacity:0.7;';
|
||||
// Check if already exists
|
||||
if (!document.getElementById('scan-line')) {
|
||||
document.querySelector('.video-wrapper').appendChild(scanOverlay);
|
||||
}
|
||||
|
||||
scanLoop();
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
overlayText.textContent = "Camera Access Denied. Check permissions.";
|
||||
document.getElementById('alp-overlay').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLoop() {
|
||||
if (!isScanning || !barcodeDetector || currentProductId) return;
|
||||
|
||||
try {
|
||||
const barcodes = await barcodeDetector.detect(video);
|
||||
if (barcodes.length > 0) {
|
||||
const code = barcodes[0].rawValue;
|
||||
console.log("Barcode Detected:", code);
|
||||
playSound(); // Optional feedback
|
||||
|
||||
isScanning = false; // Stop scanning immediately
|
||||
fetchProduct(code);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Detection error (common while moving camera)
|
||||
}
|
||||
|
||||
// Scan loop (optimized)
|
||||
if (isScanning) {
|
||||
requestAnimationFrame(scanLoop);
|
||||
}
|
||||
}
|
||||
|
||||
function playSound() {
|
||||
// Simple beep
|
||||
const context = new(window.AudioContext || window.webkitAudioContext)();
|
||||
const oscillator = context.createOscillator();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.connect(context.destination);
|
||||
oscillator.start();
|
||||
setTimeout(() => oscillator.stop(), 100);
|
||||
}
|
||||
|
||||
function fetchProduct(identifier) {
|
||||
// Remove scan line if exists
|
||||
const line = document.getElementById('scan-line');
|
||||
if (line) line.remove();
|
||||
|
||||
const btnSubmit = manualForm.querySelector('button');
|
||||
const originalText = btnSubmit.innerHTML;
|
||||
btnSubmit.disabled = true;
|
||||
btnSubmit.innerHTML = 'Searching...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'searchProduct');
|
||||
fd.append('identifier', identifier);
|
||||
|
||||
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadProductView(data.data);
|
||||
} else {
|
||||
alert(data.message || 'Product not found');
|
||||
resetApp(); // Go back to scanning
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
alert('Network Error');
|
||||
resetApp();
|
||||
})
|
||||
.finally(() => {
|
||||
btnSubmit.disabled = false;
|
||||
btnSubmit.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
{literal}
|
||||
function loadProductView(productData) {
|
||||
currentProductId = productData.id_product;
|
||||
// Smarty ignores the curly braces and ${} inside the literal tags
|
||||
productNameEl.textContent = `[${productData.reference || ''}] ${productData.name}`;
|
||||
|
||||
renderPhotos(productData.existing_photos);
|
||||
|
||||
// Switch View
|
||||
stepScan.style.display = 'none';
|
||||
stepAction.style.display = 'block';
|
||||
}
|
||||
{/literal}
|
||||
|
||||
function takePhoto(type) {
|
||||
if (!currentProductId) return;
|
||||
|
||||
// Use canvas to capture high-res frame
|
||||
// Set canvas to video dimension for full resolution
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Convert to WebP (0.8 quality)
|
||||
const dataUrl = canvas.toDataURL('image/webp', 0.8);
|
||||
|
||||
// Upload
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'uploadImage');
|
||||
fd.append('id_product', currentProductId);
|
||||
fd.append('image_type', type);
|
||||
fd.append('imageData', dataUrl);
|
||||
|
||||
// Visual feedback
|
||||
const btn = (type === 'expiry') ? btnExpiry : btnPkg;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="icon-refresh icon-spin"></i> Saving...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
addPhotoToDom(data.photo);
|
||||
// Provide haptic feedback if available
|
||||
if (navigator.vibrate) navigator.vibrate(200);
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Upload failed'))
|
||||
.finally(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderPhotos(photos) {
|
||||
photoListEl.innerHTML = '';
|
||||
if (photos && photos.length) {
|
||||
photos.forEach(addPhotoToDom);
|
||||
} else {
|
||||
photoListEl.innerHTML = '<p class="text-muted" style="width:100%">No photos yet.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function addPhotoToDom(photo) {
|
||||
// Remove "No photos" msg if exists
|
||||
const emptyMsg = photoListEl.querySelector('p');
|
||||
if (emptyMsg) emptyMsg.remove();
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'alp-thumb';
|
||||
|
||||
const badgeClass = (photo.type === 'expiry') ? 'badge-success' : 'badge-info';
|
||||
{literal}
|
||||
div.innerHTML = `
|
||||
<a href="${photo.url}" target="_blank">
|
||||
<img src="${photo.url}">
|
||||
</a>
|
||||
<span class="badge ${badgeClass}">${photo.type}</span>
|
||||
<button class="btn-delete" onclick="deletePhoto(${currentProductId}, '${photo.name}', this)">×</button>
|
||||
`;
|
||||
{/literal}
|
||||
|
||||
photoListEl.prepend(div);
|
||||
}
|
||||
|
||||
function resetApp() {
|
||||
currentProductId = null;
|
||||
document.getElementById('alp-manual-input').value = '';
|
||||
stepAction.style.display = 'none';
|
||||
stepScan.style.display = 'block';
|
||||
|
||||
// Reset controls
|
||||
zoomLevel = 1;
|
||||
if (videoTrack) {
|
||||
try { videoTrack.applyConstraints({ advanced: [{ zoom: 1 }] }); } catch (e) {}
|
||||
btnZoom.textContent = '1x';
|
||||
btnZoom.classList.remove('active');
|
||||
}
|
||||
|
||||
if (barcodeDetector) {
|
||||
isScanning = true;
|
||||
// Re-add scan line
|
||||
const scanOverlay = document.createElement('div');
|
||||
scanOverlay.id = 'scan-line';
|
||||
scanOverlay.style.cssText =
|
||||
'position:absolute; top:50%; left:10%; right:10%; height:2px; background:red; box-shadow:0 0 4px red; opacity:0.7;';
|
||||
if (!document.getElementById('scan-line')) {
|
||||
document.querySelector('.video-wrapper').appendChild(scanOverlay);
|
||||
}
|
||||
scanLoop();
|
||||
}
|
||||
}
|
||||
|
||||
// Expose delete function globally
|
||||
window.deletePhoto = function(idProduct, imgName, btnEl) {
|
||||
if (!confirm('Delete this photo?')) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'deleteImage');
|
||||
fd.append('id_product', idProduct);
|
||||
fd.append('image_name', imgName);
|
||||
|
||||
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btnEl.closest('.alp-thumb').remove();
|
||||
} else {
|
||||
alert('Error deleting');
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -1,228 +1,114 @@
|
||||
{*
|
||||
* 2007-2023 PrestaShop
|
||||
*
|
||||
* NOTICE OF LICENSE
|
||||
*
|
||||
* This source file is subject to the Academic Free License (AFL 3.0)
|
||||
* that is bundled with this package in the file LICENSE.txt.
|
||||
* It is also available through the world-wide-web at this URL:
|
||||
* http://opensource.org/licenses/afl-3.0.php
|
||||
* If you did not receive a copy of the license and are unable to
|
||||
* obtain it through the world-wide-web, please send an email
|
||||
* to license@prestashop.com so we can send you a copy immediately.
|
||||
*
|
||||
* DISCLAIMER
|
||||
*
|
||||
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
|
||||
* versions in the future. If you wish to customize PrestaShop for your
|
||||
* needs please refer to http://www.prestashop.com for more information.
|
||||
*
|
||||
* @author Your Name <your@email.com>
|
||||
* @copyright 2007-2023 PrestaShop SA
|
||||
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
|
||||
* International Registered Trademark & Property of PrestaShop SA
|
||||
*}
|
||||
|
||||
{if isset($live_photos) && !empty($live_photos)}
|
||||
<div id="addlivephoto-container" class="mt-3">
|
||||
<h6 class="h6">{l s='Freshness Guaranteed: See Today\'s Stock' d='Modules.Addlivephoto.Shop'}</h6>
|
||||
<div id="addlivephoto-container" class="mt-3 mb-3">
|
||||
<h6 class="h6 text-uppercase text-muted mb-2" style="font-size: 0.8rem; letter-spacing: 0.05em;">
|
||||
<i class="material-icons" style="font-size: 1rem; vertical-align: text-bottom;">verified</i>
|
||||
{l s='Live Warehouse Photos' d='Modules.Addlivephoto.Shop'}
|
||||
</h6>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{foreach from=$live_photos item=photo name=livephotoloop}
|
||||
<a href="{$photo.url|escape:'htmlall':'UTF-8'}" class="live-photo-thumb" data-bs-toggle="modal"
|
||||
data-bs-target="#livePhotoModal" data-photo-index="{$smarty.foreach.livephotoloop.index}"
|
||||
title="{$photo.title|escape:'htmlall':'UTF-8'}">
|
||||
<img src="{$photo.url|escape:'htmlall':'UTF-8'}" alt="{$photo.alt|escape:'htmlall':'UTF-8'}" class="img-thumbnail"
|
||||
width="80" height="80" loading="lazy">
|
||||
</a>
|
||||
<div class="position-relative">
|
||||
<a href="{$photo.url|escape:'htmlall':'UTF-8'}" class="live-photo-thumb d-block" data-bs-toggle="modal"
|
||||
data-bs-target="#livePhotoModal" data-photo-index="{$smarty.foreach.livephotoloop.index}">
|
||||
<img src="{$photo.url|escape:'htmlall':'UTF-8'}" alt="{$photo.alt|escape:'htmlall':'UTF-8'}"
|
||||
class="img-thumbnail" width="80" height="80" loading="lazy" style="object-fit: cover;">
|
||||
</a>
|
||||
|
||||
{* BADGES *}
|
||||
{if $photo.type == 'expiry'}
|
||||
<span class="badge bg-success position-absolute bottom-0 start-0 w-100 rounded-0 rounded-bottom"
|
||||
style="font-size: 0.6rem;">
|
||||
{l s='Expiry Date' d='Modules.Addlivephoto.Shop'}
|
||||
</span>
|
||||
{else}
|
||||
<span class="badge bg-info position-absolute bottom-0 start-0 w-100 rounded-0 rounded-bottom"
|
||||
style="font-size: 0.6rem;">
|
||||
{l s='Packaging' d='Modules.Addlivephoto.Shop'}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{* SCHEMA.ORG METADATA FOR GOOGLE (Hidden but readable by bots) *}
|
||||
<div style="display:none;" itemprop="image" itemscope itemtype="https://schema.org/ImageObject">
|
||||
<meta itemprop="contentUrl" content="{$photo.url|escape:'htmlall':'UTF-8'}" />
|
||||
<meta itemprop="uploadDate" content="{$photo.date|escape:'htmlall':'UTF-8'}" />
|
||||
<meta itemprop="description" content="{$photo.alt|escape:'htmlall':'UTF-8'}" />
|
||||
</div>
|
||||
</div>
|
||||
{/foreach}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{* --- MODAL --- *}
|
||||
<div class="modal fade" id="livePhotoModal" tabindex="-1" aria-labelledby="livePhotoModalLabel" aria-hidden="true">
|
||||
{* Modal code remains mostly same, just ensuring script handles it *}
|
||||
<div class="modal fade" id="livePhotoModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="livePhotoModalLabel">{l s='Live Product Photo' d='Modules.Addlivephoto.Shop'}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
||||
aria-label="{l s='Close' d='Shop.Theme.Actions'}"></button>
|
||||
<h5 class="modal-title">{l s='Live Stock Photo' d='Modules.Addlivephoto.Shop'}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="position-relative">
|
||||
<img id="livePhotoModalImage" src="" alt="" class="img-fluid w-100">
|
||||
<button id="livePhotoPrevBtn" class="btn btn-light modal-nav-btn prev"><</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 class="modal-body text-center p-0 bg-light">
|
||||
<img id="livePhotoModalImage" src="" class="img-fluid" style="max-height: 80vh;">
|
||||
</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 class="modal-footer justify-content-between">
|
||||
<small class="text-muted" id="livePhotoModalDate"></small>
|
||||
<p id="livePhotoModalCaption" class="mb-0 fw-bold"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* --- STYLES AND SCRIPTS --- *}
|
||||
<style>
|
||||
.live-photo-thumb img {
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.live-photo-thumb:hover img {
|
||||
transform: scale(1.05);
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.modal-nav-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid #ccc;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-nav-btn.prev {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.modal-nav-btn.next {
|
||||
right: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Data passed directly from Smarty to JavaScript
|
||||
// 1. Ініціалізація даних
|
||||
const photos = {$live_photos|json_encode nofilter};
|
||||
let currentIndex = 0;
|
||||
// Отримуємо дані про товар з Smarty (PrestaShop зазвичай має змінну $product)
|
||||
const productId = '{$product.id_product|default:0}';
|
||||
const productName = '{$product.name|escape:"javascript"}';
|
||||
|
||||
// Елементи модального вікна
|
||||
const modalElement = document.getElementById('livePhotoModal');
|
||||
if (!modalElement) return;
|
||||
const modalImg = document.getElementById('livePhotoModalImage');
|
||||
const modalCap = document.getElementById('livePhotoModalCaption');
|
||||
const modalDate = document.getElementById('livePhotoModalDate');
|
||||
|
||||
const modalImage = document.getElementById('livePhotoModalImage');
|
||||
const modalCaption = document.getElementById('livePhotoModalCaption');
|
||||
const prevBtn = document.getElementById('livePhotoPrevBtn');
|
||||
const nextBtn = document.getElementById('livePhotoNextBtn');
|
||||
|
||||
// SEO meta tags
|
||||
const metaUrl = document.getElementById('livePhotoMetaUrl');
|
||||
const metaDesc = document.getElementById('livePhotoMetaDesc');
|
||||
|
||||
const thumbnailLinks = document.querySelectorAll('.live-photo-thumb');
|
||||
|
||||
// Function to update the modal's content based on the current index
|
||||
const updateModalContent = (index) => {
|
||||
if (!photos[index]) return;
|
||||
|
||||
const photo = photos[index];
|
||||
modalImage.src = photo.url;
|
||||
modalImage.alt = photo.alt;
|
||||
modalCaption.textContent = photo.alt; // Use the descriptive alt text as a caption
|
||||
|
||||
// Update hidden SEO metadata
|
||||
metaUrl.setAttribute('content', photo.url);
|
||||
metaDesc.setAttribute('content', photo.alt);
|
||||
|
||||
// Show/hide navigation buttons
|
||||
prevBtn.style.display = (index === 0) ? 'none' : 'block';
|
||||
nextBtn.style.display = (index === photos.length - 1) ? 'none' : 'block';
|
||||
};
|
||||
|
||||
// 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}
|
||||
// --- ФУНКЦІЯ TREKING (GA4) ---
|
||||
const trackClick = (photoType) => {
|
||||
if (typeof gtag === 'function') {
|
||||
gtag('event', 'select_content', {
|
||||
'content_type': 'live_photo',
|
||||
'item_id': productId,
|
||||
'item_name': productName,
|
||||
'photo_type': photoType, // 'expiry' або 'packaging'
|
||||
'event_category': 'Product Engagement',
|
||||
'event_label': 'Live Photo Click'
|
||||
});
|
||||
console.log('addLivePhoto GA4: Fired event "click_live_photo" for product ID {$product.id}.');
|
||||
} catch (e) {
|
||||
console.error('addLivePhoto GA4: Error firing click event.', e);
|
||||
console.log('GA4 Event sent: ' + photoType);
|
||||
} else {
|
||||
console.log('GA4 not loaded');
|
||||
}
|
||||
};
|
||||
|
||||
// --- ОБРОБНИКИ КЛІКІВ ---
|
||||
document.querySelectorAll('.live-photo-thumb').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const idx = e.currentTarget.dataset.photoIndex;
|
||||
|
||||
if (photos[idx]) {
|
||||
// Оновлення модалки
|
||||
modalImg.src = photos[idx].url;
|
||||
modalCap.textContent = photos[idx].alt;
|
||||
modalDate.textContent = "Дата зйомки/завантаження: " + photos[idx].date;
|
||||
|
||||
// ВІДПРАВКА ПОДІЇ
|
||||
// photos[idx].type ми додали в попередньому кроці (expiry/packaging)
|
||||
trackClick(photos[idx].type || 'unknown');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// (Опціонально) Відстеження перегляду самого блоку, якщо він видимий
|
||||
// Можна реалізувати через IntersectionObserver, але кліку зазвичай достатньо.
|
||||
});
|
||||
</script>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user