npm.io
2.1.1 • Published 2 months ago

@shoppi/analytics

Licence
MIT
Version
2.1.1
Deps
0
Size
433 kB
Vulns
0
Weekly
0

@shoppi/analytics

Lightweight analytics SDK for Shoppi-powered storefronts. Captures impressions, clicks, add-to-cart, and purchases across search, similar-products, outfits, and chat surfaces. Feeds Shoppi's merchant dashboard in real time.

  • 3.6 KB gzipped (13 KB minified) — one global click listener, one IntersectionObserver, one MutationObserver
  • Method-first API with DOM auto-capture — zero code for impressions + clicks when product tiles carry a handful of data-shoppi-* attributes
  • Typed Feature + InputType unions — typo-catch at compile time for TypeScript consumers
  • Orthogonal feature + inputType dimensions — voice search inside chat is feature='chat', inputType='voice', not a combined event name
  • Cross-origin iframe identity relay — a chat widget on a different origin can receive parent-page identity and attribute to the same shopper
  • 24 h queryId attribution window — conversions that happen tomorrow still attribute back to today's search
  • Framework-agnostic — works with any storefront; vanilla, React, Vue, Svelte

Install

<script async src="https://cdn.shoppi.ai/analytics/v1/analytics.js"
        data-client-id="your-client-id"></script>

The bundle auto-initialises from the data-client-id attribute. window.ShoppiAnalytics is exposed for manual calls.

Optional attributes: data-api-url, data-debug, data-user-token, data-authenticated-user-token, data-default-feature.

npm (for bundled apps and SPAs)
npm install @shoppi/analytics
import { ShoppiAnalytics } from '@shoppi/analytics'

ShoppiAnalytics.init({ clientId: 'your-client-id' })

init() attaches the DOM observers and click listener itself — no second call required.


Init options

ShoppiAnalytics.init({
  clientId               : 'your-client-id',   // required

  userToken?             : 'visitor-abc',      // soft identity — anonymous-ok
  authenticatedUserToken?: 'customer-42',      // hard identity after login

  userHasOptedOut?       : false,              // GDPR kill-switch
  defaultFeature?        : 'search',           // fallback when a method omits `feature`

  endpoint?              : 'https://analytics.shoppi.ai',
  debug?                 : false,              // console-log every event
  onEvent?               : (e) => { … },       // typed fan-out for GA/Segment/Klaviyo

  // Cross-origin iframe identity relay — see "Embedded iframes" below.
  anonymousId?           : 'relayed-anon-uuid',
  sessionId?             : 'relayed-session-uuid',
})

DOM auto-capture (zero code for impressions + clicks)

Stamp these attributes on every product tile. The SDK handles impressions (IntersectionObserver) and clicks (delegated listener) automatically:

<a href="/products/red-dress"
   data-shoppi-object-id="prod_123"
   data-shoppi-feature="search"
   data-shoppi-event-name="Search Results Page"
   data-shoppi-position="1"
   data-shoppi-query-id="q_abc123"></a>
Attribute Required Purpose
data-shoppi-object-id yes Product identifier
data-shoppi-feature recommended search · similar · outfits · chat
data-shoppi-event-name optional Merchant-defined dashboard label
data-shoppi-position recommended 1-based rank in the result list
data-shoppi-query-id for ranked surfaces Shoppi's queryId from the response

Three things happen automatically:

  1. Impression when the tile is 50 % visible (once per tile)
  2. Click on any interaction inside the tile
  3. Chat / lazy-loaded / infinite-scroll tiles picked up by MutationObserver — no per-surface code

Method API

Every method takes a single params object and validates feature against the Feature literal union at compile time.

Search outcome
ShoppiAnalytics.searched({
  feature  : 'search',
  eventName: 'Search Bar',
  query    : 'red sneakers',
  queryId  : response.queryId,
  inputType: 'text',               // 'text' | 'voice' | 'image'
  objectIDs: response.results.map(r => r.id),
})

// Empty response:
ShoppiAnalytics.zeroResults({
  feature  : 'search',
  eventName: 'Search Bar',
  query    : 'red unicorn',
  queryId  : response.queryId,
  inputType: 'text',
})

searched() primes per-turn context so DOM auto-capture inherits it; it does not itself fan out impression events (prevents double-counting with IntersectionObserver). For non-DOM consumers use viewedObjectIDs explicitly.

Object-level signals
ShoppiAnalytics.viewedObjectIDs({
  feature  : 'similar',
  eventName: 'Similar Widget on PDP',
  objectIDs: ['prod_1', 'prod_2'],
  queryId  : 'q_xyz',              // optional — falls back to 24h-remembered
  positions: [1, 2],
})

ShoppiAnalytics.clickedObjectIDs({
  feature  : 'search',
  eventName: 'Search Results Page',
  queryId  : 'q_abc',
  objectIDs: ['prod_1'],
  positions: [3],
})
Cart + purchase
ShoppiAnalytics.addedToCart({
  feature  : 'search',
  eventName: 'Search Add to Cart',
  objectIDs: ['variant_id_1'],
  value    : 29.99,
  currency : 'USD',
  queryId  : 'q_abc',              // optional
})

ShoppiAnalytics.purchased({
  eventName : 'Order Complete',
  objectIDs : ['sku_1', 'sku_2'],
  value     : 74.50,
  currency  : 'USD',
  orderId   : '10045',
})

Per-item attribution via objectData:

ShoppiAnalytics.purchased({
  eventName : 'Order Complete',
  objectIDs : ['sku_1', 'sku_2'],
  objectData: [
    { queryID: 'q_abc', price: 29.99, quantity: 1 },
    { queryID: 'q_def', price: 44.51, quantity: 1 },
  ],
  value     : 74.50,
  currency  : 'USD',
})
Filter + sort events
// Facet toggled
ShoppiAnalytics.clickedFilters({
  feature  : 'search',
  eventName: 'Price Facet Click',
  filters  : ['price:Under $50', 'brand:Nike'],
})

// Facet-panel impression
ShoppiAnalytics.viewedFilters({
  feature  : 'search',
  eventName: 'Facet Panel View',
  filters  : ['price:Under $50'],
})

// Cart-add with active filters
ShoppiAnalytics.convertedFilters({
  feature  : 'search',
  eventName: 'Filtered Conversion',
  filters  : ['price:Under $50', 'brand:Nike'],
})

// Sort changed
ShoppiAnalytics.sortChanged({
  feature  : 'search',
  eventName: 'Sort Dropdown',
  sortKey  : 'price-asc',
})

Filter labels are free-form strings — merchants typically use <facet>:<value> so they're easy to group in the dashboard.

Raw escape hatch
ShoppiAnalytics.sendEvents([
  { event_type: 'click', product_id: 'abc', feature: 'search', event_name: 'Custom', timestamp: new Date().toISOString(), query_id: 'q1' },
])

Identity

ShoppiAnalytics.setUserToken(user.id)                  // soft — anonymous-ok
ShoppiAnalytics.setAuthenticatedUserToken(user.id)     // hard — post-login
ShoppiAnalytics.getUserToken()
ShoppiAnalytics.getAuthenticatedUserToken()

// Subscribe to identity changes (e.g. to mirror into your own analytics)
const unsubscribe = ShoppiAnalytics.onUserTokenChange((token) => {
  // token is the new userToken or authenticatedUserToken value
})

Soft + hard identity coexist. Pre-login activity carries the anonymous id alongside a soft userToken; after login the authenticatedUserToken is set and every subsequent event carries both — stitching the two halves of the journey.


Embedded iframes (cross-origin widget identity)

If you bundle @shoppi/analytics inside an iframe on a different origin from the parent page, the iframe's localStorage is isolated and getAnonymousId() would mint a new id — splitting attribution. Relay the parent's identity on iframe load:

// In the parent page:
iframe.addEventListener('load', () => {
  iframe.contentWindow?.postMessage({
    type: 'MY_APP_IDENTITY',
    payload: {
      clientId               : 'your-client-id',
      anonymousId            : ShoppiAnalytics.getAnonymousId(),
      sessionId              : ShoppiAnalytics.getSessionId(),
      userToken              : ShoppiAnalytics.getUserToken(),
      authenticatedUserToken : ShoppiAnalytics.getAuthenticatedUserToken(),
    },
  }, 'https://your-iframe-origin')
})

// In the iframe:
window.addEventListener('message', (e) => {
  if (e.data?.type !== 'MY_APP_IDENTITY') return
  const { clientId, anonymousId, sessionId, userToken, authenticatedUserToken } = e.data.payload
  ShoppiAnalytics.init({
    clientId,
    anonymousId,
    sessionId,
    userToken,
    authenticatedUserToken,
  })
})

The relayed identity is held in memory for the iframe's lifetime; the parent re-relays on every iframe reload.


ShoppiAnalytics.init({ clientId, userHasOptedOut: !consent.analytics })

// Or flip at runtime when consent changes:
ShoppiAnalytics.disable()    // halt buffer + wipe stored identifiers
ShoppiAnalytics.enable()     // resume after consent granted
ShoppiAnalytics.isEnabled()

disable() clears every shoppi_* storage key (anonymous id, session id, soft + hard tokens, remembered queryId, variant).


Method reference

Method Event row When to call
searched (context only) After a search returns — primes queryId for DOM auto-capture
zeroResults zero_results After a search returns no results
viewedObjectIDs impression ×N Manual impression fan-out when DOM auto-capture isn't feasible
clickedObjectIDs click ×N Manual click fan-out
addedToCart add_to_cart ×N Cart-add — queryId optional (falls back to 24h-remembered)
purchased purchase ×N Order confirmation
viewedFilters impression Shopper saw the facet panel with these filters applied
clickedFilters click Shopper toggled / selected a facet value
convertedFilters add_to_cart Shopper added to cart while these filters were active
sortChanged click Shopper changed the sort order
sendEvents raw Escape hatch for custom event shapes

Debug helpers: getClientId(), getSessionId(), getAnonymousId(), getSessionSource(), getVersion(), flush().


Event shape (what hits the server)

interface FeedbackEvent {
  event_type              : 'impression' | 'click' | 'add_to_cart' | 'purchase' | 'zero_results'
  feature?                : string    // 'search' | 'similar' | 'outfits' | 'chat' | …
  event_name?             : string    // merchant-defined label
  input_type?             : string    // 'text' | 'voice' | 'image' | ''
  query_id                : string
  query?                  : string
  product_id              : string
  position?               : number    // 1-based
  session_id?             : string
  timestamp               : string    // ISO 8601 UTC
  event_id?               : string    // UUID for dedup
  revenue?                : number
  currency?               : string    // ISO 4217
  quantity?               : number
  user_token?             : string
  authenticated_user_token?: string
  anonymous_id?           : string
  filters?                : string[]
  sort_key?               : string
  // …plus experiment_id, variant, session_source, conversation_id
}

Events are buffered and flushed every 5 s or every 50 events, whichever comes first. On page close, navigator.sendBeacon drains the remaining buffer reliably. A client-generated event_id on every row lets the server deduplicate overlap between the timer flush and the beacon drain.


Browser support

  • Chrome, Firefox, Safari, Edge — all recent versions
  • Requires IntersectionObserver, MutationObserver, navigator.sendBeacon — no polyfills bundled
  • crypto.randomUUID falls back to a Math.random-based UUID on older browsers
  • Gracefully degrades if localStorage / sessionStorage is blocked (private browsing)

Changelog

See CHANGELOG.md.

License

MIT Shoppi AI