vipros-sdk
Version:
VIPros SDK - Universal Web Components for cashback & VIPoints. Works with any framework!
1,715 lines (1,451 loc) • 120 kB
JavaScript
/**
* VIPros SDK v1.0.0 - ES Module
* Generated: 2025-11-05T14:19:57.836Z
*/
/**
* 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
*/
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.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.features[key])
}
}
/**
* Auto-détection de l'URL d'API selon le doma