mk-sdk-git 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/mk-sdk.js +1 -0
- package/package.json +30 -0
- package/src/core/autoTrack.js +159 -0
- package/src/core/config.js +84 -0
- package/src/core/identity.js +57 -0
- package/src/core/pageContext.js +83 -0
- package/src/core/sanitize.js +73 -0
- package/src/core/transport.js +124 -0
- package/src/ecommerce/extractors.js +144 -0
- package/src/ecommerce/funnel.js +131 -0
- package/src/ecommerce/jsonld.js +23 -0
- package/src/index.js +67 -0
- package/src/platforms/generic.js +41 -0
- package/src/platforms/hybris.js +44 -0
- package/src/platforms/index.js +86 -0
- package/src/platforms/nuvemshop.js +48 -0
- package/src/platforms/shopify.js +35 -0
- package/src/platforms/vtex.js +34 -0
- package/src/platforms/wake.js +52 -0
- package/src/tryon/anchor.js +122 -0
- package/src/tryon/automount.js +255 -0
- package/src/tryon/bridge.js +210 -0
- package/src/tryon/buttons/config.js +56 -0
- package/src/tryon/buttons/gregory-black.js +38 -0
- package/src/tryon/buttons/gregory-card.js +76 -0
- package/src/tryon/buttons/helpers.js +57 -0
- package/src/tryon/buttons/registry.js +21 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// platforms/index.js — registry de plataformas. CADA e-commerce num módulo
|
|
2
|
+
// (detect + resolveSku + addToCart juntos). À prova de colapso:
|
|
3
|
+
// • nova PLATAFORMA = 1 arquivo aqui + 1 linha no ADAPTERS
|
|
4
|
+
// • ajuste de 1 LOJA = receita/config server-driven, NUNCA código
|
|
5
|
+
// O nº de arquivos cresce com plataformas (poucas), não com lojas (ilimitadas).
|
|
6
|
+
|
|
7
|
+
import { getConfig, log } from '../core/config.js';
|
|
8
|
+
import * as shopify from './shopify.js';
|
|
9
|
+
import * as hybris from './hybris.js';
|
|
10
|
+
import * as vtex from './vtex.js';
|
|
11
|
+
import * as nuvemshop from './nuvemshop.js';
|
|
12
|
+
import * as wake from './wake.js';
|
|
13
|
+
import * as generic from './generic.js';
|
|
14
|
+
|
|
15
|
+
// ordem importa: o primeiro detect() verdadeiro vence
|
|
16
|
+
const ADAPTERS = [shopify, hybris, vtex, nuvemshop, wake];
|
|
17
|
+
|
|
18
|
+
// plataformas que sabemos detectar mas ainda sem módulo dedicado (só rótulo p/ telemetria)
|
|
19
|
+
const EXTRA_DETECT = [
|
|
20
|
+
['woocommerce', () => !!(window.wc_add_to_cart_params || window.woocommerce_params)],
|
|
21
|
+
['lojaintegrada', () => !!window.LojaIntegrada]
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
let _current;
|
|
25
|
+
export function currentPlatform() {
|
|
26
|
+
if (_current) return _current;
|
|
27
|
+
_current = ADAPTERS.find((p) => { try { return p.detect(); } catch (_) { return false; } }) || generic;
|
|
28
|
+
return _current;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** id da plataforma pra telemetria (null se desconhecida). */
|
|
32
|
+
export function detectPlatformId() {
|
|
33
|
+
const a = ADAPTERS.find((p) => { try { return p.detect(); } catch (_) { return false; } });
|
|
34
|
+
if (a) return a.id;
|
|
35
|
+
for (const [pid, fn] of EXTRA_DETECT) { try { if (fn()) return pid; } catch (_) {} }
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** SKU: plataforma específica primeiro, genérico (JSON-LD/microdata) como fallback. */
|
|
40
|
+
export function resolveSku() {
|
|
41
|
+
const plat = currentPlatform();
|
|
42
|
+
if (plat.id !== 'generic' && plat.resolveSku) {
|
|
43
|
+
try { const r = plat.resolveSku(); if (r) return r; } catch (_) {}
|
|
44
|
+
}
|
|
45
|
+
return generic.resolveSku();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** add-to-cart pela plataforma ativa; fallback = clique nativo (generic). */
|
|
49
|
+
export async function addToCart(payload) {
|
|
50
|
+
payload = payload || {};
|
|
51
|
+
const norm = {
|
|
52
|
+
size: payload.size || payload.selectedSize || null,
|
|
53
|
+
sku: payload.sku || payload.identifier || payload.selectedIdentifier || null,
|
|
54
|
+
quantity: payload.quantity || 1
|
|
55
|
+
};
|
|
56
|
+
const plat = currentPlatform();
|
|
57
|
+
try {
|
|
58
|
+
const result = plat.addToCart ? await plat.addToCart(norm) : await generic.addToCart(norm);
|
|
59
|
+
log('cart: adicionado', result);
|
|
60
|
+
notifyTheme();
|
|
61
|
+
maybeRedirect(plat);
|
|
62
|
+
return result;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
log('cart: adapter "' + plat.id + '" falhou — fallback clique nativo', e && e.message);
|
|
65
|
+
const r = await generic.addToCart(norm);
|
|
66
|
+
notifyTheme();
|
|
67
|
+
maybeRedirect(plat);
|
|
68
|
+
return r;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function notifyTheme() {
|
|
73
|
+
['cart:refresh', 'cart:updated', 'cart:build', 'ajaxProduct:added', 'product:added-to-cart'].forEach((n) => {
|
|
74
|
+
try { document.dispatchEvent(new CustomEvent(n)); } catch (_) {}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// leva o usuário ao carrinho após adicionar. Cada plataforma pode pular (skipRedirect,
|
|
79
|
+
// quando o tema abre o mini-cart) ou ter sua rota própria (cartUrl).
|
|
80
|
+
function maybeRedirect(plat) {
|
|
81
|
+
const cfg = getConfig();
|
|
82
|
+
if (!cfg.cartRedirect) return;
|
|
83
|
+
if (plat && plat.skipRedirect) return;
|
|
84
|
+
const url = (plat && plat.cartUrl) || '/cart';
|
|
85
|
+
setTimeout(() => { try { window.location.href = url; } catch (_) {} }, 250);
|
|
86
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// nuvemshop.js — Nuvemshop / Tiendanube: detecção, SKU e carrinho.
|
|
2
|
+
// Carrinho: casa a variação no #product_form e dispara form.requestSubmit()
|
|
3
|
+
// (o tema intercepta, adiciona via AJAX e abre o mini-cart). Validado ao vivo (eora).
|
|
4
|
+
|
|
5
|
+
export const id = 'nuvemshop';
|
|
6
|
+
export const skipRedirect = true; // o tema abre o mini-cart sozinho após adicionar
|
|
7
|
+
|
|
8
|
+
export function detect() { return !!(window.LS && (window.LS.cart || window.LS.addToCart)); }
|
|
9
|
+
|
|
10
|
+
// O catálogo MK usa o SKU textual da VARIANTE (ex: LUADM), não o productId.
|
|
11
|
+
// Ele vive em LS.variants[].sku — pegamos o da variante selecionada (variant_id do form).
|
|
12
|
+
export function resolveSku() {
|
|
13
|
+
try {
|
|
14
|
+
const LS = window.LS;
|
|
15
|
+
const variants = (LS && LS.variants) || [];
|
|
16
|
+
const hidden = document.querySelector('[name="variant_id"]');
|
|
17
|
+
let variant = (hidden && hidden.value) ? variants.find((v) => String(v.id) === String(hidden.value)) : null;
|
|
18
|
+
if (!variant) variant = variants.find((v) => v.available !== false && v.sku) || variants.find((v) => v.sku) || variants[0];
|
|
19
|
+
if (variant && variant.sku) return { value: String(variant.sku), source: 'nuvemshop/variant' };
|
|
20
|
+
if (LS && LS.product && LS.product.sku) return { value: String(LS.product.sku), source: 'nuvemshop/product' };
|
|
21
|
+
} catch (_) {}
|
|
22
|
+
return null; // cai no genérico (JSON-LD/microdata)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const norm = (s) => String(s == null ? '' : s).trim().toLowerCase();
|
|
26
|
+
|
|
27
|
+
export async function addToCart({ size, quantity }) {
|
|
28
|
+
const form = document.getElementById('product_form') || document.querySelector('.js-product-form, form[action*="/comprar"]');
|
|
29
|
+
if (!form) throw new Error('nuvemshop: #product_form não encontrado');
|
|
30
|
+
|
|
31
|
+
// casa a variação escolhida no provador (cor/tamanho) com o(s) select(s)
|
|
32
|
+
if (size) {
|
|
33
|
+
form.querySelectorAll('select[name^="variation"]').forEach((sel) => {
|
|
34
|
+
const opt = [...sel.options].find((o) => norm(o.textContent).includes(norm(size)) || norm(o.value) === norm(size));
|
|
35
|
+
if (opt) { sel.value = opt.value; sel.dispatchEvent(new Event('change', { bubbles: true })); }
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const qtyEl = form.querySelector('[name="quantity"]');
|
|
39
|
+
if (qtyEl && quantity) qtyEl.value = String(quantity);
|
|
40
|
+
|
|
41
|
+
await new Promise((r) => setTimeout(r, 400)); // deixa o tema recalcular o variant_id
|
|
42
|
+
|
|
43
|
+
if (typeof form.requestSubmit === 'function') form.requestSubmit();
|
|
44
|
+
else form.submit();
|
|
45
|
+
|
|
46
|
+
const vId = (form.querySelector('[name="variant_id"]') || {}).value || null;
|
|
47
|
+
return { platform: 'nuvemshop', variantId: vId };
|
|
48
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// shopify.js — TUDO da plataforma Shopify: detecção, SKU e carrinho.
|
|
2
|
+
// Carrinho validado ao vivo (lksneakers): /cart/add.js por variantId achado por tamanho.
|
|
3
|
+
|
|
4
|
+
export const id = 'shopify';
|
|
5
|
+
export function detect() { return !!(window.Shopify || window.ShopifyAnalytics); }
|
|
6
|
+
|
|
7
|
+
export function resolveSku() {
|
|
8
|
+
const w = window;
|
|
9
|
+
if (w.ShopifyAnalytics && w.ShopifyAnalytics.meta && w.ShopifyAnalytics.meta.product)
|
|
10
|
+
return { value: String(w.ShopifyAnalytics.meta.product.id), source: 'shopify' };
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const norm = (s) => String(s == null ? '' : s).trim().toLowerCase();
|
|
15
|
+
|
|
16
|
+
export async function addToCart({ size, sku, quantity }) {
|
|
17
|
+
const handle = (location.pathname.split('/products/')[1] || '').split(/[?#]/)[0];
|
|
18
|
+
if (!handle) throw new Error('shopify: sem handle de produto na URL (não é PDP?)');
|
|
19
|
+
const prod = await fetch('/products/' + handle + '.js').then((r) => r.json());
|
|
20
|
+
const vars = prod.variants || [];
|
|
21
|
+
const matchesSize = (x) => [x.option1, x.option2, x.option3, x.title].some((o) => norm(o) === norm(size));
|
|
22
|
+
|
|
23
|
+
let v = null;
|
|
24
|
+
if (size) v = vars.find((x) => matchesSize(x) && x.available) || vars.find(matchesSize);
|
|
25
|
+
if (!v && sku) v = vars.find((x) => norm(x.sku) === norm(sku));
|
|
26
|
+
if (!v) v = vars.find((x) => x.available) || vars[0];
|
|
27
|
+
if (!v) throw new Error('shopify: variante não encontrada');
|
|
28
|
+
|
|
29
|
+
const resp = await fetch('/cart/add.js', {
|
|
30
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ items: [{ id: v.id, quantity: quantity || 1 }] })
|
|
32
|
+
});
|
|
33
|
+
if (!resp.ok) throw new Error('shopify: /cart/add.js HTTP ' + resp.status);
|
|
34
|
+
return { platform: 'shopify', variantId: v.id, size: v.title, sku: v.sku || null };
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// vtex.js — plataforma VTEX: detecção, SKU e carrinho.
|
|
2
|
+
// Carrinho via window.vtexjs.checkout.addToCart (mapeia o tamanho → skuId em
|
|
3
|
+
// window.skuJson_0). ⚠️ NÃO validado ao vivo ainda — se vtexjs faltar ou falhar,
|
|
4
|
+
// o registry cai no fallback de clique nativo.
|
|
5
|
+
|
|
6
|
+
export const id = 'vtex';
|
|
7
|
+
export function detect() { return !!(window.vtex || window.vtexjs || window.vtxctx || window.skuJson_0); }
|
|
8
|
+
|
|
9
|
+
export function resolveSku() {
|
|
10
|
+
const w = window;
|
|
11
|
+
if (w.vtxctx && w.vtxctx.skus && w.vtxctx.skus[0]) return { value: String(w.vtxctx.skus[0]), source: 'vtex/vtxctx' };
|
|
12
|
+
if (w.skuJson_0 && w.skuJson_0.skus && w.skuJson_0.skus[0]) return { value: String(w.skuJson_0.skus[0].sku), source: 'vtex/skuJson' };
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const norm = (s) => String(s == null ? '' : s).trim().toLowerCase();
|
|
17
|
+
|
|
18
|
+
export async function addToCart({ size, quantity }) {
|
|
19
|
+
const skus = (window.skuJson_0 && window.skuJson_0.skus) || [];
|
|
20
|
+
if (!skus.length) throw new Error('vtex: skuJson_0 indisponível');
|
|
21
|
+
|
|
22
|
+
const matchSize = (s) => norm(s.skuname) === norm(size)
|
|
23
|
+
|| (s.dimensions && Object.values(s.dimensions).some((d) => norm(d) === norm(size)));
|
|
24
|
+
|
|
25
|
+
let sku = size ? (skus.find((s) => matchSize(s) && s.available) || skus.find(matchSize)) : null;
|
|
26
|
+
if (!sku) sku = skus.find((s) => s.available) || skus[0];
|
|
27
|
+
if (!sku) throw new Error('vtex: variante não encontrada');
|
|
28
|
+
|
|
29
|
+
if (window.vtexjs && window.vtexjs.checkout && window.vtexjs.checkout.addToCart) {
|
|
30
|
+
await window.vtexjs.checkout.addToCart([{ id: parseInt(sku.sku, 10), quantity: quantity || 1, seller: '1' }]);
|
|
31
|
+
return { platform: 'vtex', skuId: sku.sku, size: sku.skuname || size };
|
|
32
|
+
}
|
|
33
|
+
throw new Error('vtex: vtexjs.checkout indisponível'); // → fallback clique nativo
|
|
34
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// wake.js — Wake Commerce (ex-Fbits; ex: Gregory): detecção, SKU e carrinho.
|
|
2
|
+
// Carrinho via window.addOrCreateCheckout([{productVariantId,quantity,customization}])
|
|
3
|
+
// (API do StorefrontClient). Validado ao vivo (Gregory): adiciona + dispara
|
|
4
|
+
// o evento productAddedToCart.
|
|
5
|
+
//
|
|
6
|
+
// Nota: produtos-matriz (cor×tamanho) resolvem o variantId pela seleção na PDP
|
|
7
|
+
// (input[name="product-variant-id"]). Tentamos aplicar o tamanho via selectAttribute,
|
|
8
|
+
// mas se não propagar, usa-se a variante atual da PDP.
|
|
9
|
+
|
|
10
|
+
export const id = 'wake';
|
|
11
|
+
export const skipRedirect = true; // o tema mostra overlay/mini-cart após adicionar
|
|
12
|
+
|
|
13
|
+
export function detect() {
|
|
14
|
+
return !!(window.StorefrontClient || typeof window.addOrCreateCheckout === 'function' || typeof window.addToCartClick === 'function');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// O catálogo MK (Gregory) usa o productId da loja (ex: 164968), NÃO o sku do
|
|
18
|
+
// JSON-LD (308573). Está no input[name="product-id"] e no fim da URL /produto/…-{id}.
|
|
19
|
+
export function resolveSku() {
|
|
20
|
+
try {
|
|
21
|
+
const el = document.querySelector('input[name="product-id"], [data-product-id]');
|
|
22
|
+
const pid = el ? (el.value || el.getAttribute('data-product-id')) : null;
|
|
23
|
+
if (pid) return { value: String(pid).trim(), source: 'wake/product-id' };
|
|
24
|
+
const m = (location.pathname || '').match(/-(\d+)\/?(?:[?#]|$)/);
|
|
25
|
+
if (m) return { value: m[1], source: 'wake/url' };
|
|
26
|
+
} catch (_) {}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const norm = (s) => String(s == null ? '' : s).trim().toLowerCase();
|
|
31
|
+
|
|
32
|
+
export async function addToCart({ size, quantity }) {
|
|
33
|
+
// tenta aplicar o tamanho escolhido no provador (radio attribute-value == size)
|
|
34
|
+
if (size) {
|
|
35
|
+
const radio = [...document.querySelectorAll('input[attribute-value]')]
|
|
36
|
+
.find((r) => norm(r.getAttribute('attribute-value')) === norm(size));
|
|
37
|
+
if (radio) {
|
|
38
|
+
try { radio.checked = true; } catch (_) {}
|
|
39
|
+
try { if (typeof window.selectAttribute === 'function') window.selectAttribute(radio); else radio.click(); } catch (_) {}
|
|
40
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const vEl = document.querySelector('[name="product-variant-id"]');
|
|
45
|
+
const variantId = vEl && vEl.value;
|
|
46
|
+
if (!variantId) throw new Error('wake: product-variant-id não encontrado');
|
|
47
|
+
if (typeof window.addOrCreateCheckout !== 'function') throw new Error('wake: addOrCreateCheckout indisponível');
|
|
48
|
+
|
|
49
|
+
const ok = await window.addOrCreateCheckout([{ productVariantId: Number(variantId), quantity: quantity || 1, customization: [] }]);
|
|
50
|
+
if (!ok) throw new Error('wake: addOrCreateCheckout retornou false');
|
|
51
|
+
return { platform: 'wake', productVariantId: variantId, size: size || null };
|
|
52
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// anchor.js — decide ONDE injetar o botão de try-on na PDP.
|
|
2
|
+
// Ordem: override (receita/picker) > heurística (add-to-cart nativo).
|
|
3
|
+
//
|
|
4
|
+
// Escolha de variante (desktop/mobile) é VISIBILIDADE-PRIMEIRO: usa a variante
|
|
5
|
+
// cujo elemento-âncora está realmente visível agora (segue o layout responsivo
|
|
6
|
+
// da loja, independe do breakpoint dela). Largura (breakpoint configurável) só
|
|
7
|
+
// desempata quando ambas estão visíveis ou nenhuma.
|
|
8
|
+
//
|
|
9
|
+
// Retorna { el, position, mode, style } | null.
|
|
10
|
+
|
|
11
|
+
import { getConfig } from '../core/config.js';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BP_MOBILE = 767; // <= mobile
|
|
14
|
+
const DEFAULT_BP_TABLET = 1024; // <= tablet; acima = desktop
|
|
15
|
+
const ANCHOR_PATTERNS = /(adicionar\s*(à|a|ao)?\s*(sacola|carrinho|cesta|bolsa))|adicionar ao carrinho|add to (cart|bag)|comprar agora|^comprar$|finalizar compra|eu quero/i;
|
|
16
|
+
const ANCHOR_NEGATIVE = /continuar|como comprar|lista|desejos|favorito|looks|cupom/i;
|
|
17
|
+
|
|
18
|
+
function isVisible(el) {
|
|
19
|
+
try {
|
|
20
|
+
const r = el.getBoundingClientRect();
|
|
21
|
+
if (r.width <= 0 || r.height <= 0) return false;
|
|
22
|
+
const s = getComputedStyle(el);
|
|
23
|
+
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
24
|
+
} catch (_) { return false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function selectorVisible(sel) {
|
|
28
|
+
if (!sel) return false;
|
|
29
|
+
try { const el = document.querySelector(sel); return !!el && isVisible(el); } catch (_) { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function textOf(el) {
|
|
33
|
+
const t = el.tagName === 'INPUT' ? (el.value || '') : (el.textContent || '');
|
|
34
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tolerância defensiva: se o CMS gravou o ENVELOPE inteiro `{mkAnchor:1, host,
|
|
39
|
+
* capturedAt, anchor:{...}}` como valor de um slot (em vez de extrair `anchor`
|
|
40
|
+
* antes do merge), desempacota automaticamente. O fix CERTO é no CMS — esta
|
|
41
|
+
* função evita que receitas bem capturadas mas mal-salvas fiquem inutilizáveis.
|
|
42
|
+
*/
|
|
43
|
+
function unwrapEnvelopeSlots(anchor) {
|
|
44
|
+
if (!anchor || typeof anchor !== 'object' || anchor.selector) return anchor;
|
|
45
|
+
let needsUnwrap = false;
|
|
46
|
+
['mobile', 'tablet', 'desktop'].forEach((s) => {
|
|
47
|
+
const v = anchor[s];
|
|
48
|
+
if (v && v.mkAnchor === 1 && v.anchor && typeof v.anchor === 'object') needsUnwrap = true;
|
|
49
|
+
});
|
|
50
|
+
if (!needsUnwrap) return anchor;
|
|
51
|
+
const out = {};
|
|
52
|
+
['mobile', 'tablet', 'desktop'].forEach((s) => {
|
|
53
|
+
const v = anchor[s];
|
|
54
|
+
if (!v) return;
|
|
55
|
+
if (v.selector) { out[s] = v; return; }
|
|
56
|
+
if (v.mkAnchor === 1 && v.anchor && typeof v.anchor === 'object') {
|
|
57
|
+
const inner = v.anchor[s] || v.anchor.desktop || v.anchor.mobile || v.anchor.tablet;
|
|
58
|
+
if (inner && inner.selector) out[s] = inner;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
['bpMobile', 'bpTablet', 'breakpoint'].forEach((k) => { if (anchor[k] != null) out[k] = anchor[k]; });
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Escolhe a variante de âncora pro layout atual entre mobile/tablet/desktop.
|
|
67
|
+
* Aceita formato plano (compat: {selector,...}) ou { mobile, tablet, desktop, bpMobile?, bpTablet? }.
|
|
68
|
+
* Visibilidade-primeiro: usa a variante cujo seletor está visível; largura desempata.
|
|
69
|
+
*/
|
|
70
|
+
export function pickAnchorVariant(anchor) {
|
|
71
|
+
if (!anchor || typeof anchor !== 'object') return null;
|
|
72
|
+
anchor = unwrapEnvelopeSlots(anchor); // tolera envelope mal-mergeado pelo CMS
|
|
73
|
+
if (anchor.selector) return anchor; // formato plano (retrocompat)
|
|
74
|
+
|
|
75
|
+
const slots = [
|
|
76
|
+
['mobile', anchor.mobile],
|
|
77
|
+
['tablet', anchor.tablet],
|
|
78
|
+
['desktop', anchor.desktop]
|
|
79
|
+
].filter((s) => s[1] && s[1].selector);
|
|
80
|
+
if (slots.length === 0) return null;
|
|
81
|
+
if (slots.length === 1) return slots[0][1];
|
|
82
|
+
|
|
83
|
+
// 1. visibilidade-primeiro (independe do breakpoint da loja)
|
|
84
|
+
const visible = slots.filter(([, v]) => selectorVisible(v.selector));
|
|
85
|
+
if (visible.length === 1) return visible[0][1];
|
|
86
|
+
|
|
87
|
+
// 2. desempate por largura (breakpoints configuráveis; defaults 767 / 1024)
|
|
88
|
+
const w = window.innerWidth || 1024;
|
|
89
|
+
const bpM = Number(anchor.bpMobile) || Number(anchor.breakpoint) || DEFAULT_BP_MOBILE;
|
|
90
|
+
const bpT = Number(anchor.bpTablet) || DEFAULT_BP_TABLET;
|
|
91
|
+
const byId = (id) => (slots.find((s) => s[0] === id) || [])[1];
|
|
92
|
+
const pref = (w <= bpM) ? ['mobile', 'tablet', 'desktop']
|
|
93
|
+
: (w <= bpT) ? ['tablet', 'desktop', 'mobile']
|
|
94
|
+
: ['desktop', 'tablet', 'mobile'];
|
|
95
|
+
for (const id of pref) { const v = byId(id); if (v) return v; }
|
|
96
|
+
return slots[0][1];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resolveAnchor() {
|
|
100
|
+
const cfg = getConfig();
|
|
101
|
+
|
|
102
|
+
// 1. Override (receita / picker) — variante escolhida por visibilidade/breakpoint
|
|
103
|
+
const variant = pickAnchorVariant(cfg && cfg.anchor);
|
|
104
|
+
if (variant && variant.selector) {
|
|
105
|
+
try {
|
|
106
|
+
const o = document.querySelector(variant.selector);
|
|
107
|
+
if (o) return { el: o, position: variant.position || 'afterend', mode: variant.mode || 'flow', style: variant.style || '', align: variant.align || 'top-left', selector: variant.selector };
|
|
108
|
+
} catch (_) {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. Heurística: o botão de carrinho nativo visível é a âncora natural
|
|
112
|
+
const candidates = document.querySelectorAll(
|
|
113
|
+
'button, [role="button"], a.btn, a.button, input[type="submit"], input[type="button"]'
|
|
114
|
+
);
|
|
115
|
+
for (const el of candidates) {
|
|
116
|
+
const t = textOf(el);
|
|
117
|
+
if (!t || t.length > 40) continue;
|
|
118
|
+
if (ANCHOR_NEGATIVE.test(t)) continue;
|
|
119
|
+
if (ANCHOR_PATTERNS.test(t) && isVisible(el)) return { el, position: 'afterend', mode: 'flow', style: '', selector: null };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// automount.js — gatilho zero-touch do botão de try-on.
|
|
2
|
+
// Ao detectar PDP: resolve o SKU → checa disponibilidade → busca o template
|
|
3
|
+
// do projeto → injeta o botão (Shadow DOM) na âncora resolvida.
|
|
4
|
+
// Também expõe um canal de preview pro picker de posição do addon.
|
|
5
|
+
|
|
6
|
+
import { getConfig, log } from '../core/config.js';
|
|
7
|
+
import { track } from '../core/transport.js';
|
|
8
|
+
import { resolveSku } from '../ecommerce/extractors.js';
|
|
9
|
+
import { getAvailability, open } from './bridge.js';
|
|
10
|
+
import { resolveAnchor, pickAnchorVariant } from './anchor.js';
|
|
11
|
+
import { renderButton, fetchButtonTemplate, normalizeButtonConfig } from './buttons/registry.js';
|
|
12
|
+
|
|
13
|
+
const HOST_ID = 'mk-tryon-button-host';
|
|
14
|
+
let mountedKey = null; // identifica o estado montado (sku + anchor) — pra SPA/preview
|
|
15
|
+
let mounting = false;
|
|
16
|
+
const availCache = {}; // sku -> Promise<bool> (dedupe a checagem a cada pageview SPA)
|
|
17
|
+
let resizeWatchSet = false;
|
|
18
|
+
|
|
19
|
+
// Template do projeto cacheado (1 fetch por sessão). A mesma Promise é compartilhada
|
|
20
|
+
// entre as chamadas concorrentes de maybeMountButton — pra autoaplicar o ANCHOR
|
|
21
|
+
// (template.tryOnButton.anchor) antes do 1º mount, sem flicker e sem 2 fetches.
|
|
22
|
+
let _tplPromise = null;
|
|
23
|
+
function getTemplateCached(apiUrl, projectId) {
|
|
24
|
+
if (!_tplPromise) _tplPromise = fetchButtonTemplate(apiUrl, projectId).catch(() => null);
|
|
25
|
+
return _tplPromise;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// re-avalia a variante visível quando o layout muda (resize, debounced).
|
|
29
|
+
// maybeMountButton re-monta só se a variante escolhida (o key) realmente mudou.
|
|
30
|
+
function ensureResizeWatch() {
|
|
31
|
+
if (resizeWatchSet) return;
|
|
32
|
+
resizeWatchSet = true;
|
|
33
|
+
let t = null;
|
|
34
|
+
try {
|
|
35
|
+
window.addEventListener('resize', () => {
|
|
36
|
+
clearTimeout(t);
|
|
37
|
+
t = setTimeout(() => { if (mountedKey) maybeMountButton('pdp'); }, 200);
|
|
38
|
+
});
|
|
39
|
+
} catch (_) {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function removeButton() {
|
|
43
|
+
const h = document.getElementById(HOST_ID);
|
|
44
|
+
if (h) {
|
|
45
|
+
if (typeof h.__mkCleanup === 'function') { try { h.__mkCleanup(); } catch (_) {} }
|
|
46
|
+
if (h.parentNode) h.parentNode.removeChild(h);
|
|
47
|
+
}
|
|
48
|
+
mountedKey = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const REPLACED = { IMG: 1, INPUT: 1, VIDEO: 1, CANVAS: 1, EMBED: 1, OBJECT: 1, IFRAME: 1, BR: 1, HR: 1, SELECT: 1, TEXTAREA: 1, SVG: 1 };
|
|
52
|
+
|
|
53
|
+
// ponto de ancoragem dentro do elemento (modo sobrepor) — grade 3x3.
|
|
54
|
+
// A centralização (meios/centro) usa transform no HOST; o offset/escala do picker
|
|
55
|
+
// vai no botão, então não há conflito de transform.
|
|
56
|
+
function alignStyle(align) {
|
|
57
|
+
switch (align) {
|
|
58
|
+
case 'top-center': return 'top:0;left:50%;transform:translateX(-50%);';
|
|
59
|
+
case 'top-right': return 'top:0;right:0;';
|
|
60
|
+
case 'middle-left': return 'top:50%;left:0;transform:translateY(-50%);';
|
|
61
|
+
case 'center': return 'top:50%;left:50%;transform:translate(-50%,-50%);';
|
|
62
|
+
case 'middle-right': return 'top:50%;right:0;transform:translateY(-50%);';
|
|
63
|
+
case 'bottom-left': return 'bottom:0;left:0;';
|
|
64
|
+
case 'bottom-center': return 'bottom:0;left:50%;transform:translateX(-50%);';
|
|
65
|
+
case 'bottom-right': return 'bottom:0;right:0;';
|
|
66
|
+
case 'top-left':
|
|
67
|
+
default: return 'top:0;left:0;';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Modo "sobrepor": posiciona o host por cima do ref SEM sair do container.
|
|
72
|
+
// Vira filho do ref (que passa a ser o containing block via position:relative —
|
|
73
|
+
// inócuo, não move o ref), no canto escolhido, e acompanha ele sem listeners.
|
|
74
|
+
// Se o ref não aceita filhos (img etc), usa o container pai e reposiciona sobre o ref.
|
|
75
|
+
function placeOverlay(ref, host, align) {
|
|
76
|
+
const acceptsChildren = !REPLACED[ref.tagName];
|
|
77
|
+
const container = acceptsChildren ? ref : (ref.parentElement || document.body);
|
|
78
|
+
try { if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; } catch (_) {}
|
|
79
|
+
|
|
80
|
+
if (container === ref) {
|
|
81
|
+
host.style.cssText = 'position:absolute;z-index:2147483000;' + alignStyle(align);
|
|
82
|
+
ref.appendChild(host); // sobrepõe no canto escolhido, dentro do ref; acompanha sem reposicionar
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// ref é replaced (img/input...): host no container pai, posicionado sobre o ref no ponto do align
|
|
86
|
+
host.style.cssText = 'position:absolute;z-index:2147483000;';
|
|
87
|
+
container.appendChild(host);
|
|
88
|
+
const reposition = () => {
|
|
89
|
+
try {
|
|
90
|
+
const rr = ref.getBoundingClientRect();
|
|
91
|
+
const cr = container.getBoundingClientRect();
|
|
92
|
+
const hw = host.offsetWidth, hh = host.offsetHeight;
|
|
93
|
+
const baseTop = rr.top - cr.top + container.scrollTop;
|
|
94
|
+
const baseLeft = rr.left - cr.left + container.scrollLeft;
|
|
95
|
+
const a = align || 'top-left';
|
|
96
|
+
let top = baseTop, left = baseLeft;
|
|
97
|
+
if (a.indexOf('bottom') === 0) top = baseTop + rr.height - hh;
|
|
98
|
+
else if (a.indexOf('middle') === 0 || a === 'center') top = baseTop + (rr.height - hh) / 2;
|
|
99
|
+
if (a.indexOf('right') >= 0) left = baseLeft + rr.width - hw;
|
|
100
|
+
else if (a.indexOf('center') >= 0) left = baseLeft + (rr.width - hw) / 2;
|
|
101
|
+
host.style.top = top + 'px';
|
|
102
|
+
host.style.left = left + 'px';
|
|
103
|
+
} catch (_) {}
|
|
104
|
+
};
|
|
105
|
+
reposition();
|
|
106
|
+
const onWin = () => reposition();
|
|
107
|
+
window.addEventListener('resize', onWin);
|
|
108
|
+
const t1 = setTimeout(reposition, 600);
|
|
109
|
+
const t2 = setTimeout(reposition, 1500);
|
|
110
|
+
host.__mkCleanup = () => { window.removeEventListener('resize', onWin); clearTimeout(t1); clearTimeout(t2); };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Insere `node` relativo a `ref` conforme a posição (nomes do insertAdjacentElement). */
|
|
114
|
+
function insertByPosition(ref, node, position) {
|
|
115
|
+
try {
|
|
116
|
+
ref.insertAdjacentElement(position || 'afterend', node);
|
|
117
|
+
return true;
|
|
118
|
+
} catch (_) {
|
|
119
|
+
try { ref.parentNode.insertBefore(node, ref.nextSibling); return true; } catch (_) { return false; }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Chamado a cada pageview (inclusive SPA). Idempotente.
|
|
125
|
+
*/
|
|
126
|
+
export async function maybeMountButton(pageType) {
|
|
127
|
+
const cfg = getConfig();
|
|
128
|
+
|
|
129
|
+
if (pageType !== 'pdp') { removeButton(); return; }
|
|
130
|
+
if (!cfg.projectId) return;
|
|
131
|
+
|
|
132
|
+
// Auto-aplica a receita de posição salva no CMS (template.tryOnButton.anchor).
|
|
133
|
+
// Precedência: window.__MK.anchor (inline da loja) > extensão (postMessage) >
|
|
134
|
+
// API/CMS > heurística do resolveAnchor. Se a loja já setou inline, NÃO toca.
|
|
135
|
+
// Se o CMS não tem anchor, segue sem (cai na heurística — comportamento antigo).
|
|
136
|
+
if (!cfg.anchor) {
|
|
137
|
+
const tpl0 = await getTemplateCached(cfg.apiUrl, cfg.projectId);
|
|
138
|
+
if (tpl0 && tpl0.anchor) cfg.anchor = tpl0.anchor;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (mounting) return;
|
|
142
|
+
|
|
143
|
+
const skuRes = resolveSku();
|
|
144
|
+
const sku = skuRes && skuRes.value;
|
|
145
|
+
const force = !!cfg.forceButton;
|
|
146
|
+
// key canônico (campos em ordem FIXA — não depende da ordem das chaves do objeto).
|
|
147
|
+
// Assim preview e save com a mesma config geram o MESMO key e o botão NÃO é
|
|
148
|
+
// re-montado ("pula"/desce) ao salvar.
|
|
149
|
+
const variant = pickAnchorVariant(cfg.anchor);
|
|
150
|
+
const key = [sku || 'demo', variant
|
|
151
|
+
? [variant.selector, variant.position, variant.mode, variant.align, variant.style].join('~')
|
|
152
|
+
: 'heuristic'].join('|');
|
|
153
|
+
|
|
154
|
+
const existing = document.getElementById(HOST_ID);
|
|
155
|
+
if (existing && mountedKey === key) return; // mesmo produto + mesma âncora
|
|
156
|
+
if (existing) removeButton(); // mudou → re-monta
|
|
157
|
+
|
|
158
|
+
if (!sku && !force) { log('auto-mount: SKU não resolvido na PDP'); return; }
|
|
159
|
+
|
|
160
|
+
mounting = true;
|
|
161
|
+
try {
|
|
162
|
+
let available = force;
|
|
163
|
+
if (!force) {
|
|
164
|
+
// cache de PROMISE (guardada sincronamente) → dedupe mesmo chamadas concorrentes
|
|
165
|
+
if (!availCache[sku]) {
|
|
166
|
+
availCache[sku] = getAvailability(cfg.projectId, sku)
|
|
167
|
+
.then((r) => !!(r && r.available)).catch(() => false);
|
|
168
|
+
}
|
|
169
|
+
available = await availCache[sku];
|
|
170
|
+
}
|
|
171
|
+
if (!available) { log('auto-mount: produto sem try-on', sku); return; }
|
|
172
|
+
|
|
173
|
+
const tpl = await getTemplateCached(cfg.apiUrl, cfg.projectId);
|
|
174
|
+
const isMobile = window.matchMedia('(max-width: 767px)').matches;
|
|
175
|
+
const config = normalizeButtonConfig(tpl, isMobile);
|
|
176
|
+
|
|
177
|
+
const anchor = resolveAnchor();
|
|
178
|
+
const mode = (anchor && anchor.mode) || 'flow';
|
|
179
|
+
// re-resolve o SKU no clique (caso o usuário troque a variante na PDP depois)
|
|
180
|
+
const btn = renderButton(config, () => {
|
|
181
|
+
const live = resolveSku();
|
|
182
|
+
open({ projectId: cfg.projectId, identifier: (live && live.value) || sku || '' });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const host = document.createElement('div');
|
|
186
|
+
host.id = HOST_ID;
|
|
187
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
188
|
+
shadow.appendChild(btn);
|
|
189
|
+
|
|
190
|
+
let placed = false;
|
|
191
|
+
if (anchor && anchor.el && anchor.el.parentNode) {
|
|
192
|
+
if (mode === 'overlay') {
|
|
193
|
+
placeOverlay(anchor.el, host, anchor.align);
|
|
194
|
+
placed = true;
|
|
195
|
+
} else {
|
|
196
|
+
// flow: o host flui dentro do container — vira flex item num flex, bloco num block.
|
|
197
|
+
// Sem margem forçada (o gap natural do container cuida); ajuste fino via CSS extra.
|
|
198
|
+
host.style.cssText = 'display:contents;';
|
|
199
|
+
placed = insertByPosition(anchor.el, host, anchor.position);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!placed) {
|
|
203
|
+
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483000;';
|
|
204
|
+
document.body.appendChild(host);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// CSS extra (offset/escala/css) sempre no BOTÃO — assim não conflita com o
|
|
208
|
+
// transform de centralização que o overlay aplica no host (align center/meios).
|
|
209
|
+
if (anchor && anchor.style) {
|
|
210
|
+
try { btn.style.cssText += ';' + anchor.style; } catch (_) {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
mountedKey = key;
|
|
214
|
+
ensureResizeWatch();
|
|
215
|
+
log('auto-mount: botão injetado', { style: config.style, sku, anchorFound: !!anchor, position: anchor && anchor.position, mode, forced: force });
|
|
216
|
+
track('button_rendered', {
|
|
217
|
+
style: config.style, sku: sku || null, skuSource: skuRes && skuRes.source,
|
|
218
|
+
anchorFound: !!anchor, position: anchor ? anchor.position : null, mode, forced: force
|
|
219
|
+
});
|
|
220
|
+
} finally {
|
|
221
|
+
mounting = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Canal de preview pro picker do addon: ao receber um anchor, re-injeta o botão
|
|
227
|
+
* naquela posição (forçado, ignora disponibilidade) pra visualização ao vivo.
|
|
228
|
+
*/
|
|
229
|
+
export function initPickerBridge() {
|
|
230
|
+
try {
|
|
231
|
+
const cfg = getConfig();
|
|
232
|
+
const original = { anchor: cfg.anchor, forceButton: cfg.forceButton };
|
|
233
|
+
window.addEventListener('message', (e) => {
|
|
234
|
+
if (e.source !== window) return;
|
|
235
|
+
const d = e.data;
|
|
236
|
+
if (!d || !d.__mkCmd) return;
|
|
237
|
+
if (d.__mkCmd === 'remount') {
|
|
238
|
+
cfg.anchor = d.anchor || null; // preview da variante sendo editada (plano)
|
|
239
|
+
cfg.forceButton = true; // preview sempre mostra o botão
|
|
240
|
+
maybeMountButton('pdp'); // re-monta só se a config (key) mudou
|
|
241
|
+
} else if (d.__mkCmd === 'applyRecipe') {
|
|
242
|
+
// pós-save: aplica a receita completa. Sem removeButton — se a posição é a
|
|
243
|
+
// mesma do preview (key igual), o botão NÃO é re-montado (não "desce"/pula).
|
|
244
|
+
cfg.anchor = d.anchor || null;
|
|
245
|
+
cfg.forceButton = true;
|
|
246
|
+
maybeMountButton('pdp');
|
|
247
|
+
} else if (d.__mkCmd === 'cancelPreview') {
|
|
248
|
+
cfg.anchor = original.anchor; // restaura o estado anterior ao picker
|
|
249
|
+
cfg.forceButton = original.forceButton;
|
|
250
|
+
removeButton();
|
|
251
|
+
maybeMountButton('pdp');
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
} catch (_) {}
|
|
255
|
+
}
|