npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@shoppi/analytics

v2.1.1

Published

Universal analytics tracking solution for e-commerce and websites

Readme

@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 tag (recommended for most storefronts)

<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.


GDPR / consent

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