vipros-sdk
Version:
VIPros SDK - Universal Web Components for cashback & VIPoints. Works with any framework!
1,686 lines (1,427 loc) • 123 kB
JavaScript
/**
* VIPros SDK Vue Plugin v1.0.0 - ES Module
* Generated: 2025-11-05T14:19:58.990Z
*/
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
/**
* 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.entrie