In test environments without HTTPS, the camera may still work depending on browser settings. Now shows warning instead of blocking completely. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
641 lines
19 KiB
JavaScript
641 lines
19 KiB
JavaScript
/**
|
|
* HandyBarcodeScanner - Mobile Barcode Scanner for Dolibarr
|
|
* Integrated version using SCANNER_CONFIG
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Use SCANNER_CONFIG from page (Dolibarr integrated)
|
|
// Exit early if not on scanner page
|
|
if (typeof window.SCANNER_CONFIG === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const CONFIG = window.SCANNER_CONFIG;
|
|
|
|
// Validate required config
|
|
if (!CONFIG.ajaxUrl || !CONFIG.token) {
|
|
console.error('HandyBarcodeScanner: Invalid configuration');
|
|
return;
|
|
}
|
|
|
|
// State
|
|
let currentMode = CONFIG.mode || 'order';
|
|
let isScanning = false;
|
|
let lastScannedCode = null;
|
|
let currentProduct = null;
|
|
let selectedSupplier = null;
|
|
let allSuppliers = [];
|
|
let quaggaInitialized = false;
|
|
|
|
// DOM Elements (will be set on init)
|
|
let elements = {};
|
|
|
|
// Initialize
|
|
function init() {
|
|
// Get DOM elements
|
|
elements = {
|
|
startBtn: document.getElementById('start-scan-btn'),
|
|
stopBtn: document.getElementById('stop-scan-btn'),
|
|
videoContainer: document.getElementById('scanner-video-container'),
|
|
video: document.getElementById('scanner-video'),
|
|
lastScanCode: document.getElementById('last-scan-code'),
|
|
resultArea: document.getElementById('result-area')
|
|
};
|
|
|
|
if (!elements.startBtn || !elements.videoContainer) {
|
|
// Not on scanner page - exit silently
|
|
return;
|
|
}
|
|
|
|
// Check for camera support
|
|
checkCameraSupport();
|
|
|
|
bindEvents();
|
|
loadAllSuppliers();
|
|
}
|
|
|
|
// Check camera support and show appropriate messages
|
|
function checkCameraSupport() {
|
|
// Check HTTPS (required for camera access except on localhost)
|
|
// Note: In test environments without HTTPS, camera may not work but we still allow trying
|
|
const isSecure = location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
|
|
if (!isSecure) {
|
|
// Show warning but don't disable - some browsers/configs may still work
|
|
console.warn('HandyBarcodeScanner: HTTPS empfohlen für Kamerazugriff');
|
|
}
|
|
|
|
// Check if getUserMedia is supported
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
showCameraError('Kamera wird von diesem Browser nicht unterstützt');
|
|
elements.startBtn.disabled = true;
|
|
elements.startBtn.classList.add('butActionRefused');
|
|
elements.startBtn.classList.remove('butAction');
|
|
return;
|
|
}
|
|
|
|
// Check if Quagga is loaded
|
|
if (typeof Quagga === 'undefined') {
|
|
showCameraError('Scanner-Bibliothek nicht geladen');
|
|
elements.startBtn.disabled = true;
|
|
elements.startBtn.classList.add('butActionRefused');
|
|
elements.startBtn.classList.remove('butAction');
|
|
return;
|
|
}
|
|
|
|
// All checks passed - enable button
|
|
elements.startBtn.disabled = false;
|
|
}
|
|
|
|
function showCameraError(message) {
|
|
const errorDiv = document.createElement('div');
|
|
errorDiv.className = 'error-message';
|
|
errorDiv.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; text-align: center; padding: 20px;';
|
|
errorDiv.innerHTML = '<div style="font-size: 40px; margin-bottom: 10px;">⚠️</div><div>' + message + '</div>';
|
|
elements.videoContainer.appendChild(errorDiv);
|
|
}
|
|
|
|
// Event Bindings
|
|
function bindEvents() {
|
|
elements.startBtn.addEventListener('click', startScanner);
|
|
elements.stopBtn.addEventListener('click', stopScanner);
|
|
}
|
|
|
|
// Load all suppliers for manual selection
|
|
function loadAllSuppliers() {
|
|
fetch(CONFIG.ajaxUrl + 'getsuppliers.php?token=' + CONFIG.token)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
allSuppliers = data.suppliers;
|
|
}
|
|
})
|
|
.catch(err => console.error('Error loading suppliers:', err));
|
|
}
|
|
|
|
// Scanner Functions
|
|
function startScanner() {
|
|
if (isScanning) return;
|
|
if (elements.startBtn.disabled) return;
|
|
|
|
// Check if Quagga is available
|
|
if (typeof Quagga === 'undefined') {
|
|
showToast(CONFIG.lang.error + ': Scanner library not loaded', 'error');
|
|
return;
|
|
}
|
|
|
|
// Disable button while initializing
|
|
elements.startBtn.disabled = true;
|
|
elements.startBtn.textContent = 'Initialisiere...';
|
|
|
|
Quagga.init({
|
|
inputStream: {
|
|
name: "Live",
|
|
type: "LiveStream",
|
|
target: elements.videoContainer,
|
|
constraints: {
|
|
facingMode: "environment",
|
|
width: { min: 640, ideal: 1280, max: 1920 },
|
|
height: { min: 480, ideal: 720, max: 1080 }
|
|
}
|
|
},
|
|
decoder: {
|
|
readers: [
|
|
"ean_reader",
|
|
"ean_8_reader",
|
|
"code_128_reader",
|
|
"code_39_reader"
|
|
]
|
|
},
|
|
locate: true,
|
|
frequency: 10
|
|
}, function(err) {
|
|
if (err) {
|
|
console.error('Quagga init error:', err);
|
|
elements.startBtn.disabled = false;
|
|
elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten';
|
|
|
|
// Show specific error message
|
|
let errorMsg = CONFIG.lang.cameraError || 'Kamerafehler';
|
|
if (err.name === 'NotAllowedError') {
|
|
errorMsg = 'Kamerazugriff verweigert. Bitte Berechtigung erteilen.';
|
|
} else if (err.name === 'NotFoundError') {
|
|
errorMsg = 'Keine Kamera gefunden.';
|
|
} else if (err.name === 'NotReadableError') {
|
|
errorMsg = 'Kamera wird bereits verwendet.';
|
|
}
|
|
showToast(errorMsg, 'error');
|
|
return;
|
|
}
|
|
|
|
// Success - start scanning
|
|
Quagga.start();
|
|
quaggaInitialized = true;
|
|
isScanning = true;
|
|
elements.startBtn.classList.add('hidden');
|
|
elements.stopBtn.classList.remove('hidden');
|
|
elements.videoContainer.classList.add('scanning');
|
|
|
|
// Register detection handler
|
|
Quagga.onDetected(onBarcodeDetected);
|
|
});
|
|
}
|
|
|
|
function stopScanner() {
|
|
if (!isScanning) return;
|
|
|
|
if (quaggaInitialized) {
|
|
Quagga.offDetected(onBarcodeDetected);
|
|
Quagga.stop();
|
|
}
|
|
|
|
isScanning = false;
|
|
quaggaInitialized = false;
|
|
elements.startBtn.classList.remove('hidden');
|
|
elements.startBtn.disabled = false;
|
|
elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten';
|
|
elements.stopBtn.classList.add('hidden');
|
|
elements.videoContainer.classList.remove('scanning');
|
|
}
|
|
|
|
function onBarcodeDetected(result) {
|
|
const code = result.codeResult.code;
|
|
|
|
// Prevent duplicate scans
|
|
if (code === lastScannedCode) return;
|
|
lastScannedCode = code;
|
|
|
|
// Vibration feedback
|
|
if (CONFIG.enableVibration && navigator.vibrate) {
|
|
navigator.vibrate(100);
|
|
}
|
|
|
|
// Sound feedback
|
|
if (CONFIG.enableSound) {
|
|
playBeep();
|
|
}
|
|
|
|
elements.lastScanCode.textContent = code;
|
|
|
|
// Search product
|
|
searchProduct(code);
|
|
|
|
// Reset duplicate prevention after 2 seconds
|
|
setTimeout(() => {
|
|
lastScannedCode = null;
|
|
}, 2000);
|
|
}
|
|
|
|
// Play beep sound
|
|
function playBeep() {
|
|
try {
|
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const oscillator = audioCtx.createOscillator();
|
|
const gainNode = audioCtx.createGain();
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioCtx.destination);
|
|
oscillator.frequency.value = 1000;
|
|
oscillator.type = 'sine';
|
|
gainNode.gain.value = 0.3;
|
|
oscillator.start();
|
|
setTimeout(() => oscillator.stop(), 100);
|
|
} catch (e) {
|
|
console.log('Audio not available');
|
|
}
|
|
}
|
|
|
|
// Product Search
|
|
function searchProduct(barcode) {
|
|
showLoading();
|
|
|
|
fetch(CONFIG.ajaxUrl + 'findproduct.php?token=' + CONFIG.token + '&barcode=' + encodeURIComponent(barcode))
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
hideLoading();
|
|
if (data.success && data.product) {
|
|
currentProduct = data.product;
|
|
showProductResult(data.product);
|
|
} else {
|
|
showNotFound(barcode);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
hideLoading();
|
|
console.error('Search error:', err);
|
|
showToast(CONFIG.lang.error, 'error');
|
|
});
|
|
}
|
|
|
|
// Display Results based on Mode
|
|
function showProductResult(product) {
|
|
switch (currentMode) {
|
|
case 'order':
|
|
showOrderMode(product);
|
|
break;
|
|
case 'shop':
|
|
showShopMode(product);
|
|
break;
|
|
case 'inventory':
|
|
showInventoryMode(product);
|
|
break;
|
|
}
|
|
elements.resultArea.classList.remove('hidden');
|
|
}
|
|
|
|
// ORDER MODE
|
|
function showOrderMode(product) {
|
|
const suppliers = product.suppliers || [];
|
|
const cheapest = suppliers.length > 0 ? suppliers[0] : null;
|
|
|
|
let suppliersHtml = '';
|
|
|
|
if (suppliers.length > 0) {
|
|
suppliersHtml = `
|
|
<div class="supplier-list">
|
|
<span class="supplier-label">${CONFIG.lang.selectSupplier}:</span>
|
|
${suppliers.map((s, i) => `
|
|
<div class="supplier-option ${i === 0 ? 'selected' : ''}" data-supplier-id="${s.id}" data-price="${s.price}">
|
|
<div class="supplier-radio"></div>
|
|
<div class="supplier-info">
|
|
<div class="supplier-name">${escapeHtml(s.name)}${i === 0 ? '<span class="cheapest-badge">' + CONFIG.lang.cheapest + '</span>' : ''}</div>
|
|
</div>
|
|
<div class="supplier-price">${formatPrice(s.price)} €</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
selectedSupplier = cheapest;
|
|
} else {
|
|
// No supplier assigned - show all available suppliers
|
|
if (allSuppliers.length > 0) {
|
|
suppliersHtml = `
|
|
<div class="supplier-list">
|
|
<div class="no-supplier">${CONFIG.lang.noSupplier}</div>
|
|
<span class="supplier-label" style="margin-top: 15px; display: block;">${CONFIG.lang.selectSupplier}:</span>
|
|
${allSuppliers.map((s, i) => `
|
|
<div class="supplier-option ${i === 0 ? 'selected' : ''}" data-supplier-id="${s.id}" data-price="0">
|
|
<div class="supplier-radio"></div>
|
|
<div class="supplier-info">
|
|
<div class="supplier-name">${escapeHtml(s.name)}</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
selectedSupplier = allSuppliers[0];
|
|
} else {
|
|
suppliersHtml = `<div class="no-supplier">${CONFIG.lang.noSupplier}</div>`;
|
|
}
|
|
}
|
|
|
|
elements.resultArea.innerHTML = `
|
|
<div class="product-card">
|
|
<div class="product-name">${escapeHtml(product.label)}</div>
|
|
<div class="product-ref">${CONFIG.lang.ref}: ${escapeHtml(product.ref)}</div>
|
|
<div class="product-stock">${CONFIG.lang.stock}: ${product.stock} ${escapeHtml(product.stock_unit || 'Stk')}</div>
|
|
</div>
|
|
${suppliersHtml}
|
|
<div class="quantity-section">
|
|
<span class="quantity-label">${CONFIG.lang.quantity}:</span>
|
|
<div class="quantity-controls">
|
|
<button type="button" class="qty-btn" id="qty-minus">−</button>
|
|
<input type="number" class="qty-input" id="qty-input" value="1" min="1">
|
|
<button type="button" class="qty-btn" id="qty-plus">+</button>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="action-btn btn-primary" id="add-to-order">${CONFIG.lang.add}</button>
|
|
`;
|
|
|
|
bindOrderEvents();
|
|
}
|
|
|
|
function bindOrderEvents() {
|
|
// Supplier selection
|
|
document.querySelectorAll('.supplier-option').forEach(opt => {
|
|
opt.addEventListener('click', function() {
|
|
document.querySelectorAll('.supplier-option').forEach(o => o.classList.remove('selected'));
|
|
this.classList.add('selected');
|
|
selectedSupplier = {
|
|
id: this.dataset.supplierId,
|
|
price: parseFloat(this.dataset.price) || 0
|
|
};
|
|
});
|
|
});
|
|
|
|
// Quantity controls
|
|
const qtyInput = document.getElementById('qty-input');
|
|
document.getElementById('qty-minus').addEventListener('click', () => {
|
|
const val = parseInt(qtyInput.value) || 1;
|
|
if (val > 1) qtyInput.value = val - 1;
|
|
});
|
|
document.getElementById('qty-plus').addEventListener('click', () => {
|
|
const val = parseInt(qtyInput.value) || 1;
|
|
qtyInput.value = val + 1;
|
|
});
|
|
|
|
// Add to order
|
|
document.getElementById('add-to-order').addEventListener('click', addToOrder);
|
|
}
|
|
|
|
function addToOrder() {
|
|
if (!currentProduct || !selectedSupplier) {
|
|
showToast(CONFIG.lang.error, 'error');
|
|
return;
|
|
}
|
|
|
|
const qty = parseInt(document.getElementById('qty-input').value) || 1;
|
|
|
|
showLoading();
|
|
|
|
fetch(CONFIG.ajaxUrl + 'addtoorder.php', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: `token=${CONFIG.token}&product_id=${currentProduct.id}&supplier_id=${selectedSupplier.id}&qty=${qty}&price=${selectedSupplier.price || 0}`
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
hideLoading();
|
|
if (data.success) {
|
|
showToast(`${CONFIG.lang.added}: ${currentProduct.label} (${qty}x)`, 'success');
|
|
hideResult();
|
|
} else {
|
|
showToast(data.error || CONFIG.lang.error, 'error');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
hideLoading();
|
|
console.error('Add to order error:', err);
|
|
showToast(CONFIG.lang.error, 'error');
|
|
});
|
|
}
|
|
|
|
// SHOP MODE
|
|
function showShopMode(product) {
|
|
const suppliers = product.suppliers || [];
|
|
|
|
let linksHtml = '';
|
|
|
|
if (suppliers.length > 0) {
|
|
linksHtml = suppliers.map(s => {
|
|
if (s.url) {
|
|
return `
|
|
<a href="${escapeHtml(s.url)}" target="_blank" rel="noopener" class="shop-link-btn">
|
|
<span class="shop-link-name">${escapeHtml(s.name)}</span>
|
|
<span class="shop-link-arrow">→</span>
|
|
</a>
|
|
`;
|
|
}
|
|
return `
|
|
<div class="shop-link-btn" style="opacity: 0.5; cursor: not-allowed;">
|
|
<span class="shop-link-name">${escapeHtml(s.name)}</span>
|
|
<span class="shop-link-arrow">—</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} else {
|
|
linksHtml = `<div class="no-supplier">${CONFIG.lang.noSupplier}</div>`;
|
|
}
|
|
|
|
elements.resultArea.innerHTML = `
|
|
<div class="product-card">
|
|
<div class="product-name">${escapeHtml(product.label)}</div>
|
|
<div class="product-ref">${CONFIG.lang.ref}: ${escapeHtml(product.ref)}</div>
|
|
<div class="product-stock">${CONFIG.lang.stock}: ${product.stock} ${escapeHtml(product.stock_unit || 'Stk')}</div>
|
|
</div>
|
|
<div class="shop-links">
|
|
<span class="supplier-label">${CONFIG.lang.openShop}:</span>
|
|
${linksHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// INVENTORY MODE
|
|
function showInventoryMode(product) {
|
|
elements.resultArea.innerHTML = `
|
|
<div class="product-card">
|
|
<div class="product-name">${escapeHtml(product.label)}</div>
|
|
<div class="product-ref">${CONFIG.lang.ref}: ${escapeHtml(product.ref)}</div>
|
|
</div>
|
|
<div class="stock-display">
|
|
<div class="stock-item">
|
|
<div class="stock-value" id="current-stock">${product.stock}</div>
|
|
<div class="stock-label">${CONFIG.lang.currentStock}</div>
|
|
</div>
|
|
<div class="stock-arrow">→</div>
|
|
<div class="stock-item">
|
|
<div class="stock-value" id="new-stock-preview">${product.stock}</div>
|
|
<div class="stock-label">${CONFIG.lang.newStock}</div>
|
|
</div>
|
|
</div>
|
|
<div class="quantity-section">
|
|
<span class="quantity-label">${CONFIG.lang.newStock}:</span>
|
|
<div class="quantity-controls">
|
|
<button type="button" class="qty-btn" id="stock-minus">−</button>
|
|
<input type="number" class="qty-input" id="stock-input" value="${product.stock}" min="0">
|
|
<button type="button" class="qty-btn" id="stock-plus">+</button>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="action-btn btn-primary" id="save-stock">${CONFIG.lang.save}</button>
|
|
`;
|
|
|
|
bindInventoryEvents(product.stock);
|
|
}
|
|
|
|
function bindInventoryEvents(originalStock) {
|
|
const stockInput = document.getElementById('stock-input');
|
|
const preview = document.getElementById('new-stock-preview');
|
|
|
|
function updatePreview() {
|
|
preview.textContent = stockInput.value;
|
|
}
|
|
|
|
stockInput.addEventListener('input', updatePreview);
|
|
|
|
document.getElementById('stock-minus').addEventListener('click', () => {
|
|
const val = parseInt(stockInput.value) || 0;
|
|
if (val > 0) {
|
|
stockInput.value = val - 1;
|
|
updatePreview();
|
|
}
|
|
});
|
|
|
|
document.getElementById('stock-plus').addEventListener('click', () => {
|
|
const val = parseInt(stockInput.value) || 0;
|
|
stockInput.value = val + 1;
|
|
updatePreview();
|
|
});
|
|
|
|
document.getElementById('save-stock').addEventListener('click', () => {
|
|
const newStock = parseInt(stockInput.value) || 0;
|
|
if (newStock !== originalStock) {
|
|
showConfirmDialog(originalStock, newStock);
|
|
} else {
|
|
showToast(CONFIG.lang.saved, 'success');
|
|
hideResult();
|
|
}
|
|
});
|
|
}
|
|
|
|
function showConfirmDialog(oldStock, newStock) {
|
|
const dialog = document.createElement('div');
|
|
dialog.className = 'confirm-dialog';
|
|
dialog.innerHTML = `
|
|
<div class="confirm-content">
|
|
<div class="confirm-title">${CONFIG.lang.confirmStockChange}</div>
|
|
<div class="confirm-message">${oldStock} → ${newStock}</div>
|
|
<div class="confirm-buttons">
|
|
<button type="button" class="action-btn btn-secondary" id="confirm-cancel">${CONFIG.lang.cancel}</button>
|
|
<button type="button" class="action-btn btn-primary" id="confirm-ok">${CONFIG.lang.confirm}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(dialog);
|
|
|
|
document.getElementById('confirm-cancel').addEventListener('click', () => {
|
|
dialog.remove();
|
|
});
|
|
|
|
document.getElementById('confirm-ok').addEventListener('click', () => {
|
|
dialog.remove();
|
|
saveStock(newStock);
|
|
});
|
|
}
|
|
|
|
function saveStock(newStock) {
|
|
if (!currentProduct) return;
|
|
|
|
showLoading();
|
|
|
|
fetch(CONFIG.ajaxUrl + 'updatestock.php', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: `token=${CONFIG.token}&product_id=${currentProduct.id}&stock=${newStock}`
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
hideLoading();
|
|
if (data.success) {
|
|
showToast(CONFIG.lang.saved, 'success');
|
|
hideResult();
|
|
} else {
|
|
showToast(data.error || CONFIG.lang.error, 'error');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
hideLoading();
|
|
console.error('Save stock error:', err);
|
|
showToast(CONFIG.lang.error, 'error');
|
|
});
|
|
}
|
|
|
|
// Not Found
|
|
function showNotFound(barcode) {
|
|
elements.resultArea.innerHTML = `
|
|
<div class="error-message">
|
|
<div class="error-icon">❌</div>
|
|
<div class="error-text">${CONFIG.lang.productNotFound}</div>
|
|
<div class="product-ref" style="margin-top: 10px;">${escapeHtml(barcode)}</div>
|
|
</div>
|
|
`;
|
|
elements.resultArea.classList.remove('hidden');
|
|
}
|
|
|
|
// UI Helpers
|
|
function hideResult() {
|
|
elements.resultArea.classList.add('hidden');
|
|
elements.resultArea.innerHTML = '';
|
|
currentProduct = null;
|
|
selectedSupplier = null;
|
|
}
|
|
|
|
function showLoading() {
|
|
// Use jQuery blockUI if available (Dolibarr standard)
|
|
if (typeof jQuery !== 'undefined' && jQuery.blockUI) {
|
|
jQuery.blockUI({ message: '<div class="spinner"></div>' });
|
|
}
|
|
}
|
|
|
|
function hideLoading() {
|
|
if (typeof jQuery !== 'undefined' && jQuery.unblockUI) {
|
|
jQuery.unblockUI();
|
|
}
|
|
}
|
|
|
|
function showToast(message, type = 'success') {
|
|
// Remove existing toasts
|
|
document.querySelectorAll('.scanner-toast').forEach(t => t.remove());
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = 'scanner-toast ' + type;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 3000);
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatPrice(price) {
|
|
return parseFloat(price).toFixed(2).replace('.', ',');
|
|
}
|
|
|
|
// Start when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|