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