@ekomerc/storefront 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,9 +42,89 @@ interface StorefrontClientConfig {
42
42
  apiKey: string; // API key (sfk_...)
43
43
  storage?: StorageAdapter; // Cart token storage (default: auto-detect)
44
44
  cacheTTL?: number; // Query cache TTL in ms (default: 5 minutes)
45
+ trackingAttributionTTL?: number; // Landing attribution TTL in ms (default: 30 days)
46
+ tracking?: StorefrontTrackingRuntimeConfig; // Unified tracking runtime config
45
47
  }
46
48
  ```
47
49
 
50
+ ### Tracking Runtime Configuration
51
+
52
+ `tracking` wires up the unified tracking runtime — consent signaling, GTM adapter dispatch, and optional diagnostics.
53
+
54
+ ```typescript
55
+ interface StorefrontTrackingRuntimeConfig {
56
+ // Controls whether browser adapters may dispatch when consent is "unknown".
57
+ // Defaults to true when not set.
58
+ dispatchOnUnknownConsent?: boolean;
59
+
60
+ // Called before every event to get the current consent state.
61
+ // Return "granted", "denied", or "unknown". Defaults to "unknown".
62
+ resolveConsentState?: () => AnalyticsConsentState | Promise<AnalyticsConsentState>;
63
+
64
+ // Browser adapters to forward events to (GTM-only supported path in v1).
65
+ adapters?: AnalyticsBrowserAdapter[];
66
+
67
+ // Optional diagnostic observer — called after every ingest/adapter/consent operation.
68
+ // Never throws; errors inside are swallowed.
69
+ onDiagnostic?: (diagnostic: AnalyticsDiagnostic) => void;
70
+ }
71
+ ```
72
+
73
+ **Consent states:** `"granted"` | `"denied"` | `"unknown"`
74
+
75
+ - `dispatchOnUnknownConsent: false`: events forwarded only when consent is `"granted"`.
76
+ - `dispatchOnUnknownConsent: true` (default): events forwarded unless consent is `"denied"`.
77
+
78
+ Example with a consent management platform:
79
+
80
+ ```typescript
81
+ const client = createStorefrontClient({
82
+ endpoint: "https://api.yourstore.com/graphql",
83
+ apiKey: "sfk_...",
84
+ });
85
+
86
+ await client.init({
87
+ analytics: {
88
+ dispatchOnUnknownConsent: true,
89
+ resolveConsentState: () => myCmp.getConsentState(), // "granted" | "denied" | "unknown"
90
+ adapters: [gtmAdapter].filter(Boolean),
91
+ onDiagnostic: (d) => console.debug("[tracking]", d),
92
+ },
93
+ });
94
+ ```
95
+
96
+ The SDK persists landing attribution (`utm_*`, `gclid`, `gbraid`, `wbraid`, `fbclid`, `ttclid`) in `localStorage` under `ekomerc_tracking_attribution` and expires it on read. Fresh URL params always overwrite the stored snapshot.
97
+
98
+ Call `client.init()` once on storefront bootstrap. It loads the store analytics config from the storefront API, captures landing attribution, and removes known attribution params from the address bar by default after capture. If you need to keep them in the URL, pass:
99
+
100
+ ```typescript
101
+ await client.init({
102
+ analytics: {
103
+ stripUrl: false,
104
+ },
105
+ });
106
+ ```
107
+
108
+ ### GTM-First Browser Model (v1)
109
+
110
+ - SDK remains the single browser event producer.
111
+ - First-party ingest (`/analytics/ingest`) remains authoritative for Ekomerc analytics/audit.
112
+ - Browser-side vendor routing is GTM-only in this phase.
113
+ - Backend provider forwarding is env-gated and defaults to disabled (`TRACKING_PROVIDER_FORWARDING_ENABLED=false`).
114
+ - Hosted/default storefront GTM support is deferred (follow-up TODO).
115
+
116
+ Custom storefront setup in this phase:
117
+
118
+ 1. Install the GTM snippet in storefront code (merchant-owned, outside SDK).
119
+ 2. Import `packages/sdk/storefront/src/gtm-recipes/ekomerc-gtm-browser-recipe-v1.export-v2.json` into GTM.
120
+ 3. Fill provider IDs/settings in GTM and publish manually.
121
+
122
+ Anti-duplication checklist (required):
123
+
124
+ 1. Disable GA4 Enhanced Measurement in the GA4 data stream used by this GTM setup.
125
+ 2. Disable Meta Automatic Event Setup in Meta Events Manager for the pixel used by this setup.
126
+ 3. Disable TikTok automatic event setup or equivalent automatic browser-event features for this setup.
127
+
48
128
  ### Custom Endpoint URLs
49
129
 
50
130
  `endpoint` is used exactly as provided. The SDK sends `POST` requests directly to that URL and does not rewrite hosts or paths.
@@ -123,15 +203,31 @@ const result = await client.products.list({
123
203
  });
124
204
  // Returns: { items: Product[], pageInfo: PageInfo }
125
205
 
126
- // Get single product by handle or ID
206
+ // Get single product by handle or GID
127
207
  const result = await client.products.get("blue-shirt");
128
- const result = await client.products.get("UHJvZHVjdDoxMjM="); // Global ID
208
+ const result = await client.products.get("UHJvZHVjdDoxMjM="); // GID (global ID)
129
209
 
130
210
  // Batch fetch by handles
131
211
  const result = await client.products.getByHandles(["shirt-1", "shirt-2"]);
132
212
  // Returns: (Product | null)[]
133
213
  ```
134
214
 
215
+ Each `Product` includes a `detailSections: DetailSection[]` field containing structured sections for use in product detail pages. Each section has a `sectionType` (`"RICH_TEXT"` | `"BULLET_LIST"` | `"TABLE"`), `displayIntent` (`"ACCORDION"` | `"SPECIFICATIONS"` | `"BOTH"`), `title`, `position`, and type-specific `content`.
216
+
217
+ ```typescript
218
+ if (result.isOk()) {
219
+ for (const section of result.value.detailSections) {
220
+ if (section.sectionType === "RICH_TEXT") {
221
+ console.log(section.content.html);
222
+ } else if (section.sectionType === "BULLET_LIST") {
223
+ console.log(section.content.items);
224
+ } else if (section.sectionType === "TABLE") {
225
+ console.log(section.content.rows); // [string, string][]
226
+ }
227
+ }
228
+ }
229
+ ```
230
+
135
231
  ### Collections
136
232
 
137
233
  ```typescript
@@ -166,17 +262,17 @@ const result = await client.cart.create();
166
262
  // Returns: Cart
167
263
 
168
264
  // Add item to cart
169
- // variantId must be a Global ID (GID) — use variant.id from product queries
265
+ // variantId must be a GID (global ID) — use variant.id from product queries
170
266
  const result = await client.cart.addItem(variantId, quantity);
171
267
  // Returns: Cart
172
268
 
173
269
  // Update item quantity
174
- // variantId must be a Global ID (GID) — use variant.id from product queries
270
+ // variantId must be a GID (global ID) — use variant.id from product queries
175
271
  const result = await client.cart.updateItem(variantId, newQuantity);
176
272
  // Returns: Cart
177
273
 
178
274
  // Remove item from cart
179
- // variantId must be a Global ID (GID) — use variant.id from product queries
275
+ // variantId must be a GID (global ID) — use variant.id from product queries
180
276
  const result = await client.cart.removeItem(variantId);
181
277
  // Returns: Cart
182
278
 
@@ -196,7 +292,7 @@ const result = await client.cart.removePromoCode();
196
292
  // Returns: Cart (with appliedPromoCode cleared)
197
293
  ```
198
294
 
199
- Note: `variantId` in `cart.addItem`, `cart.updateItem`, and `cart.removeItem` must be a GraphQL Global ID (GID), not a raw UUID. Use variant IDs returned by product queries.
295
+ Note: `variantId` in `cart.addItem`, `cart.updateItem`, and `cart.removeItem` must be a GID (global ID), not a raw UUID. Use variant IDs returned by product queries.
200
296
 
201
297
  ### Pricing Display
202
298
 
@@ -276,6 +372,44 @@ const result = await client.checkout.abandon();
276
372
  // Returns: Cart
277
373
  ```
278
374
 
375
+ ### Analytics
376
+
377
+ `client.analytics.track()` sends a first-party event to the storefront ingest endpoint and, when GTM adapter is configured, mirrors supported events to GTM `dataLayer`.
378
+
379
+ ```typescript
380
+ // Page view
381
+ await client.analytics.track("page_view");
382
+
383
+ // Product view (productId can be a raw UUID, GID, or base64-encoded GID)
384
+ await client.analytics.track("product_view", { productId: product.id, variantId: variant.id });
385
+
386
+ // Collection view
387
+ await client.analytics.track("collection_view", { collectionId: collection.id });
388
+
389
+ // Search
390
+ await client.analytics.track("search_performed", { query: "shirt", resultsCount: 42 });
391
+
392
+ // Cart events
393
+ await client.analytics.track("add_to_cart", { cartId: cart.id, quantity: 1, itemsCount: 3, cartValue: 29.99 });
394
+ await client.analytics.track("remove_from_cart", { cartId: cart.id, quantity: 1, itemsCount: 2, cartValue: 19.99 });
395
+
396
+ // Checkout funnel
397
+ await client.analytics.track("checkout_started", { cartId: cart.id });
398
+ await client.analytics.track("checkout_step_completed", { cartId: cart.id, step: "contact" }); // "contact" | "shipping" | "payment" | "review"
399
+ await client.analytics.track("checkout_completed", { orderId: order.id, cartId: cart.id, orderTotal: 49.99 });
400
+
401
+ // Custom event — sent as analytics.custom with eventName in properties
402
+ await client.analytics.track("homepage.banner_clicked", { position: 0 });
403
+ ```
404
+
405
+ All preset events also accept an optional `AnalyticsContext` as the last argument to override path, referrer, UTM params, consent state, or visitor/session IDs.
406
+
407
+ Important purchase behavior:
408
+
409
+ - `track("checkout_completed", ...)` is ingest-only.
410
+ - Browser purchase event (`ekomerc.purchase`) is emitted only on successful `checkout.complete()` using server-confirmed `purchaseTracking`.
411
+ - `checkout.complete()` API remains `Result<Order, StorefrontError>` (non-breaking).
412
+
279
413
  ### Cache
280
414
 
281
415
  ```typescript
@@ -296,6 +430,81 @@ client.setCartToken("cart-token-uuid");
296
430
  client.clearCartToken();
297
431
  ```
298
432
 
433
+ ### Store
434
+
435
+ ```typescript
436
+ const result = await client.store.get();
437
+ if (result.isOk()) {
438
+ const store = result.value;
439
+ // store.analytics — derived analytics availability and GTM config for this store
440
+ console.log(store.analytics);
441
+ // {
442
+ // enabled: true,
443
+ // dispatchOnUnknownConsent: true,
444
+ // gtm: { enabled: true, containerId: "GTM-XXXXXX" },
445
+ // }
446
+ }
447
+ ```
448
+
449
+ Use `store.analytics` to decide whether GTM forwarding should be active at runtime:
450
+
451
+ ```typescript
452
+ import {
453
+ createGtmBrowserAdapter,
454
+ } from "@ekomerc/storefront";
455
+
456
+ const storeResult = await client.store.get();
457
+ if (storeResult.isOk()) {
458
+ const { analytics } = storeResult.value;
459
+ const adapters = [
460
+ createGtmBrowserAdapter(analytics.gtm),
461
+ ].filter(Boolean);
462
+
463
+ console.log(adapters, analytics.dispatchOnUnknownConsent);
464
+ }
465
+ ```
466
+
467
+ ## Browser Adapters
468
+
469
+ GTM is the supported first-party browser adapter in this phase. The adapter returns `null` when GTM is disabled or container ID is missing.
470
+
471
+ ### GTM (`createGtmBrowserAdapter`)
472
+
473
+ Pushes namespaced Ekomerc events to `window.dataLayer` (`ekomerc.*`) and updates GTM Consent Mode via `gtag("consent", "update", ...)`.
474
+
475
+ ```typescript
476
+ import { createGtmBrowserAdapter } from "@ekomerc/storefront";
477
+
478
+ const gtmAdapter = createGtmBrowserAdapter(
479
+ store.browserTrackingConfig.gtm, // { enabled, containerId }
480
+ { dataLayerName: "dataLayer" }, // optional — defaults to "dataLayer"
481
+ );
482
+ // Returns null if disabled or containerId is missing
483
+ ```
484
+
485
+ GTM mapping example: `page_view` -> `ekomerc.page_view`, `product_view` -> `ekomerc.view_item`, `add_to_cart` -> `ekomerc.add_to_cart`, `checkout_started` -> `ekomerc.begin_checkout`, `search_performed` -> `ekomerc.search`.
486
+ `checkout_step_completed` and `analytics.custom` remain ingest-only in this phase.
487
+
488
+ ### Custom Adapters
489
+
490
+ Implement `AnalyticsBrowserAdapter` to add your own integration:
491
+
492
+ ```typescript
493
+ import type { AnalyticsBrowserAdapter } from "@ekomerc/storefront";
494
+
495
+ const myAdapter: AnalyticsBrowserAdapter = {
496
+ provider: "gtm", // reuse an existing provider key
497
+ dispatch({ event, consent }) {
498
+ if (event.eventType === "analytics.page_view") {
499
+ myAnalytics.page(event.context.path);
500
+ }
501
+ },
502
+ updateConsent(consent) {
503
+ myAnalytics.setConsent(consent.consentState === "granted");
504
+ },
505
+ };
506
+ ```
507
+
299
508
  ## Error Handling
300
509
 
301
510
  The SDK uses Result types for error handling. Errors are typed and categorized:
@@ -348,15 +557,13 @@ Always use a full absolute URL like `https://api.example.com/storefront`. The SD
348
557
 
349
558
  ### Cart State Errors
350
559
 
351
- Cart operations are only allowed when the cart is in `active` state. Attempting to modify a cart in `checkout`, `converted`, `abandoned`, or `expired` state returns a `StateError`:
560
+ Cart mutations (add/update/remove/clear) work on `active` carts. If the cart is in `checkout` state, the backend automatically reactivates it (releases reservations, reverts to `active`) before applying the mutation. This does not mark the cart as abandoned. Attempting to modify a `converted` or `expired` cart returns a `StateError`:
352
561
 
353
562
  ```typescript
354
- // Cart is in checkout state
355
563
  const result = await client.cart.addItem(variantId, 1);
356
564
 
357
565
  if (result.isErr() && result.error instanceof StateError) {
358
566
  console.log(`Cannot modify cart: ${result.error.message}`);
359
- // "Cannot modify cart in 'checkout' state. Cart operations are only allowed when cart is in 'active' state."
360
567
  }
361
568
  ```
362
569
 
@@ -377,6 +584,11 @@ import type {
377
584
  ProductOption,
378
585
  ProductImage,
379
586
  QuantityPricingTier,
587
+ DetailSection,
588
+ RichTextDetailSection,
589
+ BulletListDetailSection,
590
+ TableDetailSection,
591
+ DisplayIntent,
380
592
 
381
593
  // Collections
382
594
  Collection,
@@ -398,8 +610,23 @@ import type {
398
610
  PaginatedResult,
399
611
 
400
612
  // Common
401
- Money,
402
613
  Store,
614
+ BrowserTrackingConfig,
615
+ GtmBrowserTrackingConfig,
616
+
617
+ // Tracking runtime
618
+ StorefrontTrackingRuntimeConfig,
619
+ AnalyticsBrowserAdapter,
620
+ AnalyticsConsentState,
621
+ AnalyticsDispatchOnUnknownConsent,
622
+ AnalyticsDiagnostic,
623
+ AnalyticsContext,
624
+ AnalyticsIngestResponse,
625
+
626
+ // Browser adapter types
627
+ GtmBrowserAdapterOptions,
628
+ GtmDataLayerEvent,
629
+ GtmConsentModeUpdate,
403
630
  } from "@ekomerc/storefront";
404
631
  ```
405
632