@vio-live/web-sdk 0.28.0 → 0.32.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 CHANGED
@@ -14,9 +14,250 @@ How to keep this file: see [CONTRIBUTING → Changelog](CONTRIBUTING.md#changelo
14
14
  The details of each feature live in the [README](README.md); this file says *what*
15
15
  and *when*, and links there.
16
16
 
17
- ## Unreleased
17
+ ## 0.32.0 — 2026-10-28
18
18
 
19
- Nothing yet.
19
+ > First version published to npm since 0.29.0: **0.30.0 and 0.31.0 were never published on their own** — their
20
+ > changes (below) ship in this version too. A host on 0.29.0 should read the three sections.
21
+
22
+ ### Added
23
+ - **Options for the product detail** (README → *Options of the detail and the product cards*):
24
+ `gallery="carousel"` (every photo in one swipeable row with dots, no thumbnails),
25
+ `description-mode` (`preview` · `folded` · `open`), `highlights`, `delivery-time`,
26
+ `carrier-logos`; theme keys `detailGallery`, `detailCarouselPeek`, `detailDescriptionMode`,
27
+ `detailDescriptionPreviewLines`, `detailHighlightsDisplay`, `detailShippingReturnsDisplay`.
28
+ - **The brand's promises** under the buy zone (icon, title, line of text — up to three), from the
29
+ sponsor block's `highlights` (dashboard Settings → Brand) unless the host passes its own.
30
+ - **«Frakt & retur»** fold after «Beskrivelse»: one row per carrier (its logo from Commerce's
31
+ `logo_url`, or `carrier-logos`, or a line icon by the kind of delivery), how it delivers, the
32
+ price; the delivery time the brand wrote on the rate («Leveringstid 1-5 dager»); the right of
33
+ withdrawal and the brand's pages.
34
+ - **Product cards:** `<vio-product-carousel layout="list">` (one under the other),
35
+ `show-description` / `descriptions` (the catalogue's text or the editor's own per product),
36
+ `button-style` (`icon` · `bar` · `hidden`) and `button-label` (the Vev card's «Kjøp» bar); card
37
+ look through theme keys `card*` and `--vio-card-*`.
38
+ - `commerce.channel.product.getShippingRates` returns each rate's `description` and `logoUrl`.
39
+
40
+ ### Changed
41
+ - **The detail's order is the same on every screen**: sizes first in the buy zone, «Frakt fra …»
42
+ under them, stock on the name's line, «Beskrivelse» at the very bottom. The buy zone no longer
43
+ pins itself on desktop (`detailBuyPosition: 'sticky'` brings that back).
44
+ - **«Beskrivelse» shows a preview** (the first three lines and «Les mer») instead of being folded
45
+ shut. `description-mode="folded"` keeps the old behaviour.
46
+ - **The sheets close with a pull down from anywhere** while their content is at the top, not only
47
+ from the grab bar or the top bar (detail, cart, checkout).
48
+
49
+ ### ⚠️ Upgrade notes
50
+ - Rates are asked with `logo_url`; a gateway without the field is asked again without it once
51
+ (and not again on that page), so the rates never go missing over a logo.
52
+ - A host that styled `details.description-block` for «Beskrivelse» now gets
53
+ `.description-preview` (`part="description"` unchanged).
54
+
55
+ ## 0.31.0 — 2026-10-28
56
+
57
+ ### Added
58
+ - **Delivery and returns under the price in the product detail.** It reads «Frakt fra X · 14
59
+ dagers angrerett», where X is the product's cheapest shipping rate for the page's market (the
60
+ same `product_shipping` rates the backend cart offers). A cheapest rate of 0 reads «Gratis frakt».
61
+ With no known rate, only «14 dagers angrerett» is shown (`part="delivery-note"`). The rates come
62
+ from a separate query (`commerce.channel.product.getShippingRates`), so a failure there only
63
+ loses this line.
64
+ - **The cart drawer shows shipping and the total including it.** It uses the rate chosen on the
65
+ server cart, or the cheapest rate offered. Without a known rate it reads «Frakt: Beregnes i
66
+ kassen» and shows no total (`part="cart-shipping"`, `part="cart-total"`).
67
+ - **«Lagt i handlekurven» after «Legg i handlekurv».** The detail closes and a small toast offers
68
+ **«Til kassen»** and **«Fortsett å handle»** (`part="added-toast"`). «Til kassen» dispatches
69
+ `vio:checkout-open` for that brand, the same event the cart's «Til kassen» sends. It does nothing
70
+ when the cart was emptied in the meantime. The toast closes by itself after 8 s.
71
+ - **A line under «Kjøp nå med Vipps» in the product detail:** «Vipps fyller inn navn og adresse –
72
+ du godkjenner og betaler i appen» (`part="vipps-note"`). It is shown only when that button goes
73
+ straight to the app: Express is on for the brand and the brand's cart is empty. In other cases
74
+ the button opens the checkout first.
75
+ ### Changed
76
+ - **The product detail no longer picks a size for the shopper.** Only an option with a single
77
+ value is preselected. The chosen value is shown beside the option's name («Størrelse: M») and
78
+ on its pill. Every buy button (Vipps, «Legg i handlekurv», «Kjøp nå», Stripe, Klarna, Apple Pay)
79
+ checks for a missing choice first: it shows «Velg størrelse» (or «Velg farge», etc.) and adds or
80
+ pays nothing. «Få igjen — N stk» now counts only the variant actually chosen. Before a size is
81
+ chosen it says «På lager» if any size is in stock.
82
+ - **Two Vipps paths.** The product detail's «Kjøp nå med Vipps» stays **Express**. The
83
+ **cart's** Vipps button now opens the checkout with Vipps preselected. It no longer starts
84
+ Express or hands over to Vipps. **The checkout's Vipps is always the standard payment**, even
85
+ when the brand has Express on:
86
+ - the address form, the delivery and its price are required before paying, as for Stripe;
87
+ - the summary counts our shipping rate, so «Velges i Vipps» is gone;
88
+ - the payment is `startVippsPayment(sponsorId, form, { express: false })`, which now puts the
89
+ form's complete address on the checkout's shipping and billing address.
90
+
91
+ `COLLECTS_OWN_ADDRESS` no longer lists Vipps. See
92
+ [README → Two Vipps paths](README.md#the-hand-over-to-vipps-028).
93
+ - **«Mobilnummer» in the checkout's address form.** It is required only for the standard Vipps
94
+ payment, and optional for every other method; it never blocks their buttons or widgets.
95
+ - It accepts any market's number. A `+`/`00` prefix sets the calling code; without one, the
96
+ shipping country does (NO 47, SE 46, DK 45, FI 358, DE 49, GB 44…), falling back to the
97
+ page's country. A trunk 0 is dropped.
98
+ - A number that cannot be read shows «Skriv et gyldig mobilnummer…».
99
+ - It is sent as `phone` (national digits) + `phone_code` (digits, no plus) on the shipping and
100
+ billing address of every method that takes our form. `CheckoutAddress.phone` is new.
101
+ - **The checkout's Vipps refuses to start without the delivery saved.** If the chosen rate cannot
102
+ be saved on the cart, the Vipps or Stripe-link payment does not start and the checkout says
103
+ «Kunne ikke lagre fraktmetoden. Prøv igjen.». This covers a failed save and a rate without a
104
+ supplier. `startVippsPayment(…, { express: false })` also throws before creating anything when
105
+ the address is incomplete or has no usable mobile.
106
+
107
+ ### Fixed
108
+ - **«Velg størrelse» no mueve las tallas.** El aviso sale en la misma línea que el nombre de la opción, así que tocar comprar sin talla ya no desplaza los botones bajo el dedo.
109
+ - **No checkout and no payment for an empty cart or a total of 0 kr.** `Vio.checkout.open()`
110
+ throws for a cart that totals 0, as it already did for an empty one. Vipps (`startVippsPayment`,
111
+ `startVippsExpress`), the Stripe link, native Stripe and Adyen read the cart from the server and
112
+ refuse to create a checkout or a payment when it is empty or 0 kr. The buttons check the same
113
+ thing first: the cart's «Til kassen» is disabled, the checkout's pay buttons are disabled and
114
+ say «Handlekurven er tom.», and the detail shows no Vipps or Apple Pay button for a product at 0 kr.
115
+ - **Desktop: the product detail's pinned buy bar no longer covers the size picker.** The option
116
+ pickers now sit at the top of the buy zone (`part="buy-options"`), so they move with the
117
+ buttons whether the zone is static or sticky.
118
+ - **No buttons for payment methods the brand does not have while its methods load.** The
119
+ product detail and the cart drawer show a neutral placeholder (`part="buy-pending"` /
120
+ `.methods-pending`) until `getAvailablePaymentMethods` answers. They used to show every method,
121
+ so a Vipps-only brand flashed Stripe and Klarna. «Legg i handlekurv» and «Til kassen» are there
122
+ from the start. A failed lookup still shows every button, as before.
123
+ - **Storage never blocks add to cart or checkout (in-app browsers, private modes).** Every read
124
+ and write of `localStorage`/`sessionStorage` goes through one guard, `src/core/safe-storage.ts`.
125
+ A storage property that throws on access, a `null` storage or a refused write now falls back to
126
+ the page's memory. This covers the cart, the customer session id, the market's
127
+ currency/country/locale and the Klarna return. Before, `typeof localStorage` outside a `try`
128
+ could throw while the SDK started. Without storage, every browser also sent the same customer
129
+ session id, `cs_session_default`. It is now one id per page.
130
+
131
+ ### ⚠️ Upgrade notes
132
+ - The detail's buy buttons stay enabled before a size is chosen. Tapping one shows the prompt
133
+ instead of buying. A host that clicks `.add-to-cart` from a script needs a value in every option
134
+ first.
135
+ - A host's `vio:checkout-open` handler that calls `Vio.checkout.open()` must still catch the throw
136
+ (it already threw for an empty cart). It now also throws for a cart of 0 kr.
137
+ - Host CSS that placed `::part(option-group)` relative to `::part(buy)` should be checked: the
138
+ option groups are now inside `buy`.
139
+ - A host that already answers `vio:added-to-cart` with its own UI (for example, opening its cart
140
+ drawer) can turn the new toast off with `<vio-product-detail no-added-toast>`. The toast's
141
+ «Til kassen» relies on the host's `vio:checkout-open` handler, like the cart's does.
142
+ - **Backend (shopcart):** the checkout's Vipps payment is now `express: false` with the address
143
+ already on the checkout. shopcart must not request the Vipps profile on these standard
144
+ payments, and must carry the address `phone`/`phone_code` through to the order. Until that is
145
+ deployed, check a QA order to confirm the phone arrives.
146
+ - Hosts that pre-fill the checkout form through `Vio.checkout.setAddress()` should include
147
+ `phone`, or the shopper has to type it when paying with Vipps. Other methods do not need it.
148
+ - **The cart's Vipps button now opens the checkout through `vio:checkout-open`**, with
149
+ `paymentMethod: 'vipps'`. A host without a handler for that event loses direct Vipps from the
150
+ cart. The `vio:payment-redirect-*` events with `source: 'cart'` are gone; those with
151
+ `'detail'` and `'checkout'` remain.
152
+ - **`startVippsPayment(sponsorId, form, { express: false })` writes the full address**, including
153
+ the mobile, to the checkout's shipping and billing address before it creates the payment.
154
+ Without the flag, or with `express: true`, nothing changes.
155
+ - `Vio.checkout.open()` throws on a cart that totals 0, as well as on an empty one.
156
+ - The «Lagt i handlekurven» toast is on by default. Use `<vio-product-detail no-added-toast>` to
157
+ turn it off.
158
+
159
+ ## 0.30.0 — 2026-10-27
160
+
161
+ ### Added
162
+ - **Internal traffic** (ADR-0041): opening any page with **`?vio_internal=1`** marks the browser
163
+ (`localStorage`, `vio.internal.v1`) and `?vio_internal=0` clears it; read once at `Vio.init()`.
164
+ While marked, every analytics event carries **`client.internal: true`** and the collector's
165
+ reads leave it out. **`Vio.analytics.setInternal(boolean)`** / `Vio.analytics.isInternal` for
166
+ hosts; one `console.info` when marked. See
167
+ [README → Internal traffic](README.md#internal-traffic--mark-your-browser-as-a-tester-unreleased).
168
+
169
+ ### Changed
170
+ - **Stripe.js (~300 KB) is downloaded only for a brand that uses Stripe.** It used to load on
171
+ every page as soon as the SDK started, to have Apple Pay ready. Now it loads when the brand's
172
+ payment methods come back with a Stripe key (or when the host passes `stripePublishableKey`
173
+ to `Vio.init`). A brand that only takes Vipps never downloads it. Apple Pay still has it before
174
+ the tap: its button waits for the same key.
175
+ - **`component_impression` of `<vio-product-carousel>` carries `commerce.items`** — the products
176
+ shown, like its `view_item_list` — so the collector can count per-product impressions
177
+ (ADR-0041). The impression is observed once the products have loaded (the 1 s dwell starts
178
+ then); a failed fetch still counts it, without items. `Vio.analytics.observeImpression()` takes
179
+ a `commerce` getter, read when the impression fires. See
180
+ [README → Impressions carry the products shown](README.md#impressions-carry-the-products-shown-unreleased).
181
+ - Events never carry more than 100 `commerce.items` (the collector rejects the whole event above
182
+ its cap).
183
+
184
+ ### ⚠️ Upgrade notes
185
+ - `client.internal` needs the collector that knows the field; an older one strips it (the event is
186
+ kept, just not marked). Nothing to do on the host.
187
+
188
+ ## 0.29.0 — 2026-10-24
189
+
190
+ ### Added
191
+ - **`vio:cart-updated`** on `document` — `{ sponsorId, cartId, items, itemCount, subtotal, currency, source }`,
192
+ `source: 'server'` when the cart was just read from the server (right before a checkout is
193
+ created or a payment link minted, and when the drawer or the checkout opens), `'storage'` when
194
+ another tab of the site changed it. For hosts that draw their own cart or badge.
195
+ `Vio.cart.refreshFromServer(sponsorId)` asks on demand. See
196
+ [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).
197
+ - **`add_payment_info`** (analytics, GA4 name) the moment a backend checkout exists for a payment
198
+ — Vipps from the detail, the cart or the checkout, a Stripe payment link or Element, Klarna,
199
+ Apple Pay, Nexi, Kustom, Qliro, Walley, Adyen — with `commerce.checkout_id`, `payment_method`,
200
+ the cart's lines and the same context as `begin_checkout` (per campaign like it). Once per
201
+ checkout per page load, none when the checkout could not be created, and by beacon BEFORE the
202
+ page leaves for Vipps or a Stripe link. It is how a paid order whose `purchase` never came back
203
+ keeps its session, page and slot (ADR-0039; order 4128, 2026-10-23). The checkout manager
204
+ announces it on `window` as **`vio:checkout-created`** `{ sponsorId, checkoutId, paymentMethod,
205
+ campaignId?, redirect }`. See [README → `add_payment_info`](README.md#add_payment_info--the-checkout-linked-to-this-session-0290).
206
+
207
+ ### Changed
208
+ - **«Kjøp nå med Vipps» on the product detail goes straight to Vipps only when the cart is
209
+ empty.** With lines already in the cart, the product is added and the checkout opens first
210
+ (`vio:checkout-open`, Vipps preselected, no `vio:payment-redirect-*` events), every line and
211
+ the total on screen. Before, the tap paid the WHOLE cart with only the Vipps page saying so
212
+ (Mote & Livsstil, 2026-10-23: a jumper left in the cart 13 minutes earlier was paid along
213
+ with the top bought «nå»). The cart's own Vipps button is unchanged: it pays the lines it shows.
214
+ - **Apple Pay on the product detail shows what it charges.** The sheet's amount is fixed before
215
+ the tap and was the product's price, while the charge was the sponsor's whole cart. The sheet
216
+ now opens only on an empty cart, prepared for the product × the chosen quantity (it is prepared
217
+ again when the quantity or variant changes); with lines already in the cart — or while the
218
+ sheet for a new quantity is still being prepared — the product is added and the checkout opens
219
+ (`vio:checkout-open` with `paymentMethod: 'apple-pay'`), where Apple Pay charges the total shown.
220
+
221
+ ### Fixed
222
+ - **The lines the shopper pays for are the lines the page shows.** The checkout, and the
223
+ Vipps / Stripe page, are built on the backend cart — every line in it — while the drawer
224
+ and the checkout summary drew this tab's memory. Now the SDK reads the server's cart right
225
+ before a checkout is created or a payment link minted, and when `<vio-cart>` / `<vio-checkout>`
226
+ open, and draws that; an open checkout recomputes its lines and total when the cart changes
227
+ under it.
228
+ - **Other tabs of the site are heard.** The carts are shared through `localStorage` but each
229
+ tab read them once, at load: a payment in one tab left the others showing — and adding to —
230
+ a cart that was already paid; a line added in one tab was missing from the others until a
231
+ reload. Now a tab adopts what another tab wrote (`storage` event), except for a sponsor it is
232
+ mid-add for.
233
+ - **Vipps Express: the checkout no longer adds our shipping to the total.** The delivery is
234
+ chosen and paid inside Vipps, but with Vipps selected the checkout still added the seller's
235
+ first rate (Careless QA, 2026-10-24: «Frakt – Default Shipping» 10 kr, 1 308 kr on screen and
236
+ on the button, 1 298 kr in Vipps). With Vipps as the method and Express on for the seller,
237
+ the summary says «Frakt: Velges i Vipps» and the total and «Betal … med Vipps» are the items.
238
+ Vipps without Express, and every other method, still add the rate as before.
239
+ - **The `purchase` of a paid return is sent, not left in the queue** (2026-10-24). Order 4128
240
+ (Careless, prod, 2026-10-23 22:40, after two abandoned trips to Vipps): the shopper
241
+ paid, came back, saw the confirmation, and the brand's panel never counted the sale — Commerce
242
+ had its `order_paid`, the collector no `purchase`. The return was read as paid; the one way the
243
+ code loses that purchase is the queue: it was built, with the order number, into a queue sent
244
+ every 5 s, and leaving the page right away (back to the Vipps app, the home screen) beaconed
245
+ only a purchase still waiting for its number — a phone that discards a hidden tab never runs
246
+ the next tick. Now the purchase is sent as soon as it is built, and when the page hides
247
+ everything still queued leaves by beacon.
248
+
249
+ ### ⚠️ Upgrade notes
250
+ - **The collector must accept `add_payment_info`** before it is counted: one that does not know the
251
+ name rejects that event alone (`202` with `rejected`), the rest of the batch is kept. Nothing to
252
+ do in the host.
253
+ - A host that handles `vio:checkout-open` only from the cart: the product detail now emits it
254
+ too when «Kjøp nå med Vipps» or Apple Pay is tapped on a non-empty cart (it already did when
255
+ Express was off for the seller), now also with `paymentMethod: 'apple-pay'`. Check that your
256
+ handler opens `<vio-checkout>` for the detail's event.
257
+ - A host with its own cart drawer or badge: listen to `vio:cart-updated` (or `Vio.cart`'s
258
+ `change`) — the lines can now change without a tap in that tab.
259
+ - One more `GetCart` per checkout creation / payment link and per opening of the drawer or the
260
+ checkout.
20
261
 
21
262
  ## 0.28.0 — 2026-10-22
22
263
 
package/README.md CHANGED
@@ -487,11 +487,81 @@ could never be netted.
487
487
  arrives after the event left with the checkout id adds no second event. `ts`
488
488
  and `event_id` are fixed at the payment, whenever the event leaves; a purchase
489
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).
490
494
  - **A host with its own sink** (Vev) reads the same ids from the detail with
491
495
  `purchaseIdsOf(detail)` → `{ orderId, checkoutId, orderIdKind }` (or
492
496
  `purchaseFromPaymentSuccess(detail)`, exported from `/core`), and waits for the
493
497
  number the same way through `Vio.checkout.getOrderReference(checkoutId)`.
494
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
+
522
+ ### Impressions carry the products shown (unreleased)
523
+
524
+ `<vio-product-carousel>`'s **`component_impression`** (≥50% visible for ≥1 s, once
525
+ per session and carousel) carries the products it shows in **`commerce.items`**
526
+ (`product_id`, `name`, `price`) and `commerce.currency` — the same lines as its
527
+ `view_item_list`. The collector counts the per-product "saw" step of the funnel
528
+ from them (ADR-0041). While the products load nothing is observed: the 1 s dwell
529
+ starts once they are on screen. A carousel whose fetch failed (slotted fallback)
530
+ still counts its impression, without items. A host calling
531
+ `Vio.analytics.observeImpression(el, context, { commerce: () => ({ items, currency }) })`
532
+ does the same for its own elements: the getter is read when the impression fires.
533
+
534
+ ### Internal traffic — mark your browser as a tester (unreleased)
535
+
536
+ Our own visits (testers, the team, agents) must not count in the dashboard. Mark
537
+ **each browser you test with** once:
538
+
539
+ 1. Open any page of the site that runs the SDK with **`?vio_internal=1`** added to
540
+ the URL — e.g. `https://example.com/article?vio_internal=1` (or
541
+ `…?utm_source=x&vio_internal=1` when the URL already has a query).
542
+ 2. Open the browser's console: the SDK says once
543
+ `[Vio.analytics] this browser is marked as internal traffic …`.
544
+ 3. That's it. The mark is remembered in this browser (`localStorage`,
545
+ `vio.internal.v1`, per site) for every page and every later visit, and every
546
+ analytics event it sends carries **`client.internal: true`**; the collector's
547
+ reads leave those events out.
548
+
549
+ To **undo** it, open any page with **`?vio_internal=0`**. Things to know:
550
+
551
+ - The mark is per browser and per site: Safari and Chrome on the same phone, a
552
+ private window, or another site are marked separately. Clearing the site's data
553
+ clears the mark.
554
+ - In-app browsers (Instagram, Facebook) and private windows may not keep
555
+ `localStorage`: there, only the page opened with `?vio_internal=1` is marked.
556
+ Prefer a regular browser for testing.
557
+ - The URL is read once per page load (at `Vio.init()`), not on client-side
558
+ navigation.
559
+ - The mark is the tester's own choice, not a tracking id: it is written even
560
+ before consent (`requireConsent`).
561
+ - A host can mark from code: **`Vio.analytics.setInternal(true | false)`**
562
+ (`Vio.analytics.isInternal` reads it). `resolveInternal()` / `INTERNAL_KEY` are
563
+ exported from `/core`.
564
+
495
565
  ## Sponsor identity — name and logo
496
566
 
497
567
  The elements say **who sells**: the sponsor's (brand's) display name and logo.
@@ -623,7 +693,7 @@ or on the element (`vio-product-detail { --vio-detail-logo-height: 48px; }`).
623
693
  | `detailOptionHeight` / `detailOptionGap` | `--vio-detail-option-height` / `--vio-detail-option-gap` | `36px` / `6px` | Size / option buttons (height is also their min width) |
624
694
  | `detailOptionRadius` / `detailOptionWrap` | `--vio-detail-option-radius` / `--vio-detail-option-wrap` | `0` / `wrap` | `nowrap` = one line that scrolls sideways |
625
695
  | `detailButtonHeight` / `detailButtonRadius` | `--vio-detail-button-height` / `--vio-detail-button-radius` | `52px` / `0` | Quantity stepper and «Legg i handlekurv» |
626
- | `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 |
696
+ | `detailBuyPosition` / `detailBuyShadow` | `--vio-detail-buy-position` / `--vio-detail-buy-shadow` | `static` | `sticky` pins the buy zone (Vipps, quantity, add to cart) to the bottom of the panel while scrolling |
627
697
  | `cartWidth` / `checkoutWidth` | `--vio-cart-width` / `--vio-checkout-width` | `440px` | Drawer widths (desktop) |
628
698
  | `confirmLogoHeight` / `confirmTitleSize` / `confirmCheckBg` | `--vio-confirm-logo-height` / `--vio-confirm-title-size` / `--vio-confirm-check-bg` | `40px` / `24px` / green | The confirmation |
629
699
  | `redirectBg` / `redirectSpinner` | `--vio-redirect-bg` / `--vio-redirect-spinner` | translucent white / Vipps orange | The «Åpner Vipps …» overlay |
@@ -639,10 +709,14 @@ Also settable as CSS only: `--vio-detail-logo-max-width` (`220px`), `--vio-detai
639
709
  **`::part` names**, for what the properties do not cover:
640
710
 
641
711
  - `<vio-product-detail>`: `topbar`, `sponsor`, `close`, `gallery`, `image`, `thumbs`, `thumb`
642
- (+ `thumb-active`), `thumbs-arrow`, `info`, `brand`, `title`, `price`, `stock`,
643
- `option-group`, `option-label`, `options`, `option` (+ `option-selected`), `buy`,
644
- `buy-vipps`, `quantity-row`, `add-to-cart`, `seller-note`, `seller-terms`, `seller-refund`,
645
- `description`.
712
+ (+ `thumb-active`), `thumbs-arrow`, `info`, `brand`, `title`, `price`, `delivery-note`
713
+ (`delivery-shipping`, `delivery-withdrawal`), `stock`, `buy`, `buy-options`, `option-group`,
714
+ `option-label`, `option-chosen`, `option-prompt`, `options`, `option` (+ `option-selected`),
715
+ `buy-pending`, `buy-vipps`, `vipps-note`, `quantity-row`, `add-to-cart`, `seller-note`,
716
+ `seller-terms`, `seller-refund`, `description`, `highlights`, `highlight`, `shipping-returns`,
717
+ `slide`, `image-dots`, `carousel-arrow`, `added-toast` (`added-toast-checkout`,
718
+ `added-toast-continue`).
719
+ - `<vio-cart>`: `cart-shipping`, `cart-total`.
646
720
  - `<vio-checkout>`: `sponsor`, `confirmation`, `confirm-title`, `confirm-sponsor`, `confirm-details`.
647
721
 
648
722
  ```css
@@ -650,14 +724,51 @@ vio-product-detail::part(add-to-cart) { letter-spacing: 0; text-transform: none;
650
724
  vio-product-detail::part(option-selected) { background: #2b2b2b; }
651
725
  ```
652
726
 
653
- **The detail's order:** the selling brand top left; the main image; the thumbnails;
654
- name, price, stock, sizes; then, **on phones**, the buy zone — **Vipps first** (when the
655
- channel has it), quantity + «Legg i handlekurv», then the other ways to pay, and under them
656
- «Du handler direkte hos {brand}.» (the product's brand, never the article's) with links to
657
- the brand's «Kjøpsvilkår» and «Angrerett og retur» when it gave them (the sponsor block's
658
- `termsUrl` / `refundPolicyUrl`, https only, opened in a new tab) — and the
659
- description; **on desktop**, the description first and the buy zone pinned at the bottom
660
- of the panel. The description folds: «Beskrivelse» opens on a tap.
727
+ **The detail's order:** the selling brand top left; the images (thumbnails, or a carousel);
728
+ name and stock on one line, the price; the buy zone — the sizes (none preselected; every buy
729
+ button asks «Velg størrelse» first), «Frakt fra X · 14 dagers angrerett», **Vipps first** (when
730
+ the channel has it), quantity + «Legg i handlekurv», the other ways to pay, and under them «Du
731
+ handler direkte hos {brand}.» (the product's brand, never the article's) with links to the
732
+ brand's «Kjøpsvilkår» and «Angrerett og retur» when it gave them (the sponsor block's
733
+ `termsUrl` / `refundPolicyUrl`, https only, new tab); the brand's promises; «Beskrivelse»; and
734
+ «Frakt & retur». The same order on every screen.
735
+
736
+ ### Options of the detail and the product cards (0.32)
737
+
738
+ Every one is off or as before unless the host sets it: per element (attribute), or for the
739
+ whole page through `applyVioTheme` (theme key → custom property). The element wins.
740
+
741
+ **`<vio-product-detail>`**
742
+
743
+ | Attribute | Theme key | Values | What |
744
+ |---|---|---|---|
745
+ | `gallery` | `detailGallery` | `thumbs` (default) · `carousel` | `carousel`: every photo in one swipeable row with a dot per photo, no thumbnails — less height. `detailCarouselPeek` (`--vio-detail-carousel-peek`) shows the edge of the next one |
746
+ | `description-mode` | `detailDescriptionMode` | `preview` (default) · `folded` · `open` | «Beskrivelse»: the first lines fading out + «Les mer» (`detailDescriptionPreviewLines`, `3`); only the heading until a tap; or all of it |
747
+ | `highlights` | — | `[{ icon, title, text }]` (JSON) | The brand's promises under the buy zone. By default the brand's own (dashboard Settings → Brand, sponsor block `highlights`); this list replaces them on the page. Icons: `plane heart shirt truck return leaf star shield mailbox home pin clock`, or an image URL. `detailHighlightsDisplay: 'none'` hides the strip |
748
+ | `delivery-time` | — | text, e.g. `2-5 virkedager` | «Leveringstid …» in «Frakt & retur» for a brand whose rates do not say it (each rate's own text in Commerce — «Leveringstid 1-5 dager» — wins) |
749
+ | `carrier-logos` | — | `{ "Bring": "https://…" }` (JSON) | Carrier logos by the name the rate starts with («Bring - Pakke i postkasse»), for rates without their own logo in Commerce (`logo_url`, which wins); otherwise a line icon by the kind of delivery |
750
+ | — | `detailShippingReturnsDisplay` | `block` (default) · `none` | «Frakt & retur»: one row per carrier (logo or icon, carrier, how it delivers, price), the delivery time, the right of withdrawal and the brand's pages |
751
+
752
+ The sheet closes with a pull down from its top bar, or from anywhere while its content is at the top.
753
+
754
+ **`<vio-product-carousel>`** (and its cards, `<vio-product>`)
755
+
756
+ | Attribute | Theme key | Values | What |
757
+ |---|---|---|---|
758
+ | `layout` | `carouselLayout` | `carousel` (default) · `list` | `list`: one card under the other, as wide as the column (`--vio-carousel-list-max-width`, `480px`) |
759
+ | `show-description` | — | boolean | Each card shows its product's description (`cardDescriptionLines`, `4`) |
760
+ | `descriptions` | — | `{ "<productId>": "text" }` (JSON) | The editor's own text for a product, over the catalogue's — shown even without `show-description`; `""` hides that one |
761
+ | `button-style` | — | `icon` (default) · `bar` · `hidden` | The buy button: the round bag on the image, a full-width «Kjøp» bar under the price (as the Vev card), or none. Both add to the cart, or open the product when it has sizes |
762
+ | `button-label` | — | text, default `Kjøp` | The bar's text («Kjøp · 2» once two are in the cart) |
763
+
764
+ Card look, by theme key (custom property): `cardFont`, `cardBorder`, `cardImageAspect`,
765
+ `cardImageFit`, `cardImageBg`, `cardImagePadding`, `cardMetaPadding`, `cardPriceFont`,
766
+ `cardAccent`, `cardBarRadius`, `cardDescriptionLines`, `cardRetailerOrder` (`-1` lifts the seller
767
+ line above the name). CSS only: `--vio-card-border-hover-color`, `--vio-card-add-size`,
768
+ `--vio-card-add-icon-size`, `--vio-card-brand-{size,tracking,weight,color,gap}`,
769
+ `--vio-card-name-{font,size,weight,gap}`, `--vio-card-price-{size,weight,gap}`,
770
+ `--vio-card-retailer-{display,size,tracking,weight,color,margin}`,
771
+ `--vio-card-description-{size,color,gap}`, `--vio-card-accent-text`.
661
772
 
662
773
  ### The hand-over to Vipps (0.28)
663
774
 
@@ -674,6 +785,57 @@ tap, so a second payment cannot start. Events on `document`:
674
785
  `source` is `'detail'`, `'cart'` or `'checkout'`. The overlay is `.vio-redirect-overlay`
675
786
  in the light DOM.
676
787
 
788
+ **The detail's Vipps button pays the whole cart.** «Kjøp nå med Vipps» on the product
789
+ detail adds the product to the sponsor's cart and the checkout is built on that cart —
790
+ every line in it. It goes straight to Vipps only when the cart was **empty**; with lines
791
+ already in it, the product is added and the checkout opens first (`vio:checkout-open`
792
+ with `paymentMethod: 'vipps'`, no hand-over events), every line and the total on screen,
793
+ so what the Vipps page lists is what the shopper saw.
794
+
795
+ **Two Vipps paths (2026-10-28).** The detail's «Kjøp nå med Vipps» is **Express**: the app
796
+ fills in name and address. The **cart's** Vipps button opens the checkout with Vipps
797
+ preselected, and the checkout's Vipps is always the **standard** payment, even when the
798
+ brand has Express on. Our form collects name, e-mail, **Mobilnummer**, address and the
799
+ delivery with its price before paying. The mobile is required for Vipps only and accepts any
800
+ market's number: the `+` prefix or the shipping country sets the calling code. The delivery is
801
+ saved on the cart first; if that fails, no payment starts. The payment goes out with
802
+ `express: false`, and the address (with `phone` + `phone_code`) is on the checkout's shipping
803
+ and billing address. **Apple Pay on the detail follows the same
804
+ rule**: its sheet's amount is fixed before the tap, so it opens only on an empty cart, for the
805
+ product × the chosen quantity; otherwise the checkout opens (`paymentMethod: 'apple-pay'`).
806
+
807
+ ### What the shopper pays is what the page shows — the cart, the server, the other tabs
808
+
809
+ The cart lives in this tab's memory and in `localStorage` (`vio.cart.v1`, one entry per
810
+ sponsor with the backend cart id), and the checkout — and the Vipps / Stripe page — is
811
+ built on the **backend** cart, every line of it. Three things keep the two the same:
812
+
813
+ - **Before a checkout is created or a payment link minted**, and **when `<vio-cart>` or
814
+ `<vio-checkout>` opens**, the SDK asks the server for the cart (`GetCart`) and draws that
815
+ answer: a line another tab added, a cart another tab emptied. `Vio.cart.refreshFromServer(sponsorId)`
816
+ does the same on demand and resolves with the sponsor's cart. A `GetCart` that fails leaves
817
+ the cart as it was (and warns) — the checkout then goes on with the lines the last mutation
818
+ answered, as before.
819
+ - **Other tabs of the site are heard.** A payment in one tab (its cart cleared) empties the
820
+ drawer of the others and the next add there opens a new backend cart; a line added in one
821
+ tab is in the others without a reload. Through the browser's `storage` event — a tab that
822
+ is mid-add for a sponsor keeps its own lines until its answer lands.
823
+ - **An open checkout follows its cart**: its lines and its total are recomputed when the
824
+ cart changes under it (the widgets that captured an amount at mount ask to be refreshed,
825
+ as they already did for a changed cart).
826
+
827
+ `vio:cart-updated` on `document` says when either of the first two happened:
828
+
829
+ ```ts
830
+ document.addEventListener('vio:cart-updated', (e) => {
831
+ const { sponsorId, cartId, items, itemCount, subtotal, currency, source } = e.detail
832
+ // source: 'server' (GetCart, right before a checkout / on opening) | 'storage' (another tab)
833
+ })
834
+ ```
835
+
836
+ `items` are copies of the lines (`CartLineItem[]`). A host that draws its own cart or badge
837
+ redraws it from this event; `<vio-cart>` and the badge wired to `Vio.cart`'s `change` need nothing.
838
+
677
839
  ## Headless / SSR
678
840
 
679
841
  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):