@hanzo/event 0.3.31 → 0.3.33

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/hz.js DELETED
@@ -1,553 +0,0 @@
1
- /*! hz.js — the no-build distribution of @hanzo/event.
2
- *
3
- * A <script> tag for surfaces that have no bundler: a CMS page, a landing page,
4
- * a docs site. It speaks the SAME wire as the npm client and posts to the SAME
5
- * front door — one batch of WireEvents to POST {host}/v1/event:
6
- *
7
- * { batch: [ { messageId, type, event, timestamp, distinctId, anonymousId,
8
- * sessionId, product, url, path, referrer, properties }, … ] }
9
- *
10
- * <script async src="https://unpkg.com/@hanzo/event/hz.js"
11
- * data-product="hanzo.ai" // required: which surface this is
12
- * data-publishable-key="pk-…" // required off api.hanzo.ai's own origin
13
- * data-host="https://api.hanzo.ai" // optional: API host override
14
- * data-ga="G-XXXX" data-fb="123" // optional: also fan out to GA4 / Meta
15
- * data-capture="1"></script> // optional: autocapture off with "0"
16
- *
17
- * It adds what a bundled app does not need and a plain page cannot get: DOM
18
- * AUTOCAPTURE. Clicks on interactive elements (with a compact element locator),
19
- * outbound links, scroll depth, form submits and core web vitals arrive as
20
- * `$click` / `$outbound` / `$scroll` / `$form` / `$vitals` events on the one
21
- * stream. Manual: window.hanzo.track(name, props) · identify(id, traits) · page().
22
- * Respects DNT. No PII beyond the short element text the locator carries.
23
- *
24
- * It used to live in hanzoai/analytics and post a BARE JSON ARRAY of
25
- * {site, ts, type, path, …} to analytics.hanzo.ai/v1/event — a second protocol
26
- * behind an identical path spelling, served by a second collector with its own
27
- * database. Both are deleted. There is one wire, one door and one client home,
28
- * and this file is that client's script-tag form.
29
- */
30
- ;(function () {
31
- var s = document.currentScript
32
- if (!s) return
33
-
34
- // ── consent ───────────────────────────────────────────────────────────────
35
- // The same three sources the bundled stack honours, restated here for the same
36
- // reason the uid minter and the scrubber are: a script tag has no bundler and
37
- // cannot import them. An EXPLICIT stored choice — `hz_consent`, the key a Hanzo
38
- // consent banner writes — outranks the browser signal in BOTH directions,
39
- // because that is what "explicit" means. Otherwise Global Privacy Control (the
40
- // signal CPRA actually obliges a site to obey) and Do Not Track are refusals.
41
- var choice = null
42
- try {
43
- choice = localStorage.getItem('hz_consent')
44
- } catch (e) {}
45
- if (choice !== 'granted') {
46
- if (choice === 'denied' || window.hzDNT) return
47
- if (navigator.globalPrivacyControl === true) return
48
- if (
49
- navigator.doNotTrack === '1' ||
50
- navigator.doNotTrack === 'yes' ||
51
- window.doNotTrack === '1' ||
52
- navigator.msDoNotTrack === '1'
53
- )
54
- return
55
- }
56
-
57
- var LIB = 'hz.js'
58
- var VERSION = '0.3.31'
59
- var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
60
- var product = s.getAttribute('data-product') || location.hostname
61
- var capture = s.getAttribute('data-capture') !== '0'
62
- // The publishable ingest key (pk-…). Write-only and safe in page source: it
63
- // attributes a write and mints no reading principal.
64
- //
65
- // Without it a tag on any origin but the door's own sends an UNATTRIBUTED
66
- // write, and the door refuses one (401 ingest_key_required) — silently, since
67
- // nothing here reads the response. Through 0.3.11 this file had no way to
68
- // present a key at all, so every keyed static surface looked wired, measured
69
- // fine in the browser, and filed nothing.
70
- // data-publishable-key is the name; data-ingest-key is the retiring spelling.
71
- // The value is the surface's own KMS-sourced key, stamped into the tag by the
72
- // deploy — never a literal baked in here. With neither attribute the tag is
73
- // keyless and its writes file under $public; the fix is to stamp the key, not
74
- // to hardcode the org's credential into this file.
75
- var key = s.getAttribute('data-publishable-key') || s.getAttribute('data-ingest-key') || ''
76
-
77
- // ── the shared anonymous-identity chain ───────────────────────────────────
78
- // COPIED VERBATIM from @hanzo/event's src/anon.js, markers and all, for the same
79
- // reason `clean` restates scrub.ts: this file has no bundler and cannot import
80
- // anything. It is the ONE chain and the ONE key, so a page carrying this tag and
81
- // the npm client resolves to one person; src/anon.test.ts fails on a byte of
82
- // drift between the two copies. EDIT src/anon.js, never this copy, and do not
83
- // reformat it — the indentation is part of the byte comparison.
84
-
85
- /* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
86
-
87
- /** The ONE anonymous-id key, on every surface and in every distribution. */
88
- var HZ_ANON_KEY = 'hz_anon_id'
89
-
90
- /** hz.js used to write `hz_id` — a SECOND identity space, so the one-paste tag
91
- * and the npm client were two different people on one page. It is READ and never
92
- * written: an id already in the wild is ADOPTED into the shared identity, because
93
- * minting over one detaches a returning visitor from their own history. */
94
- var HZ_ANON_LEGACY_KEY = 'hz_id'
95
-
96
- /** The registrable domain the cookie is scoped to, so docs, cloud, console,
97
- * studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
98
- * ORIGIN-scoped, which is what made one journey arrive as several strangers. */
99
- var HZ_ANON_DOMAIN = 'hanzo.ai'
100
-
101
- /** Two years, rewritten on every read, so the cookie rolls forward with the
102
- * visitor instead of expiring two years after first touch. Safari caps a
103
- * SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
104
- * what keeps a returning Safari visitor: each read re-arms the 7-day window. */
105
- var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
106
-
107
- /** Last resort for a browser that refuses cookies AND localStorage: without it
108
- * every event in a page load would mint an id of its own. */
109
- var hzAnonMemo
110
-
111
- /**
112
- * hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
113
- *
114
- * It has to be v7, and this is the only minter any distribution may use. The
115
- * session rollups on the event plane derive a session's start instant FROM THE ID
116
- * and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
117
- * is not merely unordered there — it is DISCARDED, silently, and the rollup stays
118
- * empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
119
- */
120
- function hzUuidv7(now) {
121
- var b = new Uint8Array(16)
122
- var i
123
- var c = typeof crypto !== 'undefined' ? crypto : undefined
124
- if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
125
- else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
126
- var t = Math.floor(now === undefined ? Date.now() : now)
127
- for (i = 5; i >= 0; i--) {
128
- b[i] = t % 256
129
- t = Math.floor(t / 256)
130
- }
131
- b[6] = 0x70 | (b[6] & 0x0f) // version 7
132
- b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
133
- var h = ''
134
- for (i = 0; i < 16; i++) {
135
- h += (b[i] + 0x100).toString(16).slice(1)
136
- if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
137
- }
138
- return h
139
- }
140
-
141
- /** The cookie jar, or null wherever there is no document to read one from. */
142
- function hzAnonJar() {
143
- try {
144
- if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
145
- return document
146
- } catch (e) {
147
- return null // sandboxed frame with an opaque origin
148
- }
149
- }
150
-
151
- /** localStorage, or null when the browser refuses it (Safari private mode). */
152
- function hzAnonStore() {
153
- try {
154
- if (typeof window === 'undefined' || !window.localStorage) return null
155
- return window.localStorage
156
- } catch (e) {
157
- return null
158
- }
159
- }
160
-
161
- /** One stored value, or '' — a jar can read as well as refuse to. */
162
- function hzAnonItem(store, name) {
163
- try {
164
- return (store && store.getItem(name)) || ''
165
- } catch (e) {
166
- return ''
167
- }
168
- }
169
-
170
- /** The value of cookie `name`, or ''. */
171
- function hzAnonCookie(name) {
172
- var d = hzAnonJar()
173
- if (!d) return ''
174
- var parts = d.cookie.split(';')
175
- for (var i = 0; i < parts.length; i++) {
176
- var eq = parts[i].indexOf('=')
177
- if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
178
- var v = parts[i].slice(eq + 1).trim()
179
- if (!v) continue
180
- try {
181
- return decodeURIComponent(v)
182
- } catch (e) {
183
- return v // not percent-encoded — take it as written
184
- }
185
- }
186
- return ''
187
- }
188
-
189
- /** Writes `name` on the registrable domain, for as long as the browser allows. */
190
- function hzAnonWrite(name, value) {
191
- var d = hzAnonJar()
192
- if (!d) return
193
- var host = ''
194
- var secure = false
195
- try {
196
- if (typeof window !== 'undefined' && window.location) {
197
- host = window.location.hostname || ''
198
- // A Secure cookie is refused outright by a non-secure origin, which would
199
- // strand http://localhost dev on the localStorage path.
200
- secure = window.location.protocol === 'https:'
201
- }
202
- } catch (e) {
203
- /* location unreachable — write a host-only, non-secure cookie */
204
- }
205
- // encodeURIComponent leaves a UUID byte-identical while making any value that is
206
- // not one unable to forge a `;` and inject an attribute.
207
- var c = name + '=' + encodeURIComponent(value)
208
- c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
209
- // Off hanzo.ai (localhost, previews, other registrable domains) the attribute
210
- // would be rejected and the whole cookie dropped, so it stays host-only there.
211
- // Prefixing both sides with '.' matches the domain itself and its subdomains
212
- // while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
213
- if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
214
- c += '; Domain=' + HZ_ANON_DOMAIN
215
- }
216
- if (secure) c += '; Secure'
217
- try {
218
- d.cookie = c
219
- } catch (e) {
220
- /* cookies refused — localStorage still carries the id */
221
- }
222
- }
223
-
224
- /**
225
- * hzAnonId returns the stable anonymous id for this browser, '' during SSR.
226
- *
227
- * Resolution is strictly ADDITIVE — every id that already exists is ADOPTED, and
228
- * only a browser holding none of them is given a new one:
229
- *
230
- * cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
231
- *
232
- * Minting over an id resets a returning visitor and detaches them from their own
233
- * history, so the order is the migration: the cookie is the shared home, the two
234
- * localStorage keys are what the three implementations wrote before it existed,
235
- * and each is read until nothing is left to adopt.
236
- *
237
- * localStorage keeps being written, so a rollback finds everyone where it left
238
- * them, and a browser that refuses cookies still holds one id per origin.
239
- */
240
- function hzAnonId() {
241
- if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
242
- var s = hzAnonStore()
243
- var id =
244
- hzAnonCookie(HZ_ANON_KEY) ||
245
- hzAnonItem(s, HZ_ANON_KEY) ||
246
- hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
247
- hzAnonMemo ||
248
- hzUuidv7()
249
- hzAnonMemo = id
250
- hzAnonWrite(HZ_ANON_KEY, id)
251
- try {
252
- if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
253
- } catch (e) {
254
- /* quota exhausted, or a private-mode jar that reads but refuses writes */
255
- }
256
- return id
257
- }
258
-
259
- /* ── END hz anon chain ─────────────────────────────────────────────────────── */
260
-
261
- function stored(store, key) {
262
- try {
263
- var v = store.getItem(key)
264
- if (!v) store.setItem(key, (v = hzUuidv7()))
265
- return v
266
- } catch (e) {
267
- return 'anon'
268
- }
269
- }
270
- // This used to mint into a localStorage key of its own, so the tag and
271
- // @hanzo/event counted one visitor as two people. The chain adopts that old id
272
- // where it exists rather than orphaning it.
273
- var anon = hzAnonId()
274
- var sid = stored(sessionStorage, 'hz_sid')
275
- var person = null
276
- try {
277
- person = localStorage.getItem('hz_uid')
278
- } catch (e) {}
279
-
280
- var queue = [],
281
- timer
282
- function flush() {
283
- clearTimeout(timer)
284
- if (!queue.length) return
285
- var body = JSON.stringify({ batch: queue.splice(0, queue.length) })
286
- var url = host + '/v1/event'
287
- // BOTH transports send a SIMPLE request, and for the same reason. The key is
288
- // publishable (data-publishable-key, write-only and safe in page source), so
289
- // it rides the query — the one carrier neither transport needs a header for —
290
- // and the body is text/plain, the CORS-safelisted type. A simple request is
291
- // sent whatever origin it is on, because the browser asks permission to READ
292
- // a cross-origin response, never to send one, and nothing here reads it.
293
- //
294
- // That property is what lets this file run on a customer's own page at all.
295
- // Every send from there is cross-origin; an Authorization header or a JSON
296
- // content type makes the POST preflighted instead, and an origin that does not
297
- // pass the preflight loses the batch, which splice has already emptied.
298
- //
299
- // The door reads the raw body and dispatches on its first non-space byte, so
300
- // the type names the CORS class and nothing else.
301
- var wire = key ? url + '?ingest_key=' + encodeURIComponent(key) : url
302
- try {
303
- if (navigator.sendBeacon && navigator.sendBeacon(wire, new Blob([body], { type: 'text/plain' })))
304
- return
305
- } catch (e) {}
306
- fetch(wire, {
307
- method: 'POST',
308
- body: body,
309
- keepalive: true,
310
- credentials: 'omit',
311
- headers: { 'content-type': 'text/plain' },
312
- }).catch(function () {})
313
- }
314
- // ── location redaction ────────────────────────────────────────────────────
315
- // The same policy src/scrub.ts applies in the npm client, restated here because
316
- // this file has no bundler and therefore cannot import it: a reset, invite or
317
- // magic link carries a JWT in the query and an address in `?email=`, and the
318
- // location is stamped on EVERY event — so without this, one page load ships the
319
- // credential to the warehouse and every later click repeats it.
320
- //
321
- // Deliberately a SUBSET: the shapes that actually appear in a URL. Free-text
322
- // error scrubbing (PANs, private keys, stack text) has no counterpart here
323
- // because this distribution has no error plane. Keep the markers identical to
324
- // scrub.ts — a warehouse row must not reveal which distribution wrote it.
325
- var SECRETS = [
326
- /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT
327
- /\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
328
- /\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
329
- /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
330
- /\bhk-[A-Za-z0-9]{16,}/g,
331
- /\bgh[posru]_[A-Za-z0-9]{20,}/g,
332
- /\bAIza[0-9A-Za-z_-]{20,}/g,
333
- /\bAKIA[0-9A-Z]{16}\b/g,
334
- // Bounded like scrub.ts's: the unbounded form backtracks quadratically on
335
- // colon-rich text that never reaches an '@'.
336
- /[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g,
337
- ]
338
- var EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
339
- function clean(u) {
340
- if (!u) return u
341
- if (u.length > 8192) u = u.slice(0, 8192) + '… [truncated]'
342
- for (var i = 0; i < SECRETS.length; i++) u = u.replace(SECRETS[i], '[redacted]')
343
- return u.replace(EMAIL, '[email]')
344
- }
345
-
346
- // send builds ONE WireEvent — the same shape core.ts build() produces, so the
347
- // server cannot tell which distribution emitted it.
348
- function send(kind, event, props) {
349
- queue.push({
350
- messageId: hzUuidv7(),
351
- type: kind,
352
- event: event,
353
- timestamp: new Date().toISOString(),
354
- distinctId: person || anon,
355
- anonymousId: anon,
356
- personId: person || undefined,
357
- sessionId: sid,
358
- product: product,
359
- url: clean(location.href),
360
- path: clean(location.pathname),
361
- referrer: clean(document.referrer) || undefined,
362
- properties: props || undefined,
363
- library: LIB,
364
- libraryVersion: VERSION,
365
- })
366
- clearTimeout(timer)
367
- timer = setTimeout(flush, 400)
368
- }
369
-
370
- // ── element locator (the autocapture detail) ──────────────────────────────
371
- // A compact, stable, PII-light descriptor of the element interacted with, so
372
- // movements read logically: tag, short text, id, data-*, and an ancestor path.
373
- function locator(el) {
374
- if (!el || el === document) return null
375
- var o = {
376
- tag: el.tagName ? el.tagName.toLowerCase() : '',
377
- id: el.id || undefined,
378
- name:
379
- (el.getAttribute && (el.getAttribute('name') || el.getAttribute('aria-label'))) ||
380
- undefined,
381
- }
382
- var txt = (el.innerText || el.value || '').trim().replace(/\s+/g, ' ').slice(0, 80)
383
- if (txt) o.text = txt
384
- // A link target is a URL like any other — a share/invite href carries the
385
- // same token shapes the page URL does.
386
- if (el.getAttribute && el.getAttribute('href')) o.href = clean(el.getAttribute('href'))
387
- if (el.dataset) for (var k in el.dataset) if (k !== 'hz') (o.data = o.data || {})[k] = el.dataset[k]
388
- var p = [],
389
- n = el,
390
- i = 0
391
- while (n && n.tagName && i++ < 4) {
392
- var seg = n.tagName.toLowerCase()
393
- if (n.id) {
394
- seg += '#' + n.id
395
- p.unshift(seg)
396
- break
397
- }
398
- if (n.className && typeof n.className === 'string')
399
- seg += '.' + n.className.trim().split(/\s+/).slice(0, 2).join('.')
400
- p.unshift(seg)
401
- n = n.parentElement
402
- }
403
- o.sel = p.join('>')
404
- return o
405
- }
406
- function interactive(el) {
407
- return el && el.closest && el.closest('a,button,[role=button],input,select,textarea,[data-hz],[onclick]')
408
- }
409
-
410
- // ── auto pageviews (initial + SPA) ────────────────────────────────────────
411
- var last = ''
412
- function page() {
413
- var k = location.pathname + location.search
414
- if (k === last) return
415
- last = k
416
- send('pageview', '$pageview')
417
- }
418
- page()
419
- ;['pushState', 'replaceState'].forEach(function (m) {
420
- var o = history[m]
421
- history[m] = function () {
422
- var r = o.apply(this, arguments)
423
- page()
424
- return r
425
- }
426
- })
427
- addEventListener('popstate', page)
428
-
429
- // ── autocapture: clicks, outbound, scroll depth, form submits ─────────────
430
- if (capture) {
431
- addEventListener(
432
- 'click',
433
- function (e) {
434
- var el = interactive(e.target)
435
- if (!el) return
436
- var loc = locator(el)
437
- send('event', '$click', loc)
438
- if (el.tagName === 'A' && el.host && el.host !== location.host)
439
- send('event', '$outbound', { url: clean(el.href), el: loc })
440
- },
441
- true,
442
- )
443
- addEventListener('submit', function (e) { send('event', '$form', locator(e.target)) }, true)
444
- var seen = {}
445
- addEventListener(
446
- 'scroll',
447
- function () {
448
- var d = document.documentElement
449
- var pct = Math.round(((scrollY + innerHeight) / (d.scrollHeight || 1)) * 100)
450
- ;[25, 50, 75, 100].forEach(function (m) {
451
- if (pct >= m && !seen[m]) {
452
- seen[m] = 1
453
- send('event', '$scroll', { depth: m })
454
- }
455
- })
456
- },
457
- { passive: true },
458
- )
459
- }
460
-
461
- // ── core web vitals (best-effort, no dep) ─────────────────────────────────
462
- var vitals = {}
463
- try {
464
- new PerformanceObserver(function (l) {
465
- l.getEntries().forEach(function (x) { vitals.lcp = Math.round(x.startTime) })
466
- }).observe({ type: 'largest-contentful-paint', buffered: true })
467
- new PerformanceObserver(function (l) {
468
- l.getEntries().forEach(function (x) {
469
- if (!x.hadRecentInput) vitals.cls = +((vitals.cls || 0) + x.value).toFixed(3)
470
- })
471
- }).observe({ type: 'layout-shift', buffered: true })
472
- } catch (e) {}
473
- // A document can be taken away on either signal, and neither one alone covers
474
- // every browser: visibilitychange is what fires when a tab is backgrounded or
475
- // discarded, pagehide is what fires on the navigation path where it does not.
476
- // core.ts listens for both; this listens for both. flush() returns on an empty
477
- // queue, so whichever arrives second finds nothing left to send.
478
- var vitalsSent = false
479
- function leaving() {
480
- // The web vitals are one measurement of one page view. Hiding a tab twice
481
- // does not make two of them.
482
- if (!vitalsSent && (vitals.lcp != null || vitals.cls != null)) {
483
- vitalsSent = true
484
- send('event', '$vitals', vitals)
485
- }
486
- flush()
487
- }
488
- addEventListener('visibilitychange', function () {
489
- if (document.visibilityState === 'hidden') leaving()
490
- })
491
- addEventListener('pagehide', leaving)
492
-
493
- // ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
494
- function assign(a, b) {
495
- if (b) for (var k in b) a[k] = b[k]
496
- return a
497
- }
498
- window.hanzo = {
499
- track: function (name, props) { send('event', name, props || undefined) },
500
- identify: function (id, traits) {
501
- person = id
502
- try { localStorage.setItem('hz_uid', id) } catch (e) {}
503
- send('identify', undefined, traits || undefined)
504
- },
505
- page: function (props) {
506
- last = ''
507
- page()
508
- if (props) send('event', 'page_props', props)
509
- },
510
- flush: flush,
511
- }
512
- var ga = s.getAttribute('data-ga'),
513
- fb = s.getAttribute('data-fb')
514
- function load(src) {
515
- var el = document.createElement('script')
516
- el.async = true
517
- el.src = src
518
- document.head.appendChild(el)
519
- }
520
- if (ga) {
521
- load('https://www.googletagmanager.com/gtag/js?id=' + ga)
522
- window.dataLayer = window.dataLayer || []
523
- window.gtag = function () { dataLayer.push(arguments) }
524
- gtag('js', new Date())
525
- gtag('config', ga)
526
- var _t = window.hanzo.track
527
- window.hanzo.track = function (n, p) {
528
- _t(n, p)
529
- try { gtag('event', n, p || {}) } catch (e) {}
530
- }
531
- }
532
- if (fb) {
533
- !(function (f) {
534
- if (f.fbq) return
535
- var n = (f.fbq = function () {
536
- n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments)
537
- })
538
- if (!f._fbq) f._fbq = n
539
- n.push = n
540
- n.loaded = !0
541
- n.version = '2.0'
542
- n.queue = []
543
- })(window)
544
- load('https://connect.facebook.net/en_US/fbevents.js')
545
- fbq('init', fb)
546
- fbq('track', 'PageView')
547
- var _u = window.hanzo.track
548
- window.hanzo.track = function (n, p) {
549
- _u(n, p)
550
- try { fbq('trackCustom', n, p || {}) } catch (e) {}
551
- }
552
- }
553
- })()