improve photos

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

View File

@@ -1,88 +1,557 @@
{**
This script block passes the unique, secure AJAX URL from the PHP controller to our JavaScript.
The 'javascript' escaper is crucial to prevent encoding issues.
**}
<script type="text/javascript">
var addLivePhotoAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
{* Pass URL to JS *}
<script>
var alpAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
</script>
<div class="panel">
<div class="panel" id="alp-app">
<div class="panel-heading">
<i class="icon-camera"></i> {l s='Live Photo Uploader' d='Modules.Addlivephoto.Admin'}
<i class="icon-camera"></i> {l s='Live Photo Scanner' d='Modules.Addlivephoto.Admin'}
</div>
<div class="container-fluid">
<div class="row">
<div class="col-lg-8 col-lg-offset-2">
<div class="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>

View File

@@ -1,228 +1,114 @@
{*
* 2007-2023 PrestaShop
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License (AFL 3.0)
* that is bundled with this package in the file LICENSE.txt.
* It is also available through the world-wide-web at this URL:
* http://opensource.org/licenses/afl-3.0.php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to http://www.prestashop.com for more information.
*
* @author Your Name <your@email.com>
* @copyright 2007-2023 PrestaShop SA
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
* International Registered Trademark & Property of PrestaShop SA
*}
{if isset($live_photos) && !empty($live_photos)}
<div id="addlivephoto-container" class="mt-3">
<h6 class="h6">{l s='Freshness Guaranteed: See Today\'s Stock' d='Modules.Addlivephoto.Shop'}</h6>
<div id="addlivephoto-container" class="mt-3 mb-3">
<h6 class="h6 text-uppercase text-muted mb-2" style="font-size: 0.8rem; letter-spacing: 0.05em;">
<i class="material-icons" style="font-size: 1rem; vertical-align: text-bottom;">verified</i>
{l s='Live Warehouse Photos' d='Modules.Addlivephoto.Shop'}
</h6>
<div class="d-flex flex-wrap gap-2">
{foreach from=$live_photos item=photo name=livephotoloop}
<a href="{$photo.url|escape:'htmlall':'UTF-8'}" class="live-photo-thumb" data-bs-toggle="modal"
data-bs-target="#livePhotoModal" data-photo-index="{$smarty.foreach.livephotoloop.index}"
title="{$photo.title|escape:'htmlall':'UTF-8'}">
<img src="{$photo.url|escape:'htmlall':'UTF-8'}" alt="{$photo.alt|escape:'htmlall':'UTF-8'}" class="img-thumbnail"
width="80" height="80" loading="lazy">
</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">&lt;</button>
<button id="livePhotoNextBtn" class="btn btn-light modal-nav-btn next">&gt;</button>
</div>
<div class="text-muted text-wrap small mb-0">
{l s='Please Note: This is a live photo of a randomly selected package from our current stock to show its freshness. The expiry date on the product you receive will be the same or newer, but the lot number may differ.' d='Modules.Addlivephoto.Shop'}
</div>
<div 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}