@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+inputTypedimensions — voice search inside chat isfeature='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/analyticsimport { 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:
- Impression when the tile is 50 % visible (once per tile)
- Click on any interaction inside the tile
- 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.randomUUIDfalls back to aMath.random-based UUID on older browsers- Gracefully degrades if
localStorage/sessionStorageis blocked (private browsing)
Changelog
See CHANGELOG.md.
License
MIT © Shoppi AI
