# @shopkit/events

Event-driven analytics system for e-commerce storefronts.

## Features

- **20 standardized events** aligned with GA4 enhanced e-commerce
- **Type-safe EventBus** with middleware pipeline and ring buffer
- **5-stage middleware** — schema validation, deduplication, user enrichment, dev logging, server relay
- **Cart tracking at the API layer** — a fetch/XHR interceptor catches every cart mutation, including ones made by theme forms and third-party apps
- **Automatic page views** with page type detection
- **Product view tracking** with 20s engagement timer
- **Affiliate / UTM capture** on landing — feeds the user-enricher so every pixel event carries attribution context automatically
- **Data mappers** for cart, product, and order data (with priceDivisor for paise→rupees conversion)
- **sendBeacon batching** with fetch keepalive fallback
- **PII hashing** via Web Crypto API (deferred, non-blocking)
- **Performance budget** of 3.2ms per event end-to-end

## Installation

```bash
npm install @shopkit/events
# or
bun add @shopkit/events
```

**Peer dependencies:** `react`, `zod`, `next` (optional), `zustand` (optional)

## Quick Start

### 1. Add EventProvider in your root layout

```tsx
// app/layout.tsx
'use client';

import { EventProvider } from '@shopkit/events/react';
import { usePathname, useSearchParams } from 'next/navigation';

function EventProviderWrapper({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  const searchParams = useSearchParams()?.toString();

  return (
    <EventProvider pathname={pathname} searchParams={searchParams}>
      {children}
    </EventProvider>
  );
}

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <EventProviderWrapper>{children}</EventProviderWrapper>
      </body>
    </html>
  );
}
```

### 2. Wire cart events

`AddToCart` needs no wiring — `EventProvider` installs the cart API
interceptor, which observes cart requests directly. Tune or disable it via
`config.cartApi`:

```tsx
<EventProvider config={{ cartApi: { priceDivisor: 100 } }}>  // default
<EventProvider config={{ cartApi: false }}>                   // opt out
```

The `onCartEvent` wiring below still works and is safe to keep — it defers to
the interceptor when one is installed, so nothing double-counts:

```typescript
// bootstrap/cart.ts
import { eventBus } from '@shopkit/events';
import { createCartMapper } from '@shopkit/events/mappers';
import { createCartEventHandler } from '@shopkit/events/emitters';
import { configureCart } from '@shopkit/cart';

// priceDivisor: 100 converts paise (35500) to rupees (355.00)
const mapper = createCartMapper('INR', 100);
const handleCartEvent = createCartEventHandler(eventBus, mapper);

configureCart({
  // ...existing config...
  onCartEvent: handleCartEvent,
});
```

### 3. Track product views

```tsx
// components/ProductPage.tsx
import { useProductViewEmitter } from '@shopkit/events/emitters';
import { useEventBus } from '@shopkit/events/react';

function ProductPage({ product }) {
  const bus = useEventBus();
  const standardItem = {
    item_id: product.id,
    item_name: product.title,
    item_brand: product.vendor,
    item_category: product.productType,
    price: product.price,
    quantity: 1,
    currency: 'INR',
  };

  useProductViewEmitter(bus, standardItem);

  return <div>{/* product UI */}</div>;
}
```

### 4. Emit events manually

```tsx
import { useTrackEvent } from '@shopkit/events/react';
import { OpenStoreEventType } from '@shopkit/events';

function SearchResults({ term, count }) {
  const emit = useTrackEvent();

  useEffect(() => {
    emit(OpenStoreEventType.SEARCH, {
      search_term: term,
      results_count: count,
    });
  }, [term, count]);

  return <div>{/* results */}</div>;
}
```

## API Reference

### Core

#### `eventBus`

Singleton `EventBus` instance. The single point through which all analytics events flow.

| Method | Signature | Description |
|--------|-----------|-------------|
| `subscribe` | `(type: OpenStoreEventType, handler) => Unsubscribe` | Subscribe to a specific event type |
| `subscribeAll` | `(handler) => Unsubscribe` | Subscribe to all events |
| `emit` | `(type, data, overrides?) => OpenStoreEvent \| null` | Emit an event (null if blocked by middleware) |
| `use` | `(middleware: Middleware) => void` | Register middleware |
| `getEventLog` | `() => ReadonlyArray<OpenStoreEvent>` | Get ring buffer of last 100 events |
| `reset` | `() => void` | Clear all state (testing only) |

### Event Types

```typescript
import { OpenStoreEventType } from '@shopkit/events';

// 16 client-side events
OpenStoreEventType.PAGE_VIEW
OpenStoreEventType.VIEW_PRODUCT
OpenStoreEventType.ENGAGE_CONTENT
OpenStoreEventType.ADD_TO_CART
OpenStoreEventType.REMOVE_FROM_CART
OpenStoreEventType.VIEW_CART
OpenStoreEventType.BEGIN_CHECKOUT
OpenStoreEventType.ADD_PAYMENT_INFO
OpenStoreEventType.ADD_SHIPPING_INFO
OpenStoreEventType.PURCHASE
OpenStoreEventType.SEARCH
OpenStoreEventType.ADD_TO_WISHLIST
OpenStoreEventType.KWIKPASS_LOGIN_ATTEMPTED
OpenStoreEventType.KWIKPASS_LOGIN_COMPLETED
OpenStoreEventType.VIEW_PROMO
OpenStoreEventType.AB_TEST_VIEWED

// 4 server-side events
OpenStoreEventType.ORDER_FULFILLED
OpenStoreEventType.ORDER_SHIPPED
OpenStoreEventType.ORDER_DELIVERED
OpenStoreEventType.ORDER_CANCELLED
```

### Middleware (`@shopkit/events/middleware`)

| Middleware | Factory | Description |
|-----------|---------|-------------|
| `schemaValidatorMiddleware` | `createSchemaValidatorMiddleware()` | Validates payloads against Zod schemas |
| `deduplicatorMiddleware` | `createDeduplicatorMiddleware(windowMs?)` | Blocks duplicate events within 2s window |
| `userEnricherMiddleware` | `createUserEnricherMiddleware()` | Enriches with cookies, affiliate data, PII hashes |
| `devLoggerMiddleware` | `createDevLoggerMiddleware(options?)` | Console logging (dev default, env/console toggle) |
| `serverRelayMiddleware` | `createServerRelayMiddleware(config?)` | Batches and sends via sendBeacon |

#### Dev Logger Config

```typescript
interface DevLoggerOptions {
  enabled?: boolean; // Explicitly enable/disable. Default: auto (dev mode only)
}
```

Logging is active when ANY of these is true:
1. `enabled: true` (e.g., `process.env.NEXT_PUBLIC_EVENT_DEBUG === "true"`)
2. `NODE_ENV === "development"` (default, unless `enabled: false`)
3. `window.__shopkit_debug = true` (ad-hoc toggle from browser console — works in production)

#### Server Relay Config

```typescript
interface ServerRelayConfig {
  ingestUrl?: string;        // Default: /api/events/ingest
  flushIntervalMs?: number;  // Default: 5000
  maxBatchSize?: number;     // Default: 10
}
```

### Emitters (`@shopkit/events/emitters`)

#### `installCartApiInterceptor(bus, mapper, options?)`

Patches `window.fetch` and `XMLHttpRequest` to emit `AddToCart` from cart API
traffic. Installed automatically by `EventProvider` — you only call this
directly outside React. Returns an uninstall function.

Because it observes the API rather than the cart store, it also captures
mutations made by theme forms and third-party apps, which never touch
`@shopkit/cart`. The request body identifies the variant and quantity; the
response supplies title, price, currency and totals.

```typescript
interface CartApiInterceptorOptions {
  addPattern?: RegExp;        // Default: /\/cart\/add(\?|$)/
  updatePattern?: RegExp;     // Default: /\/cart\/change(\?|$)/
  readPattern?: RegExp;       // Default: /\/cart(\/get)?(\?|$)/ — seeds the baseline, emits nothing
  productIdentifier?: ProductIdentifier;
  defaultCurrency?: string;   // Default: "INR"
  onError?: (error: unknown) => void;  // Failures are silent without this
}
```

Removals are a change to quantity 0, so they emit nothing. When a change
response reports `items_added`, that delta is used directly; otherwise the
increase is derived from the request body against the last known quantities.

`value` is the value of what the request added — `sum(item_price × quantity)`
over `contents` — not the cart total. Price scaling comes from the mapper's
`priceDivisor`, so pass the same divisor you give `createCartMapper`.

#### `createCartEventHandler(bus, mapper, options?)`

Creates a handler for `@shopkit/cart`'s `onCartEvent` callback.

```typescript
interface CartEmitterOptions {
  productIdentifier?: ProductIdentifier; // Override bus.productIdentifier
}
```

`value` is the value of what the event added — `item_price × quantity` over the
units added, matching `num_items` and the interceptor above. A quantity change
from 1 to 5 emits `num_items: 4` and the value of 4 units, not 5.

The handler normalizes raw cart store items (snake_case fields, price as number) automatically. `productIdentifier` is read lazily from `bus.productIdentifier` at event-fire time, so it respects config set by `EventProvider` even if the cart is bootstrapped first.

**Defers to the interceptor.** When the cart API interceptor is installed, this
handler emits nothing — the interceptor owns `AddToCart` and builds the richer
payload. Wiring both is safe and does not double-count.

#### `<PageEmitter bus={bus} pathname={pathname} searchParams={searchParams} />`

React component that emits `page_view` on route changes. Rendered automatically by `EventProvider`.

#### `useProductViewEmitter(bus, product, options?)`

Hook that emits `view_product` on mount and `engage_content` after 20s dwell time.

### Mappers (`@shopkit/events/mappers`)

| Mapper | Factory | Description |
|--------|---------|-------------|
| `CartMapper` | `createCartMapper(currency?, priceDivisor?)` | CartItem to ProductFields. `priceDivisor` converts raw price units (e.g. 100 for paise→rupees) |
| `ShopifyProductMapper` | `createShopifyProductMapper(currency?)` | Product GraphQL to StandardItem |
| `ShopifyOrderMapper` | `createShopifyOrderMapper(currency?)` | Order to PurchasePayload |

> **Note:** `ShopifyCartMapper` and `createShopifyCartMapper` are deprecated aliases for `CartMapper` and `createCartMapper`.

### React (`@shopkit/events/react`)

#### `<EventProvider config? pathname? searchParams?>`

Root provider. Initializes EventBus with middleware pipeline and renders PageEmitter.

```typescript
interface EventProviderConfig {
  enableDevTools?: boolean;
  ingestUrl?: string;
  disablePageView?: boolean;
  disableServerRelay?: boolean;
  productIdentifier?: ProductIdentifier; // "product_id" | "sku" | "variant_id"
  bus?: EventBus;  // For testing
}
```

#### Hooks

| Hook | Return Type | Description |
|------|-------------|-------------|
| `useEventBus()` | `EventBus` | Access EventBus from context |
| `useTrackEvent()` | `(type, data) => OpenStoreEvent \| null` | Stable emit function |
| `useEventSubscribe(type, handler)` | `void` | Subscribe with auto-cleanup |
| `useEventSubscribeAll(handler)` | `void` | Subscribe to all events |
| `useEventLog()` | `ReadonlyArray<OpenStoreEvent>` | Access event ring buffer |

### Schemas (`@shopkit/events/schemas`)

Zod validation schemas for all 20 events plus shared data shapes.

| Export | Description |
|--------|-------------|
| `StandardItemSchema` | 15-field GA4-aligned item schema |
| `StandardUserDataSchema` | PII hashed user data schema |
| `StandardCookiesSchema` | Platform tracking cookies schema |
| `eventPayloadSchemas` | Record mapping event type to Zod schema |
| `OpenStoreEventEnvelopeSchema` | Full event envelope structural schema |

### Affiliate (`@shopkit/events/affiliate`)

Client-side capture of UTM parameters and platform click IDs (`gclid`, `fbclid`, `msclkid`, `ttclid`, `twclid`, `li_fat_id`) from the landing URL. Writes to `sessionStorage` (default) under the `"affiliate_data"` key — the same key the built-in `userEnricherMiddleware` reads from, so every event downstream gets attribution context for free.

| Export | Description |
|--------|-------------|
| `<AffiliateTracker autoCapture />` | Drop-in client component; mount once at the layout root |
| `getAffiliateParams()` | Read the stored snapshot (returns `null` if nothing captured) |
| `useAutoCapture(config?)` | Hook variant for capture |
| `useAffiliateTracker()` | Hook returning `{ affiliateParams, captureParams, clearParams, refreshParams }` |
| `captureAffiliateParams(config?)` | Imperative capture |
| `clearAffiliateParams()` | Imperative clear |
| `configureAffiliateTracker(config)` | Adjust defaults (storage type, attribution model, TTL) |

```tsx
// app/layout.tsx
import { AffiliateTracker } from '@shopkit/events/affiliate';

<AppShell>
  <AffiliateTracker autoCapture />
  {children}
</AppShell>
```

Pair with the existing event pipeline — nothing else to wire. To forward captured params to a downstream checkout API, read once via `getAffiliateParams()` and spread into the request payload.

## Sub-path Exports

```typescript
import { eventBus, OpenStoreEventType } from '@shopkit/events';
import { schemaValidatorMiddleware } from '@shopkit/events/middleware';
import { installCartApiInterceptor } from '@shopkit/events/emitters';
import { createCartMapper } from '@shopkit/events/mappers';
import { EventProvider, useTrackEvent } from '@shopkit/events/react';
import { eventPayloadSchemas } from '@shopkit/events/schemas';
import { AffiliateTracker, getAffiliateParams } from '@shopkit/events/affiliate';
```

## License

MIT
