mkfashion-sdk 2.5.0 → 2.7.1

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.
Binary file
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mkfashion-sdk",
3
- "version": "2.5.0",
4
- "description": "SDK para integrar o provador virtual mKFashion com suporte a Wake Commerce e tracking analytics",
3
+ "version": "2.7.1",
4
+ "description": "SDK para integrar o provador virtual mKFashion com suporte a Wake Commerce e tracking analytics completo",
5
5
  "main": "src/mkfashion.js",
6
6
  "scripts": {
7
7
  "test": "echo \"Abra test.html no navegador\""
package/src/mkfashion.js CHANGED
@@ -304,31 +304,24 @@ const mkfashion = {
304
304
  const data = await response.json()
305
305
  this._log('Disponibilidade recebida', data)
306
306
 
307
- // Tracking: cada checagem de availability é um sinal de "impressão"
308
- // do produto na vitrine. Funciona mesmo se o usuário não clicar.
307
+ // NOTA: availability check é decisão interna (mostra botão ou não) e
308
+ // NÃO emite evento pro collector não traz valor analítico.
309
+ //
310
+ // MAS é o gatilho mais comum em PDPs: a partir daqui temos projectId
311
+ // confirmado e ativamos o auto-tracking (page_view, click, scroll,
312
+ // form, engagement). Sem isso, páginas onde o usuário só visualiza
313
+ // (sem clicar no try-on) ficariam invisíveis no collector.
309
314
  try {
310
315
  if (!this._config) this._config = { projectId: resolved, identifier }
311
- this._emitToCollector('availability_checked', {
312
- projectId: resolved,
313
- identifier,
314
- available: data.available === true,
315
- reason: data.reason || data.message || null
316
- })
316
+ if (!this._autoTrackInitialized && this.autoTrack !== false) {
317
+ this._initAutoTrack()
318
+ }
317
319
  } catch (_) {}
318
320
 
319
321
  return data
320
322
 
321
323
  } catch (error) {
322
324
  console.error('[mKFashion] Erro ao verificar disponibilidade:', error)
323
- // Tracking: também emite erros de availability — útil pra detectar 404/500 em prod.
324
- try {
325
- if (!this._config) this._config = { projectId: resolved, identifier }
326
- this._emitToCollector('availability_error', {
327
- projectId: resolved,
328
- identifier,
329
- error: String(error.message || error).slice(0, 200)
330
- })
331
- } catch (_) {}
332
325
  throw error
333
326
  }
334
327
  },
@@ -621,42 +614,317 @@ const mkfashion = {
621
614
  },
622
615
 
623
616
  /**
624
- * Envia evento pro mk-collector-api. Fire-and-forget nunca atrapalha UX.
625
- * Identificação: X-MK-Project-Id (o próprio projectId do mkfashion).
617
+ * Enfileira evento no batch buffer. O flush real acontece em _flushQueue.
618
+ * Fire-and-forget nunca atrapalha UX.
626
619
  */
627
620
  _emitToCollector(eventName, data) {
628
621
  try {
629
- if (!this._config || !this._config.projectId) return
622
+ if (!this._config || !this._config.projectId) {
623
+ // Antes do projectId estar setado, enfileira pra flush quando estiver.
624
+ this._preConfigQueue = this._preConfigQueue || []
625
+ if (this._preConfigQueue.length < 50) {
626
+ this._preConfigQueue.push({ eventName, data, ts: Date.now() })
627
+ }
628
+ return
629
+ }
630
630
  if (!this.collectorUrl) return
631
631
 
632
- const params = { identifier: this._config.identifier || null }
633
- if (data && typeof data === 'object' && !Array.isArray(data)) {
634
- Object.assign(params, data)
635
- } else if (data !== null && data !== undefined) {
636
- params.value = data
632
+ // Drena qualquer evento que foi enfileirado antes do config existir.
633
+ if (this._preConfigQueue && this._preConfigQueue.length > 0) {
634
+ const pending = this._preConfigQueue
635
+ this._preConfigQueue = []
636
+ for (const p of pending) this._queueEvent(p.eventName, p.data, p.ts)
637
637
  }
638
638
 
639
- const payload = JSON.stringify({
640
- visitorId: this._getVisitorId(),
641
- sessionId: this._getSessionId(),
642
- events: [{ name: 'sdk_' + eventName, ts: Date.now(), params: params }],
643
- site: this._detectSiteContext()
644
- })
639
+ this._queueEvent(eventName, data)
645
640
 
646
- fetch(this.collectorUrl + '/v1/track/sdk', {
647
- method: 'POST',
648
- keepalive: true,
649
- headers: {
650
- 'Content-Type': 'application/json',
651
- 'X-MK-Project-Id': this._config.projectId
652
- },
653
- body: payload
654
- }).catch(() => {})
641
+ // Auto-start tracking de site no primeiro emit com config.
642
+ if (!this._autoTrackInitialized && this.autoTrack !== false) {
643
+ this._initAutoTrack()
644
+ }
655
645
  } catch (e) {
656
646
  if (this.debug) console.warn('[mKFashion tracking]', e.message)
657
647
  }
658
648
  },
659
649
 
650
+ /**
651
+ * Normaliza nome de evento — tira prefixo "on" e converte camelCase pra snake_case.
652
+ * Ex: 'onAddToCart' → 'add_to_cart'; 'modal_opened' → 'modal_opened' (já snake).
653
+ */
654
+ _normalizeEventName(name) {
655
+ if (typeof name !== 'string' || !name) return 'unknown'
656
+ var n = name.replace(/^on/, '')
657
+ n = n.replace(/[A-Z]/g, function (m, i) { return (i > 0 ? '_' : '') + m.toLowerCase() })
658
+ if (n.charAt(0) >= 'A' && n.charAt(0) <= 'Z') n = n.toLowerCase()
659
+ return n
660
+ },
661
+
662
+ /**
663
+ * Adiciona ao buffer de batch. Flush é disparado por timer ou tamanho.
664
+ * Todos os eventos recebem prefixo 'page_'.
665
+ */
666
+ _queueEvent(eventName, data, tsOverride) {
667
+ this._eventQueue = this._eventQueue || []
668
+ const params = { identifier: this._config.identifier || null }
669
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
670
+ Object.assign(params, data)
671
+ } else if (data !== null && data !== undefined) {
672
+ params.value = data
673
+ }
674
+ this._eventQueue.push({
675
+ name: 'page_' + this._normalizeEventName(eventName),
676
+ ts: tsOverride || Date.now(),
677
+ params: params
678
+ })
679
+
680
+ // Flush sincrono se atingiu limite de tamanho.
681
+ if (this._eventQueue.length >= 20) {
682
+ this._flushQueue()
683
+ return
684
+ }
685
+ // Senão, agenda flush em 2s.
686
+ if (!this._flushTimer) {
687
+ this._flushTimer = setTimeout(() => { this._flushTimer = null; this._flushQueue() }, 2000)
688
+ }
689
+ },
690
+
691
+ _flushQueue(useBeacon) {
692
+ if (!this._eventQueue || this._eventQueue.length === 0) return
693
+ if (!this._config || !this._config.projectId) return
694
+ if (!this.collectorUrl) return
695
+
696
+ const events = this._eventQueue.splice(0, 50) // máximo 50 por POST
697
+ const payload = JSON.stringify({
698
+ visitorId: this._getVisitorId(),
699
+ sessionId: this._getSessionId(),
700
+ events: events,
701
+ site: this._detectSiteContext()
702
+ })
703
+
704
+ const url = this.collectorUrl + '/v1/track/sdk'
705
+ const headers = {
706
+ 'Content-Type': 'application/json',
707
+ 'X-MK-Project-Id': this._config.projectId
708
+ }
709
+
710
+ // sendBeacon no pagehide pra garantir envio durante unload.
711
+ if (useBeacon && navigator.sendBeacon) {
712
+ try {
713
+ const blob = new Blob([payload], { type: 'application/json' })
714
+ // sendBeacon nao suporta headers custom — usamos query param.
715
+ navigator.sendBeacon(url + '?projectId=' + encodeURIComponent(this._config.projectId), blob)
716
+ return
717
+ } catch (_) {}
718
+ }
719
+
720
+ fetch(url, { method: 'POST', keepalive: true, headers: headers, body: payload })
721
+ .catch(() => {})
722
+ },
723
+
724
+ // ============ AUTO-TRACK (pageview/click/scroll/form/engagement) ============
725
+
726
+ _autoTrackInitialized: false,
727
+ _scrollDepthSeen: null,
728
+ _engagementTimer: null,
729
+ _engagementLastTick: 0,
730
+
731
+ /**
732
+ * Inicia captura automática de comportamento no site host. Chamado quando
733
+ * _config recebe um projectId, OU manualmente via mkfashion.startTracking().
734
+ * Setar mkfashion.autoTrack = false antes do init pra desabilitar.
735
+ */
736
+ _initAutoTrack() {
737
+ if (this._autoTrackInitialized) return
738
+ this._autoTrackInitialized = true
739
+ try {
740
+ this._trackPageview()
741
+ this._initClickTracking()
742
+ this._initScrollTracking()
743
+ this._initFormTracking()
744
+ this._initEngagementTracking()
745
+ this._initSpaNavigation()
746
+ this._initFlushOnUnload()
747
+ this._log('Auto-tracking iniciado')
748
+ } catch (e) {
749
+ if (this.debug) console.warn('[mKFashion auto-track init]', e.message)
750
+ }
751
+ },
752
+
753
+ /**
754
+ * API pública pra ativar auto-tracking sem precisar abrir o modal.
755
+ * Útil em sites onde a SDK só renderiza botão (sem getAvailability) ou
756
+ * em landing pages sem produto definido.
757
+ */
758
+ startTracking(projectId, identifier) {
759
+ if (!projectId) {
760
+ console.error('[mKFashion] startTracking: projectId obrigatorio')
761
+ return
762
+ }
763
+ const resolved = this._resolveProjectId(projectId)
764
+ if (!this._config) this._config = { projectId: resolved, identifier: identifier || null }
765
+ this._initAutoTrack()
766
+ },
767
+
768
+ _trackPageview() {
769
+ this._scrollDepthSeen = {}
770
+ // 'view' aqui vira 'page_view' (prefix 'page_' é adicionado em _queueEvent).
771
+ this._emitToCollector('view', {
772
+ url: location.href,
773
+ pathname: location.pathname,
774
+ referrer: document.referrer || null,
775
+ title: document.title || null
776
+ })
777
+ },
778
+
779
+ _initClickTracking() {
780
+ if (!document.body) return
781
+ const INTERACTIVE = { A: 1, BUTTON: 1, INPUT: 1, SELECT: 1, TEXTAREA: 1 }
782
+ let lastClick = 0
783
+ document.body.addEventListener('click', (e) => {
784
+ try {
785
+ const now = Date.now()
786
+ if (now - lastClick < 100) return
787
+ lastClick = now
788
+ let el = e.target
789
+ for (let i = 0; i < 5 && el && el !== document.body; i++) {
790
+ if (INTERACTIVE[el.tagName] || (el.getAttribute && el.getAttribute('role') === 'button')) break
791
+ el = el.parentElement
792
+ }
793
+ if (!el) return
794
+ if (!INTERACTIVE[el.tagName] && !(el.getAttribute && el.getAttribute('role') === 'button')) return
795
+
796
+ const data = { tag: el.tagName }
797
+ const t = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' ? el.value : el.textContent
798
+ data.text = (t || '').replace(/\s+/g, ' ').trim().substring(0, 100)
799
+ if (el.href) data.href = String(el.href).substring(0, 500)
800
+ if (el.id) data.id = el.id
801
+ if (el.className && typeof el.className === 'string') {
802
+ data.classes = el.className.trim().substring(0, 200)
803
+ }
804
+ this._emitToCollector('click', data)
805
+ } catch (_) {}
806
+ }, { capture: true, passive: true })
807
+ },
808
+
809
+ _initScrollTracking() {
810
+ const TH = [25, 50, 75, 100]
811
+ let scrollTimer = null
812
+ const check = () => {
813
+ try {
814
+ const sy = window.scrollY || document.documentElement.scrollTop || 0
815
+ const dh = Math.max(
816
+ document.body && document.body.scrollHeight || 0,
817
+ document.documentElement && document.documentElement.scrollHeight || 0
818
+ )
819
+ if (dh <= 0) return
820
+ const p = Math.min(100, Math.floor((sy + window.innerHeight) / dh * 100))
821
+ if (!this._scrollDepthSeen) this._scrollDepthSeen = {}
822
+ for (const t of TH) {
823
+ if (p >= t && !this._scrollDepthSeen[t]) {
824
+ this._scrollDepthSeen[t] = true
825
+ this._emitToCollector('scroll_depth', { depth: t })
826
+ }
827
+ }
828
+ } catch (_) {}
829
+ }
830
+ check()
831
+ window.addEventListener('scroll', () => {
832
+ if (scrollTimer) return
833
+ scrollTimer = setTimeout(() => { scrollTimer = null; check() }, 250)
834
+ }, { passive: true })
835
+ },
836
+
837
+ _initFormTracking() {
838
+ if (!document.body) return
839
+ const CC = ['wpcf7-form', 'wpforms-form', 'gform_wrapper', 'formidable']
840
+ const NP = ['subscribe', 'newsletter', 'mailchimp', 'convertkit']
841
+ document.body.addEventListener('submit', (e) => {
842
+ try {
843
+ const f = e.target
844
+ if (!f || f.tagName !== 'FORM') return
845
+ // Skip WooCommerce checkout (capturado por outro caminho)
846
+ if (f.classList && (f.classList.contains('woocommerce-checkout') ||
847
+ f.classList.contains('wc-block-checkout__form'))) return
848
+
849
+ let ft = 'other'
850
+ const cls = f.className || ''
851
+ const act = (f.action || '').toLowerCase()
852
+ if (f.querySelector('input[type="search"]') || f.querySelector('input[name="s"]')) ft = 'search'
853
+ else {
854
+ for (const c of CC) if (cls.indexOf(c) !== -1) { ft = 'contact'; break }
855
+ if (ft === 'other') for (const n of NP) if (act.indexOf(n) !== -1) { ft = 'newsletter'; break }
856
+ }
857
+ this._emitToCollector('form_submit', {
858
+ form_id: f.id || '',
859
+ form_action: (f.action || '').substring(0, 300),
860
+ form_type: ft
861
+ })
862
+ } catch (_) {}
863
+ }, { capture: true })
864
+ },
865
+
866
+ _initEngagementTracking() {
867
+ const INTERVAL = 15000
868
+ this._engagementLastTick = Date.now()
869
+ this._engagementTimer = setInterval(() => {
870
+ if (document.hidden) return
871
+ const now = Date.now()
872
+ const elapsedSec = Math.round((now - this._engagementLastTick) / 1000)
873
+ this._engagementLastTick = now
874
+ if (elapsedSec > 0 && elapsedSec < 60) {
875
+ this._emitToCollector('engagement', { url: location.pathname, dwellSec: elapsedSec })
876
+ }
877
+ }, INTERVAL)
878
+
879
+ document.addEventListener('visibilitychange', () => {
880
+ if (document.hidden) {
881
+ const elapsedSec = Math.round((Date.now() - this._engagementLastTick) / 1000)
882
+ if (elapsedSec > 0 && elapsedSec < 600) {
883
+ this._emitToCollector('engagement', { url: location.pathname, dwellSec: elapsedSec })
884
+ }
885
+ this._engagementLastTick = Date.now()
886
+ } else {
887
+ this._engagementLastTick = Date.now()
888
+ }
889
+ })
890
+ },
891
+
892
+ _initSpaNavigation() {
893
+ // Captura SPA navigation (React/Vue/Next/etc) emitindo page_view a cada
894
+ // mudança de URL via history API.
895
+ let lastUrl = location.href
896
+ const self = this
897
+ const onChange = () => {
898
+ if (location.href !== lastUrl) {
899
+ lastUrl = location.href
900
+ self._trackPageview()
901
+ }
902
+ }
903
+ try {
904
+ const origPush = history.pushState
905
+ const origReplace = history.replaceState
906
+ history.pushState = function () { origPush.apply(this, arguments); setTimeout(onChange, 0) }
907
+ history.replaceState = function () { origReplace.apply(this, arguments); setTimeout(onChange, 0) }
908
+ window.addEventListener('popstate', onChange)
909
+ } catch (_) {}
910
+ },
911
+
912
+ _initFlushOnUnload() {
913
+ // Flush sincrono no pagehide pra não perder engagement final + qualquer evento pendente.
914
+ window.addEventListener('pagehide', () => {
915
+ try {
916
+ const elapsedSec = Math.round((Date.now() - this._engagementLastTick) / 1000)
917
+ if (elapsedSec > 0 && elapsedSec < 600) {
918
+ this._emitToCollector('engagement', { url: location.pathname, dwellSec: elapsedSec })
919
+ }
920
+ this._flushQueue(true)
921
+ } catch (_) {}
922
+ })
923
+ window.addEventListener('beforeunload', () => {
924
+ try { this._flushQueue(true) } catch (_) {}
925
+ })
926
+ },
927
+
660
928
  _buildUrl() {
661
929
  const encoded = encodeURIComponent(this._config.identifier).replace(/\./g, '%2E')
662
930
  const url = `${this.appUrl}/${this._config.projectId}/${encoded}`