UNPKG

vipros-sdk

Version:

VIPros SDK - Universal Web Components for cashback & VIPoints. Works with any framework!

1,714 lines (1,449 loc) 127 kB
/** * VIPros SDK React Wrapper v1.0.0 * Generated: 2025-11-05T14:19:58.990Z */ 'use strict'; var React = require('react'); /** * TemplateLoader - Gestionnaire de templates HTML modulaires * * Charge et met en cache les templates HTML externes pour le composant ViprosOfferCard. * Utilise un système de templating simple avec remplacement de variables {{variable}}. */ class TemplateLoader { constructor(debug = false) { this.debug = debug; this.templates = new Map(); this.baseUrl = this.detectBaseUrl(); } /** * Détecte l'URL de base pour les templates */ detectBaseUrl() { // Toujours utiliser l'URL API pour les templates const baseUrl = window.location.origin; if (window.location.hostname.includes('ddev.site')) { return baseUrl + '/api/sdk/components/templates' } // Pour production, adapter selon le domaine return baseUrl + '/api/sdk/components/templates' } /** * Charge un template depuis le serveur */ async loadTemplate(templateName) { if (this.templates.has(templateName)) { return this.templates.get(templateName) } try { const response = await fetch(`${this.baseUrl}/${templateName}.html`); if (!response.ok) { throw new Error(`Template ${templateName} not found: ${response.status}`) } const html = await response.text(); this.templates.set(templateName, html); return html } catch (error) { console.error(`[TemplateLoader] Error loading template ${templateName}:`, error); return this.getFallbackTemplate(templateName) } } /** * Rend un template avec des données */ async render(templateName, data = {}) { const template = await this.loadTemplate(templateName); return this.interpolate(template, data) } /** * Système de templating amélioré - gère {{#if}}, {{else}}, {{/if}}, {{#unless}} */ interpolate(template, data) { // D'abord traiter les blocs conditionnels let processed = this.processConditionalBlocks(template, data); // Puis remplacer les variables simples processed = processed.replace(/\{\{([^}#/][^}]*)\}\}/g, (match, key) => { const value = this.resolveNestedKey(data, key.trim()); return value !== null ? value : '' }); return processed } /** * Résout les clés imbriquées comme "cashback.value" */ resolveNestedKey(obj, key) { // Les conditions sont maintenant gérées par processConditionalBlocks // Ignorer les tokens de condition restants if (key.startsWith('#') || key.startsWith('/')) { return '' } // Résolution de clé normale return key.split('.').reduce((current, prop) => { return current && current[prop] !== undefined ? current[prop] : null }, obj) } /** * Traite les blocs conditionnels {{#if}}...{{else}}...{{/if}} et {{#unless}}...{{/unless}} * Gère les conditions imbriquées de façon récursive */ processConditionalBlocks(template, data) { let processed = template; let iterations = 0; const maxIterations = 10; // Protection contre les boucles infinies // Traiter récursivement jusqu'à ce qu'il n'y ait plus de conditions while (processed.includes('{{#if') || processed.includes('{{#unless')) { iterations++; if (iterations > maxIterations) { console.warn('[TemplateLoader] Too many iterations in conditional processing'); break } const before = processed; // Traiter les blocs {{#if}}...{{/if}} (de l'intérieur vers l'extérieur) processed = this.processIfBlocks(processed, data); // Traiter les blocs {{#unless}}...{{/unless}} processed = this.processUnlessBlocks(processed, data); // Si rien n'a changé, arrêter pour éviter une boucle infinie if (processed === before) { break } } return processed } /** * Traite les blocs {{#if}}...{{/if}} en gérant les conditions imbriquées */ processIfBlocks(template, data) { // Trouver le {{#if}} le plus imbriqué (sans autres {{#if}} à l'intérieur) const ifPattern = /\{\{#if\s+([^}]+)\}\}((?:(?!\{\{#if)[\s\S])*?)\{\{\/if\}\}/g; return template.replace(ifPattern, (match, condition, content) => { const value = this.resolveNestedKey(data, condition.trim()); const isTrue = Boolean(value); // Chercher {{else}} dans le contenu (mais pas dans des sous-conditions) const elseParts = content.split(/\{\{else\}\}/); if (elseParts.length === 2) { // Il y a un {{else}} return isTrue ? elseParts[0] : elseParts[1] } else { // Pas de {{else}}, juste la condition return isTrue ? content : '' } }) } /** * Traite les blocs {{#unless}}...{{/unless}} */ processUnlessBlocks(template, data) { const unlessPattern = /\{\{#unless\s+([^}]+)\}\}((?:(?!\{\{#unless)[\s\S])*?)\{\{\/unless\}\}/g; return template.replace(unlessPattern, (match, condition, content) => { const value = this.resolveNestedKey(data, condition.trim()); const isFalse = !Boolean(value); return isFalse ? content : '' }) } /** * Gestion basique des conditions {{#if}} et {{#unless}} (déprécié - utiliser processConditionalBlocks) */ handleConditional(data, key, isIf) { const conditionKey = key.split(' ')[1]; const value = this.resolveNestedKey(data, conditionKey); const condition = Boolean(value); return (isIf ? condition : !condition) ? '' : '<!-- condition-false -->' } /** * Templates de fallback en cas d'erreur de chargement */ getFallbackTemplate(templateName) { const fallbacks = { 'CashbackTemplate': ` <div class="vipros-offer-card cashback-mode"> <div class="offer-content"> <div class="main-value"> <span class="amount">{{cashback.value}}</span> <span class="currency">€</span> <span class="label">remboursés</span> </div> </div> </div> `, 'VIPointsTemplate': ` <div class="vipros-offer-card vipoints-mode"> <div class="offer-content"> <div class="main-value"> <span class="amount">{{vipoints.perTenEuros}}</span> <span class="label">VIPoints</span> </div> </div> </div> `, 'LoadingTemplate': ` <div class="vipros-offer-card loading-state"> <div class="loading-text">Recherche...</div> </div> `, 'ErrorTemplate': ` <div class="vipros-offer-card error-state"> <div class="error-text">{{message}}</div> <div class="debug-notice">Erreur affichée car le mode debug est activé</div> </div> ` }; return fallbacks[templateName] || '<div class="vipros-offer-card">Template not found</div>' } /** * Précharge tous les templates pour de meilleures performances */ async preloadTemplates() { const templateNames = ['CashbackTemplate', 'VIPointsTemplate', 'LoadingTemplate', 'ErrorTemplate']; const promises = templateNames.map(name => this.loadTemplate(name).catch(error => { if (this.debug) console.warn(`[TemplateLoader] Failed to preload ${name}:`, error.message); return null }) ); await Promise.all(promises); if (this.debug) console.log('[TemplateLoader] Templates preloaded'); } /** * Nettoie le cache des templates */ clearCache() { this.templates.clear(); } } /** * ProductDataTransformer - Transformation des données API vers format composant * * Centralise toute la logique de transformation des données API * vers le format attendu par les templates. */ class ProductDataTransformer { /** * Transforme les données API vers le format template */ static transform(apiProduct, price = null) { if (!apiProduct) { return null } const hasCashback = Boolean(apiProduct.cashback?.value > 0); const generosityRate = apiProduct.brand?.generosity_rate || 0; // Calculer les VIPoints selon le contexte const vipointsData = this.calculateVIPoints(generosityRate, price); return { id: apiProduct.id || 'not_found', ean: apiProduct.ean, name: apiProduct.name || 'Produit inconnu', price: price, brand: this.transformBrandData(apiProduct.brand), cashback: this.transformCashbackData(apiProduct.cashback), vipoints: vipointsData, hasCashback, viprosLink: this.buildViprosLink(apiProduct), // URL de l'image VIPoints calculée dynamiquement vipointsImageUrl: null // Sera définie par le composant } } /** * Transforme les données de marque */ static transformBrandData(brandData) { if (!brandData) { return { name: 'Marque inconnue', displayName: null, logoUrl: null, generosityRate: 0 } } const displayName = brandData.name && brandData.name !== 'Marque inconnue' ? brandData.name : null; return { name: brandData.name || 'Marque inconnue', displayName, logoUrl: brandData.logo_url || null, generosityRate: brandData.generosity_rate || 0 } } /** * Transforme les données de cashback */ static transformCashbackData(cashbackData) { if (!cashbackData) { return { value: 0, name: null, imageUrl: null, startDate: null, endDate: null } } return { value: cashbackData.value || 0, name: cashbackData.name || null, imageUrl: cashbackData.image_url || null, startDate: cashbackData.start_date || null, endDate: cashbackData.end_date || null } } /** * Calcule les données VIPoints selon le contexte */ static calculateVIPoints(generosityRate, price) { let perTenEuros = 0; let hasBonus = false; let showExact = false; if (generosityRate > 0) { hasBonus = true; if (price && price > 0) { // Prix renseigné : calculer le nombre exact de VIPoints perTenEuros = Math.floor((generosityRate * price) / 10); showExact = true; } else { // Pas de prix : affichage générique "1 VIPoint tous les 10€" perTenEuros = 1; showExact = false; } } return { perTenEuros, generosityRate, hasBonus, showExact } } /** * Détermine le mode d'affichage selon les données */ static determineDisplayMode(transformedData) { if (!transformedData) { return 'empty' } if (transformedData.hasCashback) { return 'cashback' } if (transformedData.vipoints.hasBonus) { return 'vipoints' } return 'empty' } /** * Valide les données transformées */ static validate(transformedData) { if (!transformedData) { return { isValid: false, errors: ['No data provided'] } } const errors = []; if (!transformedData.ean) { errors.push('EAN is required'); } if (transformedData.hasCashback && !transformedData.cashback.value) { errors.push('Cashback value is required when hasCashback is true'); } if (transformedData.vipoints.hasBonus && !transformedData.vipoints.generosityRate) { errors.push('Generosity rate is required when VIPoints bonus is available'); } return { isValid: errors.length === 0, errors } } /** * Prépare les données pour le template */ static prepareTemplateData(transformedData, vipointsImageUrl = null) { if (!transformedData) { return null } return { ...transformedData, vipointsImageUrl: vipointsImageUrl || this.getDefaultVIPointsImageUrl() } } /** * Construit l'URL VIPros complète avec les paramètres origin */ static buildViprosLink(apiProduct) { const baseUrl = apiProduct.vipros_link || 'https://vipros.fr'; // Récupérer le nom du point de vente depuis les données API const origin = this.getSalesPointName(apiProduct); // URL de la page actuelle const originUrl = typeof window !== 'undefined' ? window.location.href : ''; // Construire l'URL avec les paramètres const url = new URL(baseUrl); if (origin) { url.searchParams.set('origin', origin); } if (originUrl) { url.searchParams.set('origin_url', originUrl); } return url.toString() } /** * Extrait le nom du point de vente depuis les données API */ static getSalesPointName(apiProduct) { // Chercher dans les sales_points nested if (apiProduct.sales_points && Array.isArray(apiProduct.sales_points)) { const salesPoint = apiProduct.sales_points.find(sp => sp.name); if (salesPoint) { return salesPoint.name } } // Fallback : utiliser le domaine actuel comme nom du point de vente if (typeof window !== 'undefined') { const hostname = window.location.hostname; // Transformer le domaine en nom plus lisible return hostname.replace(/^www\./, '').replace(/\./g, ' ').split(' ')[0] } return null } /** * URL par défaut de l'image VIPoints */ static getDefaultVIPointsImageUrl() { // Sera définie dynamiquement par le composant selon son contexte return '/api/sdk/assets/image-vipoints.jpg' } } /** * DisplayModeResolver - Résolution du mode d'affichage * * Détermine comment afficher le composant selon les données produit * et les options de configuration utilisateur. */ class DisplayModeResolver { /** * Résout le mode d'affichage principal */ static resolveDisplayMode(productData) { if (!productData) { return 'empty' } // Mode cashback prioritaire if (productData.hasCashback) { return 'cashback' } // Mode VIPoints si disponible if (productData.vipoints.hasBonus) { return 'vipoints' } // Aucune offre disponible return 'empty' } /** * Résout le template à utiliser selon le mode */ static resolveTemplateName(displayMode) { const templateMap = { 'cashback': 'CashbackTemplate', 'vipoints': 'VIPointsTemplate', 'loading': 'LoadingTemplate', 'error': 'ErrorTemplate', 'empty': null, 'hidden': null }; return templateMap[displayMode] || null } /** * Détermine si le composant doit être visible */ static shouldDisplay(displayMode) { return !['empty', 'hidden'].includes(displayMode) } /** * Résout la classe CSS du conteneur */ static resolveContainerClass(displayMode) { const classMap = { 'cashback': 'cashback-mode', 'vipoints': 'vipoints-mode', 'loading': 'loading-state', 'error': 'error-state', 'empty': 'empty-state', 'hidden': 'hidden-state' }; return `vipros-offer-card ${classMap[displayMode] || ''}` } /** * Validation du mode d'affichage */ static validateDisplayMode(displayMode) { const validModes = ['cashback', 'vipoints', 'loading', 'error', 'empty', 'hidden']; if (!validModes.includes(displayMode)) { console.warn(`[DisplayModeResolver] Invalid display mode: ${displayMode}`); return 'error' } return displayMode } /** * Analyse complète pour déterminer l'affichage */ static analyze(productData, options = {}) { const { isLoading = false, error = null, debug = false } = options; // États d'erreur et de chargement prioritaires if (error) { // Afficher les erreurs seulement si debug est activé if (debug) { return { mode: 'error', template: 'ErrorTemplate', shouldDisplay: true, containerClass: this.resolveContainerClass('error'), data: { message: error.message || 'Une erreur est survenue', debugMode: true } } } else { // En mode non-debug, ne pas afficher les erreurs (masquer complètement) return { mode: 'hidden', template: null, shouldDisplay: false, containerClass: this.resolveContainerClass('hidden'), data: {} } } } if (isLoading) { return { mode: 'loading', template: 'LoadingTemplate', shouldDisplay: true, containerClass: this.resolveContainerClass('loading'), data: {} } } // Résolution normale const mode = this.resolveDisplayMode(productData); const template = this.resolveTemplateName(mode); const shouldDisplay = this.shouldDisplay(mode); const containerClass = this.resolveContainerClass(mode); return { mode: this.validateDisplayMode(mode), template, shouldDisplay, containerClass, data: shouldDisplay ? productData : {} } } /** * Vérifie si les données sont suffisantes pour l'affichage */ static hasMinimumRequiredData(productData) { if (!productData) { return false } // Au minimum besoin d'un EAN if (!productData.ean) { return false } // Doit avoir soit cashback soit VIPoints return productData.hasCashback || productData.vipoints.hasBonus } /** * Suggestions d'amélioration des données */ static suggestDataImprovements(productData) { if (!productData) { return ['Product data is required'] } const suggestions = []; if (!productData.name || productData.name === 'Produit inconnu') { suggestions.push('Product name could be improved'); } if (productData.hasCashback && !productData.cashback.imageUrl) { suggestions.push('Cashback image would improve visual appeal'); } if (!productData.price && productData.vipoints.hasBonus) { suggestions.push('Price would enable exact VIPoints calculation'); } if (!productData.brand.displayName) { suggestions.push('Brand name would improve VIPoints messaging'); } return suggestions } } /** * EventManager - Gestionnaire d'événements pour ViprosOfferCard * * Centralise la gestion des événements du composant Web Component. * Simplifie l'émission et l'écoute d'événements personnalisés. */ class EventManager { constructor(element) { this.element = element; this.listeners = new Map(); } /** * Émet un événement personnalisé */ emit(eventName, detail = {}) { const event = new CustomEvent(eventName, { detail, bubbles: true, cancelable: true }); this.element.dispatchEvent(event); // Log pour debugging if (this.element.getAttribute('debug') === 'true') { console.log(`[ViprosOfferCard] Event emitted: ${eventName}`, detail); } } /** * Émet l'événement d'offre chargée */ emitOfferLoaded(productData, ean) { this.emit('vipros-offer-loaded', { product: productData, ean: ean, timestamp: Date.now() }); } /** * Émet l'événement d'erreur */ emitError(error, ean) { this.emit('vipros-offer-error', { error: { message: error.message || 'Unknown error', type: error.constructor.name, stack: error.stack }, ean: ean, timestamp: Date.now() }); } /** * Émet l'événement de clic sur offre */ emitOfferClick(url, productData, ean) { this.emit('vipros-offer-click', { url, product: productData, ean: ean, timestamp: Date.now() }); } /** * Émet l'événement de synchronisation démarrée */ emitSyncStarted(ean, url) { this.emit('vipros-sync-started', { ean, url, timestamp: Date.now() }); } /** * Émet l'événement de synchronisation réussie */ emitSyncSuccess(ean, result) { this.emit('vipros-sync-success', { ean, result, timestamp: Date.now() }); } /** * Émet l'événement d'erreur de synchronisation */ emitSyncError(ean, error) { this.emit('vipros-sync-error', { ean, error: { message: error.message || 'Sync error', type: error.constructor.name }, timestamp: Date.now() }); } /** * Ajoute des listeners pour les clics sur les liens */ attachLinkListeners(shadowRoot, productData, ean) { const links = shadowRoot.querySelectorAll('a[href]'); links.forEach(link => { const clickHandler = (event) => { this.emitOfferClick(link.href, productData, ean); // Optionnel : tracking analytics if (window.gtag) { window.gtag('event', 'vipros_offer_click', { ean: ean, url: link.href, product_name: productData?.name }); } }; link.addEventListener('click', clickHandler); // Stocker la référence pour cleanup if (!this.listeners.has('linkClicks')) { this.listeners.set('linkClicks', []); } this.listeners.get('linkClicks').push({ link, handler: clickHandler }); }); } /** * Supprime tous les listeners attachés */ cleanup() { // Nettoyer les listeners de liens if (this.listeners.has('linkClicks')) { this.listeners.get('linkClicks').forEach(({ link, handler }) => { link.removeEventListener('click', handler); }); } this.listeners.clear(); } /** * Configuration des événements globaux pour debugging */ enableGlobalDebugging() { const events = [ 'vipros-offer-loaded', 'vipros-offer-error', 'vipros-offer-click', 'vipros-sync-started', 'vipros-sync-success', 'vipros-sync-error' ]; events.forEach(eventName => { document.addEventListener(eventName, (event) => { console.log(`[ViprosOfferCard] Global event: ${eventName}`, event.detail); }); }); } /** * Méthodes statiques pour les composants sans instance */ static emit(element, eventName, detail = {}) { const manager = new EventManager(element); manager.emit(eventName, detail); } static emitOfferLoaded(element, productData, ean) { const manager = new EventManager(element); manager.emitOfferLoaded(productData, ean); } static emitError(element, error, ean) { const manager = new EventManager(element); manager.emitError(error, ean); } } /** * ViprosOfferCard - Web Component for displaying VIPros offers */ let ViprosOfferCard$1 = class ViprosOfferCard extends HTMLElement { constructor() { super(); // Shadow DOM for component isolation this.attachShadow({ mode: 'open' }); this.templateLoader = new TemplateLoader(); this.eventManager = new EventManager(this); // Component state this.state = { isLoading: false, productData: null, error: null, apiClient: null }; // Bind methods this.init = this.init.bind(this); this.loadProduct = this.loadProduct.bind(this); this.render = this.render.bind(this); } // Observed attributes static get observedAttributes() { return [ 'ean', 'price', 'api-base-url', 'api-key', 'primary-color', 'text-color', 'background-color', 'card-bonus-bg', 'card-bonus-color' ] } // Attribute getters get ean() { return this.getAttribute('ean') || this.getConfigValue('ean') } get price() { const attrPrice = parseFloat(this.getAttribute('price')) || 0; if (attrPrice > 0) return attrPrice const configPrice = this.getConfigValue('price'); if (configPrice && parseFloat(configPrice) > 0) return parseFloat(configPrice) return null } get apiBaseUrl() { return this.getConfigValue('apiBaseUrl') || this.getAttribute('api-base-url') || this.detectFallbackApiBaseUrl() } get apiKey() { return this.getConfigValue('apiKey') || this.getAttribute('api-key') } get primaryColor() { return this.getAttribute('primary-color') || this.getConfigValue('primaryColor') } get textColor() { return this.getAttribute('text-color') || this.getConfigValue('textColor') } get backgroundColor() { return this.getAttribute('background-color') || this.getConfigValue('backgroundColor') } get cardBonusBg() { return this.getAttribute('card-bonus-bg') || this.getConfigValue('cardBonusBg') } get cardBonusColor() { return this.getAttribute('card-bonus-color') || this.getConfigValue('cardBonusColor') } // SDK configuration helpers getConfigValue(key) { const sdk = this.getSDKInstance(); return sdk?.config?.[key] || null } getSDKInstance() { return typeof window !== 'undefined' ? window.ViprosSDK : null } detectFallbackApiBaseUrl() { // Edge case: No window object (SSR or Node.js environment) if (typeof window === 'undefined') { this.log('[ViprosOfferCard] No window object - defaulting to production API'); return 'https://msys.vipros.fr/api' } try { // Edge case: window.location is not available or corrupted if (!window.location || typeof window.location.hostname !== 'string') { this.log('[ViprosOfferCard] Invalid window.location - defaulting to production API'); return 'https://msys.vipros.fr/api' } const protocol = window.location.protocol || 'https:'; const hostname = window.location.hostname.toLowerCase(); // Edge case: Empty or invalid hostname if (!hostname || hostname.length === 0) { this.log('[ViprosOfferCard] Empty hostname - defaulting to production API'); return 'https://msys.vipros.fr/api' } // Edge case: Local development or non-standard protocols if (protocol === 'file:' || hostname === 'localhost' || hostname.startsWith('127.0.0.1') || hostname.startsWith('0.0.0.0')) { this.log('[ViprosOfferCard] Local development detected - using DDEV fallback'); return 'https://vipros-connect.ddev.site/api' } // Correspondance directe des domaines (même logique que ConfigManager) if (hostname === 'vipros-connect.ddev.site') { return `${protocol}//${hostname}/api` } if (hostname === 'msys.preprod.vipros.fr') { return `${protocol}//msys.preprod.vipros.fr/api` } // Edge case: Subdomain detection for enterprise customers if (hostname.endsWith('.vipros.fr')) { this.log(`[ViprosOfferCard] VIPros subdomain detected: ${hostname}`); return `${protocol}//${hostname}/api` } // Par défaut : production (toujours HTTPS pour la sécurité) this.log(`[ViprosOfferCard] Unknown domain: ${hostname} - defaulting to production API`); return 'https://msys.vipros.fr/api' } catch (error) { // Edge case: Exception during detection this.log(`[ViprosOfferCard] Error detecting API base URL: ${error.message} - defaulting to production`); return 'https://msys.vipros.fr/api' } } // Lifecycle methods connectedCallback() { this.log('[ViprosOfferCard] Component connected'); this.init(); } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue && this.isConnected) { if (name === 'ean') { this.log(`[ViprosOfferCard] EAN changed: ${oldValue} → ${newValue}`); this.loadProduct(); } else if ([ 'primary-color', 'text-color', 'background-color', 'gift-icon-color', 'gift-card-bonus-bg', 'gift-card-bonus-color', 'vipoints-bonus-bg', 'vipoints-bonus-color' ].includes(name)) { this.log(`[ViprosOfferCard] Color changed: ${name} = ${newValue}`); this.updateCustomColors(); } } } disconnectedCallback() { this.log('[ViprosOfferCard] Component disconnected'); this.cleanup(); } // Initialization async init() { try { // Load Google Fonts in document head this.loadGoogleFonts(); // Load CSS styles await this.loadStyles(); // Apply custom colors if provided this.updateCustomColors(); // Initialize API client await this.initApiClient(); // Preload templates await this.templateLoader.preloadTemplates(); // Load product data if (this.ean) { await this.loadProduct(); } else { this.setError(new Error('EAN requis pour afficher une offre VIPros')); } } catch (error) { this.logError('[ViprosOfferCard] Init error:', error); this.setError(error); } } // Load Google Fonts in document head loadGoogleFonts() { const fontsToLoad = [ 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap', 'https://fonts.googleapis.com/css2?family=Encode+Sans+Condensed:wght@100;200;300;400;500;600;700;800;900&display=swap' ]; fontsToLoad.forEach(fontUrl => { // Check if font link already exists const existingLink = document.querySelector(`link[href="${fontUrl}"]`); if (!existingLink) { const linkElement = document.createElement('link'); linkElement.rel = 'stylesheet'; linkElement.href = fontUrl; linkElement.crossOrigin = 'anonymous'; document.head.appendChild(linkElement); } }); } // Load CSS styles async loadStyles() { try { const styleUrl = this.templateLoader.baseUrl.replace('/components/templates', '/components/styles/offer-card.css'); const response = await fetch(styleUrl); if (response.ok) { const css = await response.text(); const styleElement = document.createElement('style'); styleElement.textContent = css; this.shadowRoot.appendChild(styleElement); } else { // Fallback avec styles minimaux this.injectFallbackStyles(); } } catch (error) { this.logError('[ViprosOfferCard] CSS loading failed, using fallback'); this.injectFallbackStyles(); } } // Update custom colors via CSS variables updateCustomColors() { if (!this.shadowRoot) return const host = this.shadowRoot.host; const customColors = {}; // Map attributes to CSS variables if (this.primaryColor) { customColors['--vipros-primary'] = this.primaryColor; customColors['--vipros-text-accent'] = this.primaryColor; } if (this.textColor) { customColors['--vipros-text-main'] = this.textColor; } if (this.backgroundColor) { customColors['--vipros-background'] = this.backgroundColor; } if (this.cardBonusBg) { customColors['--card-bonus-bg'] = this.cardBonusBg; } if (this.cardBonusColor) { customColors['--card-bonus-color'] = this.cardBonusColor; } // Apply custom colors to the host element Object.entries(customColors).forEach(([property, value]) => { host.style.setProperty(property, value); }); this.log('[ViprosOfferCard] Custom colors applied:', customColors); } injectFallbackStyles() { const fallbackCSS = ` .vipros-offer-card { display: block; padding: 20px; background: #e8ecf8; border-radius: 12px; font-family: sans-serif; } .loading-content, .error-content { text-align: center; padding: 40px; } `; const styleElement = document.createElement('style'); styleElement.textContent = fallbackCSS; this.shadowRoot.appendChild(styleElement); } // Méthodes de logging respectant le mode debug log(message, ...args) { const sdk = this.getSDKInstance(); if (sdk && sdk.config && sdk.config.debug === true) { console.log(message, ...args); } } logError(message, ...args) { const sdk = this.getSDKInstance(); if (sdk && sdk.config && sdk.config.debug === true) { console.error(message, ...args); } } // Initialisation API client async initApiClient() { const sdk = this.getSDKInstance(); if (sdk && sdk.getApiClient) { this.state.apiClient = sdk.getApiClient(); this.log('[ViprosOfferCard] Using shared SDK ApiClient'); } else { // Fallback : créer son propre ApiClient const { default: ApiClient } = await Promise.resolve().then(function () { return ApiClient$1; }); this.state.apiClient = new ApiClient({ apiBaseUrl: this.apiBaseUrl, apiKey: this.apiKey }); this.log('[ViprosOfferCard] Created local ApiClient'); } } // Chargement des données produit async loadProduct() { if (!this.ean || this.state.isLoading) return // Attendre que l'apiClient soit prêt if (!this.state.apiClient) { setTimeout(() => this.loadProduct(), 100); return } this.setLoading(true); try { this.log(`[ViprosOfferCard] Loading product: ${this.ean}`); // NOUVEAU Phase 8: Préparer les options pour la sync inline si activée const sdk = this.getSDKInstance(); const apiOptions = { timeout: 5000 // 5 secondes pour éviter les timeouts longs sur rate limit }; // Activer la sync inline si configuré dans le SDK if (sdk?.config?.inlineSyncEnabled) { apiOptions.enableInlineSync = true; apiOptions.productUrl = window.location.href; this.log(`[ViprosOfferCard] Inline sync enabled for EAN ${this.ean}`); } // Appel API avec options de sync inline const response = await this.state.apiClient.get('/catalog/items', { ean: this.ean, items_per_page: 1 }, apiOptions); const bloomMetadata = response?.metadata?.bloomFilter; const debugEnabled = Boolean(this.getConfigValue('debug') || this.getAttribute('debug') === 'true'); if (bloomMetadata?.skipped) { this.log(`[ViprosOfferCard] Bloom filter skipped API call for EAN ${this.ean}`); if (debugEnabled) { const bloomError = new Error('404 | Produit introuvable (Bloom filter)'); bloomError.status = 404; bloomError.type = 'BLOOM_FILTER_NOT_FOUND'; bloomError.data = { ean: this.ean, reason: bloomMetadata.reason || 'NOT_IN_FILTER' }; this.setError(bloomError); } else { this.setProductData(null); } return } // Log sync metadata if present (Phase 8) const syncMetadata = response?.sdk_metadata?.sync; if (syncMetadata && this.getConfigValue('debug')) { this.log(`[ViprosOfferCard] Sync metadata:`, syncMetadata); } if (response.items && response.items.length > 0) { // Transformer les données const productData = ProductDataTransformer.transform(response.items[0], this.price); productData.vipointsImageUrl = this.getVIPointsImageUrl(); this.setProductData(productData); this.log(`[ViprosOfferCard] Product loaded: ${productData.name}`); // Synchronisation en arrière-plan (seulement si inline sync n'a pas été tentée) // On vérifie si sync metadata existe, peu importe si triggered=true ou cooldown const inlineSyncWasAttempted = syncMetadata !== undefined && syncMetadata !== null; if (!inlineSyncWasAttempted) { this.triggerBackgroundSync(); } else { this.log(`[ViprosOfferCard] Skipping background sync (inline sync was attempted, status: ${syncMetadata.reason || 'unknown'})`); } // Émettre événement this.eventManager.emitOfferLoaded(productData, this.ean); } else { this.log(`[ViprosOfferCard] No product found for EAN: ${this.ean}`); this.setProductData(null); } } catch (error) { this.logError(`[ViprosOfferCard] Error loading EAN ${this.ean}:`, error); // Gestion spécifique des erreurs rate limit if (error.status === 429 || error.type === 'RATE_LIMIT_ERROR') { const rateLimitError = new Error('API rate limit exceeded - Trop de requêtes simultanées'); rateLimitError.status = 429; rateLimitError.type = 'RATE_LIMIT_ERROR'; this.setError(rateLimitError); } else { this.setError(error); } } finally { this.setLoading(false); } } // Gestion d'état setLoading(isLoading) { this.state.isLoading = isLoading; // Un seul render() - il gère lui-même l'état loading this.render(); } setProductData(productData) { this.state.productData = productData; this.state.error = null; // Ne pas render ici, sera fait par setLoading(false) } setError(error) { this.state.error = error; this.state.productData = null; this.render(); this.eventManager.emitError(error, this.ean); } // Rendu principal async render() { // Toujours analyser l'état actuel pour déterminer le template const analysis = DisplayModeResolver.analyze(this.state.productData, { isLoading: this.state.isLoading, error: this.state.error, debug: this.getConfigValue('debug') || false }); if (!analysis.shouldDisplay) { this.updateShadowContent('<div class="vipros-offer-card hidden"></div>'); return } if (analysis.template) { const html = await this.templateLoader.render(analysis.template, analysis.data); // Mettre à jour le contenu en préservant les styles this.updateShadowContent(html); // Attacher les event listeners seulement si pas en loading if (!this.state.isLoading) { this.attachEventListeners(); } } } // Rendus spécialisés (déprécié - utiliser render() directement) async renderLoading() { // Cette méthode est dépréciée - render() gère maintenant le loading await this.render(); } // Utilitaire pour mettre à jour le contenu en préservant les styles updateShadowContent(html) { // Collecter tous les styles existants const styles = Array.from(this.shadowRoot.querySelectorAll('style')); const styleTexts = styles.map(style => style.textContent); // Destruction complète du contenu this.shadowRoot.replaceChildren(); // Parser le nouveau HTML et l'ajouter const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; // Transférer tous les enfants du div temporaire vers shadowRoot while (tempDiv.firstChild) { this.shadowRoot.appendChild(tempDiv.firstChild); } // Recréer les styles styleTexts.forEach(styleText => { const styleElement = document.createElement('style'); styleElement.textContent = styleText; this.shadowRoot.appendChild(styleElement); }); } // Event listeners attachEventListeners() { if (this.state.productData) { this.eventManager.attachLinkListeners( this.shadowRoot, this.state.productData, this.ean ); } } // Utilitaires getVIPointsImageUrl() { const baseUrl = (this.apiBaseUrl || '').replace('/api', ''); return `${baseUrl}/api/sdk/assets/image-vipoints.jpg` } triggerBackgroundSync() { const sdk = this.getSDKInstance(); if (sdk?.syncService) { setTimeout(() => { this.log(`[ViprosOfferCard] Background sync for EAN ${this.ean}`); sdk.syncProductInBackground(this.ean, window.location.href); }, 100); } } // Nettoyage cleanup() { this.eventManager.cleanup(); this.state.apiClient = null; this.state.productData = null; } }; // Référence SDK (méthodes statiques de l'ancien composant) ViprosOfferCard$1.setSDKInstance = function(sdk) { if (sdk?.config?.debug) { console.log('[ViprosOfferCard] SDK instance connected'); } }; class ConfigManager { static create (userConfig = {}) { const defaultConfig = ConfigManager.getDefaultConfig(); const mergedConfig = ConfigManager.mergeConfig(defaultConfig, userConfig); const validatedConfig = ConfigManager.validateConfig(mergedConfig); return validatedConfig } static getDefaultConfig () { return { // API Configuration apiBaseUrl: ConfigManager.detectApiBaseUrl(), apiKey: null, // Clé API obligatoire - doit être fournie explicitement timeout: 10000, // EAN Configuration ean: null, // EAN par défaut pour les composants sans attribut ean // Global Component Defaults price: null, // Prix par défaut pour les composants sans attribut price primaryColor: null, // Couleur principale par défaut pour les composants sans attribut primary-color textColor: null, // Couleur de texte par défaut pour les composants sans attribut text-color backgroundColor: null, // Couleur de fond par défaut pour les composants sans attribut background-color cardBonusBg: null, // Couleur de fond des badges bonus par défaut pour les composants sans attribut card-bonus-bg cardBonusColor: null, // Couleur de texte/icône des badges bonus par défaut pour les composants sans attribut card-bonus-color // Cache Configuration cacheTimeout: 300000, // 5 minutes cachePrefix: 'vipros_sdk_', maxCacheSize: 100, // Container Configuration containerSelector: '[data-ean]', eanAttribute: 'data-ean', // Rendering Configuration renderPosition: 'replace', // 'before', 'after', 'prepend', 'append', 'replace' excludeSelectors: ['.vipros-offer', '[data-vipros-exclude]'], // Template Configuration - Templates par défaut seront utilisés par TemplateEngine templates: { // Ne pas spécifier de valeurs par défaut - TemplateEngine a ses propres templates intégrés }, // Styling Configuration styling: { theme: 'minimal', // 'minimal', 'branded', 'custom' customCSS: {}, inlineStyles: true, cssPrefix: 'vipros-', responsive: true }, // Performance Configuration batchRequests: true, lazyLoad: true, maxConcurrentRequests: 3, debounceDelay: 300, // Behavior Configuration hideOnError: false, retryOnError: true, gracefulDegradetion: true, // Sync Configuration syncEnabled: true, syncOptions: { cooldownTime: 24 * 60 * 60 * 1000, // 24 heures - une sync par jour par produit syncDelay: 100, // Délai avant sync en ms maxRetries: 3, retryDelay: 1000 // Délai entre retry en ms }, // Debug Configuration debug: false, verbose: false, logLevel: 'warn', // 'error', 'warn', 'info', 'debug' // Event Callbacks onReady: null, onOfferFound: null, onOfferRendered: null, onOfferLoaded: null, // Nouveau callback quand une offre est complètement chargée et affichée onError: null, // Feature Flags features: { analyticsTracking: false, performanceMonitoring: false, a11yEnhancements: true, seoOptimizations: true }, // Internationalization locale: 'fr-FR', currency: 'EUR', dateFormat: 'DD/MM/YYYY', // Version version: '1.0.0' } } static mergeConfig (defaultConfig, userConfig) { return ConfigManager.deepMerge(defaultConfig, userConfig) } static deepMerge (target, source) { const result = { ...target }; for (const [key, value] of Object.entries(source)) { if (value === null || value === undefined) { continue } if (ConfigManager.isObject(value) && ConfigManager.isObject(result[key])) { result[key] = ConfigManager.deepMerge(result[key], value); } else { result[key] = value; } } return result } static isObject (item) { return item && typeof item === 'object' && !Array.isArray(item) } static validateConfig (config) { const errors = []; // Validate API Configuration if (!config.apiBaseUrl || typeof config.apiBaseUrl !== 'string') { errors.push('apiBaseUrl must be a valid string'); } else { try { new URL(config.apiBaseUrl); } catch (e) { errors.push('apiBaseUrl must be a valid URL'); } } if (!config.apiKey) { errors.push('apiKey is required'); } if (config.apiKey && typeof config.apiKey !== 'string') { errors.push('apiKey must be a string'); } if (config.timeout && (typeof config.timeout !== 'number' || config.timeout <= 0)) { errors.push('timeout must be a positive number'); } // Validate Cache Configuration if (config.cacheTimeout && (typeof config.cacheTimeout !== 'number' || config.cacheTimeout < 0)) { errors.push('cacheTimeout must be a non-negative number'); } if (config.maxCacheSize && (typeof config.maxCacheSize !== 'number' || config.maxCacheSize <= 0)) { errors.push('maxCacheSize must be a positive number'); } // Validate Container Configuration if (config.containerSelector && typeof config.containerSelector !== 'string') { errors.push('containerSelector must be a string'); } if (config.eanAttribute && typeof config.eanAttribute !== 'string') { errors.push('eanAttribute must be a string'); } // Validate Styling if (config.styling) { const validThemes = ['minimal', 'branded', 'custom']; if (config.styling.theme && !validThemes.includes(config.styling.theme)) { errors.push(`styling.theme must be one of: ${validThemes.join(', ')}`); } if (config.styling.customCSS && !ConfigManager.isObject(config.styling.customCSS)) { errors.push('styling.customCSS must be an object'); } } // Validate Performance Configuration if (config.maxConcurrentRequests && (typeof config.maxConcurrentRequests !== 'number' || config.maxConcurrentRequests <= 0)) { errors.push('maxConcurrentRequests must be a positive number'); } if (config.debounceDelay && (typeof config.debounceDelay !== 'number' || config.debounceDelay < 0)) { errors.push('debounceDelay must be a non-negative number'); } // Validate Log Level const validLogLevels = ['error', 'warn', 'info', 'debug']; if (config.logLevel && !validLogLevels.includes(config.logLevel)) { errors.push(`logLevel must be one of: ${validLogLevels.join(', ')}`); } // Validate Event Callbacks const eventCallbacks = ['onReady', 'onOfferFound', 'onOfferRendered', 'onOfferLoaded', 'onError']; for (const callback of eventCallbacks) { if (config[callback] && typeof config[callback] !== 'function') { errors.push(`${callback} must be a function`); } } // Validate Locale if (config.locale && typeof config.locale !== 'string') { errors.push('locale must be a string'); } // Validate Currency if (config.currency && typeof config.currency !== 'string') { errors.push('currency must be a string'); } if (errors.length > 0) { const error = new Error(`Configuration validation failed:\n${errors.join('\n')}`); error.validationErrors = errors; throw error } // Apply post-validation processing return ConfigManager.processConfig(config) } static processConfig (config) { const processed = { ...config }; // Normalize API Base URL processed.apiBaseUrl = processed.apiBaseUrl.replace(/\/$/, ''); // Ensure containerSelector is properly formatted if (!processed.containerSelector.startsWith('[') && !processed.containerSelector.startsWith('.') && !processed.containerSelector.startsWith('#')) { processed.containerSelector = `[${processed.eanAttribute}]`; } // Set debug flags based on logLevel if (processed.logLevel === 'debug') { processed.debug = true; processed.verbose = true; } else if (processed.logLevel === 'info') { processed.debug = true; } // Validate and normalize CSS custom properties if (processed.styling.customCSS) { const normalizedCSS = {}; for (const [key, value] of Object.entries(processed.styling.customCSS)) { const cssProperty = key.startsWith('--') ? key : `--vipros-${key}`; normalizedCSS[cssProperty] = value; } processed.styling.customCSS = normalizedCSS; } // Set cache prefix with version processed.cachePrefix = `${processed.cachePrefix}v${processed.version.replace(/\./g, '_')}_`; // Calculate derived values processed.derived = { isProduction: !processed.debug, shouldCache: processed.cacheTimeout > 0, shouldBatch: processed.batchRequests && processed.maxConcurrentRequests > 1, shouldLazyLoad: processed.lazyLoad, hasCustomStyling: processed.styling.theme === 'custom' || Object.keys(processed.styling.customCSS || {}).length > 0 }; return processed } static createSecureConfig (userConfig, serverConfig = {}) { const clientSafeServerConfig = { apiBaseUrl: serverConfig.apiBaseUrl, allowedFeatures: serverConfig.allowedFeatures, rateLimits: serverConfig.rateLimits, version: serverConfig.version }; return ConfigManager.create({ ...clientSafeServerConfig, ...userConfig }) } static validateRuntimeConfig (config) { if (!config) { throw new Error('Configuration is required') } if (!config.derived) { throw new Error('Configuration has not been processed') } return true } static getConfigSummary (config) { return { version: config.version, apiBaseUrl: config.apiBaseUrl, theme: config.styling.theme, cacheEnabled: config.derived.shouldCache, debugMode: config.debug, features: Object.keys(config.features).filter(key => config.featu