@vio-live/web-sdk 0.27.0 → 0.29.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/CHANGELOG.md ADDED
@@ -0,0 +1,359 @@
1
+ # Changelog — `@vio-live/web-sdk`
2
+
3
+ What changed in each version, and **what a host has to know before upgrading**.
4
+ Every version has up to four parts:
5
+
6
+ - **Added** — new things you can use.
7
+ - **Changed** — the same feature behaves differently (screens, events, data on the wire).
8
+ - **Fixed** — bugs that are gone.
9
+ - **⚠️ Upgrade notes** — what a host (Vev, Replit, a custom page) must do, check or
10
+ watch for. Read these before bumping, even for a patch.
11
+
12
+ Newest first. `Unreleased` is merged on `main` but **not published to npm** yet.
13
+ How to keep this file: see [CONTRIBUTING → Changelog](CONTRIBUTING.md#changelog).
14
+ The details of each feature live in the [README](README.md); this file says *what*
15
+ and *when*, and links there.
16
+
17
+ ## 0.29.0 — 2026-10-24
18
+
19
+ ### Added
20
+ - **`vio:cart-updated`** on `document` — `{ sponsorId, cartId, items, itemCount, subtotal, currency, source }`,
21
+ `source: 'server'` when the cart was just read from the server (right before a checkout is
22
+ created or a payment link minted, and when the drawer or the checkout opens), `'storage'` when
23
+ another tab of the site changed it. For hosts that draw their own cart or badge.
24
+ `Vio.cart.refreshFromServer(sponsorId)` asks on demand. See
25
+ [README → What the shopper pays is what the page shows](README.md#what-the-shopper-pays-is-what-the-page-shows--the-cart-the-server-the-other-tabs).
26
+ - **`add_payment_info`** (analytics, GA4 name) the moment a backend checkout exists for a payment
27
+ — Vipps from the detail, the cart or the checkout, a Stripe payment link or Element, Klarna,
28
+ Apple Pay, Nexi, Kustom, Qliro, Walley, Adyen — with `commerce.checkout_id`, `payment_method`,
29
+ the cart's lines and the same context as `begin_checkout` (per campaign like it). Once per
30
+ checkout per page load, none when the checkout could not be created, and by beacon BEFORE the
31
+ page leaves for Vipps or a Stripe link. It is how a paid order whose `purchase` never came back
32
+ keeps its session, page and slot (ADR-0039; order 4128, 2026-10-23). The checkout manager
33
+ announces it on `window` as **`vio:checkout-created`** `{ sponsorId, checkoutId, paymentMethod,
34
+ campaignId?, redirect }`. See [README → `add_payment_info`](README.md#add_payment_info--the-checkout-linked-to-this-session-0290).
35
+
36
+ ### Changed
37
+ - **«Kjøp nå med Vipps» on the product detail goes straight to Vipps only when the cart is
38
+ empty.** With lines already in the cart, the product is added and the checkout opens first
39
+ (`vio:checkout-open`, Vipps preselected, no `vio:payment-redirect-*` events), every line and
40
+ the total on screen. Before, the tap paid the WHOLE cart with only the Vipps page saying so
41
+ (Mote & Livsstil, 2026-10-23: a jumper left in the cart 13 minutes earlier was paid along
42
+ with the top bought «nå»). The cart's own Vipps button is unchanged: it pays the lines it shows.
43
+ - **Apple Pay on the product detail shows what it charges.** The sheet's amount is fixed before
44
+ the tap and was the product's price, while the charge was the sponsor's whole cart. The sheet
45
+ now opens only on an empty cart, prepared for the product × the chosen quantity (it is prepared
46
+ again when the quantity or variant changes); with lines already in the cart — or while the
47
+ sheet for a new quantity is still being prepared — the product is added and the checkout opens
48
+ (`vio:checkout-open` with `paymentMethod: 'apple-pay'`), where Apple Pay charges the total shown.
49
+
50
+ ### Fixed
51
+ - **The lines the shopper pays for are the lines the page shows.** The checkout, and the
52
+ Vipps / Stripe page, are built on the backend cart — every line in it — while the drawer
53
+ and the checkout summary drew this tab's memory. Now the SDK reads the server's cart right
54
+ before a checkout is created or a payment link minted, and when `<vio-cart>` / `<vio-checkout>`
55
+ open, and draws that; an open checkout recomputes its lines and total when the cart changes
56
+ under it.
57
+ - **Other tabs of the site are heard.** The carts are shared through `localStorage` but each
58
+ tab read them once, at load: a payment in one tab left the others showing — and adding to —
59
+ a cart that was already paid; a line added in one tab was missing from the others until a
60
+ reload. Now a tab adopts what another tab wrote (`storage` event), except for a sponsor it is
61
+ mid-add for.
62
+ - **Vipps Express: the checkout no longer adds our shipping to the total.** The delivery is
63
+ chosen and paid inside Vipps, but with Vipps selected the checkout still added the seller's
64
+ first rate (Careless QA, 2026-10-24: «Frakt – Default Shipping» 10 kr, 1 308 kr on screen and
65
+ on the button, 1 298 kr in Vipps). With Vipps as the method and Express on for the seller,
66
+ the summary says «Frakt: Velges i Vipps» and the total and «Betal … med Vipps» are the items.
67
+ Vipps without Express, and every other method, still add the rate as before.
68
+ - **The `purchase` of a paid return is sent, not left in the queue** (2026-10-24). Order 4128
69
+ (Careless, prod, 2026-10-23 22:40, after two abandoned trips to Vipps): the shopper
70
+ paid, came back, saw the confirmation, and the brand's panel never counted the sale — Commerce
71
+ had its `order_paid`, the collector no `purchase`. The return was read as paid; the one way the
72
+ code loses that purchase is the queue: it was built, with the order number, into a queue sent
73
+ every 5 s, and leaving the page right away (back to the Vipps app, the home screen) beaconed
74
+ only a purchase still waiting for its number — a phone that discards a hidden tab never runs
75
+ the next tick. Now the purchase is sent as soon as it is built, and when the page hides
76
+ everything still queued leaves by beacon.
77
+
78
+ ### ⚠️ Upgrade notes
79
+ - **The collector must accept `add_payment_info`** before it is counted: one that does not know the
80
+ name rejects that event alone (`202` with `rejected`), the rest of the batch is kept. Nothing to
81
+ do in the host.
82
+ - A host that handles `vio:checkout-open` only from the cart: the product detail now emits it
83
+ too when «Kjøp nå med Vipps» or Apple Pay is tapped on a non-empty cart (it already did when
84
+ Express was off for the seller), now also with `paymentMethod: 'apple-pay'`. Check that your
85
+ handler opens `<vio-checkout>` for the detail's event.
86
+ - A host with its own cart drawer or badge: listen to `vio:cart-updated` (or `Vio.cart`'s
87
+ `change`) — the lines can now change without a tap in that tab.
88
+ - One more `GetCart` per checkout creation / payment link and per opening of the drawer or the
89
+ checkout.
90
+
91
+ ## 0.28.0 — 2026-10-22
92
+
93
+ ### Added
94
+ - **The detail's layout is public**: 31 new `applyVioTheme` keys, each a `--vio-*` property
95
+ (logo height and zoom, image fit / aspect / max height / zoom / side margin, thumbnail size and gap, option
96
+ height and gap, button height and radius, a sticky buy zone, cart / checkout widths,
97
+ confirmation logo and title, the redirect overlay), plus `::part` names on every piece of
98
+ the detail and the confirmation. The same keys serve our demo, Replit and Vev's Config block.
99
+ See [README → Layout](README.md#layout-of-the-detail-cart-checkout-and-confirmation-028).
100
+ - **The hand-over to Vipps**: a full-screen «Åpner Vipps …» with a soft fade from the tap to
101
+ the Vipps page (detail, cart, checkout), which also stops a second tap; events
102
+ `vio:payment-redirect-start` (cancelable — draw your own loader), `vio:payment-redirect-error`
103
+ and `vio:payment-redirect-cancel`. See [README → The hand-over to Vipps](README.md#the-hand-over-to-vipps-028).
104
+ - `commerce.checkout_id` on every `purchase` the SDK can tie to a checkout, and
105
+ `props.order_id_kind` (`'order'` | `'checkout'`).
106
+ - `vio:payment-success` may carry `orderNumber` when the checkout already knows it.
107
+ - `purchaseIdsOf(detail)` → `{ orderId, checkoutId, orderIdKind }` (from `/core`) for
108
+ hosts with their own analytics sink.
109
+
110
+ ### Changed
111
+ - **Product detail** (asked by Mote & Livsstil): the selling brand's logo (its name without
112
+ one; a square logo with the name beside it) sits **top left beside ✕**, no longer above the
113
+ image; every main image shows in **one 4:5 box it fills** (`cover`) — no white bars, no jump
114
+ between photos of different shapes; thumbnails are **one row** (swipe, arrows with a mouse,
115
+ the selected one kept in view) instead of wrapping; sizes are **compact** (36px); **Vipps
116
+ comes first**, above quantity and «Legg i handlekurv»; the **description folds** («Beskrivelse»
117
+ opens on a tap); under the buy buttons, **«Du handler direkte hos {brand}.»**, with links to the
118
+ brand's «Kjøpsvilkår» and «Angrerett og retur» when its sponsor block carries `termsUrl` /
119
+ `refundPolicyUrl` (https only; `Vio.sponsorIdentity()` returns them too). On desktop the
120
+ description comes before the buy zone, which stays pinned at the bottom of the panel; phones
121
+ keep buy zone, then description.
122
+ - The confirmation's brand logo is 40px tall (was max 32px).
123
+
124
+ ### Fixed
125
+ - **Phones: the detail, the cart and the checkout close by pulling them down**, from the grab bar
126
+ or the top bar (logo / title and ✕): the sheet follows the finger, and closes when let go far
127
+ enough or with a flick, or springs back. Before, the grab bar only answered a tap — pulling it
128
+ scrolled the sheet. The checkout never closes this way mid-payment.
129
+ - **Phones: the page behind stays put while a sheet is open.** A swipe past the end of the
130
+ detail, cart or checkout used to scroll the article underneath. The page is locked while any
131
+ sheet is open and comes back to the same place when the last one closes. Desktop drawers are
132
+ unchanged.
133
+ - **The `purchase` event's `commerce.order_id` is Vio's order number** (`4415`), not the
134
+ checkout's uuid. Before, every purchase paid through a redirect (Vipps, Klarna, hosted
135
+ Stripe) or whose order a webhook creates carried the uuid, so Commerce could never net
136
+ a cancellation or refund (`order_cancelled` / `order_refunded`, ADR-0036) against it.
137
+ ([#88](https://github.com/vio-live/vio-web-sdk/pull/88))
138
+
139
+ ### ⚠️ Upgrade notes
140
+ - **The product detail looks different** after upgrading (see *Changed*). A host that styled
141
+ `.sponsor-header` / `.topbar-brand` from outside: they are gone — use `::part(sponsor)`.
142
+ Hosts that want the old look back: `detailImageAspect: '1 / 1'`, `detailImageFit: 'contain'`,
143
+ `detailOptionHeight: '42px'`, `detailBuyPosition: 'static'`. The `4 / 5` box crops photos of
144
+ other shapes: pick the proportion that fits the catalog, or `contain`.
145
+ - A host with its own «loading Vipps» screen: call `preventDefault()` on
146
+ `vio:payment-redirect-start`, or the SDK's overlay shows on top of yours.
147
+ - **The `purchase` may leave up to 10 s after `vio:payment-success`**: the SDK asks the
148
+ checkout for the order number at once and then after 0.5, 1, 2, 3 and 3 s. The confirmation screen, the
149
+ cart and the payment never wait — only the analytics event. If the page hides first,
150
+ the event leaves at once with the checkout id (`order_id_kind: 'checkout'`).
151
+ - **Hosts with their own sink (Vev):** read the ids with `purchaseIdsOf(detail)` and wait
152
+ for the number the same way (`Vio.checkout.getOrderReference(checkoutId)`), or your
153
+ purchases keep the uuid.
154
+ - **Dashboards / queries** that joined purchases by checkout uuid: use `checkout_id`.
155
+ - **Hosts that lock the page themselves when a Vio sheet opens** (phones): the SDK does it now —
156
+ `position: fixed` on `<body>` at the current offset, its own inline styles restored and the
157
+ scroll put back on close. Drop yours, or make sure it does not undo the body's styles while a
158
+ sheet is still open.
159
+ - One `purchase` per checkout per page load: a second `vio:payment-success` for the same
160
+ checkout adds nothing.
161
+ - **«Kjøpsvilkår · Angrerett og retur» need the backend**: the links show only when the
162
+ sponsor block carries `termsUrl` / `refundPolicyUrl`, which the campaigns service sends
163
+ once the brand fills them in (Settings → Brand → Shopper policies). Until then the
164
+ sentence «Du handler direkte hos {brand}.» shows alone. Nothing to do on the host.
165
+
166
+ ## 0.27.0 — 2026-10-21
167
+
168
+ ### Added
169
+ - **The brand that sells is named** — name and logo — in the product detail (top),
170
+ the checkout header («Du handler hos …») and the confirmation («Du har handlet hos
171
+ …»), also after a Vipps return and a reload. ([#91](https://github.com/vio-live/vio-web-sdk/pull/91))
172
+ - `Vio.sponsorIdentity(sponsorId, { checkoutId? })` → `{ id, name, logoUrl, avatarUrl } | null`.
173
+ - `vio:checkout-open` and `vio:payment-success` carry `sponsor: { id, name, logoUrl }`.
174
+ - `Vio.checkout.getOrderReference(checkoutId, sponsorId?)` also answers `sponsor`.
175
+ - `sponsorIdentityFrom(bootstrap, sponsorId)` exported from `/core`.
176
+
177
+ ### Changed
178
+ - The detail's top line names the seller only once (no repeated seller name).
179
+ - The checkout remembers, per tab, which sponsor each checkout belongs to
180
+ (`sessionStorage` `vio.checkoutSponsor.v1`, last 20 checkouts).
181
+
182
+ ### ⚠️ Upgrade notes
183
+ - The sponsor in the checkout and its confirmation is **the checkout's** (the cart's
184
+ brand), never the article's: an article of one brand can sell another brand's product.
185
+ - A host confirmation page that must survive a reload keeps `checkoutId` and `sponsorId`
186
+ in its own URL and asks `getOrderReference` again: the SDK cleans its return
187
+ parameters after using them. Recipe in [README → Sponsor identity](README.md#sponsor-identity--name-and-logo).
188
+ - New marks are styled with `--vio-*` tokens and exposed as `part="sponsor"`.
189
+
190
+ ## 0.26.1 — 2026-10-21
191
+
192
+ ### Fixed
193
+ - A surface with **no active campaign** still names its channel sponsor in the
194
+ attribution (before, the sponsor came out empty). ([#89](https://github.com/vio-live/vio-web-sdk/pull/89))
195
+
196
+ ## 0.26.0 — 2026-10-20
197
+
198
+ ### Added
199
+ - **The order is born attributed** (ADR-0035 B): every `AddItem` / quantity increase
200
+ carries the line's `origin` (`campaign_id`, slot, `component_kind`, `content_url`,
201
+ `content_title`), and `CreateCheckout` / the last `UpdateCheckout` carry the checkout's
202
+ `attribution`. See [README → Attribution on the order](README.md#attribution-on-the-order--what-commerce-receives-026).
203
+
204
+ ### Changed
205
+ - `content_url` is sent **without query string or fragment**.
206
+ - Privacy mode `minimal` sends neither the page nor the slot.
207
+ - The surface's privacy mode is preloaded, so the first events already respect it.
208
+ - `vio:payment-success` never waits longer than the page stays open; one reference
209
+ request per checkout.
210
+ - The landing `referrer` has no query and is dropped when it is a self-referral;
211
+ `buyer_id` only with consent.
212
+
213
+ ### ⚠️ Upgrade notes
214
+ - **Against a gateway without the new fields** (`origin` on `LineItemInput` / `UpdateItem`,
215
+ `attribution` on the checkout) the SDK resends the operation without them once per page
216
+ load and says so in the console: the cart works as in 0.25, **but the order is not
217
+ attributed**. Deploy the backend for the attribution to reach Commerce.
218
+ - Hosts that add to the cart with their own button should pass
219
+ `origin: Vio.attribution(slot)` (or `e.detail.origin`) to `Vio.cart.addProduct`.
220
+
221
+ ## 0.25.0 — 2026-10-19
222
+
223
+ ### Added
224
+ - The checkout asks the gateway for **`buyer_ref`** (a keyed hash of the buyer's email)
225
+ and puts it on `vio:payment-success` → analytics `buyer_id`.
226
+ - Every event carries a **`client`** block: language, the landing's referrer and utm
227
+ parameters, screen size and time zone (see [README → The visit and the buyer](README.md#the-visit-and-the-buyer--client-and-buyer_id-025)).
228
+ - Slot manifests carry `kind`; every element carries `component_template_id`.
229
+
230
+ ### Changed
231
+ - A slot assigned `page` counts as the page's campaign when its sponsor is known.
232
+
233
+ ### ⚠️ Upgrade notes
234
+ - Never store an email from the events: `buyerRef` is the reference to keep.
235
+ - With `requireConsent`, nothing is written (landing memory, ids) until `setConsent(true)`,
236
+ and `buyer_id` is not sent.
237
+
238
+ ## 0.24.0 — 2026-10-16
239
+
240
+ ### Added
241
+ - **The page says which campaign it belongs to** (ADR-0034, amendment 3):
242
+ `Vio.init({ campaignId })`, `<body data-vio-campaign="…">`, or `vio-campaign` on one
243
+ element. See [README → Page campaign](README.md#page-campaign--an-editorial-page-that-belongs-to-one-campaign).
244
+
245
+ ### Fixed
246
+ - The cart's reconcile skips the mutation that is answering; card context per product;
247
+ title and URL capped to the collector's limits.
248
+
249
+ ### ⚠️ Upgrade notes
250
+ - The attributes are read **when an element mounts**: in an SPA, update `data-vio-campaign`
251
+ (or call `Vio.init` again) before mounting the next article's blocks.
252
+ - A paused, unknown or non-numeric campaign falls through to the next level.
253
+
254
+ ## 0.23.1 — 2026-10-16
255
+
256
+ ### Added
257
+ - **Automatic slot identity**: with `data-vio-scope` (and `data-vio-scope-name`) on an
258
+ ancestor, every element registers itself (`article-123-carousel-1`); `Vio.attribution()`.
259
+ - The cart remembers the origin of each unit; a purchase is split per campaign.
260
+
261
+ ### Fixed
262
+ - Collector limits respected; a one-campaign purchase is one slice; stable origins.
263
+
264
+ ## 0.23.0 — 2026-10-16
265
+
266
+ ### Added
267
+ - **Slots**: the page declares its slots and each one resolves its campaign from the
268
+ dashboard (ADR-0027 / 0034). See [README → Slots](README.md#slots--where-things-happen-on-a-page).
269
+
270
+ ### Fixed
271
+ - The checkout waits for pending cart mutations before creating a checkout or a payment link.
272
+ - GraphQL cart calls time out after 15 s.
273
+
274
+ ### ⚠️ Upgrade notes
275
+ - `Vio.init({ platform: 'web' | 'custom-web' | 'vev' })` — default `'web'`. Only that
276
+ platform's assignments come back.
277
+
278
+ ## 0.22.0 — 2026-10-16
279
+ Version bump only; same code as 0.21.1.
280
+
281
+ ## 0.21.1 — 2026-10-16
282
+
283
+ ### Fixed
284
+ - Quantity changes are serialized and coalesced per cart line: quick taps on + / − end on
285
+ the quantity the shopper sees, with one request in flight at a time.
286
+
287
+ ## 0.21.0 — 2026-10-15
288
+
289
+ ### Fixed
290
+ - Analytics: `order_id` only with Vio ids, the cart's `value`, and `checkout_id` on
291
+ payment returns.
292
+
293
+ ## 0.20.5 — 2026-10-15
294
+
295
+ ### Fixed
296
+ - Analytics: the real `campaign_id`, a consistent `order_id` on `purchase`, and
297
+ `X-Api-Key` on the events request.
298
+
299
+ ## 0.20.4 — 2026-10-14
300
+
301
+ ### Fixed
302
+ - A bootstrap request started before a re-init no longer lands in the new configuration.
303
+
304
+ ## 0.20.3 — 2026-10-12
305
+
306
+ ### Changed
307
+ - A cart already paid in another tab reads «already paid», not «failed».
308
+
309
+ ## 0.20.1 / 0.20.2 — 2026-10-12
310
+
311
+ ### Fixed
312
+ - The dialogs' focus trap keeps keyboard focus inside in Safari, and pulls it back when the
313
+ browser walks out of the dialog.
314
+
315
+ ## 0.20.0 — 2026-10-09
316
+
317
+ ### Added
318
+ - Keyboard and focus in the detail, cart and checkout: focus moves in on open, Tab stays
319
+ inside, Esc closes, focus returns on close.
320
+
321
+ ### Changed
322
+ - The detail and the cart read the corner radius from the theme (`--vio-radius-*`).
323
+ - When a payment cannot start, the shopper is told what to do (empty cart, cart changed in
324
+ another tab, already paid) instead of a raw error.
325
+
326
+ ## 0.19.0 — 2026-10-08
327
+
328
+ ### Changed
329
+ - The product detail's header shows the seller's **Company name** (Vio Commerce →
330
+ Settings → Company), the brand when there is none. *(Superseded in 0.27.0 by the
331
+ sponsor's name and logo.)*
332
+
333
+ ### ⚠️ Upgrade notes
334
+ - The name is read with its own query outside the product cache (`useCache: false`): a
335
+ narrow query written to the gateway's product cache would hand the next product query a
336
+ product without price or images.
337
+
338
+ ## 0.18.0 — 2026-10-08
339
+
340
+ ### Added
341
+ - Vipps' **official button** in the detail, the cart and the checkout; **Vipps Express**
342
+ from the product and the cart (the app collects address and delivery).
343
+ - Stripe Connect in the embedded payment and Apple Pay (ADR-0022).
344
+
345
+ ### Changed
346
+ - Back from a payment page the drawer shows **only** «Bekrefter betalingen…» until it
347
+ knows the outcome — never the checkout behind it.
348
+ - The receipt's «Ordrenummer» is **Vio's order number** (`#4497`), never the checkout's uuid.
349
+
350
+ ### ⚠️ Upgrade notes
351
+ - Payment returns come back to **the same URL** with `vio_payment`, `vio_method`,
352
+ `vio_sponsor`, `checkout_id`: keep `<vio-checkout>` mounted on every route and do not
353
+ strip the query string on load.
354
+ - The Vipps button loads `cdn.vippsmobilepay.com`: allow it in a CSP.
355
+
356
+ ## 0.17.0 and earlier
357
+ Not recorded here — see the git history and the [releases](https://github.com/vio-live/vio-web-sdk/commits/main).
358
+ Highlights: Stripe on our own page (0.16), payment method first (0.15), Kustom (0.14),
359
+ Adyen (0.13), Nexi (0.12), analytics with consent (0.6 → 0.11).
package/README.md CHANGED
@@ -453,6 +453,72 @@ confirmation screen never waits. A host with its own checkout passes `buyerRef`
453
453
  (`requireConsent` until `setConsent(true)`) `buyer_id` is not sent at all: a visit
454
454
  measured with ephemeral ids carries no stable identifier of a person.
455
455
 
456
+ ### The purchase — `order_id` is Vio's order number, `checkout_id` beside it
457
+
458
+ A `purchase`'s **`commerce.order_id` is Vio's order number** — `4415`, the number
459
+ on the receipt, in the dashboard and in Commerce. Commerce's cancellations and
460
+ refunds (`order_cancelled` / `order_refunded`, ADR-0036) name the purchase they
461
+ compensate by that number: a purchase that carried the checkout's uuid instead
462
+ could never be netted.
463
+
464
+ - **Where the number comes from.** The `vio:payment-success` detail's
465
+ `orderNumber` (the SDK's checkout adds it when its ask for the buyer reference
466
+ already found the order), Apple Pay's backend confirmation
467
+ (`result.confirmation.orderId`), or an `orderId` that is a whole number (a host
468
+ passing the number; a checkout id is a uuid, never one). A redirect return
469
+ (Vipps, Klarna, hosted Stripe…) or an order a webhook creates usually fires the
470
+ event before the order exists: the SDK then **asks the checkout** —
471
+ `Vio.checkout.getOrderReference(checkoutId)`, the receipt's own path — at once
472
+ and after 0.5 / 1 / 2 / 3 / 3 s, for at most 10 s. Only the event waits: the
473
+ confirmation screen, the cart and the payment flow never do. Apple Pay and the
474
+ embedded Stripe Element (the drawer waits for the webhook's order before it
475
+ confirms) have the number at once.
476
+ - **`commerce.checkout_id`** — Vio's checkout id (uuid) — is always there when the
477
+ SDK knows the checkout (every SDK path, Apple Pay from the cart included): the
478
+ second key the collector can match a purchase by. It is new and additive; a
479
+ collector that predates it drops the key (its schema strips unknown fields) and
480
+ keeps the event.
481
+ - **`props.order_id_kind`** says what `order_id` is: `'order'` (the number) or
482
+ `'checkout'` — the number did not come within the 10 s, the page was hidden or
483
+ closed while waiting (the event leaves at once, by beacon), or the page has no
484
+ checkout manager to ask; then `order_id` is the checkout id, as before.
485
+ - **Once per checkout.** One `purchase` per checkout (or order) per page load: a
486
+ second `vio:payment-success` for the same one adds nothing, and a number that
487
+ arrives after the event left with the checkout id adds no second event. `ts`
488
+ and `event_id` are fixed at the payment, whenever the event leaves; a purchase
489
+ split per campaign waits once and every slice carries the same number.
490
+ - **Sent at once.** A `purchase` does not wait for the queue's 5 s tick: it is
491
+ sent as soon as it is built, and when the page hides (`visibilitychange`, the
492
+ last thing a phone is sure to say before it discards a tab) whatever is still
493
+ queued leaves by beacon (0.29.0; order 4128 had been recorded and never sent).
494
+ - **A host with its own sink** (Vev) reads the same ids from the detail with
495
+ `purchaseIdsOf(detail)` → `{ orderId, checkoutId, orderIdKind }` (or
496
+ `purchaseFromPaymentSuccess(detail)`, exported from `/core`), and waits for the
497
+ number the same way through `Vio.checkout.getOrderReference(checkoutId)`.
498
+
499
+ ### `add_payment_info` — the checkout, linked to this session (0.29.0)
500
+
501
+ The moment a backend checkout exists for a payment — Vipps (from the product
502
+ detail, the cart or the checkout), a Stripe payment link or Element, Klarna, Apple
503
+ Pay, Nexi, Kustom, Qliro, Walley, Adyen — the SDK emits **`add_payment_info`**
504
+ (the GA4 name) with **`commerce.checkout_id`**, **`payment_method`**, the cart's
505
+ lines (`items`, `value`, `currency`) and the same context as `begin_checkout`
506
+ (sponsor, campaign, slot, content), split per campaign like it. Once per checkout
507
+ per page load; nothing when the checkout could not be created. When the page is
508
+ about to leave for the provider (Vipps, a Stripe payment link) it goes out by
509
+ beacon first.
510
+
511
+ Why (ADR-0039): Commerce's `order_paid` is what says a sale happened, and the
512
+ browser's `purchase` can be missing (a closed tab, a phone that discards the page
513
+ on the way back). The collector attributes an `order_paid` without a `purchase` to
514
+ the session, page and slot of the `add_payment_info` with the same `checkout_id`.
515
+
516
+ The checkout manager announces each checkout on `window` as
517
+ **`vio:checkout-created`** — `{ sponsorId, checkoutId, paymentMethod, campaignId?,
518
+ redirect }` — which is what analytics listens to; a host with its own sink can
519
+ listen to it too. With `dataLayer: true` it is pushed as GA4's `add_payment_info`
520
+ with `payment_type`.
521
+
456
522
  ## Sponsor identity — name and logo
457
523
 
458
524
  The elements say **who sells**: the sponsor's (brand's) display name and logo.
@@ -558,6 +624,125 @@ useful if you show multiple differently-branded sponsors on the same page:
558
624
  applyVioTheme({ colorAccent: '#ff6600' }, document.getElementById('sponsor-2'))
559
625
  ```
560
626
 
627
+ ### Layout of the detail, cart, checkout and confirmation (0.28)
628
+
629
+ Beyond colors and fonts, the layout a host needs to fit its design is public too —
630
+ **one key in `applyVioTheme`, one `--vio-*` property behind it**. Our demo, Replit and
631
+ Vev's Config block set the same keys, so a page looks the same in the editor, the
632
+ preview and published. Set them with `applyVioTheme` (preferred) or as CSS on `:root`
633
+ or on the element (`vio-product-detail { --vio-detail-logo-height: 48px; }`).
634
+
635
+ | `applyVioTheme` key | Property | Default | What it does |
636
+ |---|---|---|---|
637
+ | `detailWidth` | `--vio-detail-width` | `440px` | Detail panel width (desktop) |
638
+ | `detailPaddingX` | `--vio-detail-padding-x` | `24px` | Side padding of the detail |
639
+ | `detailLogoHeight` | `--vio-detail-logo-height` | `44px` | Selling brand's logo, top left beside ✕ (width keeps its proportions; the name when there is no logo; a square logo — a mark — gets the name beside it) |
640
+ | `detailLogoZoom` | `--vio-detail-logo-zoom` | `1` | Zoom inside the logo's box: `1.8` crops the white around a mark in a square file |
641
+ | `detailTitleSize` / `detailPriceSize` | `--vio-detail-title-size` / `--vio-detail-price-size` | `26px` / `24px` | Name and price |
642
+ | `detailImageFit` | `--vio-detail-image-fit` | `cover` | `cover` = fills the box, crops the overflow; `contain` = whole image, bars where shapes differ |
643
+ | `detailImageAspect` | `--vio-detail-image-aspect` | `4 / 5` | One proportion for every image, so the panel never jumps: `1 / 1`, `3 / 4`… or `auto` (each image its own) |
644
+ | `detailImageMaxHeight` | `--vio-detail-image-max-height` | `85vh` | Cap on the main image's height |
645
+ | `detailImageZoom` | `--vio-detail-image-zoom` | `1` | Zoom; e.g. `1.15` crops a white margin baked into the file |
646
+ | `detailImageInset` | `--vio-detail-image-inset` | the padding | Side margin of the image; `0` = edge to edge |
647
+ | `detailImageBg` | `--vio-detail-image-bg` | muted surface | Behind the image |
648
+ | `detailThumbSize` / `detailThumbGap` | `--vio-detail-thumb-size` / `--vio-detail-thumb-gap` | `64px` / `8px` | Thumbnails — always one row: swipe on touch, arrows with a mouse, the selected one kept in view |
649
+ | `detailThumbRadius` / `detailThumbActiveColor` | `--vio-detail-thumb-radius` / `--vio-detail-thumb-active-color` | `0` / text color | Thumbnail corners, selected border |
650
+ | `detailOptionHeight` / `detailOptionGap` | `--vio-detail-option-height` / `--vio-detail-option-gap` | `36px` / `6px` | Size / option buttons (height is also their min width) |
651
+ | `detailOptionRadius` / `detailOptionWrap` | `--vio-detail-option-radius` / `--vio-detail-option-wrap` | `0` / `wrap` | `nowrap` = one line that scrolls sideways |
652
+ | `detailButtonHeight` / `detailButtonRadius` | `--vio-detail-button-height` / `--vio-detail-button-radius` | `52px` / `0` | Quantity stepper and «Legg i handlekurv» |
653
+ | `detailBuyPosition` / `detailBuyShadow` | `--vio-detail-buy-position` / `--vio-detail-buy-shadow` | phones `static`, desktop `sticky` | Whether the buy zone (Vipps, quantity, add to cart) stays at the bottom of the panel while scrolling |
654
+ | `cartWidth` / `checkoutWidth` | `--vio-cart-width` / `--vio-checkout-width` | `440px` | Drawer widths (desktop) |
655
+ | `confirmLogoHeight` / `confirmTitleSize` / `confirmCheckBg` | `--vio-confirm-logo-height` / `--vio-confirm-title-size` / `--vio-confirm-check-bg` | `40px` / `24px` / green | The confirmation |
656
+ | `redirectBg` / `redirectSpinner` | `--vio-redirect-bg` / `--vio-redirect-spinner` | translucent white / Vipps orange | The «Åpner Vipps …» overlay |
657
+
658
+ Also settable as CSS only: `--vio-detail-logo-max-width` (`220px`), `--vio-detail-image-position`,
659
+ `--vio-detail-image-radius`, `--vio-detail-thumb-fit` (`cover`), `--vio-detail-thumb-border-width`,
660
+ `--vio-detail-option-padding-x` (`12px`), `--vio-detail-option-font-size`, `--vio-detail-buy-gap`,
661
+ `--vio-detail-buy-padding`, `--vio-detail-buy-bg`, `--vio-detail-info-gap`, `--vio-detail-gallery-gap`,
662
+ `--vio-detail-title-weight`, `--vio-detail-price-weight`, `--vio-confirm-logo-max-width`,
663
+ `--vio-confirm-title-weight`, `--vio-confirm-text-size`, `--vio-redirect-color`, `--vio-redirect-font`,
664
+ `--vio-detail-seller-note-size` (`12px`), `--vio-detail-seller-note-align` (`center`).
665
+
666
+ **`::part` names**, for what the properties do not cover:
667
+
668
+ - `<vio-product-detail>`: `topbar`, `sponsor`, `close`, `gallery`, `image`, `thumbs`, `thumb`
669
+ (+ `thumb-active`), `thumbs-arrow`, `info`, `brand`, `title`, `price`, `stock`,
670
+ `option-group`, `option-label`, `options`, `option` (+ `option-selected`), `buy`,
671
+ `buy-vipps`, `quantity-row`, `add-to-cart`, `seller-note`, `seller-terms`, `seller-refund`,
672
+ `description`.
673
+ - `<vio-checkout>`: `sponsor`, `confirmation`, `confirm-title`, `confirm-sponsor`, `confirm-details`.
674
+
675
+ ```css
676
+ vio-product-detail::part(add-to-cart) { letter-spacing: 0; text-transform: none; }
677
+ vio-product-detail::part(option-selected) { background: #2b2b2b; }
678
+ ```
679
+
680
+ **The detail's order:** the selling brand top left; the main image; the thumbnails;
681
+ name, price, stock, sizes; then, **on phones**, the buy zone — **Vipps first** (when the
682
+ channel has it), quantity + «Legg i handlekurv», then the other ways to pay, and under them
683
+ «Du handler direkte hos {brand}.» (the product's brand, never the article's) with links to
684
+ the brand's «Kjøpsvilkår» and «Angrerett og retur» when it gave them (the sponsor block's
685
+ `termsUrl` / `refundPolicyUrl`, https only, opened in a new tab) — and the
686
+ description; **on desktop**, the description first and the buy zone pinned at the bottom
687
+ of the panel. The description folds: «Beskrivelse» opens on a tap.
688
+
689
+ ### The hand-over to Vipps (0.28)
690
+
691
+ Between the tap on Vipps (detail, cart or checkout) and the Vipps page, the SDK shows
692
+ a full-screen «Åpner Vipps …» with a soft fade — no empty moment, and it takes every
693
+ tap, so a second payment cannot start. Events on `document`:
694
+
695
+ | Event | Detail | When |
696
+ |---|---|---|
697
+ | `vio:payment-redirect-start` | `{ method, sponsorId, source }` | At the tap. **Cancelable**: `preventDefault()` and the SDK draws no overlay — draw your own |
698
+ | `vio:payment-redirect-error` | `… , reason` | It could not start. The overlay goes; the checkout opens with Vipps preselected |
699
+ | `vio:payment-redirect-cancel` | `… , reason` | `'express-off'` / `'not-started'`: the checkout opens instead (no error). `'back'`: the shopper came back with the browser's back button. `'cancelled'`: back through Vipps' own cancel (with `checkoutId`) |
700
+
701
+ `source` is `'detail'`, `'cart'` or `'checkout'`. The overlay is `.vio-redirect-overlay`
702
+ in the light DOM.
703
+
704
+ **The detail's Vipps button pays the whole cart.** «Kjøp nå med Vipps» on the product
705
+ detail adds the product to the sponsor's cart and the checkout is built on that cart —
706
+ every line in it. It goes straight to Vipps only when the cart was **empty**; with lines
707
+ already in it, the product is added and the checkout opens first (`vio:checkout-open`
708
+ with `paymentMethod: 'vipps'`, no hand-over events), every line and the total on screen,
709
+ so what the Vipps page lists is what the shopper saw. The cart's own Vipps button always
710
+ pays the whole cart — the lines are right there. **Apple Pay on the detail follows the same
711
+ rule**: its sheet's amount is fixed before the tap, so it opens only on an empty cart, for the
712
+ product × the chosen quantity; otherwise the checkout opens (`paymentMethod: 'apple-pay'`).
713
+
714
+ ### What the shopper pays is what the page shows — the cart, the server, the other tabs
715
+
716
+ The cart lives in this tab's memory and in `localStorage` (`vio.cart.v1`, one entry per
717
+ sponsor with the backend cart id), and the checkout — and the Vipps / Stripe page — is
718
+ built on the **backend** cart, every line of it. Three things keep the two the same:
719
+
720
+ - **Before a checkout is created or a payment link minted**, and **when `<vio-cart>` or
721
+ `<vio-checkout>` opens**, the SDK asks the server for the cart (`GetCart`) and draws that
722
+ answer: a line another tab added, a cart another tab emptied. `Vio.cart.refreshFromServer(sponsorId)`
723
+ does the same on demand and resolves with the sponsor's cart. A `GetCart` that fails leaves
724
+ the cart as it was (and warns) — the checkout then goes on with the lines the last mutation
725
+ answered, as before.
726
+ - **Other tabs of the site are heard.** A payment in one tab (its cart cleared) empties the
727
+ drawer of the others and the next add there opens a new backend cart; a line added in one
728
+ tab is in the others without a reload. Through the browser's `storage` event — a tab that
729
+ is mid-add for a sponsor keeps its own lines until its answer lands.
730
+ - **An open checkout follows its cart**: its lines and its total are recomputed when the
731
+ cart changes under it (the widgets that captured an amount at mount ask to be refreshed,
732
+ as they already did for a changed cart).
733
+
734
+ `vio:cart-updated` on `document` says when either of the first two happened:
735
+
736
+ ```ts
737
+ document.addEventListener('vio:cart-updated', (e) => {
738
+ const { sponsorId, cartId, items, itemCount, subtotal, currency, source } = e.detail
739
+ // source: 'server' (GetCart, right before a checkout / on opening) | 'storage' (another tab)
740
+ })
741
+ ```
742
+
743
+ `items` are copies of the lines (`CartLineItem[]`). A host that draws its own cart or badge
744
+ redraws it from this event; `<vio-cart>` and the badge wired to `Vio.cart`'s `change` need nothing.
745
+
561
746
  ## Headless / SSR
562
747
 
563
748
  The root and `/core` entries export the framework-agnostic core with **no DOM side-effects** — SSR-safe and **tree-shakeable** (importing one helper pulls only that helper, ~0.5 kB):