(function () { console.info('[stickerconfigurator] configurator-164.js v2.1.9 — fix TDZ mesure hauteur'); const roots = document.querySelectorAll('#sc-configurator'); roots.forEach((item, index) => { if (index > 0) { const shell = item.closest('.sc-page-shell'); (shell || item).remove(); } }); const fallbackConfig = { fonts: [ { name: 'Arial', value: 'Arial, Helvetica, sans-serif', category: 'sans' }, { name: 'Impact', value: 'Impact, Haettenschweiler, sans-serif', category: 'display' } ], colors: [ { name: 'Noir', value: '#111111' }, { name: 'Rouge', value: '#d92727' } ], materials: [ { name: 'Vinyle brillant', value: '1', price: 0 } ], priceRules: [ { min: 0, max: 0.1, price: 100 }, { min: 0.11, max: 0.2, price: 100 } ], logos: [], options: { enableTextMode: true, enableLogoMode: false, enableStickerType: false, enableMultiline: false, enableStencil: true, enableMirror: true, enableInside: true, minWidth: 5, maxWidth: 300, minHeight: 2, maxHeight: 120 } }; function getGlobalConfig() { return window.stickerConfiguratorConfig || null; } function needsDarkContrast(color) { const value = String(color || '').trim(); const normalized = value.toLowerCase(); const shortHex = value.match(/^#([0-9a-f]{3})$/i); const fullHex = value.match(/^#([0-9a-f]{6})$/i); const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); let red; let green; let blue; if (normalized === 'white' || normalized === 'blanc') { return true; } if (shortHex) { red = parseInt(shortHex[1][0] + shortHex[1][0], 16); green = parseInt(shortHex[1][1] + shortHex[1][1], 16); blue = parseInt(shortHex[1][2] + shortHex[1][2], 16); } else if (fullHex) { red = parseInt(fullHex[1].slice(0, 2), 16); green = parseInt(fullHex[1].slice(2, 4), 16); blue = parseInt(fullHex[1].slice(4, 6), 16); } else if (rgb) { red = Number(rgb[1]); green = Number(rgb[2]); blue = Number(rgb[3]); } else { return false; } return (red * 0.299 + green * 0.587 + blue * 0.114) >= 205; } function applyPreviewContrast(element, color) { if (element) { element.classList.toggle('sc-dark-contrast', needsDarkContrast(color)); } } function pageLooksLikeProduct() { return document.body.id === 'product' || document.body.classList.contains('page-product') || !!document.querySelector('input[name="id_product"]'); } function detectProductId() { const inputValue = Number(document.querySelector('input[name="id_product"]')?.value || 0); if (inputValue) { return inputValue; } const classMatch = document.body.className.match(/product-id-(\d+)/); if (classMatch) { return Number(classMatch[1]); } return Number(window.stickerConfiguratorProductId || 0); } function autoMountConfigurator() { if (!pageLooksLikeProduct()) { return; } const target = findConfiguratorMountTarget(); if (!target) { return; } const shell = document.createElement('div'); shell.className = 'sc-page-shell sc-page-shell-auto'; shell.innerHTML = `
Hauteur reelle : 12 cm
Votre texte
40 x 12 cm Vinyle brillant

Personnalise / Sticker

Sticker personnalise

1

Type de sticker

2

Texte

3

Police

4

Couleur et materiau

5

Taille

Longueur totale : 40 cm
7

Quantite

Configuration prete

`; const data = shell.querySelector('#sc-config-data'); data.textContent = JSON.stringify(getGlobalConfig() || fallbackConfig); insertConfiguratorShell(shell); } function findConfiguratorMountTarget() { return document.querySelector('.cc-configurator-wrapper') || document.querySelector('.cc-preview-area') || document.querySelector('.tabs') || document.querySelector('.product-container') || document.querySelector('#main') || document.querySelector('main'); } function insertConfiguratorShell(shell) { const themeWrapper = document.querySelector('.cc-configurator-wrapper'); if (themeWrapper) { themeWrapper.replaceChildren(shell); return; } const themePreview = document.querySelector('.cc-preview-area'); if (themePreview) { themePreview.replaceChildren(shell); return; } const tabs = document.querySelector('.tabs'); if (tabs && tabs.parentElement) { tabs.insertAdjacentElement('beforebegin', shell); return; } const productContainer = document.querySelector('.product-container'); if (productContainer && productContainer.parentElement) { productContainer.insertAdjacentElement('afterend', shell); return; } const main = document.querySelector('#main') || document.querySelector('main'); if (main) { main.append(shell); } } function relocateConfigurator() { if (!pageLooksLikeProduct()) { return; } const currentRoot = document.querySelector('#sc-configurator'); if (!currentRoot) { return; } const shell = currentRoot.closest('.sc-page-shell') || currentRoot; const tabs = document.querySelector('.tabs'); if (tabs && tabs.parentElement && shell.nextElementSibling !== tabs) { tabs.insertAdjacentElement('beforebegin', shell); } } function tidyProductPageAroundConfigurator() { if (!pageLooksLikeProduct()) { return; } const quantity = document.querySelector('#quantity_wanted'); const nativeQty = quantity ? quantity.closest('.qty') : document.querySelector('.product-quantity .qty'); if (nativeQty) { nativeQty.style.display = 'none'; nativeQty.setAttribute('data-sc-hidden-native-qty', '1'); } document.querySelectorAll('.product-buybox, .product-actions, #add-to-cart-or-refresh, .product-add-to-cart, .wishlist-button-add').forEach((buyElement) => { buyElement.style.display = 'none'; buyElement.setAttribute('data-sc-hidden-native-buybox', '1'); }); document.querySelectorAll('.product-customization, .js-product-customization').forEach((customization) => { customization.style.display = 'none'; customization.setAttribute('data-sc-hidden-native-customization', '1'); }); document.querySelectorAll('.product-variants, .js-product-variants').forEach((variants) => { if (!variants.textContent.trim()) { variants.style.display = 'none'; variants.setAttribute('data-sc-hidden-empty-variants', '1'); } }); document.querySelectorAll('.social-sharing').forEach((sharing) => { sharing.style.display = 'none'; sharing.setAttribute('data-sc-hidden-social-sharing', '1'); }); document.querySelectorAll('.images-container .product-cover').forEach((cover) => { cover.style.display = 'none'; cover.setAttribute('data-sc-hidden-native-cover', '1'); }); const reassurance = document.querySelector('.blockreassurance_product'); const shell = document.querySelector('.sc-page-shell'); const tabs = document.querySelector('.tabs'); if (reassurance && shell && tabs && reassurance.nextElementSibling !== tabs) { reassurance.classList.add('sc-reassurance-moved'); tabs.insertAdjacentElement('beforebegin', reassurance); } } function moveDeliveryBlockToCheckout() { const target = document.querySelector('.sc-checkout .sc-delivery-hook'); if (!target || target.dataset.scDeliveryReady === '1') { return; } const candidates = Array.from(document.body.querySelectorAll('div, section, article, p, span')).filter((element) => { if (element.closest('.sc-checkout') || element.closest('script, style')) { return false; } const text = (element.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase(); return text.includes('commandez maintenant') && text.includes('livraison') && text.length < 350; }); const source = candidates.sort((a, b) => { return (a.textContent || '').length - (b.textContent || '').length; })[0]; if (!source) { return; } source.style.display = ''; target.innerHTML = ''; target.appendChild(source); target.dataset.scDeliveryReady = '1'; } let root = document.querySelector('#sc-configurator'); if (!root) { autoMountConfigurator(); root = document.querySelector('#sc-configurator'); } relocateConfigurator(); tidyProductPageAroundConfigurator(); moveDeliveryBlockToCheckout(); root = document.querySelector('#sc-configurator'); const cartPreview = document.querySelector('[data-sc-cart-preview]'); function readConfig() { const globalConfig = getGlobalConfig(); try { const parsed = root ? JSON.parse(root.querySelector('#sc-config-data').textContent) : globalConfig; return { fonts: parsed.fonts && parsed.fonts.length ? parsed.fonts : fallbackConfig.fonts, colors: parsed.colors && parsed.colors.length ? parsed.colors : fallbackConfig.colors, materials: parsed.materials && parsed.materials.length ? parsed.materials : fallbackConfig.materials, priceRules: parsed.priceRules && parsed.priceRules.length ? parsed.priceRules : fallbackConfig.priceRules, logos: parsed.logos && parsed.logos.length ? parsed.logos : fallbackConfig.logos, options: { enableTextMode: !parsed.options || parsed.options.enableTextMode !== false, enableLogoMode: !!(parsed.options && parsed.options.enableLogoMode), enableStickerType: !!(parsed.options && parsed.options.enableStickerType), enableMultiline: !!(parsed.options && parsed.options.enableMultiline), enableStencil: !parsed.options || parsed.options.enableStencil !== false, enableMirror: !parsed.options || parsed.options.enableMirror !== false, enableInside: !parsed.options || parsed.options.enableInside !== false } }; } catch (error) { return fallbackConfig; } } function formatPrice(value) { return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value); } function renderCartPreview() { if (!cartPreview) { return; } if (cartPreview.dataset.serverConfig === '1') { return; } const raw = window.localStorage.getItem('stickerConfiguratorLastConfig'); const empty = cartPreview.querySelector('.sc-cart-preview-empty'); const content = cartPreview.querySelector('.sc-cart-preview-content'); const art = cartPreview.querySelector('.sc-cart-preview-art'); const summary = cartPreview.querySelector('.sc-cart-preview-summary'); if (!raw) { return; } try { const config = JSON.parse(raw); empty.hidden = true; content.hidden = false; art.style.color = config.color; applyPreviewContrast(art, config.color); art.innerHTML = config.mode === 'logo' && config.logoSvg ? config.logoSvg : `${config.text}`; summary.textContent = `${config.mode === 'logo' ? 'Logo SVG' : config.text} - ${config.width} x ${config.height} cm - ${config.colorName} - ${config.materialName} - ${config.quantity} ex.`; } catch (error) { window.localStorage.removeItem('stickerConfiguratorLastConfig'); } } function getProductId() { const value = Number(root.getAttribute('data-product-id')); return Number.isFinite(value) ? value : 0; } function getProductAttributeId() { const input = document.querySelector('input[name="id_product_attribute"]'); const value = Number(input ? input.value : 0); return Number.isFinite(value) ? value : 0; } async function saveConfiguration() { const productId = getProductId(); const globalSaveUrl = typeof stickerConfiguratorSaveUrl !== 'undefined' ? stickerConfiguratorSaveUrl : ''; const globalToken = typeof stickerConfiguratorToken !== 'undefined' ? stickerConfiguratorToken : ''; const saveUrl = root.dataset.saveUrl || globalSaveUrl || window.stickerConfiguratorSaveUrl || `${window.location.origin}/module/stickerconfigurator/save`; const saveToken = root.dataset.saveToken || globalToken || window.stickerConfiguratorToken; if (!productId || !saveUrl || !hiddenConfig.value) { throw new Error('La configuration ne peut pas être enregistrée.'); } const body = new URLSearchParams(); body.set('token', saveToken || (window.prestashop && prestashop.static_token ? prestashop.static_token : '')); body.set('id_product', String(productId)); body.set('id_product_attribute', String(getProductAttributeId())); body.set('configuration', hiddenConfig.value); const response = await window.fetch(saveUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, body: body.toString() }); const result = await response.json().catch(() => ({})); if (!response.ok || !result.success || !result.id_customization) { throw new Error(result.error || 'La configuration ne peut pas être enregistrée.'); } return result; } async function addProductToCart(savedConfiguration) { const productId = getProductId(); if (!productId || !window.prestashop || !prestashop.urls || !prestashop.urls.pages || !prestashop.urls.pages.cart) { return false; } const url = new URL(prestashop.urls.pages.cart, window.location.origin); url.searchParams.set('add', '1'); url.searchParams.set('action', 'update'); url.searchParams.set('ajax', '1'); url.searchParams.set('id_product', String(productId)); url.searchParams.set('id_product_attribute', String(getProductAttributeId())); url.searchParams.set('qty', String(savedConfiguration.quantity)); url.searchParams.set('id_customization', String(savedConfiguration.id_customization)); if (prestashop.static_token) { url.searchParams.set('token', prestashop.static_token); } const response = await window.fetch(url.toString(), { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const cartResponse = await response.clone().json().catch(() => ({})); if (!response.ok || cartResponse.hasError || (cartResponse.errors && cartResponse.errors.length)) { return false; } if (prestashop.emit) { prestashop.emit('updateCart', { reason: { idProduct: productId, linkAction: 'add-to-cart' }, resp: cartResponse }); } return true; } function fitCartPreviewVisual(visual) { if (!visual || visual.querySelector('svg')) { return; } visual.style.fontSize = '14px'; window.requestAnimationFrame(() => { let size = 14; while (visual.scrollWidth > visual.clientWidth && size > 5) { size -= 1; visual.style.fontSize = `${size}px`; } }); } function attachCartPreviewsToProductLines() { const staging = document.querySelector('.sc-cart-preview-staging'); const cacheKey = 'stickerConfiguratorCartPreviews'; let previews = staging ? Array.from(staging.querySelectorAll('[data-sc-cart-line-preview]')) : []; const checkoutTotalHt = staging ? staging.querySelector('.sc-checkout-total-ht') : null; const checkoutTotals = document.querySelector('body#checkout .cart-summary-totals'); if (checkoutTotalHt && checkoutTotals && !checkoutTotals.querySelector('.sc-checkout-total-ht')) { checkoutTotalHt.hidden = false; checkoutTotals.insertAdjacentElement('afterbegin', checkoutTotalHt); } if (previews.length) { try { window.sessionStorage.setItem(cacheKey, JSON.stringify(previews.map((preview) => preview.outerHTML))); } catch (error) { // The server-rendered preview remains available when storage is disabled. } } else { try { const cached = JSON.parse(window.sessionStorage.getItem(cacheKey) || '[]'); previews = cached.map((html) => { const template = document.createElement('template'); template.innerHTML = html; return template.content.firstElementChild; }).filter(Boolean); } catch (error) { previews = []; } } const productIds = [...new Set(previews.map((preview) => preview.dataset.productId).filter((id) => /^\d+$/.test(id || '')))]; if (productIds.length) { let guard = document.querySelector('#sc-cart-image-guard'); if (!guard) { guard = document.createElement('style'); guard.id = 'sc-cart-image-guard'; document.head.append(guard); } guard.textContent = productIds.map((id) => `.cart-item:has([data-id-product="${id}"]) .product-image > picture, .cart-item:has([data-id_product="${id}"]) .product-image > picture { visibility: hidden !important; }` ).join('\n'); } previews.forEach((preview) => { const productId = preview.dataset.productId; const customizationId = preview.dataset.customizationId; let marker = null; if (customizationId && customizationId !== '0') { marker = document.querySelector(`.cart-item [data-id-customization="${customizationId}"], .cart-item [data-id_customization="${customizationId}"]`); } if (!marker && productId) { marker = document.querySelector(`.cart-item [data-id-product="${productId}"], .cart-item [data-id_product="${productId}"]`); } const row = marker ? marker.closest('.cart-item') : null; const checkoutMarker = !row && productId ? document.querySelector(`#cart-summary-product-list li.media a[href*="/${productId}-"]`) : null; const checkoutRow = checkoutMarker ? checkoutMarker.closest('li.media') : null; const line = row || checkoutRow; const target = row ? row.querySelector('.product-line-grid-body') : checkoutRow ? checkoutRow.querySelector('.media-body') : null; const imageTarget = row ? row.querySelector('.product-line-grid-left .product-image') : checkoutRow ? checkoutRow.querySelector('.media-left a') : null; const visual = preview.querySelector('.sc-cart-preview-art'); const details = preview.querySelector('.sc-cart-preview-summary'); const cartKey = customizationId && customizationId !== '0' ? customizationId : productId; const existing = line ? line.querySelector(`[data-sc-custom-cart="${cartKey}"]`) : null; if (line) { line.querySelectorAll('.customizations').forEach((customizations) => customizations.remove()); line.querySelectorAll('a[data-target^="#product-customizations-modal-"]').forEach((link) => { const line = link.closest('.product-line-info'); (line || link).remove(); }); } if (target && imageTarget && visual && details && !existing) { visual.classList.add('sc-cart-product-visual'); visual.dataset.scCustomCart = cartKey; visual.style.fontFamily = preview.dataset.fontFamily || ''; applyPreviewContrast(visual, preview.dataset.color || preview.dataset.colorName || visual.style.color); details.classList.add('sc-cart-product-summary'); details.dataset.scCustomCart = cartKey; imageTarget.replaceChildren(visual); fitCartPreviewVisual(visual); target.append(details); if (checkoutRow) { checkoutRow.classList.add('sc-checkout-configured-product'); } preview.remove(); } else if (existing) { preview.remove(); } }); if (staging && !staging.querySelector('[data-sc-cart-line-preview]')) { staging.remove(); } } renderCartPreview(); attachCartPreviewsToProductLines(); if (window.prestashop && prestashop.on) { prestashop.on('updatedCart', () => { window.setTimeout(attachCartPreviewsToProductLines, 0); window.setTimeout(attachCartPreviewsToProductLines, 50); window.setTimeout(attachCartPreviewsToProductLines, 300); }); } const cartOverview = document.querySelector('.cart-overview, .cart-container'); if (cartOverview && window.MutationObserver) { let cartPreviewTimer = 0; new MutationObserver(() => { window.clearTimeout(cartPreviewTimer); cartPreviewTimer = window.setTimeout(attachCartPreviewsToProductLines, 0); }).observe(cartOverview, { childList: true, subtree: true }); } if (!root) { return; } const config = readConfig(); const state = { mode: 'text', text: 'Votre texte', font: config.fonts[0].name, fontCategory: 'all', color: (config.colors.find((color) => color.name.toLowerCase() === 'noir') || config.colors[0]).value, colorName: (config.colors.find((color) => color.name.toLowerCase() === 'noir') || config.colors[0]).name, material: config.materials[0].name, materialMultiplier: parseMultiplier(config.materials[0].value), logoName: config.logos[0] ? config.logos[0].name : '', logoSvg: config.logos[0] ? config.logos[0].svg : '', width: 40, height: 12, baseWidth: 40, baseHeight: 12, quantity: 1, keepRatio: true, multiline: false, stencil: false, mirror: false, inside: false, stretchX: 100, rotation: 0, zoom: 1, naturalWidthCm: 40, offsetX: 0, offsetY: 0 }; const modeSettingKeys = [ 'text', 'font', 'fontCategory', 'color', 'colorName', 'material', 'materialMultiplier', 'logoName', 'logoSvg', 'width', 'height', 'baseWidth', 'baseHeight', 'keepRatio', 'multiline', 'stencil', 'mirror', 'inside', 'stretchX', 'rotation', 'zoom', 'naturalWidthCm', 'offsetX', 'offsetY' ]; const modeSettings = { text: {}, logo: {} }; const preview = root.querySelector('#sc-sticker-preview'); const logoPreview = root.querySelector('#sc-logo-preview'); const fontGrid = root.querySelector('#sc-font-grid'); const logoGrid = root.querySelector('#sc-logo-grid'); const colorGrid = root.querySelector('#sc-color-grid'); const materialGrid = root.querySelector('#sc-material-grid'); const materialSelect = root.querySelector('#sc-material'); const textInput = root.querySelector('#sc-text-input'); const widthInput = root.querySelector('#sc-width'); const heightInput = root.querySelector('#sc-height'); const quantityInput = root.querySelector('#sc-quantity'); const quantityMinus = root.querySelector('#sc-quantity-minus'); const quantityPlus = root.querySelector('#sc-quantity-plus'); const stretchInput = root.querySelector('#sc-stretch-x'); const rotationInput = root.querySelector('#sc-rotation'); const price = root.querySelector('#sc-price'); let measureCanvasCtx = null; const HEIGHT_CALIBRATION = 1.011; const summaryText = root.querySelector('#sc-summary-text') || document.querySelector('#sc-summary-text'); const summarySize = root.querySelector('#sc-summary-size') || document.querySelector('#sc-summary-size'); const summaryFont = root.querySelector('#sc-summary-font') || document.querySelector('#sc-summary-font'); const summaryColor = root.querySelector('#sc-summary-color') || document.querySelector('#sc-summary-color'); const summaryMaterial = root.querySelector('#sc-summary-material') || document.querySelector('#sc-summary-material'); const summaryOptions = root.querySelector('#sc-summary-options') || document.querySelector('#sc-summary-options'); const summaryQuantity = root.querySelector('#sc-summary-quantity') || document.querySelector('#sc-summary-quantity'); const previewSize = root.querySelector('#sc-preview-size'); const previewFinish = root.querySelector('#sc-preview-finish'); const realSizeLabel = root.querySelector('#sc-real-size-label'); const previewSurface = root.querySelector('.sc-preview-surface'); const guideTop = root.querySelector('.sc-guide-top'); const guideBottom = root.querySelector('.sc-guide-bottom'); const dimensionLabel = root.querySelector('#sc-dimension-label'); let secondaryDimensionLabel = root.querySelector('#sc-secondary-dimension-label'); let totalDimensionLabel = root.querySelector('#sc-total-dimension-label'); const totalSizeReminder = root.querySelector('#sc-total-size-reminder'); const hiddenConfig = root.querySelector('#sc-configuration-json'); const dialog = document.querySelector('#sc-cart-dialog'); const summary = document.querySelector('#sc-cart-summary'); let fontPickerButton = null; let textEditorButton = null; const dimensionLimits = { minWidth: Number(config.options.minWidth) || 5, maxWidth: Number(config.options.maxWidth) || 300, minHeight: Number(config.options.minHeight) || 2, maxHeight: Number(config.options.maxHeight) || 120 }; function copyModeSettings() { return modeSettingKeys.reduce((settings, key) => { settings[key] = state[key]; return settings; }, {}); } function saveCurrentModeSettings() { modeSettings[state.mode] = copyModeSettings(); } function applyModeSettings(mode) { Object.assign(state, modeSettings[mode] || {}); state.mode = mode; textInput.value = state.text; textInput.rows = state.multiline ? 4 : 2; syncSizeInputs(); if (stretchInput) { stretchInput.value = state.stretchX; } if (rotationInput) { rotationInput.value = state.rotation; } materialSelect.value = state.material; [ ['#sc-keep-ratio', 'keepRatio'], ['#sc-multiline', 'multiline'], ['#sc-stencil', 'stencil'], ['#sc-mirror', 'mirror'], ['#sc-inside', 'inside'] ].forEach(([selector, key]) => { const input = root.querySelector(selector); if (input) { input.checked = !!state[key]; } }); if (fontPickerButton) { fontPickerButton.textContent = `Police : ${state.font}`; } updateTextEditorButton(); } function switchMode(mode) { if (mode === state.mode) { return; } saveCurrentModeSettings(); applyModeSettings(mode); closeOpenConfiguratorDialogs(); renderFonts(); renderLogos(); renderColors(); renderMaterials(); renderMode(); render(); } modeSettings.text = copyModeSettings(); modeSettings.logo = copyModeSettings(); modeSettings.logo.width = modeSettings.logo.height; modeSettings.logo.baseWidth = modeSettings.logo.baseHeight; syncSizeInputs(); function getLargeImageUrl(image) { return image.dataset.imageLargeSrc || image.dataset.largeSrc || image.getAttribute('data-image-large-src') || image.getAttribute('data-large-src') || image.src.replace('-small_default/', '-large_default/').replace('-home_default/', '-large_default/').replace('-medium_default/', '-large_default/'); } function openProductImagePopup(image) { let galleryDialog = document.querySelector('#sc-gallery-dialog'); if (!galleryDialog) { galleryDialog = document.createElement('dialog'); galleryDialog.id = 'sc-gallery-dialog'; galleryDialog.className = 'sc-gallery-dialog'; galleryDialog.innerHTML = '
'; galleryDialog.addEventListener('click', (event) => { if (event.target === galleryDialog) { galleryDialog.close(); } }); document.body.append(galleryDialog); } const targetImage = galleryDialog.querySelector('img'); targetImage.src = getLargeImageUrl(image); targetImage.alt = image.alt || 'Exemple de sticker'; galleryDialog.showModal(); } function installProductImageGalleryPopup() { document.querySelectorAll('.product-images img, .js-qv-mask img.thumb, .cc-product-thumbs .cc-thumb img, .cc-thumb img').forEach((image) => { if (image.dataset.scPopupReady) { return; } image.dataset.scPopupReady = '1'; image.style.cursor = 'zoom-in'; const link = image.closest('a'); const openImage = (event) => { event.preventDefault(); event.stopPropagation(); openProductImagePopup(image); }; image.addEventListener('click', openImage); if (link && link.dataset.scPopupReady !== '1') { link.dataset.scPopupReady = '1'; link.addEventListener('click', openImage); } }); } function moveLivePreviewToProductCover() { return; const previewPanel = root.querySelector('.sc-preview-panel'); const productCover = document.querySelector('.images-container .product-cover') || document.querySelector('.product-cover'); if (!previewPanel || !productCover || productCover.contains(previewPanel)) { return; } let host = productCover.querySelector('.sc-live-cover-host'); if (!host) { host = document.createElement('div'); host.className = 'sc-live-cover-host'; productCover.append(host); } productCover.querySelectorAll(':scope > picture, :scope > img, :scope > .layer, :scope > .product-flags').forEach((element) => { element.classList.add('sc-product-cover-hidden'); element.setAttribute('aria-hidden', 'true'); }); productCover.prepend(host); host.append(previewPanel); root.classList.add('sc-preview-extracted'); document.body.classList.add('sc-preview-in-product-cover'); installProductImageGalleryPopup(); } function moveProductImagesUnderPreview() { const previewPanel = root.querySelector('.sc-preview-panel'); const mask = document.querySelector('.images-container .js-qv-mask') || document.querySelector('.js-qv-mask'); if (!previewPanel || !mask || previewPanel.contains(mask)) { return; } let examples = previewPanel.querySelector('.sc-example-images'); if (!examples) { examples = document.createElement('div'); examples.className = 'sc-example-images'; examples.innerHTML = '
Exemples
'; previewPanel.append(examples); } examples.append(mask); installProductImageGalleryPopup(); } function moveProductTitleAboveConfigurator() { const title = document.querySelector('.product-detail-name'); const productContainer = document.querySelector('.product-container'); if (!title || !productContainer || title.dataset.scTitleMoved === '1') { return; } const holder = document.createElement('div'); holder.className = 'sc-product-title-holder'; productContainer.insertAdjacentElement('beforebegin', holder); holder.append(title); title.dataset.scTitleMoved = '1'; } function stabilizeProductLayout() { moveProductTitleAboveConfigurator(); relocateConfigurator(); tidyProductPageAroundConfigurator(); moveDeliveryBlockToCheckout(); moveProductImagesUnderPreview(); installProductImageGalleryPopup(); syncProductRating(); } function parseReviewNumber(text) { const match = String(text || '').match(/(\d+(?:[,.]\d+)?)/); return match ? Number(match[1].replace(',', '.')) : 0; } function findProductRating() { const outsideConfigurator = (element) => !root.contains(element); const reviewSources = [...document.querySelectorAll( '#product-comments-list-header, .comments-nb, .product-comments-additional-info, .product-list-reviews, .comments-note, .grade-stars, .star-content, [data-grade], [data-rating], [data-average]' )].filter(outsideConfigurator); let count = 0; let grade = 0; reviewSources.forEach((element) => { const text = (element.textContent || '').trim(); const labelledText = `${text} ${element.getAttribute('aria-label') || ''} ${element.getAttribute('title') || ''}`; const countMatch = labelledText.match(/(\d+)\s*(avis|commentaires?|reviews?)/i); const gradeMatch = labelledText.match(/(\d+(?:[,.]\d+)?)\s*(?:\/|sur)\s*5/i); if (!count && countMatch) { count = Number(countMatch[1]); } if (!grade && gradeMatch) { grade = parseReviewNumber(gradeMatch[1]); } ['data-grade', 'data-rating', 'data-average'].forEach((attribute) => { if (!grade && element.hasAttribute(attribute)) { grade = parseReviewNumber(element.getAttribute(attribute)); } }); }); const commentHeader = document.querySelector('#product-comments-list-header .comments-nb, #product-comments-list-header'); if (!count && commentHeader) { const headerMatch = (commentHeader.textContent || '').match(/(\d+)/); count = headerMatch ? Number(headerMatch[1]) : 0; } if (!grade) { const filledStars = [...document.querySelectorAll('.grade-stars .star-on, .star-content .star-on, .grade-stars .checked, .star-content .checked')] .filter(outsideConfigurator).length; grade = filledStars > 0 ? Math.min(5, filledStars) : 0; } return { count, grade }; } function syncProductRating() { const rating = root.querySelector('.sc-rating'); if (!rating) { return; } const foundRating = findProductRating(); if (!foundRating.count || !foundRating.grade) { const existingGrade = parseReviewNumber(rating.querySelector('strong')?.textContent || ''); const existingCount = parseReviewNumber(rating.querySelector('small')?.textContent || ''); if (!rating.hidden && existingGrade && existingCount && existingCount !== 557) { return; } rating.hidden = true; rating.setAttribute('aria-hidden', 'true'); return; } const formattedGrade = foundRating.grade.toLocaleString('fr-FR', { maximumFractionDigits: 1, minimumFractionDigits: foundRating.grade % 1 ? 1 : 0 }); const reviewLabel = `${foundRating.count} avis`; rating.hidden = false; rating.removeAttribute('aria-hidden'); rating.setAttribute('aria-label', `Note ${formattedGrade} sur 5`); rating.querySelector('strong').textContent = formattedGrade; rating.querySelector('small').textContent = reviewLabel; } function selectedFont() { return config.fonts.find((font) => font.name === state.font) || config.fonts[0]; } function selectedFontFor(settings = state) { return config.fonts.find((font) => font.name === settings.font) || config.fonts[0]; } function isModeAvailable(mode) { if (mode === 'text') { return config.options.enableTextMode !== false; } return mode === 'logo' && !!config.options.enableLogoMode; } function modeSnapshot(mode) { return mode === state.mode ? copyModeSettings() : (modeSettings[mode] || {}); } function parseMultiplier(value) { return Number(String(value).replace(',', '.')) || 1; } function parseFontCalibration(value) { const calibration = Number(String(value ?? 1).replace(',', '.')); if (!Number.isFinite(calibration) || calibration <= 0) { return 1; } return calibration > 10 ? calibration / 100 : calibration; } let _fontHeightProbe = null; let _fontHeightCanvas = null; let _fontHeightRef = null; const _fontHeightCache = {}; function measuredFontHeightFactor(fontFamily) { try { const sample = 'H\u00c1gjpqy'; const size = 300; if (!_fontHeightProbe) { _fontHeightProbe = document.createElement('span'); _fontHeightProbe.setAttribute('aria-hidden', 'true'); _fontHeightProbe.style.cssText = 'position:absolute;left:-99999px;top:0;visibility:hidden;white-space:nowrap;line-height:normal;padding:0;margin:0;font-size:' + size + 'px;'; (document.body || document.documentElement).appendChild(_fontHeightProbe); } const measureDom = function (family) { _fontHeightProbe.style.fontFamily = family || 'sans-serif'; _fontHeightProbe.textContent = sample; const range = document.createRange(); range.selectNodeContents(_fontHeightProbe); const rect = range.getBoundingClientRect(); return rect && rect.height > 0 ? rect.height : 0; }; const measureCanvas = function (family) { if (!_fontHeightCanvas) { _fontHeightCanvas = document.createElement('canvas'); } const ctx = _fontHeightCanvas.getContext('2d'); ctx.font = size + 'px ' + (family || 'sans-serif'); const metrics = ctx.measureText(sample); const h = (metrics.actualBoundingBoxAscent || 0) + (metrics.actualBoundingBoxDescent || 0); return h > 0 ? h : 0; }; const measureOne = function (family) { const dom = measureDom(family); if (dom > 0) { return dom; } const canvas = measureCanvas(family); return canvas > 0 ? canvas : size; }; if (_fontHeightRef === null) { _fontHeightRef = measureOne('Arial, Helvetica, sans-serif'); } const key = String(fontFamily || 'sans-serif'); if (_fontHeightCache[key] === undefined) { const factor = measureOne(key) / (_fontHeightRef || 1); _fontHeightCache[key] = (isFinite(factor) && factor > 0) ? factor : 1; } return _fontHeightCache[key]; } catch (error) { return 1; } } function fontCalibrationFor(settings = state) { const font = selectedFontFor(settings) || {}; const configuredHeight = parseFontCalibration(font.heightFactor); const measuredHeight = measuredFontHeightFactor(font.value); return { width: parseFontCalibration(font.widthFactor), height: (isFinite(measuredHeight) && measuredHeight > 0 ? measuredHeight : 1) * configuredHeight }; } function measureTextMetrics(fontFamily, text, multiline) { if (!measureCanvasCtx) { try { measureCanvasCtx = document.createElement('canvas').getContext('2d'); } catch (error) { measureCanvasCtx = null; } } const content = (text && String(text).trim()) ? String(text) : 'Votre texte'; const lines = multiline ? content.split(/\r?\n/) : [content]; const usableLines = lines.filter((line) => line.length > 0).length ? lines.filter((line) => line.length > 0) : [content]; if (!measureCanvasCtx) { const longest = usableLines.reduce((max, line) => Math.max(max, line.length), 1); return { width: longest * 55, height: usableLines.length * 72 }; } measureCanvasCtx.font = `100px ${fontFamily}`; let maxWidth = 1; let totalHeight = 0; usableLines.forEach((line) => { const metrics = measureCanvasCtx.measureText(line || ' '); const width = metrics.width || 1; const ascent = typeof metrics.actualBoundingBoxAscent === 'number' ? metrics.actualBoundingBoxAscent : 72; const descent = typeof metrics.actualBoundingBoxDescent === 'number' ? metrics.actualBoundingBoxDescent : 22; maxWidth = Math.max(maxWidth, width); totalHeight += Math.max(1, ascent + descent); }); if (usableLines.length > 1) { totalHeight += (usableLines.length - 1) * 22; } return { width: Math.max(1, maxWidth), height: Math.max(1, totalHeight) }; } function measuredAspectFor(settings = state) { const font = selectedFontFor(settings) || {}; const metrics = measureTextMetrics(font.value || 'sans-serif', settings.text, settings.multiline); const aspect = metrics.width > 0 ? metrics.height / metrics.width : 0.3; return aspect * HEIGHT_CALIBRATION; } function calibratedHeightCmFor(settings = state, mode = null) { const currentMode = mode || (settings === state ? state.mode : 'text'); if (currentMode !== 'text') { return settings.height; } // Width-driven model (same as the previous version): the customer sets the WIDTH, // the HEIGHT is derived from the REAL rendered text proportions, so it changes with the font. return clampHeight(Math.max(0.1, renderedWidthCmFor(settings, currentMode) * measuredAspectFor(settings))); } function setMaterial(name, multiplier) { state.material = name; state.materialMultiplier = parseMultiplier(multiplier); materialSelect.value = name; render(); renderMaterials(); } function calculatePrice() { const pricing = config.pricing || {}; const optionUnit = isFinite(Number(pricing.option)) ? Number(pricing.option) : 1.2; const logoUnit = isFinite(Number(pricing.logo)) ? Number(pricing.logo) : 1.5; const fallbackRate = isFinite(Number(pricing.fallback)) ? Number(pricing.fallback) : 0.0065; const minPrice = isFinite(Number(pricing.min)) ? Number(pricing.min) : 0.4; const area = Math.max(renderedWidthCm() * calibratedHeightCmFor(state, state.mode), 40); const surfaceM2 = renderedWidthCm() * calibratedHeightCmFor(state, state.mode) / 10000; const options = [state.stencil, state.inside].filter(Boolean).length * optionUnit; const logoExtra = state.mode === 'logo' ? logoUnit : 0; const rule = config.priceRules.find((item) => surfaceM2 + 0.0000001 >= Number(item.min) && surfaceM2 <= Number(item.max) + 0.0000001); const basePrice = rule ? Number(rule.price) * surfaceM2 : area * fallbackRate; const colorOption = config.colors.find((item) => item.name === state.colorName); const materialOption = config.materials.find((item) => item.name === state.material); const colorPrice = Math.max(0, Number(colorOption && colorOption.price) || 0); const materialPrice = Math.max(0, Number(materialOption && materialOption.price) || 0); const unit = Math.max(minPrice, basePrice * state.materialMultiplier + colorPrice + materialPrice + options + logoExtra); return unit * state.quantity; } function updateCheckoutSummary() { const activeMode = state.mode; const sizeText = `${formatCentimeters(renderedWidthCm())} x ${formatCentimeters(calibratedHeightCmFor(state, activeMode))} cm`; const fontText = activeMode === 'logo' ? (state.logoName || 'Logo SVG') : (state.font || '-'); const optionLabels = []; if (state.stencil) { optionLabels.push('Pochoir'); } if (state.inside) { optionLabels.push('Pose intérieure'); } if (state.mirror) { optionLabels.push('Miroir'); } if (summaryText) { summaryText.textContent = activeMode === 'logo' ? (state.logoName || 'Logo SVG') : (state.text || 'Votre texte'); } if (summarySize) { summarySize.textContent = sizeText; } if (summaryFont) { summaryFont.textContent = fontText; } if (summaryColor) { summaryColor.textContent = state.colorName || '-'; } if (summaryMaterial) { summaryMaterial.textContent = state.material || '-'; } if (summaryOptions) { summaryOptions.textContent = optionLabels.length ? optionLabels.join(', ') : 'Aucune'; } if (summaryQuantity) { summaryQuantity.textContent = String(state.quantity || 1); } const themeSummaryPrice = document.querySelector('#cc-summary-price'); if (themeSummaryPrice) { themeSummaryPrice.textContent = formatPrice(calculatePrice()); } } function getTransform() { return getTransformFor(state); } function getTransformFor(settings = state) { const mirror = settings.mirror ? -1 : 1; return `translate(${settings.offsetX || 0}px, ${settings.offsetY || 0}px) scaleX(${mirror * (settings.stretchX / 100)}) rotate(${settings.rotation}deg)`; } function formatCentimeters(value) { const rounded = Math.round(value * 10) / 10; return Number.isInteger(rounded) ? String(rounded) : rounded.toLocaleString('fr-FR', { maximumFractionDigits: 1 }); } function renderedWidthCm() { return renderedWidthCmFor(state); } function renderedWidthCmFor(settings = state, mode = null) { const currentMode = mode || (settings === state ? state.mode : 'text'); const widthFactor = currentMode === 'text' ? fontCalibrationFor(settings).width : 1; return Math.max(1, settings.width * (settings.stretchX / 100) * widthFactor); } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function clampWidth(value) { return Math.round(clamp(value, dimensionLimits.minWidth, dimensionLimits.maxWidth) * 10) / 10; } function clampHeight(value) { return Math.round(clamp(value, dimensionLimits.minHeight, dimensionLimits.maxHeight) * 10) / 10; } function nominalWidthFromRendered(value, settings = state, mode = null) { const currentMode = mode || (settings === state ? state.mode : 'text'); const widthFactor = currentMode === 'text' ? fontCalibrationFor(settings).width : 1; const stretchFactor = Math.max(0.01, (settings.stretchX || 100) / 100); return value / Math.max(0.01, widthFactor * stretchFactor); } function nominalHeightFromRendered(value, settings = state, mode = null) { const currentMode = mode || (settings === state ? state.mode : 'text'); const heightFactor = currentMode === 'text' ? fontCalibrationFor(settings).height : 1; return value / Math.max(0.01, heightFactor); } function syncSizeInputs(force = false) { widthInput.min = dimensionLimits.minWidth; widthInput.max = dimensionLimits.maxWidth; heightInput.min = dimensionLimits.minHeight; heightInput.max = dimensionLimits.maxHeight; if (force || document.activeElement !== widthInput) { widthInput.value = roundedDimension(renderedWidthCm()); } if (force || document.activeElement !== heightInput) { heightInput.value = roundedDimension(calibratedHeightCmFor(state, state.mode)); } } function parseDimensionInput(input) { const value = Number(String(input.value).replace(',', '.')); return Number.isFinite(value) ? value : null; } function roundedDimension(value) { return Math.round(value * 10) / 10; } function commitWidthInput(clampToLimits = false) { const rawValue = parseDimensionInput(widthInput); if (rawValue === null) { if (clampToLimits) { widthInput.value = roundedDimension(renderedWidthCm()); } return; } if (!clampToLimits && (rawValue < dimensionLimits.minWidth || rawValue > dimensionLimits.maxWidth)) { return; } const previousWidth = renderedWidthCm() || 40; const previousHeight = calibratedHeightCmFor(state, state.mode) || 12; const requestedWidth = clampToLimits ? clampWidth(rawValue) : roundedDimension(rawValue); state.width = nominalWidthFromRendered(requestedWidth); if (state.keepRatio && previousWidth) { const requestedHeight = clampHeight((requestedWidth * previousHeight) / previousWidth); state.height = nominalHeightFromRendered(requestedHeight); heightInput.value = requestedHeight; } if (clampToLimits) { widthInput.value = requestedWidth; } render(); } function commitHeightInput(clampToLimits = false) { const rawValue = parseDimensionInput(heightInput); if (rawValue === null) { if (clampToLimits) { heightInput.value = roundedDimension(calibratedHeightCmFor(state, state.mode)); } return; } if (!clampToLimits && (rawValue < dimensionLimits.minHeight || rawValue > dimensionLimits.maxHeight)) { return; } const previousWidth = renderedWidthCm() || 40; const previousHeight = calibratedHeightCmFor(state, state.mode) || 12; const requestedHeight = clampToLimits ? clampHeight(rawValue) : roundedDimension(rawValue); if (state.mode === 'text') { const aspectForHeight = measuredAspectFor(state); if (aspectForHeight > 0) { const requestedWidth = clampWidth(requestedHeight / aspectForHeight); state.width = nominalWidthFromRendered(requestedWidth); widthInput.value = requestedWidth; } } else { state.height = nominalHeightFromRendered(requestedHeight); } if (clampToLimits) { heightInput.value = requestedHeight; } render(); } function ensureSecondaryDimensionLabel() { if (!secondaryDimensionLabel && previewSurface) { secondaryDimensionLabel = document.createElement('div'); secondaryDimensionLabel.id = 'sc-secondary-dimension-label'; secondaryDimensionLabel.className = 'sc-dimension-label sc-dimension-label-secondary'; secondaryDimensionLabel.innerHTML = ''; secondaryDimensionLabel.hidden = true; previewSurface.append(secondaryDimensionLabel); } return secondaryDimensionLabel; } function ensureTotalDimensionLabel() { if (!totalDimensionLabel && previewSurface) { totalDimensionLabel = document.createElement('div'); totalDimensionLabel.id = 'sc-total-dimension-label'; totalDimensionLabel.className = 'sc-dimension-label sc-dimension-label-total'; totalDimensionLabel.innerHTML = ''; totalDimensionLabel.hidden = true; previewSurface.append(totalDimensionLabel); } return totalDimensionLabel; } function positionDimensionLabel(label, element, settings, labelPrefix, surfaceRect, mode = null) { if (!label || !element || element.hidden) { if (label) { label.hidden = true; } return null; } const rect = element.getBoundingClientRect(); const top = Math.max(10, rect.top - surfaceRect.top); const bottom = Math.min(surfaceRect.height - 10, rect.bottom - surfaceRect.top); const left = Math.max(10, rect.left - surfaceRect.left); const right = Math.min(surfaceRect.width - 10, rect.right - surfaceRect.left); const currentMode = mode || (settings === state ? state.mode : 'text'); const measuredWidthCm = renderedWidthCmFor(settings, currentMode); const measuredHeightCm = calibratedHeightCmFor(settings, currentMode); label.hidden = false; label.style.top = `${Math.max(8, top - 34)}px`; label.style.left = `${left}px`; label.style.width = `${Math.max(90, right - left)}px`; label.querySelector('span').textContent = `${labelPrefix} ${formatCentimeters(measuredWidthCm)} x ${formatCentimeters(measuredHeightCm)} cm`; return { top, bottom, left, right, measuredWidthCm }; } function updateTotalDimensionLabel(surfaceRect, activeMetrics) { const label = ensureTotalDimensionLabel(); if (!label) { return; } if (!isModeAvailable('text') || !isModeAvailable('logo') || preview.hidden || logoPreview.hidden) { label.hidden = true; if (totalSizeReminder) { totalSizeReminder.querySelector('strong').textContent = `${formatCentimeters(renderedWidthCm())} cm`; } return; } const textRect = preview.getBoundingClientRect(); const logoRect = logoPreview.getBoundingClientRect(); const left = Math.max(10, Math.min(textRect.left, logoRect.left) - surfaceRect.left); const right = Math.min(surfaceRect.width - 10, Math.max(textRect.right, logoRect.right) - surfaceRect.left); const bottom = Math.min(surfaceRect.height - 10, Math.max(textRect.bottom, logoRect.bottom) - surfaceRect.top); const pixelsPerCm = activeMetrics.measuredWidthCm / Math.max(1, activeMetrics.right - activeMetrics.left); const totalWidthCm = Math.max(1, (right - left) * pixelsPerCm); const totalText = `${formatCentimeters(totalWidthCm)} cm`; label.hidden = false; label.style.top = `${Math.min(surfaceRect.height - 28, bottom + 18)}px`; label.style.left = `${left}px`; label.style.width = `${Math.max(120, right - left)}px`; label.querySelector('span').textContent = `Longueur totale ${totalText}`; if (totalSizeReminder) { totalSizeReminder.querySelector('strong').textContent = totalText; } } function updateDimensionGuides() { const activePreview = state.mode === 'logo' ? logoPreview : preview; if (!previewSurface || !activePreview || activePreview.hidden) { return; } const surfaceRect = previewSurface.getBoundingClientRect(); const labelPrefix = isModeAvailable('text') && isModeAvailable('logo') ? (state.mode === 'logo' ? 'Logo' : 'Texte') : ''; const activeMetrics = positionDimensionLabel(dimensionLabel, activePreview, state, labelPrefix, surfaceRect, state.mode); if (!activeMetrics) { return; } const { top, bottom, left, right, measuredWidthCm } = activeMetrics; state.naturalWidthCm = measuredWidthCm / Math.max(0.01, state.stretchX / 100); previewSurface.style.setProperty('--sc-guide-top', `${top}px`); previewSurface.style.setProperty('--sc-guide-bottom', `${surfaceRect.height - bottom}px`); previewSurface.style.setProperty('--sc-guide-left', `${left}px`); previewSurface.style.setProperty('--sc-guide-right', `${surfaceRect.width - right}px`); previewSurface.style.setProperty('--sc-dimension-top', `${Math.max(8, top - 34)}px`); if (guideTop && guideBottom) { guideTop.hidden = false; guideBottom.hidden = false; } previewSize.textContent = `${formatCentimeters(measuredWidthCm)} x ${formatCentimeters(calibratedHeightCmFor(state, state.mode))} cm`; const secondaryLabel = ensureSecondaryDimensionLabel(); if (isModeAvailable('text') && isModeAvailable('logo')) { const secondaryMode = state.mode === 'logo' ? 'text' : 'logo'; const secondaryElement = secondaryMode === 'logo' ? logoPreview : preview; positionDimensionLabel(secondaryLabel, secondaryElement, modeSnapshot(secondaryMode), secondaryMode === 'logo' ? 'Logo' : 'Texte', surfaceRect, secondaryMode); } else if (secondaryLabel) { secondaryLabel.hidden = true; } updateTotalDimensionLabel(surfaceRect, activeMetrics); syncSizeInputs(); price.textContent = formatPrice(calculatePrice()); updateCheckoutSummary(); updateHiddenConfig(); } function scheduleDimensionUpdate() { window.requestAnimationFrame(updateDimensionGuides); } function setModePosition(mode, offsetX, offsetY) { const settings = mode === state.mode ? state : modeSettings[mode]; if (!settings) { return; } const otherMode = mode === 'text' ? 'logo' : 'text'; const otherSettings = modeSnapshot(otherMode); const shouldSnapY = isModeAvailable(mode) && isModeAvailable(otherMode) && Number.isFinite(otherSettings.offsetY) && Math.abs(offsetY - otherSettings.offsetY) <= 14; settings.offsetX = Math.round(offsetX); settings.offsetY = Math.round(shouldSnapY ? otherSettings.offsetY : offsetY); if (mode === state.mode) { state.offsetX = settings.offsetX; state.offsetY = settings.offsetY; } else { modeSettings[mode] = settings; } } function bindPreviewDrag(element, mode) { if (!element) { return; } let drag = null; const startDrag = (event, point) => { if (!isModeAvailable(mode) || element.hidden) { return; } if (state.mode !== mode) { switchMode(mode); } const settings = modeSnapshot(mode); drag = { startX: point.clientX, startY: point.clientY, offsetX: settings.offsetX || 0, offsetY: settings.offsetY || 0 }; if (event.pointerId !== undefined) { element.setPointerCapture?.(event.pointerId); } element.classList.add('is-dragging'); event.preventDefault(); }; const moveDrag = (event, point) => { if (!drag) { return; } setModePosition(mode, drag.offsetX + point.clientX - drag.startX, drag.offsetY + point.clientY - drag.startY); render(); event.preventDefault(); }; const stopDrag = () => { drag = null; element.classList.remove('is-dragging'); }; element.addEventListener('pointerdown', (event) => startDrag(event, event)); window.addEventListener('pointermove', (event) => moveDrag(event, event)); window.addEventListener('pointerup', stopDrag); window.addEventListener('pointercancel', stopDrag); element.addEventListener('mousedown', (event) => startDrag(event, event)); window.addEventListener('mousemove', (event) => moveDrag(event, event)); window.addEventListener('mouseup', stopDrag); element.addEventListener('touchstart', (event) => { const touch = event.touches[0]; if (touch) { startDrag(event, touch); } }, { passive: false }); window.addEventListener('touchmove', (event) => { const touch = event.touches[0]; if (touch) { moveDrag(event, touch); } }, { passive: false }); window.addEventListener('touchend', stopDrag); window.addEventListener('touchcancel', stopDrag); element.addEventListener('dblclick', () => { setModePosition(mode, 0, 0); render(); }); } function updateHiddenConfig() { saveCurrentModeSettings(); const textSettings = modeSnapshot('text'); const logoSettings = modeSnapshot('logo'); hiddenConfig.value = JSON.stringify({ mode: state.mode, modeConfigurations: { text: textSettings, logo: logoSettings }, text: state.text, font: state.font, fontFamily: selectedFontFor(state).value, fontWidthFactor: fontCalibrationFor(state).width, fontHeightFactor: fontCalibrationFor(state).height, color: state.color, colorName: state.colorName, materialName: state.material, nominalWidth: state.width, nominalHeight: calibratedHeightCmFor(state, state.mode) / Math.max(0.01, parseFontCalibration((selectedFontFor(state) || {}).heightFactor)), width: renderedWidthCm(), height: calibratedHeightCmFor(state, state.mode), quantity: state.quantity, stencil: state.stencil, mirror: state.mirror, inside: state.inside, stretchX: state.stretchX, rotation: state.rotation, offsetX: state.offsetX, offsetY: state.offsetY, logoName: state.logoName, logoSvg: state.logoSvg, transform: getTransform(), combined: { textEnabled: isModeAvailable('text'), logoEnabled: isModeAvailable('logo'), text: textSettings, logo: logoSettings }, price: calculatePrice() }); } function renderFonts() { fontGrid.innerHTML = ''; config.fonts .filter((font) => state.fontCategory === 'all' || font.category === state.fontCategory) .forEach((font) => { const button = document.createElement('button'); button.type = 'button'; button.className = `sc-font-card${font.name === state.font ? ' active' : ''}`; button.innerHTML = `ABC${font.name}`; button.addEventListener('click', () => { state.font = font.name; if (fontPickerButton) { fontPickerButton.textContent = `Police : ${font.name}`; } render(); renderFonts(); }); fontGrid.append(button); }); } function updateTextEditorButton() { if (!textEditorButton) { return; } const label = state.text.trim() || 'Votre texte'; textEditorButton.textContent = `Texte : ${label}`; } function closeOpenConfiguratorDialogs(exceptDialog) { document.querySelectorAll('#sc-font-picker-dialog, #sc-text-editor-dialog').forEach((dialogElement) => { if (dialogElement !== exceptDialog && dialogElement.open) { dialogElement.close(); } }); } function openConfiguratorDialog(dialogElement) { closeOpenConfiguratorDialogs(dialogElement); if (!dialogElement.open) { dialogElement.showModal(); } } function setupMobileFontPicker() { if (!window.matchMedia('(max-width: 680px)').matches || root.dataset.scFontPickerReady === '1') { return; } const fontStep = fontGrid.closest('.sc-step'); const fontTabs = root.querySelector('.sc-font-tabs'); const stepTitle = fontStep ? fontStep.querySelector('.sc-step-title') : null; if (!fontStep || !fontTabs || !stepTitle) { return; } root.dataset.scFontPickerReady = '1'; fontPickerButton = document.createElement('button'); fontPickerButton.type = 'button'; fontPickerButton.className = 'sc-font-picker-button'; fontPickerButton.textContent = `Police : ${state.font}`; stepTitle.insertAdjacentElement('afterend', fontPickerButton); const picker = document.createElement('dialog'); picker.id = 'sc-font-picker-dialog'; picker.className = 'sc-font-picker-dialog'; picker.innerHTML = '

Choisir une police

Votre texte
'; document.body.append(picker); const content = picker.querySelector('.sc-font-picker-content'); content.append(fontTabs); content.append(fontGrid); fontPickerButton.addEventListener('click', () => openConfiguratorDialog(picker)); picker.querySelector('.sc-font-picker-validate').addEventListener('click', () => picker.close()); } function setupTextEditor() { if (root.dataset.scTextEditorReady === '1') { return; } const textStep = textInput.closest('.sc-step'); const stepTitle = textStep ? textStep.querySelector('.sc-step-title') : null; if (!textStep || !stepTitle) { return; } root.dataset.scTextEditorReady = '1'; textEditorButton = document.createElement('button'); textEditorButton.type = 'button'; textEditorButton.className = 'sc-text-editor-button'; updateTextEditorButton(); stepTitle.insertAdjacentElement('afterend', textEditorButton); const editor = document.createElement('dialog'); editor.id = 'sc-text-editor-dialog'; editor.className = 'sc-text-editor-dialog'; editor.innerHTML = '

Modifier le texte

Votre texte
'; document.body.append(editor); const editorInput = editor.querySelector('.sc-text-editor-input'); const editorPreview = editor.querySelector('#sc-text-editor-preview'); function syncFromEditor() { state.text = editorInput.value; textInput.value = state.text; editorPreview.textContent = state.text.trim() || 'Votre texte'; updateTextEditorButton(); render(); } function focusEditorInput() { editorInput.focus({ preventScroll: true }); editorInput.setSelectionRange(editorInput.value.length, editorInput.value.length); } textEditorButton.addEventListener('click', () => { editorInput.value = state.text; editorPreview.textContent = state.text.trim() || 'Votre texte'; openConfiguratorDialog(editor); focusEditorInput(); window.setTimeout(focusEditorInput, 80); }); editorInput.addEventListener('input', syncFromEditor); editorInput.addEventListener('keyup', syncFromEditor); editorInput.addEventListener('change', syncFromEditor); editorInput.addEventListener('compositionend', syncFromEditor); editorInput.addEventListener('paste', () => window.setTimeout(syncFromEditor, 0)); editor.querySelector('.sc-text-editor-validate').addEventListener('click', () => editor.close()); } function setupMobileEditingBehavior() { document.body.classList.remove('sc-config-editing'); } function renderLogos() { logoGrid.innerHTML = ''; if (!config.logos.length) { logoGrid.innerHTML = '

Aucun logo SVG configuré.

'; return; } config.logos.forEach((logo) => { const button = document.createElement('button'); button.type = 'button'; button.className = `sc-logo-card${logo.name === state.logoName ? ' active' : ''}`; button.innerHTML = `${logo.svg}${logo.name}`; button.addEventListener('click', () => { state.logoName = logo.name; state.logoSvg = logo.svg; render(); renderLogos(); }); logoGrid.append(button); }); } function renderColors() { colorGrid.innerHTML = ''; config.colors.forEach((color) => { const button = document.createElement('button'); button.type = 'button'; button.className = `sc-swatch${color.value === state.color ? ' active' : ''}`; button.style.setProperty('--swatch', color.value); const colorExtra = Math.max(0, Number(color.price) || 0); const colorLabel = colorExtra > 0 ? `${color.name} (+${formatPrice(colorExtra)})` : color.name; button.title = colorLabel; button.setAttribute('aria-label', colorLabel); button.addEventListener('click', () => { state.color = color.value; state.colorName = color.name; render(); renderColors(); }); colorGrid.append(button); }); } function renderMaterials() { materialSelect.innerHTML = ''; if (materialGrid) { materialGrid.innerHTML = ''; } config.materials.forEach((material) => { const option = document.createElement('option'); option.value = material.name; option.textContent = material.name; option.dataset.multiplier = material.value; materialSelect.append(option); if (materialGrid) { const button = document.createElement('button'); button.type = 'button'; button.className = `sc-material-card${material.name === state.material ? ' active' : ''}`; const materialExtra = Math.max(0, Number(material.price) || 0); const extraLabel = materialExtra > 0 ? ` + ${formatPrice(materialExtra)}` : ''; button.innerHTML = `${material.name}${extraLabel ? `${extraLabel.trim()}` : ''}`; button.addEventListener('click', () => setMaterial(material.name, material.value)); materialGrid.append(button); } }); materialSelect.value = state.material; } function renderMode() { applyFeatureVisibility(); const isLogo = state.mode === 'logo'; const showTextPreview = isModeAvailable('text'); const showLogoPreview = isModeAvailable('logo'); root.querySelectorAll('.sc-text-panel').forEach((panel) => { panel.hidden = isLogo; }); root.querySelector('.sc-logo-panel').hidden = !isLogo; preview.hidden = !showTextPreview; logoPreview.hidden = !showLogoPreview; root.querySelector('.sc-sticker-shadow')?.classList.toggle('sc-combined-preview', showTextPreview && showLogoPreview); root.querySelectorAll('.sc-mode-tabs button').forEach((button) => { button.classList.toggle('active', button.dataset.mode === state.mode); }); } function applyFeatureVisibility() { const allowTextMode = config.options.enableTextMode !== false; const allowLogoMode = !!config.options.enableLogoMode; const availableModes = [allowTextMode ? 'text' : null, allowLogoMode ? 'logo' : null].filter(Boolean); const allowStickerType = !!config.options.enableStickerType && availableModes.length > 1; const allowMultiline = !!config.options.enableMultiline; const allowedSpecials = { stencil: config.options.enableStencil !== false, mirror: config.options.enableMirror !== false, inside: config.options.enableInside !== false }; const modeStep = root.querySelector('.sc-mode-tabs')?.closest('.sc-step'); const multilineOption = root.querySelector('#sc-multiline')?.closest('.sc-check-row'); const forcedMode = availableModes.includes(state.mode) ? state.mode : (availableModes[0] || 'text'); if (modeStep) { modeStep.hidden = !allowStickerType; } if (!allowStickerType) { if (state.mode !== forcedMode) { saveCurrentModeSettings(); applyModeSettings(forcedMode); } else { state.mode = forcedMode; } root.querySelectorAll('.sc-mode-tabs button').forEach((button) => { button.classList.toggle('active', button.dataset.mode === state.mode); }); } root.querySelectorAll('.sc-mode-tabs button').forEach((button) => { button.hidden = !availableModes.includes(button.dataset.mode); }); if (multilineOption) { multilineOption.hidden = !allowMultiline || state.mode !== 'text'; } if (!allowMultiline) { state.multiline = false; const multilineInput = root.querySelector('#sc-multiline'); if (multilineInput) { multilineInput.checked = false; } textInput.rows = 2; if (textInput.value.includes('\n')) { textInput.value = textInput.value.replace(/\s*\n\s*/g, ' '); state.text = textInput.value; updateTextEditorButton(); } } [ ['#sc-stencil', 'stencil'], ['#sc-mirror', 'mirror'], ['#sc-inside', 'inside'] ].forEach(([selector, key]) => { const input = root.querySelector(selector); const row = input ? input.closest('.sc-check-row') : null; const enabled = allowedSpecials[key] !== false; if (row) { row.hidden = !enabled; } if (!enabled) { state[key] = false; if (input) { input.checked = false; } } }); } function render() { const textSettings = modeSnapshot('text'); const logoSettings = modeSnapshot('logo'); const font = selectedFontFor(textSettings); const activeFont = selectedFontFor(state); const displayText = (textSettings.text || '').trim() || 'Votre texte'; const maxFontSize = window.matchMedia('(max-width: 680px)').matches ? 40 : 96; const fontSize = Math.max(34, Math.min(maxFontSize, textSettings.height * (textSettings.multiline ? 4.1 : 5.3) * textSettings.zoom)); const logoSize = Math.max(34, Math.min(maxFontSize, logoSettings.height * 5.55 * logoSettings.zoom)); preview.textContent = displayText; const pickerPreview = document.querySelector('#sc-font-picker-preview'); if (pickerPreview) { pickerPreview.textContent = (state.text || '').trim() || 'Votre texte'; pickerPreview.style.fontFamily = activeFont.value; pickerPreview.style.color = state.color; applyPreviewContrast(pickerPreview, state.color); } const textEditorPreview = document.querySelector('#sc-text-editor-preview'); if (textEditorPreview) { textEditorPreview.textContent = (state.text || '').trim() || 'Votre texte'; textEditorPreview.style.fontFamily = activeFont.value; textEditorPreview.style.color = state.color; applyPreviewContrast(textEditorPreview, state.color); } preview.style.fontFamily = font.value; preview.style.color = textSettings.color; preview.style.fontSize = `${fontSize}px`; preview.style.setProperty('--sc-preview-font-size', `${fontSize}px`); preview.style.transform = getTransformFor(textSettings); preview.classList.toggle('sc-stencil', !!textSettings.stencil); preview.classList.toggle('sc-multiline', !!textSettings.multiline); logoPreview.innerHTML = logoSettings.logoSvg || ''; logoPreview.style.color = logoSettings.color || state.color; logoPreview.style.transform = getTransformFor(logoSettings); logoPreview.style.width = `${logoSize}px`; logoPreview.style.height = `${logoSize}px`; previewSurface.classList.toggle( 'sc-dark-contrast', (isModeAvailable('text') && needsDarkContrast(textSettings.color)) || (isModeAvailable('logo') && needsDarkContrast(logoSettings.color || state.color)) ); preview.classList.toggle('is-selected-live-item', state.mode === 'text'); logoPreview.classList.toggle('is-selected-live-item', state.mode === 'logo'); if (isModeAvailable('text') && isModeAvailable('logo')) { previewSize.textContent = `Texte ${formatCentimeters(renderedWidthCmFor(textSettings, 'text'))} x ${formatCentimeters(calibratedHeightCmFor(textSettings, 'text'))} cm | Logo ${formatCentimeters(renderedWidthCmFor(logoSettings, 'logo'))} x ${formatCentimeters(calibratedHeightCmFor(logoSettings, 'logo'))} cm`; previewFinish.textContent = `Texte ${textSettings.material} - ${textSettings.colorName} | Logo ${logoSettings.material} - ${logoSettings.colorName}`; } else { previewSize.textContent = `${formatCentimeters(renderedWidthCm())} x ${formatCentimeters(calibratedHeightCmFor(state, state.mode))} cm`; previewFinish.textContent = `${state.material} - ${state.colorName}`; } realSizeLabel.textContent = `Hauteur réelle : ${formatCentimeters(calibratedHeightCmFor(state, state.mode))} cm`; syncSizeInputs(); price.textContent = formatPrice(calculatePrice()); updateCheckoutSummary(); updateHiddenConfig(); scheduleDimensionUpdate(); } root.querySelectorAll('.sc-mode-tabs button').forEach((button) => { button.addEventListener('click', () => { if (button.hidden) { return; } switchMode(button.dataset.mode); }); }); root.querySelectorAll('.sc-specials button').forEach((button) => { button.addEventListener('click', () => { const start = textInput.selectionStart; const end = textInput.selectionEnd; const char = button.textContent; textInput.value = `${textInput.value.slice(0, start)}${char}${textInput.value.slice(end)}`; textInput.focus(); textInput.selectionStart = textInput.selectionEnd = start + char.length; state.text = textInput.value; updateTextEditorButton(); render(); }); }); root.querySelectorAll('.sc-font-tabs button').forEach((button) => { button.addEventListener('click', () => { root.querySelectorAll('.sc-font-tabs button').forEach((tab) => tab.classList.remove('active')); button.classList.add('active'); state.fontCategory = button.dataset.filter; renderFonts(); }); }); root.querySelectorAll('[data-qty]').forEach((button) => { button.addEventListener('click', () => { root.querySelectorAll('[data-qty]').forEach((qty) => qty.classList.remove('active')); button.classList.add('active'); state.quantity = Number(button.dataset.qty); quantityInput.value = state.quantity; render(); }); }); function syncTextInput() { state.text = textInput.value; updateTextEditorButton(); render(); } root.addEventListener('input', (event) => { if (event.target && event.target.id === 'sc-text-input') { syncTextInput(); } }, true); root.addEventListener('beforeinput', (event) => { if (event.target && event.target.id === 'sc-text-input') { window.setTimeout(syncTextInput, 0); } }, true); textInput.addEventListener('input', syncTextInput); textInput.addEventListener('keyup', syncTextInput); textInput.addEventListener('change', syncTextInput); textInput.addEventListener('blur', syncTextInput); textInput.addEventListener('compositionend', syncTextInput); textInput.addEventListener('paste', () => window.setTimeout(syncTextInput, 0)); bindPreviewDrag(preview, 'text'); bindPreviewDrag(logoPreview, 'logo'); materialSelect.addEventListener('change', (event) => { const selected = event.target.selectedOptions[0]; if (selected) { setMaterial(selected.value, selected.dataset.multiplier); } }); widthInput.addEventListener('input', () => commitWidthInput(false)); widthInput.addEventListener('change', () => commitWidthInput(true)); widthInput.addEventListener('blur', () => commitWidthInput(true)); heightInput.addEventListener('input', () => commitHeightInput(false)); heightInput.addEventListener('change', () => commitHeightInput(true)); heightInput.addEventListener('blur', () => commitHeightInput(true)); quantityInput.addEventListener('input', () => { state.quantity = Math.max(1, Number(quantityInput.value) || 1); root.querySelectorAll('[data-qty]').forEach((qty) => { qty.classList.toggle('active', Number(qty.dataset.qty) === state.quantity); }); render(); }); function stepQuantity(delta) { state.quantity = Math.min(999, Math.max(1, (Number(state.quantity) || 1) + delta)); quantityInput.value = state.quantity; root.querySelectorAll('[data-qty]').forEach((qty) => { qty.classList.toggle('active', Number(qty.dataset.qty) === state.quantity); }); render(); } if (quantityMinus) { quantityMinus.addEventListener('click', () => stepQuantity(-1)); } if (quantityPlus) { quantityPlus.addEventListener('click', () => stepQuantity(1)); } if (stretchInput) { stretchInput.addEventListener('input', () => { state.stretchX = Number(stretchInput.value) || 100; render(); }); } if (rotationInput) { rotationInput.addEventListener('input', () => { state.rotation = Number(rotationInput.value) || 0; render(); }); } const resetRatioButton = root.querySelector('#sc-reset-ratio'); if (resetRatioButton) { resetRatioButton.addEventListener('click', () => { state.width = clampWidth(state.baseWidth); state.height = clampHeight(state.baseHeight); state.stretchX = 100; state.rotation = 0; state.offsetX = 0; state.offsetY = 0; widthInput.value = state.width; heightInput.value = state.height; if (stretchInput) { stretchInput.value = state.stretchX; } if (rotationInput) { rotationInput.value = state.rotation; } render(); }); } [ ['#sc-keep-ratio', 'keepRatio'], ['#sc-multiline', 'multiline'], ['#sc-stencil', 'stencil'], ['#sc-mirror', 'mirror'], ['#sc-inside', 'inside'] ].forEach(([selector, key]) => { const optionInput = root.querySelector(selector); if (!optionInput) { return; } optionInput.addEventListener('change', (event) => { state[key] = event.target.checked; if (key === 'multiline') { textInput.rows = state.multiline ? 4 : 2; if (state.multiline && !textInput.value.includes('\n')) { textInput.value = textInput.value.replace(' ', '\n'); state.text = textInput.value; } } render(); }); }); root.querySelector('#sc-zoom-in').addEventListener('click', () => { state.zoom = Math.min(1.4, state.zoom + 0.1); render(); }); root.querySelector('#sc-zoom-out').addEventListener('click', () => { state.zoom = Math.max(0.7, state.zoom - 0.1); render(); }); root.querySelector('#sc-config-form').addEventListener('submit', async (event) => { event.preventDefault(); updateHiddenConfig(); window.localStorage.setItem('stickerConfiguratorLastConfig', hiddenConfig.value); const submitButton = event.submitter; if (submitButton) { submitButton.disabled = true; } let added = false; let errorMessage = ''; try { const savedConfiguration = await saveConfiguration(); added = savedConfiguration.added === true; if (added && window.prestashop && prestashop.emit) { prestashop.emit('updateCart', { reason: { idProduct: getProductId(), idCustomization: savedConfiguration.id_customization, linkAction: 'add-to-cart' }, resp: savedConfiguration }); } } catch (error) { errorMessage = error instanceof Error ? error.message : 'Une erreur est survenue.'; } finally { if (submitButton) { submitButton.disabled = false; } } summary.textContent = `${state.quantity} sticker(s), ${formatCentimeters(renderedWidthCm())} x ${formatCentimeters(calibratedHeightCmFor(state, state.mode))} cm, ${state.mode === 'logo' ? state.logoName : `texte ${state.font}`}, ${state.colorName.toLowerCase()}, ${state.material}. Total estimé : ${formatPrice(calculatePrice())}.${added ? ' Produit ajouté au panier avec sa personnalisation.' : ` Ajout impossible. ${errorMessage}`}`; dialog.showModal(); }); stabilizeProductLayout(); window.addEventListener('load', stabilizeProductLayout); window.addEventListener('load', moveDeliveryBlockToCheckout); [250, 800, 1600, 3000].forEach((delay) => { window.setTimeout(stabilizeProductLayout, delay); window.setTimeout(moveDeliveryBlockToCheckout, delay); }); if (window.MutationObserver) { const observer = new MutationObserver(() => { window.clearTimeout(observer.scTimer); observer.scTimer = window.setTimeout(stabilizeProductLayout, 80); }); observer.observe(document.body, { childList: true, subtree: true }); } renderFonts(); setupMobileFontPicker(); setupTextEditor(); setupMobileEditingBehavior(); renderLogos(); renderColors(); renderMaterials(); renderMode(); render(); if (document.fonts && document.fonts.ready) { document.fonts.ready.then(function () { _fontHeightRef = null; Object.keys(_fontHeightCache).forEach(function (key) { delete _fontHeightCache[key]; }); render(); }); } })(); Erreur 404

Aucun produit disponible pour le moment

Restez à l'écoute ! D'autres produits seront affichés ici au fur et à mesure qu'ils seront ajoutés.