@vio-live/web-sdk 0.23.1 → 0.26.0

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
@@ -179,36 +179,94 @@ template that wants slots shared across articles simply sets no scope.
179
179
 
180
180
  ### Declaration
181
181
 
182
- Every mounted element registers its id (and label); after the first successful
183
- `Vio.bootstrap()` the SDK sends **one** manifest for the whole page
184
- (`POST /v2/mobile/components/manifest`, `mode: 'merge'`, so one page never
185
- deprecates another page's slots) and again whenever a new id or label appears.
186
- The browser remembers **each id (under its label) for 24 h** in `localStorage`
187
- (under the platform and a hash of the key — never the key itself), so a
188
- multi-page site posts only the ids a page adds, a revisit posts nothing, a
189
- renamed block posts again; without storage it simply posts each load. A failed
182
+ Every mounted element registers its id, its label and its **kind** (`card` |
183
+ `carousel` | `detail` — each manifest location is `{ id, displayName, kind }`; the
184
+ campaigns micro stores the kind from 2026-10 on and ignored the key before); after
185
+ the first successful `Vio.bootstrap()` the SDK sends **one** manifest for the whole
186
+ page (`POST /v2/mobile/components/manifest`, `mode: 'merge'`, so one page never
187
+ deprecates another page's slots) and again whenever a new id, label or kind appears.
188
+ The browser remembers **each id — under its label and the campaign it was
189
+ declared with — for 24 h** in `localStorage` (under the platform and a hash of
190
+ the key — never the key itself; one state per id), so a multi-page site posts
191
+ only the ids a page adds, a revisit posts nothing, a renamed block or a page that
192
+ changed campaign posts again; without storage it simply posts each load. A failed
190
193
  attempt (or a server that ignored `merge`) is not retried for 1 h; a declaration
191
194
  sends at most 100 locations and a page load at most 3 manifests (each cap warns
192
195
  once). Best-effort, never throws. By hand, e.g. from an editor (`force` re-sends
193
196
  everything and ignores the per-load cap):
194
197
 
195
198
  ```ts
196
- await Vio.declareSlots(['hero', { id: 'sidebar', displayName: 'Sidebar' }], { force: true })
199
+ await Vio.declareSlots(['hero', { id: 'sidebar', displayName: 'Sidebar', kind: 'carousel' }], { force: true })
197
200
  // → ManifestResult | null (null = skipped as already declared, or failed — see the console)
201
+
202
+ // What an element does on mount (a host block can too). `(id, name, campaignId)` still works.
203
+ const release = Vio.registerSlot('sidebar', 'Sidebar', { kind: 'carousel', campaignId: 12 })
198
204
  ```
199
205
 
206
+ The first kind an id gets stays — the cards a carousel renders share the carousel's
207
+ id, and the slot is the carousel's.
208
+
200
209
  ### Which campaign a slot shows
201
210
 
202
211
  Among the active campaigns of the bootstrap, the one whose assignment names the
203
212
  slot (for this platform, or for all) and whose window is open right now; if
204
- several, the default campaign, else the first listed. No assignment → the default
213
+ several, the default campaign, else the first listed. No assignment → the page's
214
+ campaign (see *Page campaign*) when the bootstrap lists it as active → the default
205
215
  campaign. `Vio.campaignForSlot('hero')` answers from the last bootstrap (`null`
206
- before it); the pure `resolveSlotCampaign(boot, slot, now?, platform?)` is exported
207
- from `/core`. A carousel or card **without a `sponsor-id`** uses the primary sponsor
216
+ before it); the pure `resolveSlotCampaign(boot, slot, now?, platform?, pageCampaignId?)`
217
+ is exported from `/core`. A carousel or card **without a `sponsor-id`** uses the primary sponsor
208
218
  of its slot's campaign (the default campaign's when the slot has none), and the
209
219
  detail opened by such a card does the same — before 0.23 a sponsorless card did
210
220
  nothing. An explicit `sponsor-id` still wins.
211
221
 
222
+ ### Page campaign — an editorial page that belongs to one campaign
223
+
224
+ Native-SDK style placements stay the primary model: the **dashboard assigns**
225
+ a campaign to a slot. But an editorial page — a Vev article, a Replit article —
226
+ often *is* part of one campaign, and the page can say so:
227
+
228
+ ```ts
229
+ Vio.init({ apiKey: '…', campaignId: 12 }) // the whole page
230
+ ```
231
+
232
+ ```html
233
+ <body data-vio-campaign="12"> <!-- inheritable, like the scope -->
234
+ <vio-product-carousel vio-campaign="10" …> <!-- one element of another campaign -->
235
+ ```
236
+
237
+ Precedence per element: `vio-campaign` on the element → the nearest ancestor's
238
+ `data-vio-campaign` → `Vio.init({ campaignId })`; an unusable level (not a positive
239
+ integer, paused, unknown) falls through to the next. The attributes are read when
240
+ the element mounts or its own attribute changes — there is no observer on the
241
+ ancestors. On web/Replit a **per-article** campaign needs a per-article scope too
242
+ (`data-vio-scope`, see above): without it the auto ids of every article are the
243
+ same `carousel-1`, and the articles' campaigns would overwrite one another's
244
+ assignment; Vev ids are per block, so it does not apply there. What it does:
245
+
246
+ - **Declaration.** The manifest carries the page's campaign (top-level) and an
247
+ element's own campaign per location when it differs. The campaigns service
248
+ (campaigns micro PR #16) turns that into assignments of the page's slots
249
+ (`source: 'page'`) — **unless the dashboard already assigned the slot: the
250
+ dashboard wins**, and the SDK warns once per such slot (and once when this page
251
+ moved a slot from an earlier page assignment). A page that changes campaign
252
+ declares again; a campaign the micro did not take up is retried after an hour.
253
+ - **Resolution, immediately.** `Vio.campaignForSlot()` / `Vio.attribution()` (and
254
+ so the elements, the cart origins and the events) resolve as: the slot's
255
+ assignment (a dashboard one always; a page-sourced one unless the page now
256
+ declares a different active campaign or the known sponsor takes no part in it)
257
+ → the page's campaign, when the bootstrap lists it as active and — with a known
258
+ sponsor — that sponsor takes part in it → the sponsor's campaign → the default. An editor sees the effect in the Vev
259
+ editor before the manifest round-trips.
260
+ - `Vio.pageCampaign()` returns the init-level campaign as the bootstrap lists it
261
+ (active), else `null`.
262
+ - With a **known sponsor** (a card with `sponsor-id`), the page's campaign — and a
263
+ slot assignment the page made itself (`source: 'page'`) — count only when that
264
+ sponsor takes part in the campaign; otherwise the sponsor's own campaign applies.
265
+ A dashboard assignment counts unconditionally.
266
+ - `Vio.init({ debug: true })` logs the micro's `assignment` per slot at
267
+ `console.debug`. It is independent of `Vio.analytics.start({ debug })`, which
268
+ logs the analytics events.
269
+
212
270
  ## Attribution — the slot, the campaign, the article
213
271
 
214
272
  `Vio.attribution(slot?)` is **the** attribution helper — the SDK elements use it,
@@ -216,7 +274,7 @@ and so do the Vev blocks and the Replit site:
216
274
 
217
275
  ```ts
218
276
  Vio.attribution('hero')
219
- // → { slot: 'hero', campaignId: 12, sponsorId: 7, contentId: 'article-42', contentTitle: 'Why the lamp matters' }
277
+ // → { slot: 'hero', campaignId: 12, sponsorId: 7, contentId: 'article-42', contentTitle: 'Why the lamp matters', contentUrl: 'https://host.example/article-42' } // contentUrl: origin + path
220
278
  ```
221
279
 
222
280
  `campaignId`/`sponsorId` are the slot's campaign and its primary sponsor (`{}` before
@@ -241,17 +299,25 @@ titles are cut — in one place, before anything is sent. Per element,
241
299
  tracks carries `slot` (the effective id), `location_id` (= the slot), `content_id`
242
300
  and `content_title`. `campaign_id` resolves as explicit `campaignId` (the element's
243
301
  own attribution travels in its event detail and is honored as such) → the slot's
244
- assigned campaign → the sponsor's campaign → the default. Two things changed from
302
+ assigned campaign → the page's campaign (when the sponsor takes part in it) → the
303
+ sponsor's campaign → the default. Two things changed from
245
304
  0.22 for carousels: a carousel's impressions and `view_item_list` report
246
305
  `location_id` as its slot id (`carousel-1`, or the `vio-slot`) instead of its
247
306
  `label`, and every event now carries `content_id` / `content_title` — including
248
- the single-campaign `begin_checkout` / `purchase`.
307
+ the single-campaign `begin_checkout` / `purchase`. Since 0.25 every element also
308
+ says **what it is**: `component_template_id` is `product-card` for a card,
309
+ `product-carousel` for a carousel — and for the cards it renders, so a carousel's
310
+ impressions and its clicks count under one component type — and `product-detail`
311
+ for the detail (its `view_item`, `add_to_cart` and `begin_checkout`). A host that
312
+ lays cards out itself can set `component-template-id` on them (`product-grid`).
249
313
 
250
314
  ### Cart origins and the purchase per campaign
251
315
 
252
316
  The cart remembers **where every unit came from**. The add paths take an optional
253
- `origin` (`{ campaignId?, slot?, contentId? }` — what `Vio.attribution(slot)`
254
- answers), and so does `adjustQuantity(lineItemId, sponsorId, delta, { origin })`:
317
+ `origin` (`{ campaignId?, slot?, contentId?, componentKind?, contentUrl?, contentTitle? }`
318
+ — what `Vio.attribution(slot)` answers, plus the kind of element; the elements pass
319
+ their `componentTemplateId` as it), and so does
320
+ `adjustQuantity(lineItemId, sponsorId, delta, { origin })`:
255
321
 
256
322
  ```ts
257
323
  Vio.cart.addProduct({ product, sponsorId: 1, origin: Vio.attribution('hero') })
@@ -259,10 +325,13 @@ Vio.cart.addProduct({ product, sponsorId: 1, origin: Vio.attribution('hero') })
259
325
  onProductAdd={(e) => Vio.cart.addProduct({ product, sponsorId, origin: e.detail.origin })}
260
326
  ```
261
327
 
262
- Each line keeps `origins: Array<{ campaignId?, slot?, contentId?, quantity }>`: an
263
- add with an origin increments that origin, without one the most recent (the
264
- `<vio-cart>` +/− buttons pass none), and −1/remove comes off the most recent first.
265
- They persist with the cart; carts from before origins read as one empty origin.
328
+ Each line keeps `origins: Array<{ campaignId?, slot?, contentId?, componentKind?,
329
+ contentUrl?, contentTitle?, quantity }>`: an add with an origin increments that
330
+ origin, without one the most recent (the `<vio-cart>` +/− buttons pass none), and
331
+ −1/remove comes off the most recent first. A place is campaign + slot + content +
332
+ kind of element (an add from a card and one from the detail of the same slot are two
333
+ origins); the page's URL and title are attributes of it. They persist with the cart;
334
+ carts from before origins read as one empty origin.
266
335
 
267
336
  At checkout and purchase the cart is **split per campaign**: `begin_checkout` and
268
337
  `purchase` go out **once per campaign in the cart**, each with that campaign's
@@ -283,6 +352,107 @@ Vio.cartSlices(undefined, { items }) // pure, over a snapshot of lines
283
352
  // → CartSlice[]: { campaignId?, sponsorId?, slot?, contentId?, items: VioItem[], value, currency?, slice, slices }
284
353
  ```
285
354
 
355
+ ### Attribution on the order — what Commerce receives (0.26)
356
+
357
+ Since 0.26 the origins do not stay in the browser: **Commerce receives them**
358
+ (ADR-0035 phase B), so the order is born knowing where it was sold from.
359
+
360
+ - **Per line.** Every `AddItem` carries the line's `origin` — `{ campaign_id,
361
+ location_id (the slot), component_kind, content_url, content_title }` — and a
362
+ quantity **increase** carries, on `UpdateItem`, the origin of the units it adds (the
363
+ given one, else the line's most recent; a burst of +1 from two places is two
364
+ updates, each with its own origin); a decrease and a removal carry none, and an add
365
+ without an origin sends none (the line inherits the order's campaign on the
366
+ server). `component_kind` is the kind of element as the collector names it too
367
+ (`card` | `carousel` | `detail`: the element's `componentTemplateId` without its
368
+ `product-` / `vev-` prefix, else the kind word in the slot id), so
369
+ `order_item.component_kind` and `breakdown?by=component_kind` agree. shopcart keeps
370
+ every origin of a line with its units and writes `order_item.campaign_id` /
371
+ `location_id` / `component_kind` from them.
372
+ - **Per checkout.** `CreateCheckout` and the `UpdateCheckout` right before paying
373
+ carry `attribution`: `surface_key` (the SDK key — the gateway sets it from
374
+ `X-API-Key` anyway, and shopcart checks it against the cart's channel, so a page
375
+ cannot credit a sale to another surface), `campaign_id` (the page's campaign,
376
+ `Vio.pageCampaign()` — else the campaign that explains most units of the cart under
377
+ the same rule `purchase` is split by), `content_url` / `content_title` (the page at
378
+ pay time) and `entry` (`{ referrer, utm_source, utm_medium, utm_campaign,
379
+ landing_url }`: the landing the session **remembers** — the same memory the
380
+ `client` block reads, which now keeps the landing page too — never the page the
381
+ shopper pays on taken for a landing; without a remembered landing there is no
382
+ `entry`). They become `order.surface_id` / `campaign_id` / `content_url` /
383
+ `content_title` / `entry_source`. `Vio.orderAttribution(sponsorId?)` answers what
384
+ would be sent — synchronously: nothing on the way to a payment waits for it.
385
+ - **`content_url` is origin + path**, no query string, no fragment — on the order,
386
+ on the lines and in the landing: the query is where a page carries e-mails, tokens
387
+ and tracking tails, and none of that belongs on an order row. A site whose article
388
+ lives in the query or the hash identifies it through `contentId` (`Vio.init({
389
+ contentId })`, `?vio_content=`, the `vio:content-id` meta), not through the URL.
390
+ The analytics events keep `location.href` as before.
391
+ - **Privacy.** On a **`minimal`** surface what says *which screen* a unit was sold
392
+ from and *why the shopper was there* stays off Commerce — the same promise the
393
+ collector keeps for the events (vio-analytics `privacy.ts`): no `content_url`, no
394
+ `content_title`, no `location_id` on the lines, no `entry`; `surface_key`,
395
+ `campaign_id` and `component_kind` do travel. The mode comes from
396
+ `Vio.init({ privacyMode: 'minimal' })` when the host knows it (always wins), else
397
+ the bootstrap's `surface.privacyMode`, else the surface itself: `GET
398
+ /v2/mobile/surface`, asked **once at `Vio.init()`** (and again by `bootstrap()` if
399
+ that failed), given 1.5 s, believed for the page load — a failure or a timeout is
400
+ believed for a minute, then asked again; a service that predates the endpoint (404)
401
+ means `standard`; a word the SDK does not know does not. The payment path reads
402
+ what is known at that moment (`Vio.privacyModeNow()`) and never waits; an unknown
403
+ mode counts as `minimal`. The cart's lines wait for the ask in flight, at most its
404
+ cap (`Vio.privacyMode()`).
405
+ - **A gateway that predates these arguments** (`origin` on `LineItemInput` /
406
+ `UpdateItem`, `attribution` on `CreateCheckout` / `UpdateCheckout`) refuses the
407
+ operation at validation, before anything runs: the SDK sends it again without them,
408
+ once per page load, says so in the console, and the cart works as in 0.25. Nothing
409
+ that ran — data in the answer, or an error with a `path` — is ever repeated.
410
+
411
+ Everything is capped to Commerce's columns in one place (`lineOriginWire`,
412
+ `checkoutAttributionOf`, exported from `/core`): slot 100, kind 32, URL 2 048, title
413
+ 255, referrer 2 048, utm 128; `campaign_id` only as a positive 32-bit `Int`.
414
+
415
+ ### The visit and the buyer — `client` and `buyer_id` (0.25)
416
+
417
+ Every event carries a **`client`** block, the standard context of the visit:
418
+ `language` (`navigator.language`), the **landing page's** `referrer` and
419
+ `utm_source` / `utm_medium` / `utm_campaign` / `utm_term` / `utm_content`,
420
+ `screen_w` / `screen_h` and `tz` (IANA). The landing is read once and remembered
421
+ for the session in `sessionStorage` (`vio.landing.v1`), so the third page of a visit
422
+ still says it came from the newsletter — a page's own internal referrer is never
423
+ sent. The referrer is kept as origin + path (no query string, no fragment) and
424
+ dropped when it is this same host (a self-referral says nothing about the entry). A
425
+ URL with utm parameters is a new landing that replaces the memory — unless it was
426
+ reached from this same host, i.e. an internal link that propagates utm, which keeps
427
+ the landing that was. With `requireConsent` nothing is written until
428
+ `setConsent(true)`, and `setConsent(false)` forgets it. Device type, OS, browser and
429
+ country are **not** sent: the collector derives them from the request itself. The
430
+ block is computed once per page load (capped to the collector's limits) and the
431
+ same object rides on every event. `resolveClientContext()` / `resolveLanding()` are
432
+ exported from `/core` for hosts with their own pipeline.
433
+
434
+ A `purchase` carries **`commerce.buyer_id`**: shopcart's `buyer_ref`, the keyed hash
435
+ Commerce computes from the buyer's email — one person, one ref across orders and
436
+ devices, nothing reversible — so a campaign can count distinct and repeat buyers.
437
+ The SDK never sends an email and never hashes one itself: it forwards the reference
438
+ the checkout answers (the order-number query, the Apple Pay and the Klarna native
439
+ confirmations select `buyer_ref`; a gateway that predates the field is asked again
440
+ without it, once per page load) and omits it otherwise. `Vio.checkout.getOrderReference(checkoutId, sponsorId?)`
441
+ answers `{ orderNumber, buyerRef }` (never throws; concurrent asks share one
442
+ request); `getOrderNumber` is its order-number half. The `vio:payment-success`
443
+ detail carries it as `buyerRef`; an emitter that has to ask for it asks at once (and
444
+ again at 1.2 s while the checkout has neither order nor reference — a webhook may
445
+ still be writing it), waits at most 2.5 s, fires the event at once without it when the
446
+ page hides or the element is removed, and never asks when it cannot help (the
447
+ gateway has no `buyer_ref` yet; a confirmation that already answered). The
448
+ confirmation screen never waits. A host with its own checkout passes `buyerRef` (or
449
+ `buyer_ref`) in its `vio:payment-success` / `vio:checkout-open` detail, or
450
+ `commerce.buyerId` to `Vio.analytics.track()`; anything that is not a reference
451
+ (an email, more than 128 chars, anything outside `[A-Za-z0-9._~-]`) is dropped
452
+ (`normalizeBuyerRef` / `buyerRefOf`, exported from `/core`). **Without consent**
453
+ (`requireConsent` until `setConsent(true)`) `buyer_id` is not sent at all: a visit
454
+ measured with ephemeral ids carries no stable identifier of a person.
455
+
286
456
  ## Theming
287
457
 
288
458
  Every component reads its colors and fonts from `--vio-*` CSS custom properties