# Changelog

## 0.15.0

### Minor Changes

- Assign section-A/B visitors by a stable position instead of re-rolling, and read the reporting dimensions from the render rather than from cookies.

  **`@shopkit/ab` — breaking.** `ArmAssignments` is now `Record<string, { position?: number; arm?: Arm }>` rather than `Record<string, Arm>`, and the new `drawArm()` returns `{ arm, position }` so the caller can persist the position. `assignArm()` stays as a thin wrapper for callers that only need the arm.

  The cookie now holds a visitor's fixed position on the 0–100 scale, not their arm. Re-aiming a test re-reads that position against the new split, so moving 50 → 55 moves only the ~5% of visitors the split crossed. The previous build drew a fresh random on every split change: it produced the correct _ratio_ — which reads as healthy on the dashboard — while moving about half the audience, making the two halves of a test different populations. It also settles the propagation window, where pods briefly disagree about the split and were re-rolling every visitor on every request. Cookies written by either older format (`"b"`, `"b:50"`) still parse and are migrated to a position that agrees with the arm the visitor already has, so shipping this does not reshuffle in-flight tests.

  **`@shopkit/events` and `@shopkit/analytics`.** `getExperimentParams()` and `hasExperimentData()` now take the section-level dimensions (`ab_experiment`, `ab_arm`, `ab_page`) from `window.__ab`, published by the render that actually applied the variant. They were read from cookies, which are per-origin: a background RSC prefetch of an untested route overwrote the values for the page the shopper was looking at. The older page-level (`_prima_ptr_ab_*`) names are unchanged and still read from cookies.

## 0.14.0

### Minor Changes

- feat(events): report the section-level A/B dimensions from the page, not from a cookie

  `EXPERIMENT_COOKIES` gains `AB_EXPERIMENT`, `AB_ARM` and `AB_PAGE` (`ab_experiment`,
  `ab_arm`, `ab_page`) so section-level experiments reach GA4 as three flat, readable
  dimensions. The UUID-keyed `_ab_arm` assignment cookie is deliberately not among them: it
  is right for assignment and unusable as a dimension (GA4 drops nested values, and the blob
  outgrows the 100-char parameter limit once a merchant accumulates a few experiments).

  Those three are read from `window.__ab`, published by the render that actually applied the
  variant, rather than from a cookie. Middleware sets the arm before anything renders, so it
  knows only the URL — it cannot know a product's template suffix and buckets on page type
  alone, and the render may then correctly decline the experiment. Reading cookies reported
  those visitors in arm B while they were shown arm A, which silently cost the test the
  ability to detect a real difference. Absent now means "not in a test", the honest answer
  for a declined match.

  Two fixes to the reader while it was open:

  - `getExperimentParams()` omits names that are not set instead of emitting `{ name: null }`.
    A null parameter is something GA4 accepts and then reports forever as `(not set)`.
  - Cookie values are percent-decoded, so a merchant-typed template name like `diwali sale`
    no longer reaches analytics as `diwali%20sale`. A malformed value falls back to the raw
    string rather than throwing and taking down every event on the page.

  The older page-level ("PRT") `_prima_ptr_ab_*` cookies are untouched and still read from
  the cookie jar — the two systems coexist. Consumers that tested for an unset dimension with
  `params[name] === null` must check for presence instead.

## 0.13.0

### Minor Changes

- **`AddToCart` from a quantity change is valued at the units added, not the new
  line quantity**

  `createCartEventHandler`'s `UPDATE_QUANTITY` branch valued the event at
  `fields.price × fields.quantity`, where `fields.quantity` is the line's NEW
  quantity. A line going 1 → 5 emitted `num_items: 4` (correct) next to a
  `value` for 5 units — the payload contradicted itself, and Meta/GA saw
  inflated add-to-cart value on every quantity bump.

  Both branches now value at the units the event actually added, matching
  `contents` and `num_items` by construction — the same semantics 0.12.0
  established for the cart API interceptor.

  Decrements remain silent, as before.

  **Breaking**: `getCartItems` is removed from `CartEmitterOptions`. It only fed
  a whole-cart total into `value`, which those semantics rule out. Callers
  passing it should drop it; `productIdentifier` is unaffected.

## 0.12.1

### Patch Changes

- fix(cart-api-interceptor): track quantity bumps sent as a single `variant_id` body

  The interceptor read a single-line request body only when it carried a
  top-level `id`, so the data-layer custom cart adapter's `/cart/change`
  payload — one `{ variant_id, quantity }` POST per line — parsed to zero
  requested items and emitted no `AddToCart`. Quantity steppers in the cart
  drawer were invisible to Meta and GA4, while `/cart/add` worked because it
  sends `{ items: [{ id, quantity }] }`.

  Since 0.11.0 the interceptor is the sole source of `AddToCart` — while it is
  installed, `createCartEventHandler` defers to it — so the cart store's own
  `UPDATE_QUANTITY` path was no fallback either.

  `variantId` / `variant_id` are now accepted as a single-line body alongside
  `id`. Removals stay silent: they arrive as `quantity: 0` and still fall out on
  the existing `quantity > 0` filter.

  Unchanged, and worth knowing: a `/cart/change` seen with no prior intercepted
  cart read still emits nothing. `/cart/change` sends an absolute quantity, so
  without a baseline the interceptor cannot tell a `+1` from a line that already
  held 1, and it stays silent rather than reporting the whole line as added.

## 0.12.0

### Minor Changes

- **AddToCart `value` is now the value of the items added, not the cart total**

  The cart API interceptor read `items_subtotal_price` / `total_price` (or
  `totalAmount`) off the cart response, so `value` reported everything in the
  cart rather than what the request added. Adding one ₹8 item to a cart already
  holding ₹252 of goods emitted `value: 260`.

  `value` is now summed from the added lines — `sum(item_price × quantity)` over
  `contents` — so it agrees with `contents` and `num_items` by construction. This
  is what `EVENTS.md` already specified for the event.

  Merchants will see lower, correct `AddToCart` values in Meta/GA reporting.
  Conversion values on `InitiateCheckout` and `Purchase` are unaffected — those
  are cart and order totals by definition.

  **Breaking**: `priceDivisor` is removed from `CartApiInterceptorOptions`. It
  only ever scaled the raw cart totals that are no longer read; per-unit prices
  come from the mapper, which keeps its own `priceDivisor`. Callers of
  `installCartApiInterceptor` should drop the option — `createCartMapper(currency, divisor)`
  is where scaling belongs. `EventProvider`'s `config.cartApi.priceDivisor` is
  unchanged and still feeds the mapper.

## 0.11.0

### Minor Changes

- Add a cart API interceptor as the primary source of `AddToCart`.

  `installCartApiInterceptor(bus, mapper, options?)` patches `window.fetch` and
  `XMLHttpRequest` to emit `AddToCart` from cart API traffic. Because it observes
  the API rather than the cart store, it also captures mutations made by theme
  forms and third-party apps that never touch `@shopkit/cart`. The request body
  identifies the variant and quantity; the response supplies the enriched line,
  currency and cart totals — no extra round-trip.

  `EventProvider` installs it automatically. Configure or disable via
  `config.cartApi`; pass `false` to opt out.

  `createCartEventHandler` is unchanged and still exported, but now defers to the
  interceptor when one is installed, so apps wiring both `onCartEvent` and the
  provider do not double-count.

  Behavior notes:

  - `value` is the cart item subtotal, excluding shipping and other charges.
  - `priceDivisor` defaults to `100`, matching the minor units the cart API
    returns (`price: 800` = ₹8.00). Set it to `1` for a backend returning major
    units.
  - Removals are a change to quantity 0 and emit nothing.
  - Failures are silent by design so analytics can never break a shopper's cart
    call; pass `onError` to surface them.

## 0.10.1

### Patch Changes

- fix(pixel): stop dropping payloaded browser-pixel events and duplicating Purchase

  Two independent defects that corrupted analytics on GoKwik storefronts:

  - **Pixel runtime aliasing (dropped events).** `PixelRuntime` handed every
    subscriber a _frozen, shallow_ copy of the event — nested `contents` /
    `content_ids` were shared by reference across Meta, GA4, GTM, Ads, Snapchat,
    and Shopify, and the freeze made in-place mutation throw. Meta's fbq
    serialises props at queue-drain time (after `/signals/config/<pixel>` loads),
    so by drain time the shared payload had been mutated by other consumers and
    by Meta's own ProtectedDataMode (`delete i[e]`), and payloaded events
    (ViewContent, AddToCart, InitiateCheckout, Purchase) were silently dropped.
    Each subscriber now receives an independent deep copy (`structuredClone` with
    guarded fallback) and is never frozen.

  - **CheckoutEmitter Purchase re-emit (duplicate purchases).** A `beforeunload`
    handler re-fired the saved Purchase closure on tab close / refresh / redirect
    / mobile app-switch. Because `bus.emit` stamps a fresh `event_id`, timestamp,
    and page context each call, this manufactured a brand-new Purchase from
    whatever page the customer was leaving. Removed the re-emit (transport
    reliability belongs to the SDK flush layer). The dedup guard is now keyed on
    `order_id` (was a constant string that swallowed a second real order in the
    same session) and persisted to `localStorage` so full-reload re-announcements
    are also caught. Purchase now emits with a deterministic
    `event_id` (`ratio_<cart_id>_<order_id>`) so any duplicate that still slips
    through dedups at Meta's end; checkout-funnel events (InitiateCheckout,
    AddShippingInfo, AddPaymentInfo, CompleteRegistration) emit
    `ratio_<cart_id>_<8-hex>` for cart-scoped correlation in CAPI logs, and every
    other event uses the mint-point default `ratio_<uuid>`. Also removed the
    redundant `orderSuccess` fallback branch — GoKwik always emits the
    `analyticsEvent` Purchase, so it was dead code.

## 0.10.0

### Minor Changes

- d939fba: Add `@shopkit/events/affiliate` subpath: client-side capture of UTM parameters and platform click IDs (`gclid`, `fbclid`, `msclkid`, `ttclid`, `twclid`, `li_fat_id`) from the landing URL. Writes to storage under the `"affiliate_data"` key, which the existing `userEnricherMiddleware` reads from — so every event downstream gets attribution context automatically with no extra wiring. Exports: `<AffiliateTracker>` component, `getAffiliateParams()` / `captureAffiliateParams()` / `clearAffiliateParams()` / `configureAffiliateTracker()` imperative API, and `useAffiliateTracker` / `useAutoCapture` / `useAffiliateSource` / `useHasAffiliateData` / `useAffiliateEvents` hooks. Default storage is sessionStorage; configurable per-app.

## 0.9.5

### Patch Changes

- chore: bump Next.js peerDependency to >=15.5.18

## 0.9.4

### Patch Changes

- Add product name (content_name, name) to event payloads for GA4 product attribution
  - ContentItem: added optional `name` field
  - CheckoutEmitter: passes GoKwik item `title` as `name` in contents and sets `content_name`
  - CartEmitter: passes `item_name` as `name` in contents
  - ProductEmitter: passes `item_name` as `name` in ViewContent contents
  - PurchasePayload & InitiateCheckoutPayload: added optional `content_name`
  - GA4 SDK: mapItems reads `c.name` for per-item product attribution

## 0.9.3

### Patch Changes

- fix(schemas): allow value: 0 in PurchasePayloadSchema for 100% discount coupons

  PurchasePayloadSchema used z.number().positive() which requires value > 0. When a
  100% discount coupon is applied, cartData.total is 0. The schema validator middleware
  was silently blocking the Purchase event, causing it to never reach the pixel or CAPI.

  Changed to z.number().nonnegative() to allow value: 0, consistent with all other
  event schemas that accept zero values.

## 0.9.2

### Patch Changes

- refactor(page-emitter): remove dead searchParams prop

  `PageEmitter` only emits `page_view` when the pathname changes — search params
  are irrelevant to this logic. The `searchParams` prop and its dependency were
  dead code and have been removed entirely.

  **What changed:**

  - `PageEmitterProps.searchParams` removed — passing it is now a TypeScript error
  - `useEffect` dep array no longer includes `searchParams`
  - The two-render split guard (`window.location.pathname` check) still works
    correctly — it depends only on pathname matching, which is unaffected

  **Migration:** Remove the `searchParams` prop from any `<PageEmitter>` usage.
  The component signature is now simply `{ bus, pathname }`.

## 0.9.1

### Patch Changes

- fix(page-emitter): prevent double page_view on Next.js two-render split

  When navigating to a URL with search params (e.g. `/products/foo?variant=123`),
  Next.js updates `pathname` and `searchParams` from separate hooks in two
  consecutive renders. Previously this caused `page_view` to fire twice — once
  with the bare path and once with the full path including params.

  **Fix**: compare props against `window.location` (updated atomically by the
  browser before any React render) and skip the emit if props haven't caught up
  yet. Only the settled render (where both pathname and searchParams match
  `window.location`) emits.

  **Behaviour changes:**

  - Variant changes (`?variant=A → ?variant=B`) on the same pathname now correctly
    emit a new `page_view` (previously suppressed).
  - Navigating to any URL with query params fires exactly one `page_view` instead
    of two.
  - No timers — the guard is synchronous and deterministic.

## 0.9.0

### Features

- **experiment-enricher**: New middleware that automatically reads A/B test cookies (`_prima_ptr_ab_home`, `_prima_ptr_ab_collection`, `_prima_ptr_ab_product`) and attaches to `event.experiment` on every event. Auto-registered in EventProvider — zero config needed.
- **experiment module** (`src/experiment/`): Exports `getExperimentParams()`, `hasExperimentData()`, `EXPERIMENT_COOKIES`, `ExperimentData` type. Generic `Record<string, string | null>` — not locked to Prima cookies.
- **SDK event passthrough**: `PixelRuntime.transformEventForSDK()` now passes `event.experiment` to `SDKEvent.metadata.experiment` — SDKs can consume experiment data for segmentation.

### Enhancements

- **dev-logger**: Logs `Experiment:` field when present. Uses raw timestamp instead of ISO conversion.
- **SDK logging overhaul** (meta-pixel-sdk.js, ga4-pixel-sdk.js):
  - Consistent category-based format: `[SDK] [Category] message data`
  - Categories: `Init`, `Event`, `CAPI`, `Auth`
  - Colored output (Meta: #1877F2, GA4: #4285F4)
  - Full event object logged (expandable in console) — no more cherry-picked fields
  - Removed `JSON.stringify` from all log calls
  - Removed unused `eid()` helper

## 0.8.2

### Bug Fixes

- **kwikcart-emitter**: Fix `num_items`, `quantity`, `value` — derive all from `itemsAdded` (delta) instead of `items[]` (full cart state). Clicking "+" now correctly emits `num_items: 1`, not total cart count.

### Enhancements

- **dev-logger**: Add configurable `enabled` option + `window.__shopkit_debug` console toggle for production debugging. Logs when: `enabled: true` (env config), `NODE_ENV=development` (default), or `window.__shopkit_debug = true` (ad-hoc).

## 0.8.1

### Bug Fixes

- **checkout-emitter**: Fix `num_items` counting items instead of total quantity — now sums `item.quantity` across all cart items

## 0.8.0

### Bug Fixes

- **cart-emitter**: Fix hardcoded `num_items: 1` — now uses actual quantity (`fields.quantity` for ADD_TO_CART, `addedQty` for UPDATE_QUANTITY)
- **product-emitter**: Fix `content_category: ""` sending empty string to Facebook — now omits field when `item_category` is undefined

### Enhancements

- **cart-emitter**: Add `content_category` to AddToCart events — reads `product_type` from cart item data
- **kwikcart-emitter**: Add `content_category` to AddToCart events — reads `product_type` from KwikCart postMessage items
- **payloads**: Add `content_category?: string` to `AddToCartPayload` type

## 0.7.0

### Minor Changes

- Add **KwikCartEmitter** — listens to KwikCart side cart `postMessage` events (`auto_add_to_cart` / `increase_quantity`) and emits `AddToCart`
  - Resolves actual updated quantity from full cart state (`items[]`), not the delta (`itemsAdded[]`)
  - Origin validation, `queueMicrotask` deferral, paise→rupees price conversion
  - New `kwikCartOrigins` config option on `EventProviderConfig`
  - Exported from `@shopkit/events` alongside existing emitters

## 0.6.0

### Minor Changes

- Add `@shopkit/events/meta` sub-path export for server-side CAPI integration
  - **MetaParamsBuilder** — centralized PII hashing (SHA-256 via Facebook's `capi-param-builder-nodejs`), field mapping (`email→em`, `phone→ph`, etc.), and server context enrichment (`client_ip_address`, `client_user_agent`)
  - **Validators** — `validateRawCAPIEvent()` with EMQ quality warnings (missing email/phone, missing fbp)
  - **PII Hasher** — `normalizePII()` + `hashPIIField()` with pre-hash detection to avoid double-hashing
  - **Types** — shared `FacebookPixelConfig`, `PixelDispatchResult`, `CAPIBatchPayload`, `MetaCAPIEvent`
  - **Constants** — `PII_FIELD_MAP`, `GRAPH_API_VERSION`, `SUPPORTED_EVENT_NAMES`
  - New `./meta` sub-path export (CJS + ESM + DTS)
  - 24 new tests covering pii-hasher and meta-params-builder

## 0.5.0

### Minor Changes

- Remove client-side PII hashing from event system
  - Removed SHA-256 hashing from `user-enricher` middleware — raw PII now passes through the event bus as-is
  - Simplified `UserData` type — removed hashed field variants (em, ph, fn, etc.)
  - Server-side CAPI route now handles all PII normalization, hashing, and Facebook field mapping
  - Updated schemas to reflect simplified user data structure

## 0.4.0

### Minor Changes

- Add self-registering pixel SDKs (Meta, GA4, Google Ads) with PixelRuntime, CAPI batching with sendBeacon fallback, gtag.js sharing between GA4 and Google Ads, debug logging with event_id tracing, and V2 AppProductTracker component.

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.0] - 2026-03-11

### Added

- **globalThis Singleton** — `EventBus` and `PixelRuntime` now stored on `globalThis` to survive React Suspense remounts in Next.js. No more context-based access required for non-React code.

- **`priceDivisor` in CartMapper** — New constructor parameter to convert raw price units (e.g. paise) to display currency (e.g. rupees). `createCartMapper('INR', 100)` divides all prices by 100.

- **Lazy `productIdentifier`** — Cart emitter reads `bus.productIdentifier` at event-fire time instead of setup time. This allows cart bootstrap to run before `EventProvider` sets the identifier.

- **Raw cart item normalization** — Cart emitter handles items directly from Shopify API (snake_case fields like `product_id`, `variant_id`, and `price` as plain number). Normalized before passing to mapper.

- **`productIdentifier` in EventProviderConfig** — New config option (`"product_id"` | `"sku"` | `"variant_id"`) to control which field is used for `content_ids` in analytics events. Updated on every render, not just initialization.

### Changed

- **`ShopifyCartMapper` → `CartMapper`** — Renamed to be platform-agnostic. `ShopifyCartMapper` and `createShopifyCartMapper` are deprecated aliases.

- **Pixel SDK scripts** — Meta, GA4, and Google Ads pixel SDK scripts moved to `apps/bblunt/public/scripts/` for self-registration model.

### Removed

- **`events-example` app** — Removed demo application.

## [0.3.1] - 2026-02-24

### Fixed

- **Webhook → CAPI Pipeline** — Server-side order events (fulfilled, shipped, delivered, cancelled) now dispatch to Facebook CAPI via shared CAPIDispatcher singleton and ServerEventBus listeners. Previously, webhook events were emitted but never consumed.

- **Ingest Endpoint Security** — Added origin validation (checks Origin/Referer header against `INGEST_ALLOWED_ORIGINS` env var) and batch size limit (max 50 events per request) to prevent fake event injection.

- **Health Endpoint Metrics** — Health API now reads CAPI metrics from both in-memory dispatcher (current instance) and Redis (persisted). Previously always showed `successRate: 1` because Redis metrics were never written.

- **In-Memory Dedup Fallback** — When Redis is unavailable, ingest endpoint now uses an in-memory LRU set (1000 entries) for deduplication instead of skipping dedup entirely.

### Added

- **Consent Gate for Pixel Runtime** — New optional `consentCheck` callback in `PixelConfig`. Called before each pixel app dispatch — return `false` to block firing (for GDPR/cookie consent). Default: allow all (backward compatible).

- **Shared CAPI Singleton** (`apps/storefront-starter/src/integrations/events/capi.ts`) — Extracted from ingest route into shared module. Used by both ingest endpoint and webhook handlers.

- **Server Event Listeners** (`apps/storefront-starter/src/integrations/events/server-event-listeners.ts`) — Idempotent listener setup wiring ServerEventBus → CAPIDispatcher for webhook-originated events.

- **Environment Variables** — Documented `FACEBOOK_CAPI_ACCESS_TOKEN`, `FACEBOOK_TEST_EVENT_CODE`, `SHOPIFY_WEBHOOK_SECRET`, `INGEST_ALLOWED_ORIGINS` in `.env.example`.

- **Tests** — Webhook → CAPI listener tests (event name mapping, idempotent registration, payload structure) and consent gate tests (per-app, per-event consent checks).

## [0.3.0] - 2026-02-24

### Added

- **Pixel Apps** (`src/pixel-apps/`)

  - `createMetaPixelManifest()` — Meta Pixel app: 10 events, fbq SDK calls, purchase event_id for CAPI dedup
  - `createGA4PixelManifest()` — GA4 app: all 16 client events, gtag SDK calls, GA4 item format mapping
  - `createGoogleAdsPixelManifest()` — Google Ads: purchase-only conversion tracking via gtag
  - `createTikTokPixelManifest()` — TikTok Pixel: 6 events, ttq SDK calls, PlaceAnOrder mapping
  - `createDefaultPixelApps()` — factory that creates all enabled pixel apps from a single config
  - All 4 pixel apps run in lax sandbox (no iframe overhead, 12-16MB memory savings)
  - New `./pixel-apps` sub-path export

- **Server Module** (`src/server/`)

  - `CAPIDispatcher` — Facebook Conversions API dispatcher with exponential backoff retry (jitter), rate limiting, metrics tracking (success rate, dispatch count)
  - `TokenBucketRateLimiter` — 900 tokens/sec default to stay under Facebook's 1000/sec limit
  - `ServerEventBus` — Node.js EventEmitter singleton for server-side event routing (webhook → CAPI)
  - Event name maps: `FACEBOOK_EVENT_MAP`, `GA4_EVENT_MAP`, `TIKTOK_EVENT_MAP` with helper functions
  - Server-side types: `IngestPayload`, `CAPIEvent`, `EnrichedEvent`, `HealthStatus`, `ShopifyOrderWebhook`
  - New `./server` sub-path export

- **App API Routes** (in `apps/storefront-starter/`)

  - `POST /api/events/ingest` — receives sendBeacon batches, Redis dedup (24h TTL), server enrichment (IP, UA), async CAPI dispatch. Returns 202 Accepted. Gracefully degrades without Redis.
  - `GET /api/events/health` — health check for Redis, CAPI, webhooks. Returns 503 when unhealthy.
  - `POST /api/webhooks/orders/fulfilled` — Shopify order fulfilled webhook with HMAC verification + Redis idempotency
  - `POST /api/webhooks/orders/shipped` — order shipped webhook with tracking info extraction
  - `POST /api/webhooks/orders/delivered` — order delivered webhook
  - `POST /api/webhooks/orders/cancelled` — order cancelled webhook with cancellation reason + line items

- **Redis Client** (`apps/storefront-starter/src/lib/redis.ts`) — lazy-initialized ioredis singleton with graceful degradation

- **Tests** — 7 new test files: meta-pixel, ga4-pixel, google-ads-pixel, tiktok-pixel, rate-limiter, server-event-bus, capi-dispatcher

### Notes

- Phase 3 (Pixel Apps + Server) of the event-driven analytics system
- Package now has 9 sub-path exports: `.`, `./schemas`, `./middleware`, `./emitters`, `./mappers`, `./react`, `./pixel-runtime`, `./pixel-apps`, `./server`
- Dual-Track Migration (Phase 4) and Admin UI (Phase 5) deferred to next phases

## [0.2.0] - 2026-02-24

### Added

- **Pixel Runtime** (`src/pixel-runtime/`)

  - `PixelRuntime` — app lifecycle orchestrator: register, boot (5s timeout), dispatch, destroy
  - `LaxSandbox` — try/catch + Object.freeze isolation for trusted first-party pixel apps
  - `StrictSandbox` — iframe sandbox with postMessage communication for untrusted third-party apps
  - `createAnalyticsAPI()` — read-only frozen API (subscribe, cookies, customer) exposed to pixel apps
  - Circuit breaker pattern: 5 consecutive errors → open (suspend), 60s cooldown → half-open, 2 successes → closed
  - Event queue during boot (max 100 events), replayed after boot completes
  - New `./pixel-runtime` sub-path export

- **Search Emitter** (`emitters/search-emitter.ts`) — Factory function `createSearchEventHandler()` with built-in 2-second deduplication. Emits `search` events with `search_term` and optional `results_count`.

- **Checkout Emitter** (`emitters/checkout-emitter.ts`) — React component that listens for GoKwik postMessage events. Maps `checkoutStarted` → `begin_checkout` (500ms debounce), `shippingSelected` → `add_shipping_info`, `paymentSelected` → `add_payment_info`, `orderSuccess` → `purchase`. All emissions deferred via `queueMicrotask` to protect INP. Includes `beforeunload` flush and origin validation.

- **KwikPass Emitter** (`emitters/kwikpass-emitter.ts`) — React component that listens for `kp_data_sent` custom event. Two-phase emission: `kwikpass_login_attempted` fires immediately (sync), `kwikpass_login_completed` fires after successful token decode via `/api/kwikpass/decode-token`. Enables login funnel analysis.

- **A/B Test Emitter** (`emitters/abtest-emitter.ts`) — React hook `useABTestEmitter()` that emits `ab_test_viewed` for each experiment. Session-level deduplication via `sessionStorage` prevents re-emission on route changes. Falls back to in-memory Set when sessionStorage is unavailable.

- **EventProvider updates** — New config options: `enableCheckout`, `enableKwikPass`, `checkoutOrigins`, `kwikPassDecodeEndpoint`, `pixelConfig`. Conditionally renders CheckoutEmitter and KwikPassEmitter. Boots PixelRuntime when `pixelConfig` is provided.

- **Tests** — 6 new test files covering all Phase 2 additions: search-emitter, checkout-emitter, kwikpass-emitter, abtest-emitter, sandbox-lax (circuit breaker), runtime (lifecycle)

### Notes

- Phase 2 (Emitters + Pixel Runtime) of the event-driven analytics system
- Pixel apps themselves (Meta, GA4, TikTok, Google Ads) are deferred to Phase 3
- Package now has 7 sub-path exports: `.`, `./schemas`, `./middleware`, `./emitters`, `./mappers`, `./react`, `./pixel-runtime`

## [0.1.1] - 2026-02-24

### Fixed

- **PII Hashing Race Condition** (`user-enricher.ts`) — Replaced `queueMicrotask` deferred hashing with a cached async strategy. Hashes are now computed once and cached per user identity, applied synchronously on subsequent events. First event gets hashes retroactively via same object reference before timer-based flush. Fixes issue where purchase events sent to server relay were missing hashed PII data.

- **Session ID Instability** (`event-bus.ts`) — When `sessionStorage` is unavailable (private browsing, restricted contexts), `getSessionId()` now caches a fallback UUID per EventBus instance instead of generating a new one per call. Ensures consistent session attribution across all events.

- **Cookie Value Truncation** (`user-enricher.ts`) — `getCookie()` now correctly handles cookie values containing `=` characters (common in base64-encoded cookies like `_ga`). Fixed by splitting on first `=` only via `indexOf`/`substring` instead of destructuring `split("=")`.

- **Middleware Double-Registration** (`EventProvider.tsx` + `event-bus.ts`) — Added `initialized` flag on EventBus itself (not `useRef`) to prevent middleware from being registered twice when EventProvider unmounts and remounts (React Strict Mode, error boundaries, route changes). The flag persists across React lifecycle and resets only on `bus.reset()`.

- **`detectPageType` Never Used** (`page-emitter.ts`) — Page view events now include a `page_type` field (home, product, collection, cart, checkout, search, page, account, other) detected from the URL path. Added `page_type` as optional field to `PageViewPayload` type and Zod schema.

- **Unbounded Server Relay Queue** (`server-relay.ts`) — Added queue size cap of 50 events. When the cap is reached, oldest events are dropped with a dev warning. Also added `try/catch` around `JSON.stringify` to handle potential serialization errors gracefully.

- **`CLEAR_CART` Not Handled** (`cart-emitter.ts`) — Added case for `CLEAR_CART` events in the cart emitter switch statement. Now emits `remove_from_cart` with all cart items and `cart_value: 0` when the cart is cleared, matching the documented behavior.

- **Server Relay Event Listener Safety** (`server-relay.ts`) — Added `typeof window.addEventListener === "function"` guard to prevent errors in environments where `window` exists but is a partial mock (e.g., minimal SSR shims).

## [0.1.0] - 2026-02-24

### Added

- Initial release of `@shopkit/events` package (Phase 1: Foundation)

- **EventBus** (`src/event-bus.ts`)

  - Typed in-memory publish/subscribe singleton
  - Synchronous dispatch with error isolation per handler
  - Express-style middleware chain with `next()` pattern
  - Ring buffer event log (last 100 events) for debugging
  - Auto-generated event envelope (UUID v4, timestamp, session_id, page context)
  - Wildcard subscriptions via `subscribeAll()`

- **Types** (`src/types.ts`)

  - `OpenStoreEventType` enum with 20 standardized event types
  - `StandardItem` interface (15 GA4-aligned fields)
  - `StandardUserData` interface (PII hashed fields)
  - `StandardCookies` interface (8 platform cookies)
  - `OpenStoreEvent` envelope with typed payloads
  - `OpenStoreEventMap` for type-safe subscriptions

- **Schemas** (`src/schemas.ts`)

  - Zod validation schemas for all 20 event payloads
  - Shared schemas: `StandardItemSchema`, `StandardUserDataSchema`, `StandardCookiesSchema`
  - `eventPayloadSchemas` record for middleware-driven validation

- **Middleware Pipeline** (`src/middleware/`)

  - `schemaValidatorMiddleware` - Zod-based payload validation, blocks malformed events
  - `deduplicatorMiddleware` - 2-second fingerprint window, O(1) eviction
  - `userEnricherMiddleware` - tracking cookies, affiliate data, PII hashing via Web Crypto API
  - `devLoggerMiddleware` - console.groupCollapsed output in development
  - `serverRelayMiddleware` - sendBeacon batch queue with fetch keepalive fallback

- **Emitters** (`src/emitters/`)

  - `createCartEventHandler()` - maps `@shopkit/cart` CartEvent to OpenStore events
  - `PageEmitter` - React component for automatic page_view on route change
  - `useProductViewEmitter()` - hook for view_product + engage_content after 20s dwell

- **Mappers** (`src/mappers/`)

  - `ShopifyCartMapper` - CartItem to StandardItem conversion
  - `ShopifyProductMapper` - Shopify product GraphQL to StandardItem
  - `ShopifyOrderMapper` - Shopify order to purchase event payload

- **React Integration** (`src/react/`)

  - `EventProvider` - root provider with middleware initialization
  - `useEventBus()` - context hook for EventBus access
  - `useTrackEvent()` - stable emit function hook
  - `useEventSubscribe()` - subscribe with auto-cleanup on unmount
  - `useEventSubscribeAll()` - wildcard subscription hook
  - `useEventLog()` - access event ring buffer

- **Package Setup**
  - 6 sub-path exports: `.`, `./schemas`, `./middleware`, `./emitters`, `./mappers`, `./react`
  - CJS + ESM + DTS output via tsup
  - Vitest test suite with jsdom environment

### Notes

- Phase 1 (Foundation) of the event-driven analytics system
- Zero changes to existing `@shopkit/cart` package required
- Cart integration uses existing `onCartEvent` callback in `CartConfig`
- Performance budget: 3.2ms per event end-to-end
- GoKwik/KwikPass/ABTest emitters deferred to Phase 2
- Pixel Runtime and pixel apps deferred to Phase 2-3
