UNPKG

iluria-sdk

Version:

SDK oficial do Iluria para integração com lojas e criação de frontends customizados

1,395 lines (1,176 loc) 69.7 kB
/** * Iluria SDK - Browser Version Unificada * Combina funcionalidade modular (API) com processamento DOM/Templates */ // Importar módulos necessários const HttpBrowserClient = require('./http-browser'); const Store = require('./store'); const Product = require('./product'); const Promotion = require('./promotion'); const Collection = require('./collection'); const CombinedProduct = require('./combined-product'); const Blog = require('./blog'); const Menu = require('./menu'); const BrowserCacheManager = require('./cache/browser-manager.js'); const GraphQLQueryBuilder = require('./graphql-builder.js'); // Main SDK Class class IluriaSDK { constructor(config = {}) { if (typeof config === 'string') { config = { apiKey: config }; } const defaultBaseURL = this.detectBaseURL(); this.config = { apiKey: config.apiKey, token: config.token, baseURL: config.baseURL || defaultBaseURL, autoReplace: config.autoReplace !== false, cache: config.cache !== false, cacheTTL: config.cacheTTL || 300000, // 5 minutos default cacheStorage: config.cacheStorage || 'session', cacheDebug: config.cacheDebug || false, authMethod: config.apiKey ? 'apiKey' : config.token ? 'token' : 'host', timeout: config.timeout || 30000 }; // Constrói headers condicionalmente const defaultHeaders = { 'Content-Type': 'application/json', ...(config.headers || {}) }; // Adiciona autenticação se fornecida if (this.config.apiKey) { defaultHeaders['X-API-Key'] = this.config.apiKey; } else if (this.config.token) { defaultHeaders['Authorization'] = `Bearer ${this.config.token}`; } // HTTP Client para módulos (compatibilidade com browser-full) this.http = new HttpBrowserClient(this.config.baseURL, defaultHeaders); this.http.setTimeout(this.config.timeout); // Sistema de cache robusto this.cacheManager = this.config.cache ? new BrowserCacheManager({ memory: { maxSize: 100 }, storage: this.config.cacheStorage, ttl: this.config.cacheTTL, debug: this.config.cacheDebug }) : null; // Módulos com funcionalidade completa (do browser-full.js) this.store = new Store(this.http, this.cacheManager); this.product = new Product(this.http, this.cacheManager); this.promotion = new Promotion(this.http, this.cacheManager); this.collection = new Collection(this.http, this.cacheManager); this.combinedProduct = new CombinedProduct(this.http, this.cacheManager); this.blog = new Blog(this.http, this.cacheManager); this.menu = new Menu(this.http, this.cacheManager); // Query Builder para queries customizadas this.queryBuilder = new GraphQLQueryBuilder(); // Sistema de distribuição de produtos this.componentElements = new Map(); // elementId -> DOM element this.productDistribution = new Map(); // elementId -> produtos[] // Sistema de distribuição de blogs this.blogComponentElements = new Map(); // elementId -> DOM element this.blogDistribution = new Map(); // elementId -> blogs[] // Dados para processamento DOM this.data = {}; this.loading = true; this.error = null; } /** * Detecta a URL base automaticamente * @private */ detectBaseURL() { // Browser if (typeof window !== 'undefined') { // Desenvolvimento local - qualquer localhost/127.0.0.1 if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || window.location.hostname.startsWith('192.168.')) { return 'http://localhost:8080'; } // Arquivo local (file://) if (window.location.protocol === 'file:') { return 'http://localhost:8080'; } // Produção - sempre usa o storefront central return 'https://api.iluria.com'; } // Fallback return 'http://localhost:8080'; } /** * Executa uma query GraphQL customizada (compatibilidade browser-full) * @param {string} query - Query GraphQL * @param {object} variables - Variáveis da query * @returns {object} - Resultado da query */ async query(query, variables = {}) { return this.http.graphql(query, variables); } /** * Executa múltiplas queries em uma única requisição (compatibilidade browser-full) * @param {object} queries - Objeto com queries nomeadas * @returns {object} - Resultados das queries */ async batchQuery(queries) { const queryParts = []; const allVariables = {}; Object.entries(queries).forEach(([name, { query, variables }]) => { queryParts.push(`${name}: ${query}`); Object.assign(allVariables, variables); }); const combinedQuery = `query { ${queryParts.join(' ')} }`; return this.http.graphql(combinedQuery, allVariables); } /** * Define a API Key */ setApiKey(apiKey) { // Remove token se existir (apenas um método de auth por vez) if (this.config.token) { this.http.setHeader('Authorization', null); delete this.config.token; } if (apiKey) { this.http.setApiKey(apiKey); this.config.apiKey = apiKey; this.config.authMethod = 'apiKey'; } else { // Remove API Key se passar null/undefined this.http.setApiKey(null); delete this.config.apiKey; this.config.authMethod = 'host'; } } /** * Define o Token JWT */ setToken(token) { // Remove API Key se existir (apenas um método de auth por vez) if (this.config.apiKey) { this.http.setApiKey(null); delete this.config.apiKey; } if (token) { this.http.setHeader('Authorization', `Bearer ${token}`); this.config.token = token; this.config.authMethod = 'token'; } else { // Remove token se passar null/undefined this.http.setHeader('Authorization', null); delete this.config.token; this.config.authMethod = 'host'; } } /** * Define a URL base */ setBaseURL(baseURL) { this.http.baseURL = baseURL; this.config.baseURL = baseURL; } /** * Define o timeout das requisições */ setTimeout(timeout) { this.http.setTimeout(timeout); this.config.timeout = timeout; } /** * Analisa o DOM e coleta todos os campos necessários */ analyzeDOM() { this.queryBuilder.clear(); // Coleta campos data-iluria document.querySelectorAll('[data-iluria]').forEach(el => { const path = el.dataset.iluria; // Ignora campos item.* que estão dentro de templates // Eles são processados apenas quando o template é renderizado if (path.startsWith('item.')) { return; } const productId = el.dataset.productId || el.closest('[data-product-id]')?.dataset.productId; const blogSlug = el.dataset.blogSlug || el.closest('[data-iluria-blog-slug]')?.dataset.iluriaBlogSlug; if (productId && path.startsWith('product.')) { this.queryBuilder.addField(path, { productId }); } else if (blogSlug && path.startsWith('blog.')) { this.queryBuilder.addField(path, { blogSlug }); } else { this.queryBuilder.addField(path); } }); // Coleta campos de atributos especiais const attributeMappings = { 'data-iluria-src': 'src', 'data-iluria-href': 'href', 'data-iluria-bg': 'background-image', 'data-iluria-alt': 'alt', 'data-iluria-title': 'title' }; Object.keys(attributeMappings).forEach(attr => { document.querySelectorAll(`[${attr}]`).forEach(el => { const path = el.getAttribute(attr); // Ignora campos item.* que estão dentro de templates // Eles são processados apenas quando o template é renderizado if (path.startsWith('item.')) { return; } // Ignora expressões de template (contém {} ou começa com /) // Essas são URLs ou expressões, não campos de dados GraphQL if (path.includes('{') || path.includes('}') || path.startsWith('/')) { return; } const productId = el.dataset.productId || el.closest('[data-product-id]')?.dataset.productId; const blogSlug = el.dataset.blogSlug || el.closest('[data-iluria-blog-slug]')?.dataset.iluriaBlogSlug; if (productId && path.startsWith('product.')) { this.queryBuilder.addField(path, { productId }); } else if (blogSlug && path.startsWith('blog.')) { this.queryBuilder.addField(path, { blogSlug }); } else { this.queryBuilder.addField(path); } }); }); // Coleta campos para listas de produtos document.querySelectorAll('[data-iluria-list="products"]').forEach((el, elementIndex) => { // Pega o ID único do elemento (criado pelo editor) const elementId = el.getAttribute('data-element-id') || el.closest('[data-element-id]')?.getAttribute('data-element-id') || `auto-${elementIndex}`; // Verifica se é um componente com produtos já selecionados let productIds = null; const parentComponent = el.closest('[data-component="product-showcase"]'); console.log('[IluriaSDK] Processando showcase com ID:', elementId); if (parentComponent) { const selectedProducts = parentComponent.dataset.selectedProducts; // Se tem produtos selecionados, busca apenas esses IDs if (selectedProducts && selectedProducts.trim() !== '') { productIds = selectedProducts.split(',').filter(Boolean); console.log('[IluriaSDK] IDs solicitados:', productIds); } } // Também verifica se tem IDs diretamente no elemento da lista const directIds = el.dataset.iluriaProductIds; if (directIds && directIds.trim() !== '') { productIds = directIds.split(',').filter(Boolean); } const defaultFields = ['id', 'name', 'price', 'photos.urls.medium', 'hasVariation', 'variations.photos.urls.medium']; const fields = el.dataset.iluriaFields?.split(',').map(f => f.trim()) || defaultFields; // Garante que sempre busca campos necessários para variações if (!fields.includes('hasVariation')) { fields.push('hasVariation'); } if (!fields.includes('variations.photos.urls.medium')) { fields.push('variations.photos.urls.medium'); } // Se tem produtos específicos, registra o componente if (productIds && productIds.length > 0) { // Registra componente com seus IDs específicos this.queryBuilder.registerComponent(elementId, productIds, fields); // Mapeia elemento DOM para posterior distribuição this.componentElements.set(elementId, el); console.log('[IluriaSDK] Componente registrado:', elementId, 'com', productIds.length, 'produtos'); } else { // Sem produtos selecionados - não adiciona campos para evitar buscar todos // O componente mostrará "Nenhum produto selecionado" console.log('[IluriaSDK] Componente sem produtos:', elementId); } }); // Coleta campos para listas de blogs (usando blogPosts como no backend) document.querySelectorAll('[data-iluria-list="blogPosts"]').forEach((el, elementIndex) => { // Pega o ID único do elemento (criado pelo editor) const elementId = el.getAttribute('data-element-id') || el.closest('[data-element-id]')?.getAttribute('data-element-id') || `blog-auto-${elementIndex}`; // Verifica se é um componente com blogs já selecionados let blogIds = null; const parentComponent = el.closest('[data-component="blog-showcase"]'); console.log('[IluriaSDK] Processando blog showcase com ID:', elementId); if (parentComponent) { const selectedBlogs = parentComponent.dataset.selectedBlogs; // Se tem blogs selecionados, busca apenas esses IDs if (selectedBlogs && selectedBlogs.trim() !== '') { blogIds = selectedBlogs.split(',').filter(Boolean); console.log('[IluriaSDK] Blog IDs solicitados:', blogIds); } } // Também verifica se tem IDs diretamente no elemento da lista const directIds = el.dataset.iluriaBlogIds; if (directIds && directIds.trim() !== '') { blogIds = directIds.split(',').filter(Boolean); } const defaultFields = ['id', 'title', 'slug', 'excerpt', 'photos.urls.medium', 'photos.originalUrl', 'publishedAt', 'createdAt']; const fields = el.dataset.iluriaFields?.split(',').map(f => f.trim()) || defaultFields; // Se tem blogs específicos, registra o componente de blog if (blogIds && blogIds.length > 0) { // Registra componente de blog com seus IDs específicos this.queryBuilder.registerBlogComponent(elementId, blogIds, fields); // Mapeia elemento DOM para posterior distribuição this.blogComponentElements.set(elementId, el); console.log('[IluriaSDK] Componente de blog registrado:', elementId, 'com', blogIds.length, 'blogs'); } else { // Sem blogs selecionados - não adiciona campos para evitar buscar todos // O componente mostrará mensagem apropriada console.log('[IluriaSDK] Componente de blog sem posts:', elementId); } }); // Coleta campos para blog individual por slug document.querySelectorAll('[data-iluria-blog-slug]').forEach(el => { const slug = el.dataset.iluriaBlogSlug; if (slug) { // Always include photo fields for blogs to support featuredImageUrl this.queryBuilder.addField('blog.photos.urls.medium', { blogSlug: slug }); this.queryBuilder.addField('blog.photos.originalUrl', { blogSlug: slug }); // Coleta todos os campos blog.* dentro do elemento el.querySelectorAll('[data-iluria^="blog."]').forEach(childEl => { const path = childEl.dataset.iluria; const field = path.substring(5); // Remove 'blog.' // Skip if it's featuredImageUrl since we handle it with photos fields if (field !== 'featuredImageUrl') { this.queryBuilder.addField(path, { blogSlug: slug }); } }); // Também verifica atributos especiais Object.keys(attributeMappings).forEach(attr => { el.querySelectorAll(`[${attr}^="blog."]`).forEach(childEl => { const path = childEl.getAttribute(attr); const field = path.substring(5); // Skip if it's featuredImageUrl since we handle it with photos fields if (field !== 'featuredImageUrl') { this.queryBuilder.addField(path, { blogSlug: slug }); } }); }); } }); // Coleta campos condicionais document.querySelectorAll('[data-iluria-if], [data-iluria-unless]').forEach(el => { const ifPath = el.dataset.iluriaIf; const unlessPath = el.dataset.iluriaUnless; const path = ifPath || unlessPath; if (path) { // Ignora campos item.* que estão dentro de templates if (path.startsWith('item.')) { return; } // Remove operadores de negação para adicionar ao query builder const cleanPath = path.replace(/^!/, ''); const productId = el.dataset.productId || el.closest('[data-product-id]')?.dataset.productId; const blogSlug = el.dataset.blogSlug || el.closest('[data-iluria-blog-slug]')?.dataset.iluriaBlogSlug; if (productId && cleanPath.startsWith('product.')) { this.queryBuilder.addField(cleanPath, { productId }); } else if (blogSlug && cleanPath.startsWith('blog.')) { this.queryBuilder.addField(cleanPath, { blogSlug }); } else { this.queryBuilder.addField(cleanPath); } } }); } /** * Busca dados via GraphQL (para processamento DOM) */ async fetchGraphQL(query, variables = {}) { // Gera chave de cache const cacheKey = this.cacheManager ? this.cacheManager.getCacheKey(query, variables) : null; // Verifica cache primeiro if (this.cacheManager) { const cached = await this.cacheManager.get(cacheKey); if (cached !== null) { return cached; } } // Função de fetch - usa HttpBrowserClient se disponível, senão fetch nativo const fetcher = async () => { try { let result; // Tenta usar HttpBrowserClient primeiro (mais robusto) if (this.http && this.http.graphql) { const data = await this.http.graphql(query, variables); result = { data }; } else { // Fallback para fetch nativo (compatibilidade) const headers = { 'Content-Type': 'application/json' }; if (this.config.apiKey) { headers['X-API-Key'] = this.config.apiKey; } else if (this.config.token) { headers['Authorization'] = `Bearer ${this.config.token}`; } const response = await fetch(`${this.config.baseURL}/graphql`, { method: 'POST', headers, body: JSON.stringify({ query, variables }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } result = await response.json(); } if (result.errors && result.errors.length > 0) { throw new Error(result.errors[0].message); } // Salva no cache if (this.cacheManager) { await this.cacheManager.set(cacheKey, result.data); } return result.data; } catch (error) { this.error = error; console.error('[IluriaSDK] Error fetching GraphQL:', error); throw error; } }; // Deduplica requests simultâneas if (this.cacheManager) { return this.cacheManager.dedupe(cacheKey, fetcher); } else { return fetcher(); } } /** * Distribui produtos para componentes registrados * @private */ distributeProducts(allProducts) { if (!allProducts || allProducts.length === 0) return; this.productDistribution.clear(); // Para cada componente registrado for (const [elementId, request] of this.queryBuilder.componentRequests) { // Filtra apenas os produtos que o componente solicitou const componentProducts = request.ids .map(id => allProducts.find(p => p.id === id)) .filter(Boolean); // Armazena produtos distribuídos this.productDistribution.set(elementId, componentProducts); console.log('[IluriaSDK] Distribuído', componentProducts.length, 'produtos para', elementId); } } /** * Distribui blogs para componentes registrados * @private */ distributeBlogs(allBlogs) { if (!allBlogs || allBlogs.length === 0) return; this.blogDistribution.clear(); // Para cada componente de blog registrado for (const [elementId, request] of this.queryBuilder.blogComponentRequests) { // Filtra apenas os blogs que o componente solicitou const componentBlogs = request.ids .map(id => allBlogs.find(b => b.id === id)) .filter(Boolean); // Armazena blogs distribuídos this.blogDistribution.set(elementId, componentBlogs); console.log('[IluriaSDK] Distribuído', componentBlogs.length, 'blogs para', elementId); } } /** * Refresh inteligente com suporte a atualização incremental * @param {object} options - Opções de refresh * @param {string} options.componentId - ID do componente específico para atualizar * @param {boolean} options.skipCache - Forçar nova busca ignorando cache * @param {boolean} options.partial - Buscar apenas dados novos (default: true) */ async refresh(options = {}) { const { componentId = null, skipCache = false, partial = true } = options; // Log detalhado para monitoramento console.log('[IluriaSDK Cache] Refresh iniciado:', { componentId, skipCache, partial, cacheStats: this.getCacheStats() }); // Se skipCache, limpa cache antes if (skipCache) { console.log('[IluriaSDK Cache] Limpando cache (skipCache=true)'); await this.clearCache(); } // Se não é partial ou não tem dados, faz refresh completo if (!partial || !this.data.products || skipCache) { console.log('[IluriaSDK Cache] Executando refresh completo'); // Limpa dados atuais para forçar re-busca this.data = {}; this.loading = true; this.error = null; // Limpa mapeamentos this.componentElements.clear(); this.productDistribution.clear(); this.blogComponentElements.clear(); this.blogDistribution.clear(); // Re-executa init com DOM atualizado return this.init(); } // Salva estado anterior para comparação const previousState = { components: new Map(this.queryBuilder.componentRequests), allProductIds: new Set(this.queryBuilder.allProductIds), products: this.data.products?.items || [] }; // Re-analisa DOM if (componentId) { console.log('[IluriaSDK Cache] Analisando componente específico:', componentId); this.analyzeComponent(componentId); } else { console.log('[IluriaSDK Cache] Re-analisando DOM completo'); this.analyzeDOM(); } // Detecta mudanças const changes = this.detectChanges(previousState); console.log('[IluriaSDK Cache] Mudanças detectadas:', changes); // Se não há mudanças, apenas redistribui if (changes.added.length === 0 && changes.removed.length === 0) { console.log('[IluriaSDK Cache] Sem mudanças, redistribuindo produtos existentes'); this.distributeProducts(this.data.products.items); this.replaceProductLists(); return this.data; } // Busca incremental await this.fetchIncremental(changes); // Log final com estatísticas console.log('[IluriaSDK Cache] Refresh concluído:', { cacheHitRate: this.getCacheStats().hitRate, productsFromCache: changes.unchanged.length, productsFromAPI: changes.added.length, totalProducts: this.data.products?.items?.length || 0 }); return this.data; } /** * Detecta mudanças entre estado anterior e atual * @private */ detectChanges(previousState) { const changes = { added: [], // IDs novos que precisam ser buscados removed: [], // IDs que não são mais necessários unchanged: [], // IDs que permanecem (usar cache) modified: [] // Componentes com mudanças }; // IDs atuais e anteriores const currentIds = Array.from(this.queryBuilder.allProductIds); const previousIds = Array.from(previousState.allProductIds); // Detecta adições e permanências for (const id of currentIds) { if (previousIds.includes(id)) { changes.unchanged.push(id); } else { changes.added.push(id); } } // Detecta remoções for (const id of previousIds) { if (!currentIds.includes(id)) { changes.removed.push(id); } } // Detecta componentes modificados for (const [elementId, request] of this.queryBuilder.componentRequests) { const previous = previousState.components.get(elementId); if (!previous) { // Componente novo changes.modified.push(elementId); console.log('[IluriaSDK Cache] Novo componente detectado:', elementId); } else { // Verifica se IDs mudaram const prevIds = new Set(previous.ids); const currIds = new Set(request.ids); let hasChanges = false; for (const id of currIds) { if (!prevIds.has(id)) { hasChanges = true; break; } } if (!hasChanges) { for (const id of prevIds) { if (!currIds.has(id)) { hasChanges = true; break; } } } if (hasChanges) { changes.modified.push(elementId); console.log('[IluriaSDK Cache] Componente modificado:', elementId); } } } // Remove duplicatas changes.added = [...new Set(changes.added)]; changes.removed = [...new Set(changes.removed)]; changes.unchanged = [...new Set(changes.unchanged)]; changes.modified = [...new Set(changes.modified)]; return changes; } /** * Analisa um componente específico no DOM * @private */ analyzeComponent(componentId) { // Limpa apenas dados do componente específico const element = document.querySelector(`[data-element-id="${componentId}"]`); if (!element) { console.warn('[IluriaSDK Cache] Componente não encontrado:', componentId); return; } // Remove do queryBuilder anterior this.queryBuilder.componentRequests.delete(componentId); // Re-analisa apenas este componente const parentComponent = element.closest('[data-component="product-showcase"]') || element; if (parentComponent) { const selectedProducts = parentComponent.dataset.selectedProducts; if (selectedProducts && selectedProducts.trim() !== '') { const productIds = selectedProducts.split(',').filter(Boolean); const defaultFields = ['id', 'name', 'price', 'photos.urls.medium', 'hasVariation', 'variations.photos.urls.medium']; // Re-registra componente this.queryBuilder.registerComponent(componentId, productIds, defaultFields); this.componentElements.set(componentId, element); console.log('[IluriaSDK Cache] Componente re-analisado:', componentId, 'com', productIds.length, 'produtos'); } } } /** * Busca incremental - apenas dados novos * @private */ async fetchIncremental(changes) { console.log('[IluriaSDK Cache] Busca incremental:', { toBeFetched: changes.added.length, fromCache: changes.unchanged.length, toRemove: changes.removed.length }); // Se não há nada novo para buscar, apenas redistribui if (changes.added.length === 0) { console.log('[IluriaSDK Cache] Todos os produtos já em cache'); // Filtra produtos removidos if (changes.removed.length > 0) { this.data.products.items = this.data.products.items.filter(p => !changes.removed.includes(p.id) ); } this.distributeProducts(this.data.products.items); this.replaceProductLists(); return; } // Tenta buscar produtos do cache de entidades primeiro let cachedFromEntity = []; let toFetch = [...changes.added]; if (this.cacheManager) { const { results, misses } = await this.cacheManager.getEntities('product', changes.added); if (results.size > 0) { cachedFromEntity = Array.from(results.values()); toFetch = misses; console.log('[IluriaSDK Cache] Cache de entidades:', { encontrados: cachedFromEntity.length, faltando: toFetch.length }); } } // Se todos foram encontrados no cache de entidades if (toFetch.length === 0) { console.log('[IluriaSDK Cache] Todos os produtos novos vieram do cache de entidades!'); const existingProducts = this.data.products?.items || []; const cachedProducts = existingProducts.filter(p => changes.unchanged.includes(p.id) && !changes.removed.includes(p.id) ); this.data.products = { ...this.data.products, items: [...cachedProducts, ...cachedFromEntity] }; this.distributeProducts(this.data.products.items); this.replaceProductLists(); return; } // Constrói query apenas para IDs que faltam const incrementalQuery = this.buildIncrementalQuery(toFetch); console.log('[IluriaSDK Cache] Query incremental para IDs faltantes:', toFetch); // Busca apenas produtos novos que não estão em cache try { const newData = await this.fetchGraphQL(incrementalQuery); // Salva novos produtos no cache de entidades if (this.cacheManager && newData.products && newData.products.items) { await this.cacheManager.setEntities('product', newData.products.items); } // Log para monitoramento de eficiência if (newData.products && newData.products.items) { const existingProducts = this.data.products?.items || []; const cachedProducts = existingProducts.filter(p => changes.unchanged.includes(p.id) && !changes.removed.includes(p.id) ); const totalFromCache = cachedProducts.length + cachedFromEntity.length; const totalFromAPI = newData.products.items.length; const total = totalFromCache + totalFromAPI; console.log('[IluriaSDK Cache] Eficiência do cache:', { totalProducts: total, fromCache: totalFromCache, fromCacheQuery: cachedProducts.length, fromCacheEntity: cachedFromEntity.length, fromAPI: totalFromAPI, cacheEfficiency: total > 0 ? `${(totalFromCache / total * 100).toFixed(2)}%` : '0%' }); // Processa fotos dos novos produtos const processedNewProducts = newData.products.items.map(p => this.processProductPhotos(p)); // Atualiza dados combinados this.data.products = { ...this.data.products, items: [...cachedProducts, ...cachedFromEntity, ...processedNewProducts] }; } // Redistribui produtos atualizados this.distributeProducts(this.data.products.items); // Re-renderiza componentes this.replaceProductLists(); // Dispara evento de atualização const event = new CustomEvent('iluria:updated', { detail: { changes, products: this.data.products?.items || [] } }); document.dispatchEvent(event); // Log operação completa this.logCacheOperation('Incremental Fetch Complete', { changes, performance: { fromCacheEntity: cachedFromEntity.length, fromAPI: toFetch.length, totalProducts: this.data.products?.items?.length || 0 } }); } catch (error) { console.error('[IluriaSDK Cache] Erro na busca incremental:', error); // Fallback para refresh completo em caso de erro console.log('[IluriaSDK Cache] Fallback para refresh completo'); await this.clearCache(); return this.init(); } } /** * Constrói query apenas para IDs específicos * @private */ buildIncrementalQuery(productIds) { if (!productIds || productIds.length === 0) { return null; } // Query simples e direta para buscar produtos por IDs return ` query { products(ids: [${productIds.map(id => `"${id}"`).join(', ')}]) { items { id name price hasVariation photos { urls { medium } } variations { photos { urls { medium } } } } } } `; } /** * Inicializa o SDK e substitui elementos */ async init() { try { this.loading = true; // Analisa DOM para coletar campos necessários this.analyzeDOM(); // Se não houver campos, não faz nada if (!this.queryBuilder.hasFields()) { console.warn('[IluriaSDK] Nenhum campo data-iluria encontrado no DOM'); this.loading = false; return {}; } // Constrói e executa query const query = this.queryBuilder.buildQuery(); const variables = this.queryBuilder.getVariables(); console.log('[IluriaSDK DEBUG] Query GraphQL construída:', query); console.log('[IluriaSDK DEBUG] Variáveis da query:', variables); // Log detalhado para debug do erro de price console.log('[IluriaSDK DEBUG] Grupos de produtos:', this.queryBuilder.productGroups); console.log('[IluriaSDK DEBUG] Campos solicitados:', Array.from(this.queryBuilder.productsFields)); this.data = await this.fetchGraphQL(query, variables); console.log('[IluriaSDK DEBUG] Resposta do GraphQL:', this.data); console.log('[IluriaSDK DEBUG] Produtos retornados:', this.data.products?.items?.length || 0); // Processa produtos para ajustar fotos de variações if (this.data.products && this.data.products.items) { this.data.products.items = this.data.products.items.map(product => this.processProductPhotos(product)); // Distribui produtos para componentes registrados this.distributeProducts(this.data.products.items); } // Processa e distribui blogs para componentes registrados if (this.data.blogPosts && this.data.blogPosts.items) { this.distributeBlogs(this.data.blogPosts.items); } // Processa produto individual se existir Object.keys(this.data).forEach(key => { if (key.startsWith('product_') && this.data[key]) { this.data[key] = this.processProductPhotos(this.data[key]); } }); // Flatten store data para compatibilidade if (this.data.storeData) { this.data = { ...this.data, ...this.flattenStoreData(this.data.storeData) }; } this.loading = false; if (this.config.autoReplace) { this.replaceElements(); this.replaceMetaTags(); this.replaceProductLists(); this.replaceBlogLists(); this.replaceBlogPost(); // Process conditionals LAST, after all data is in place // This ensures all elements have their data before conditionals are evaluated this.processConditionals(); } // Dispatch custom event const event = new CustomEvent('iluria:loaded', { detail: this.data }); document.dispatchEvent(event); return this.data; } catch (error) { this.loading = false; this.error = error; const event = new CustomEvent('iluria:error', { detail: error }); document.dispatchEvent(event); throw error; } } /** * Processa fotos do produto para usar fotos da primeira variação quando aplicável */ processProductPhotos(product) { if (!product) return product; // Se produto tem variação e a primeira variação tem fotos if (product.hasVariation && product.variations && product.variations.length > 0 && product.variations[0].photos && product.variations[0].photos.length > 0) { // Usa fotos da primeira variação como fotos principais product.photos = product.variations[0].photos; } return product; } /** * Flatten store data para manter compatibilidade */ flattenStoreData(storeData) { if (!storeData) return {}; const flat = { storeName: storeData.storeName }; if (storeData.branding) { flat.logoUrl = storeData.branding.logoUrl; flat.faviconUrl = storeData.branding.faviconUrl; } if (storeData.seo) { flat.metaTitle = storeData.seo.metaTitle; flat.metaDescription = storeData.seo.metaDescription; flat.seoImageUrl = storeData.seo.featuredImageUrl; } return flat; } /** * Sanitiza URLs para prevenir XSS */ sanitizeURL(url, attribute) { if (!url || typeof url !== 'string') return url; // Remove espaços e converte para lowercase para checagem const cleanUrl = url.trim().toLowerCase(); // Lista de protocolos perigosos const dangerousProtocols = [ 'javascript:', 'data:text/html', 'data:application/javascript', 'vbscript:', 'file:', 'about:', 'chrome:', 'chrome-extension:' ]; // Bloqueia URLs perigosas for (const protocol of dangerousProtocols) { if (cleanUrl.startsWith(protocol)) { console.warn(`[IluriaSDK] Blocked dangerous URL: ${url.substring(0, 50)}...`); return '#blocked'; } } // Para href, garante que URLs são seguras if (attribute === 'href') { // Permite protocolos seguros, URLs relativas, arquivos HTML locais if (!cleanUrl.match(/^(https?:\/\/|mailto:|tel:|\/|#|\.|[a-zA-Z0-9_-]+\.html)/)) { console.warn(`[IluriaSDK] Blocked suspicious href: ${url.substring(0, 50)}...`); return '#'; } } // Para src de imagens, permite data:image mas não data:text/html if (attribute === 'src') { if (cleanUrl.startsWith('data:') && !cleanUrl.startsWith('data:image/')) { console.warn(`[IluriaSDK] Blocked non-image data URL`); return ''; } } return url; } /** * Process conditional visibility separately * Can be called multiple times safely */ processConditionals() { // First pass: Store original display values for ALL conditional elements // This ensures we capture the intended display state before any processing document.querySelectorAll('[data-iluria-if], [data-iluria-unless]').forEach(el => { if (!el.hasAttribute('data-iluria-original-display')) { // Store as attribute (more reliable than dataset in iframes) const currentDisplay = el.style.display; // Never store 'none' as original - assume elements should be visible by default const originalDisplay = (currentDisplay === 'none') ? '' : currentDisplay; el.setAttribute('data-iluria-original-display', originalDisplay); } }); // Process data-iluria-if document.querySelectorAll('[data-iluria-if]').forEach(el => { const path = el.dataset.iluriaIf; const originalDisplay = el.getAttribute('data-iluria-original-display') || ''; // Special handling for images with data-iluria-src // Check if the src was actually set, not just if the data exists if (el.hasAttribute('data-iluria-src') && (el.tagName === 'IMG' || el.tagName === 'IMAGE')) { // For images, check if src was successfully set const hasSrc = el.src && el.src !== '' && el.src !== window.location.href && el.src.startsWith('http'); el.style.display = hasSrc ? originalDisplay : 'none'; } else { // Normal logic for other elements const productId = el.dataset.productId || el.closest('[data-product-id]')?.dataset.productId; const value = this.getValueByPath(path, productId); el.style.display = value ? originalDisplay : 'none'; } }); // Process data-iluria-unless document.querySelectorAll('[data-iluria-unless]').forEach(el => { const path = el.dataset.iluriaUnless; const originalDisplay = el.getAttribute('data-iluria-original-display') || ''; // Special handling for images with data-iluria-src if (el.hasAttribute('data-iluria-src') && (el.tagName === 'IMG' || el.tagName === 'IMAGE')) { // For images, check if src was successfully set (inverse logic for unless) const hasSrc = el.src && el.src !== '' && el.src !== window.location.href && el.src.startsWith('http'); el.style.display = hasSrc ? 'none' : originalDisplay; } else { // Normal logic for other elements const productId = el.dataset.productId || el.closest('[data-product-id]')?.dataset.productId; const value = this.getValueByPath(path, productId); el.style.display = value ? 'none' : originalDisplay; } }); } /** * Substitui elementos no DOM */ replaceElements() { // Replace text content document.querySelectorAll('[data-iluria]').forEach(el => { const path = el.dataset.iluria; const productId = el.dataset.productId || el.closest('[data-product-id]')?.dataset.productId; const value = this.getValueByPath(path, productId); if (value !== undefined && value !== null) { el.textContent = value; } }); // Replace attributes const attributeMappings = { 'data-iluria-src': 'src', 'data-iluria-href': 'href', 'data-iluria-alt': 'alt', 'data-iluria-title': 'title' }; Object.entries(attributeMappings).forEach(([dataAttr, targetAttr]) => { document.querySelectorAll(`[${dataAttr}]`).forEach(el => { const path = el.getAttribute(dataAttr); const productId = el.dataset.productId || el.closest('[data-product-id]')?.dataset.productId; const value = this.getValueByPath(path, productId); if (value !== undefined && value !== null && value !== '') { // Sanitizar URLs perigosas para prevenir XSS const safeValue = (targetAttr === 'href' || targetAttr === 'src') ? this.sanitizeURL(value, targetAttr) : value; el.setAttribute(targetAttr, safeValue); } }); }); // Background images document.querySelectorAll('[data-iluria-bg]').forEach(el => { const path = el.dataset.iluriaBg; const productId = el.dataset.productId || el.closest('[data-product-id]')?.dataset.productId; const value = this.getValueByPath(path, productId); if (value) { // Sanitizar URL da imagem de fundo const safeUrl = this.sanitizeURL(value, 'src'); el.style.backgroundImage = `url(${safeUrl})`; } }); } /** * Substitui listas de produtos */ replaceProductLists() { document.querySelectorAll('[data-iluria-list="products"]').forEach(container => { const template = container.querySelector('template'); if (!template) return; // SEMPRE limpa o container primeiro, independente de ter produtos ou não // Isso evita produtos fantasma quando a lista está vazia const cleanContainer = (target) => { Array.from(target.children).forEach(child => { if (child.tagName !== 'TEMPLATE') { child.remove(); } }); }; // Busca produtos distribuídos para este componente const elementId = container.getAttribute('data-element-id') || container.closest('[data-element-id]')?.getAttribute('data-element-id'); let productsToRender = []; if (elementId && this.productDistribution.has(elementId)) { // Usa produtos distribuídos específicos deste componente productsToRender = this.productDistribution.get(elementId) || []; console.log('[IluriaSDK] Renderizando', productsToRender.length, 'produtos para', elementId); } else if (this.data.products && this.data.products.items) { // Fallback para produtos genéricos (sem IDs específicos) productsToRender = this.data.products.items; console.log('[IluriaSDK] Usando produtos genéricos:', productsToRender.length); } // Se não há produtos, limpa o container if (productsToRender.length === 0) { const track = container.querySelector('.carousel-track'); if (track) { cleanContainer(track); } else { cleanContainer(container); } return; } // Verifica se é carrossel e precisa do track let targetContainer = container; const wrapper = container.querySelector('.carousel-wrapper'); const track = wrapper ? wrapper.querySelector('.carousel-track') : null; if (track) { // Se tem carousel-track, renderiza dentro dele targetContainer = track; // Limpa o track cleanContainer(track); } else { // Limpa container normal cleanContainer(container); } // Renderiza cada produto filtrado productsToRender.forEach(product => { const clone = template.content.cloneNode(true); // Substitui campos do produto clone.querySelectorAll('[data-iluria]').forEach(el => { const path = el.dataset.iluria; const value = this.getProductValue(product, path); if (value !== undefined && value !== null) { el.textContent = value; } }); // Substitui atributos do produto const attrs = ['data-iluria-src', 'data-iluria-href']; attrs.forEach(attr => { clone.querySelectorAll(`[${attr}]`).forEach(el => { const path = el.getAttribute(attr); const value = this.getProductValue(product, path); if (value) { const targetAttr = attr.replace('data-iluria-', ''); // Sanitizar URLs em produtos também const safeValue = (targetAttr === 'href' || targetAttr === 'src') ? this.sanitizeURL(value, targetAttr) : value; el.setAttribute(targetAttr, safeValue); } }); }); targetContainer.appendChild(clone); }); }); } /** * Substitui listas de blogs */ replaceBlogLists() { document.querySelectorAll('[data-iluria-list="blogPosts"]').forEach(container => { const template = container.querySelector('template'); if (!template) return; // Limpa container Array.from(container.children).forEach(child => { if (child.tagName !== 'TEMPLATE') { child.remove(); } }); // Pega o ID do elemento para distribuição de blogs const elementId = container.getAttribute('data-element-id') || container.closest('[data-element-id]')?.getAttribute('data-element-id'); let blogsToRender = []; if (elementId && this.blogDistribution.has(elementId)) { // Usa blogs distribuídos específicos deste componente blogsToRender = this.blogDistribution.get(elementId) || []; console.log('[IluriaSDK] Renderizando', blogsToRender.length, 'blogs para', elementId); } else if (this.data.blogPosts && this.data.blogPosts.items) { // Fallback para blogs genéricos (sem IDs específicos) blogsToRender = this.data.blogPosts.items; console.log('[IluriaSDK] Usando blogs genéricos:', blogsToRender.length); } if (blogsToRender.length === 0) { console.log('[IluriaSDK] Nenhum blog para renderizar'); return; } // Renderiza cada blog blogsToRender.forEach(blog => { const clone = template.content.cloneNode(true); // Substitui campos do blog clone.querySelectorAll('[data-iluria]').forEach(el => { const path = el.dataset.iluria; const value = this.getBlogValue(blog, path); if (value !== undefined && value !== null) { el.textContent = value; } }); // Substitui atributos do blog com suporte a expressões const attrs = ['data-iluria-src', 'data-iluria-href', 'data-iluria-alt', 'data-iluria-title']; attrs.forEach(attr => { clone.querySelectorAll(`[${attr}]`).forEach(el => { const path = el.getAttribute(attr); const value = this.evaluateBlogExpression(path, blog); if (value) { const targetAttr = attr.replace('data-iluria-', ''); // Sanitizar URLs const safeValue = (targetAttr === 'href' || targetAttr === 'src') ? this.sanitizeURL(value, targetAttr) : value; el.setAttribute(targetAttr, safeValue); } }); }); // Condicionais do blog clone.querySelectorAll('[data-iluria-if]').forEach(el => { const path = el.dataset.iluriaIf;