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
JavaScript
/**
* 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;