@vio-live/web-sdk 0.29.0 → 0.33.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,6 +14,225 @@ 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
+ ## 0.33.0 — 2026-10-28
18
+
19
+ ### Fixed
20
+ - **One cart per connection.** Every connection of a site (environment + key) shared one stored
21
+ cart, and every tab adopted what the others wrote: two tabs on two connections (Staging and Prod
22
+ side by side) could take each other's cart for a moment. Each connection now keeps its own
23
+ (`vio.cart.v1:<environment>:<hash of the key>` — never the key itself), a tab only listens to
24
+ its own, and `Vio.init` with another key switches `Vio.cart` to that connection's carts (the
25
+ first one's come back if the page switches back). An answer that arrives for the connection the
26
+ page left lands in that connection's cart, silently — never in the new one.
27
+ - **The commerce key is the channel of the article's campaign, not the sponsor's first block.**
28
+ With two campaigns of one sponsor on a surface (Careless: campaign 2 on channel 482, an internal
29
+ test campaign 1 on channel 480), products, carts and checkouts used the default campaign's
30
+ channel even for an article of the other. The key now follows the campaign in context (the
31
+ click's `campaignId`, `vio-campaign`, `data-vio-campaign`, `Vio.init`'s), among the sponsor's
32
+ blocks (a block with `commerce: null` is skipped instead of throwing). `Vio.commerceFor(sponsorId)`
33
+ still works and takes an optional `{ campaignId?, channelId? }`; commerce clients are kept per
34
+ sponsor and key. New: `Vio.sponsorCommerce(sponsorId, ctx?)`.
35
+ - **A cart remembers its channel.** `SponsorCartState.channelId` is stored when the backend cart is
36
+ created, and every later cart and checkout call for it (also the return from Vipps / Stripe,
37
+ through the per-checkout memory) uses that channel's key. If the bootstrap no longer lists the
38
+ channel, the stale `cartId` is dropped and the cart is created again on the current channel with
39
+ its lines — never a call with another channel's key. Carts from before keep the sponsor's first
40
+ block, as when they were created.
41
+ - **The order's campaign belongs to the cart's sponsor.** `attribution.campaign_id` on the checkout
42
+ and `campaignId` on `vio:checkout-created` (→ `add_payment_info`) took the page's campaign even
43
+ when it was another sponsor's. Now: the campaign the cart's lines were added from, else the
44
+ page's (`data-vio-campaign` / `vio-campaign` on the page, then `Vio.init`'s) only when the sponsor
45
+ takes part in it, else the slices' rule — only campaigns of that sponsor.
46
+ - **The bootstrap no longer lives for the whole page.** When the tab becomes visible again and the
47
+ cached bootstrap is older than 5 minutes, it is fetched again (the old one stays until the new
48
+ one arrives, and if the refresh fails). Carts keep their lines; an open checkout keeps paying
49
+ with its channel's key.
50
+ - **«Frakt & retur» no longer shows filler as the delivery time.** A rate's description equal to
51
+ its own name (dashboard-created rates copied it), `By Outshifter` or `Default Shipping Method`
52
+ (products' defaults) is no description in `getShippingRates`; the detail also drops one equal
53
+ to the product's title or the brand / seller name.
54
+
55
+ ### ⚠️ Upgrade notes
56
+ - **Hosts with several articles (Replit, Vev):** pass `sponsorId` **and** `campaignId` on
57
+ `vio:product-click`, set `data-vio-campaign` on each article's wrapper, and do **not** set a
58
+ site-wide `Vio.init({ campaignId })` — the campaign in context decides which channel sells.
59
+ - The carts kept in the old shared entry move, once, to the first connection that opens with none
60
+ of its own. Nothing to do.
61
+ - `Vio.cart` is the same object for the whole page, so listeners added to it keep working across a
62
+ re-init. A host that archived and restored carts per connection itself (Replit's
63
+ `vioCartConnections`) can rely on this instead.
64
+
65
+ ## 0.32.0 — 2026-10-28
66
+
67
+ > First version published to npm since 0.29.0: **0.30.0 and 0.31.0 were never published on their own** — their
68
+ > changes (below) ship in this version too. A host on 0.29.0 should read the three sections.
69
+
70
+ ### Added
71
+ - **Options for the product detail** (README → *Options of the detail and the product cards*):
72
+ `gallery="carousel"` (every photo in one swipeable row with dots, no thumbnails),
73
+ `description-mode` (`preview` · `folded` · `open`), `highlights`, `delivery-time`,
74
+ `carrier-logos`; theme keys `detailGallery`, `detailCarouselPeek`, `detailDescriptionMode`,
75
+ `detailDescriptionPreviewLines`, `detailHighlightsDisplay`, `detailShippingReturnsDisplay`.
76
+ - **The brand's promises** under the buy zone (icon, title, line of text — up to three), from the
77
+ sponsor block's `highlights` (dashboard Settings → Brand) unless the host passes its own.
78
+ - **«Frakt & retur»** fold after «Beskrivelse»: one row per carrier (its logo from Commerce's
79
+ `logo_url`, or `carrier-logos`, or a line icon by the kind of delivery), how it delivers, the
80
+ price; the delivery time the brand wrote on the rate («Leveringstid 1-5 dager»); the right of
81
+ withdrawal and the brand's pages.
82
+ - **Product cards:** `<vio-product-carousel layout="list">` (one under the other),
83
+ `show-description` / `descriptions` (the catalogue's text or the editor's own per product),
84
+ `button-style` (`icon` · `bar` · `hidden`) and `button-label` (the Vev card's «Kjøp» bar); card
85
+ look through theme keys `card*` and `--vio-card-*`.
86
+ - `commerce.channel.product.getShippingRates` returns each rate's `description` and `logoUrl`.
87
+
88
+ ### Changed
89
+ - **The detail's order is the same on every screen**: sizes first in the buy zone, «Frakt fra …»
90
+ under them, stock on the name's line, «Beskrivelse» at the very bottom. The buy zone no longer
91
+ pins itself on desktop (`detailBuyPosition: 'sticky'` brings that back).
92
+ - **«Beskrivelse» shows a preview** (the first three lines and «Les mer») instead of being folded
93
+ shut. `description-mode="folded"` keeps the old behaviour.
94
+ - **The sheets close with a pull down from anywhere** while their content is at the top, not only
95
+ from the grab bar or the top bar (detail, cart, checkout).
96
+
97
+ ### ⚠️ Upgrade notes
98
+ - Rates are asked with `logo_url`; a gateway without the field is asked again without it once
99
+ (and not again on that page), so the rates never go missing over a logo.
100
+ - A host that styled `details.description-block` for «Beskrivelse» now gets
101
+ `.description-preview` (`part="description"` unchanged).
102
+
103
+ ## 0.31.0 — 2026-10-28
104
+
105
+ ### Added
106
+ - **Delivery and returns under the price in the product detail.** It reads «Frakt fra X · 14
107
+ dagers angrerett», where X is the product's cheapest shipping rate for the page's market (the
108
+ same `product_shipping` rates the backend cart offers). A cheapest rate of 0 reads «Gratis frakt».
109
+ With no known rate, only «14 dagers angrerett» is shown (`part="delivery-note"`). The rates come
110
+ from a separate query (`commerce.channel.product.getShippingRates`), so a failure there only
111
+ loses this line.
112
+ - **The cart drawer shows shipping and the total including it.** It uses the rate chosen on the
113
+ server cart, or the cheapest rate offered. Without a known rate it reads «Frakt: Beregnes i
114
+ kassen» and shows no total (`part="cart-shipping"`, `part="cart-total"`).
115
+ - **«Lagt i handlekurven» after «Legg i handlekurv».** The detail closes and a small toast offers
116
+ **«Til kassen»** and **«Fortsett å handle»** (`part="added-toast"`). «Til kassen» dispatches
117
+ `vio:checkout-open` for that brand, the same event the cart's «Til kassen» sends. It does nothing
118
+ when the cart was emptied in the meantime. The toast closes by itself after 8 s.
119
+ - **A line under «Kjøp nå med Vipps» in the product detail:** «Vipps fyller inn navn og adresse –
120
+ du godkjenner og betaler i appen» (`part="vipps-note"`). It is shown only when that button goes
121
+ straight to the app: Express is on for the brand and the brand's cart is empty. In other cases
122
+ the button opens the checkout first.
123
+ ### Changed
124
+ - **The product detail no longer picks a size for the shopper.** Only an option with a single
125
+ value is preselected. The chosen value is shown beside the option's name («Størrelse: M») and
126
+ on its pill. Every buy button (Vipps, «Legg i handlekurv», «Kjøp nå», Stripe, Klarna, Apple Pay)
127
+ checks for a missing choice first: it shows «Velg størrelse» (or «Velg farge», etc.) and adds or
128
+ pays nothing. «Få igjen — N stk» now counts only the variant actually chosen. Before a size is
129
+ chosen it says «På lager» if any size is in stock.
130
+ - **Two Vipps paths.** The product detail's «Kjøp nå med Vipps» stays **Express**. The
131
+ **cart's** Vipps button now opens the checkout with Vipps preselected. It no longer starts
132
+ Express or hands over to Vipps. **The checkout's Vipps is always the standard payment**, even
133
+ when the brand has Express on:
134
+ - the address form, the delivery and its price are required before paying, as for Stripe;
135
+ - the summary counts our shipping rate, so «Velges i Vipps» is gone;
136
+ - the payment is `startVippsPayment(sponsorId, form, { express: false })`, which now puts the
137
+ form's complete address on the checkout's shipping and billing address.
138
+
139
+ `COLLECTS_OWN_ADDRESS` no longer lists Vipps. See
140
+ [README → Two Vipps paths](README.md#the-hand-over-to-vipps-028).
141
+ - **«Mobilnummer» in the checkout's address form.** It is required only for the standard Vipps
142
+ payment, and optional for every other method; it never blocks their buttons or widgets.
143
+ - It accepts any market's number. A `+`/`00` prefix sets the calling code; without one, the
144
+ shipping country does (NO 47, SE 46, DK 45, FI 358, DE 49, GB 44…), falling back to the
145
+ page's country. A trunk 0 is dropped.
146
+ - A number that cannot be read shows «Skriv et gyldig mobilnummer…».
147
+ - It is sent as `phone` (national digits) + `phone_code` (digits, no plus) on the shipping and
148
+ billing address of every method that takes our form. `CheckoutAddress.phone` is new.
149
+ - **The checkout's Vipps refuses to start without the delivery saved.** If the chosen rate cannot
150
+ be saved on the cart, the Vipps or Stripe-link payment does not start and the checkout says
151
+ «Kunne ikke lagre fraktmetoden. Prøv igjen.». This covers a failed save and a rate without a
152
+ supplier. `startVippsPayment(…, { express: false })` also throws before creating anything when
153
+ the address is incomplete or has no usable mobile.
154
+
155
+ ### Fixed
156
+ - **«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.
157
+ - **No checkout and no payment for an empty cart or a total of 0 kr.** `Vio.checkout.open()`
158
+ throws for a cart that totals 0, as it already did for an empty one. Vipps (`startVippsPayment`,
159
+ `startVippsExpress`), the Stripe link, native Stripe and Adyen read the cart from the server and
160
+ refuse to create a checkout or a payment when it is empty or 0 kr. The buttons check the same
161
+ thing first: the cart's «Til kassen» is disabled, the checkout's pay buttons are disabled and
162
+ say «Handlekurven er tom.», and the detail shows no Vipps or Apple Pay button for a product at 0 kr.
163
+ - **Desktop: the product detail's pinned buy bar no longer covers the size picker.** The option
164
+ pickers now sit at the top of the buy zone (`part="buy-options"`), so they move with the
165
+ buttons whether the zone is static or sticky.
166
+ - **No buttons for payment methods the brand does not have while its methods load.** The
167
+ product detail and the cart drawer show a neutral placeholder (`part="buy-pending"` /
168
+ `.methods-pending`) until `getAvailablePaymentMethods` answers. They used to show every method,
169
+ so a Vipps-only brand flashed Stripe and Klarna. «Legg i handlekurv» and «Til kassen» are there
170
+ from the start. A failed lookup still shows every button, as before.
171
+ - **Storage never blocks add to cart or checkout (in-app browsers, private modes).** Every read
172
+ and write of `localStorage`/`sessionStorage` goes through one guard, `src/core/safe-storage.ts`.
173
+ A storage property that throws on access, a `null` storage or a refused write now falls back to
174
+ the page's memory. This covers the cart, the customer session id, the market's
175
+ currency/country/locale and the Klarna return. Before, `typeof localStorage` outside a `try`
176
+ could throw while the SDK started. Without storage, every browser also sent the same customer
177
+ session id, `cs_session_default`. It is now one id per page.
178
+
179
+ ### ⚠️ Upgrade notes
180
+ - The detail's buy buttons stay enabled before a size is chosen. Tapping one shows the prompt
181
+ instead of buying. A host that clicks `.add-to-cart` from a script needs a value in every option
182
+ first.
183
+ - A host's `vio:checkout-open` handler that calls `Vio.checkout.open()` must still catch the throw
184
+ (it already threw for an empty cart). It now also throws for a cart of 0 kr.
185
+ - Host CSS that placed `::part(option-group)` relative to `::part(buy)` should be checked: the
186
+ option groups are now inside `buy`.
187
+ - A host that already answers `vio:added-to-cart` with its own UI (for example, opening its cart
188
+ drawer) can turn the new toast off with `<vio-product-detail no-added-toast>`. The toast's
189
+ «Til kassen» relies on the host's `vio:checkout-open` handler, like the cart's does.
190
+ - **Backend (shopcart):** the checkout's Vipps payment is now `express: false` with the address
191
+ already on the checkout. shopcart must not request the Vipps profile on these standard
192
+ payments, and must carry the address `phone`/`phone_code` through to the order. Until that is
193
+ deployed, check a QA order to confirm the phone arrives.
194
+ - Hosts that pre-fill the checkout form through `Vio.checkout.setAddress()` should include
195
+ `phone`, or the shopper has to type it when paying with Vipps. Other methods do not need it.
196
+ - **The cart's Vipps button now opens the checkout through `vio:checkout-open`**, with
197
+ `paymentMethod: 'vipps'`. A host without a handler for that event loses direct Vipps from the
198
+ cart. The `vio:payment-redirect-*` events with `source: 'cart'` are gone; those with
199
+ `'detail'` and `'checkout'` remain.
200
+ - **`startVippsPayment(sponsorId, form, { express: false })` writes the full address**, including
201
+ the mobile, to the checkout's shipping and billing address before it creates the payment.
202
+ Without the flag, or with `express: true`, nothing changes.
203
+ - `Vio.checkout.open()` throws on a cart that totals 0, as well as on an empty one.
204
+ - The «Lagt i handlekurven» toast is on by default. Use `<vio-product-detail no-added-toast>` to
205
+ turn it off.
206
+
207
+ ## 0.30.0 — 2026-10-27
208
+
209
+ ### Added
210
+ - **Internal traffic** (ADR-0041): opening any page with **`?vio_internal=1`** marks the browser
211
+ (`localStorage`, `vio.internal.v1`) and `?vio_internal=0` clears it; read once at `Vio.init()`.
212
+ While marked, every analytics event carries **`client.internal: true`** and the collector's
213
+ reads leave it out. **`Vio.analytics.setInternal(boolean)`** / `Vio.analytics.isInternal` for
214
+ hosts; one `console.info` when marked. See
215
+ [README → Internal traffic](README.md#internal-traffic--mark-your-browser-as-a-tester-unreleased).
216
+
217
+ ### Changed
218
+ - **Stripe.js (~300 KB) is downloaded only for a brand that uses Stripe.** It used to load on
219
+ every page as soon as the SDK started, to have Apple Pay ready. Now it loads when the brand's
220
+ payment methods come back with a Stripe key (or when the host passes `stripePublishableKey`
221
+ to `Vio.init`). A brand that only takes Vipps never downloads it. Apple Pay still has it before
222
+ the tap: its button waits for the same key.
223
+ - **`component_impression` of `<vio-product-carousel>` carries `commerce.items`** — the products
224
+ shown, like its `view_item_list` — so the collector can count per-product impressions
225
+ (ADR-0041). The impression is observed once the products have loaded (the 1 s dwell starts
226
+ then); a failed fetch still counts it, without items. `Vio.analytics.observeImpression()` takes
227
+ a `commerce` getter, read when the impression fires. See
228
+ [README → Impressions carry the products shown](README.md#impressions-carry-the-products-shown-unreleased).
229
+ - Events never carry more than 100 `commerce.items` (the collector rejects the whole event above
230
+ its cap).
231
+
232
+ ### ⚠️ Upgrade notes
233
+ - `client.internal` needs the collector that knows the field; an older one strips it (the event is
234
+ kept, just not marked). Nothing to do on the host.
235
+
17
236
  ## 0.29.0 — 2026-10-24
18
237
 
19
238
  ### Added
package/README.md CHANGED
@@ -226,7 +226,7 @@ a campaign to a slot. But an editorial page — a Vev article, a Replit article
226
226
  often *is* part of one campaign, and the page can say so:
227
227
 
228
228
  ```ts
229
- Vio.init({ apiKey: '…', campaignId: 12 }) // the whole page
229
+ Vio.init({ apiKey: '…', campaignId: 12 }) // the whole page — only on a page of ONE campaign
230
230
  ```
231
231
 
232
232
  ```html
@@ -243,6 +243,19 @@ ancestors. On web/Replit a **per-article** campaign needs a per-article scope to
243
243
  same `carousel-1`, and the articles' campaigns would overwrite one another's
244
244
  assignment; Vev ids are per block, so it does not apply there. What it does:
245
245
 
246
+ - **Which channel sells.** One article = one campaign = one channel = one
247
+ sponsor, and one sponsor may run several campaigns on a surface, each on its own
248
+ commerce channel. The SDK reads products, creates the cart and pays with the key
249
+ of the channel of the **campaign in context** — the click's `campaignId`, the
250
+ element's `vio-campaign`, an ancestor's `data-vio-campaign`, then `Vio.init`'s —
251
+ among the blocks of that sponsor (`Vio.sponsorCommerce(sponsorId, { campaignId })`,
252
+ `Vio.commerceFor(sponsorId, { campaignId })`); without one, the sponsor's first
253
+ block, as before. A cart remembers the channel it was created on (`channelId`)
254
+ and every later cart and checkout call uses that channel's key; if the bootstrap
255
+ no longer lists that channel, the cart is created again on the current one with
256
+ its lines. So on a site with several articles (Replit, Vev), **set
257
+ `data-vio-campaign` per article and pass `sponsorId` and `campaignId` on
258
+ `vio:product-click`; do not set a site-wide `Vio.init({ campaignId })`.**
246
259
  - **Declaration.** The manifest carries the page's campaign (top-level) and an
247
260
  element's own campaign per location when it differs. The campaigns service
248
261
  (campaigns micro PR #16) turns that into assignments of the page's slots
@@ -372,9 +385,11 @@ Since 0.26 the origins do not stay in the browser: **Commerce receives them**
372
385
  - **Per checkout.** `CreateCheckout` and the `UpdateCheckout` right before paying
373
386
  carry `attribution`: `surface_key` (the SDK key — the gateway sets it from
374
387
  `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
388
+ cannot credit a sale to another surface), `campaign_id` (always a campaign the
389
+ cart's sponsor takes part in: the campaign the cart's lines were added from, else
390
+ the page's — `data-vio-campaign` / `vio-campaign` on the page, then `Vio.init`'s —
391
+ else the campaign that explains most units of the cart under the same rule
392
+ `purchase` is split by), `content_url` / `content_title` (the page at
378
393
  pay time) and `entry` (`{ referrer, utm_source, utm_medium, utm_campaign,
379
394
  landing_url }`: the landing the session **remembers** — the same memory the
380
395
  `client` block reads, which now keeps the landing page too — never the page the
@@ -519,6 +534,49 @@ redirect }` — which is what analytics listens to; a host with its own sink can
519
534
  listen to it too. With `dataLayer: true` it is pushed as GA4's `add_payment_info`
520
535
  with `payment_type`.
521
536
 
537
+ ### Impressions carry the products shown (unreleased)
538
+
539
+ `<vio-product-carousel>`'s **`component_impression`** (≥50% visible for ≥1 s, once
540
+ per session and carousel) carries the products it shows in **`commerce.items`**
541
+ (`product_id`, `name`, `price`) and `commerce.currency` — the same lines as its
542
+ `view_item_list`. The collector counts the per-product "saw" step of the funnel
543
+ from them (ADR-0041). While the products load nothing is observed: the 1 s dwell
544
+ starts once they are on screen. A carousel whose fetch failed (slotted fallback)
545
+ still counts its impression, without items. A host calling
546
+ `Vio.analytics.observeImpression(el, context, { commerce: () => ({ items, currency }) })`
547
+ does the same for its own elements: the getter is read when the impression fires.
548
+
549
+ ### Internal traffic — mark your browser as a tester (unreleased)
550
+
551
+ Our own visits (testers, the team, agents) must not count in the dashboard. Mark
552
+ **each browser you test with** once:
553
+
554
+ 1. Open any page of the site that runs the SDK with **`?vio_internal=1`** added to
555
+ the URL — e.g. `https://example.com/article?vio_internal=1` (or
556
+ `…?utm_source=x&vio_internal=1` when the URL already has a query).
557
+ 2. Open the browser's console: the SDK says once
558
+ `[Vio.analytics] this browser is marked as internal traffic …`.
559
+ 3. That's it. The mark is remembered in this browser (`localStorage`,
560
+ `vio.internal.v1`, per site) for every page and every later visit, and every
561
+ analytics event it sends carries **`client.internal: true`**; the collector's
562
+ reads leave those events out.
563
+
564
+ To **undo** it, open any page with **`?vio_internal=0`**. Things to know:
565
+
566
+ - The mark is per browser and per site: Safari and Chrome on the same phone, a
567
+ private window, or another site are marked separately. Clearing the site's data
568
+ clears the mark.
569
+ - In-app browsers (Instagram, Facebook) and private windows may not keep
570
+ `localStorage`: there, only the page opened with `?vio_internal=1` is marked.
571
+ Prefer a regular browser for testing.
572
+ - The URL is read once per page load (at `Vio.init()`), not on client-side
573
+ navigation.
574
+ - The mark is the tester's own choice, not a tracking id: it is written even
575
+ before consent (`requireConsent`).
576
+ - A host can mark from code: **`Vio.analytics.setInternal(true | false)`**
577
+ (`Vio.analytics.isInternal` reads it). `resolveInternal()` / `INTERNAL_KEY` are
578
+ exported from `/core`.
579
+
522
580
  ## Sponsor identity — name and logo
523
581
 
524
582
  The elements say **who sells**: the sponsor's (brand's) display name and logo.
@@ -650,7 +708,7 @@ or on the element (`vio-product-detail { --vio-detail-logo-height: 48px; }`).
650
708
  | `detailOptionHeight` / `detailOptionGap` | `--vio-detail-option-height` / `--vio-detail-option-gap` | `36px` / `6px` | Size / option buttons (height is also their min width) |
651
709
  | `detailOptionRadius` / `detailOptionWrap` | `--vio-detail-option-radius` / `--vio-detail-option-wrap` | `0` / `wrap` | `nowrap` = one line that scrolls sideways |
652
710
  | `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 |
711
+ | `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 |
654
712
  | `cartWidth` / `checkoutWidth` | `--vio-cart-width` / `--vio-checkout-width` | `440px` | Drawer widths (desktop) |
655
713
  | `confirmLogoHeight` / `confirmTitleSize` / `confirmCheckBg` | `--vio-confirm-logo-height` / `--vio-confirm-title-size` / `--vio-confirm-check-bg` | `40px` / `24px` / green | The confirmation |
656
714
  | `redirectBg` / `redirectSpinner` | `--vio-redirect-bg` / `--vio-redirect-spinner` | translucent white / Vipps orange | The «Åpner Vipps …» overlay |
@@ -666,10 +724,14 @@ Also settable as CSS only: `--vio-detail-logo-max-width` (`220px`), `--vio-detai
666
724
  **`::part` names**, for what the properties do not cover:
667
725
 
668
726
  - `<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`.
727
+ (+ `thumb-active`), `thumbs-arrow`, `info`, `brand`, `title`, `price`, `delivery-note`
728
+ (`delivery-shipping`, `delivery-withdrawal`), `stock`, `buy`, `buy-options`, `option-group`,
729
+ `option-label`, `option-chosen`, `option-prompt`, `options`, `option` (+ `option-selected`),
730
+ `buy-pending`, `buy-vipps`, `vipps-note`, `quantity-row`, `add-to-cart`, `seller-note`,
731
+ `seller-terms`, `seller-refund`, `description`, `highlights`, `highlight`, `shipping-returns`,
732
+ `slide`, `image-dots`, `carousel-arrow`, `added-toast` (`added-toast-checkout`,
733
+ `added-toast-continue`).
734
+ - `<vio-cart>`: `cart-shipping`, `cart-total`.
673
735
  - `<vio-checkout>`: `sponsor`, `confirmation`, `confirm-title`, `confirm-sponsor`, `confirm-details`.
674
736
 
675
737
  ```css
@@ -677,14 +739,51 @@ vio-product-detail::part(add-to-cart) { letter-spacing: 0; text-transform: none;
677
739
  vio-product-detail::part(option-selected) { background: #2b2b2b; }
678
740
  ```
679
741
 
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.
742
+ **The detail's order:** the selling brand top left; the images (thumbnails, or a carousel);
743
+ name and stock on one line, the price; the buy zone — the sizes (none preselected; every buy
744
+ button asks «Velg størrelse» first), «Frakt fra X · 14 dagers angrerett», **Vipps first** (when
745
+ the channel has it), quantity + «Legg i handlekurv», the other ways to pay, and under them «Du
746
+ handler direkte hos {brand}.» (the product's brand, never the article's) with links to the
747
+ brand's «Kjøpsvilkår» and «Angrerett og retur» when it gave them (the sponsor block's
748
+ `termsUrl` / `refundPolicyUrl`, https only, new tab); the brand's promises; «Beskrivelse»; and
749
+ «Frakt & retur». The same order on every screen.
750
+
751
+ ### Options of the detail and the product cards (0.32)
752
+
753
+ Every one is off or as before unless the host sets it: per element (attribute), or for the
754
+ whole page through `applyVioTheme` (theme key → custom property). The element wins.
755
+
756
+ **`<vio-product-detail>`**
757
+
758
+ | Attribute | Theme key | Values | What |
759
+ |---|---|---|---|
760
+ | `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 |
761
+ | `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 |
762
+ | `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 |
763
+ | `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) |
764
+ | `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 |
765
+ | — | `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 |
766
+
767
+ The sheet closes with a pull down from its top bar, or from anywhere while its content is at the top.
768
+
769
+ **`<vio-product-carousel>`** (and its cards, `<vio-product>`)
770
+
771
+ | Attribute | Theme key | Values | What |
772
+ |---|---|---|---|
773
+ | `layout` | `carouselLayout` | `carousel` (default) · `list` | `list`: one card under the other, as wide as the column (`--vio-carousel-list-max-width`, `480px`) |
774
+ | `show-description` | — | boolean | Each card shows its product's description (`cardDescriptionLines`, `4`) |
775
+ | `descriptions` | — | `{ "<productId>": "text" }` (JSON) | The editor's own text for a product, over the catalogue's — shown even without `show-description`; `""` hides that one |
776
+ | `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 |
777
+ | `button-label` | — | text, default `Kjøp` | The bar's text («Kjøp · 2» once two are in the cart) |
778
+
779
+ Card look, by theme key (custom property): `cardFont`, `cardBorder`, `cardImageAspect`,
780
+ `cardImageFit`, `cardImageBg`, `cardImagePadding`, `cardMetaPadding`, `cardPriceFont`,
781
+ `cardAccent`, `cardBarRadius`, `cardDescriptionLines`, `cardRetailerOrder` (`-1` lifts the seller
782
+ line above the name). CSS only: `--vio-card-border-hover-color`, `--vio-card-add-size`,
783
+ `--vio-card-add-icon-size`, `--vio-card-brand-{size,tracking,weight,color,gap}`,
784
+ `--vio-card-name-{font,size,weight,gap}`, `--vio-card-price-{size,weight,gap}`,
785
+ `--vio-card-retailer-{display,size,tracking,weight,color,margin}`,
786
+ `--vio-card-description-{size,color,gap}`, `--vio-card-accent-text`.
688
787
 
689
788
  ### The hand-over to Vipps (0.28)
690
789
 
@@ -706,8 +805,17 @@ detail adds the product to the sponsor's cart and the checkout is built on that
706
805
  every line in it. It goes straight to Vipps only when the cart was **empty**; with lines
707
806
  already in it, the product is added and the checkout opens first (`vio:checkout-open`
708
807
  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
808
+ so what the Vipps page lists is what the shopper saw.
809
+
810
+ **Two Vipps paths (2026-10-28).** The detail's «Kjøp nå med Vipps» is **Express**: the app
811
+ fills in name and address. The **cart's** Vipps button opens the checkout with Vipps
812
+ preselected, and the checkout's Vipps is always the **standard** payment, even when the
813
+ brand has Express on. Our form collects name, e-mail, **Mobilnummer**, address and the
814
+ delivery with its price before paying. The mobile is required for Vipps only and accepts any
815
+ market's number: the `+` prefix or the shipping country sets the calling code. The delivery is
816
+ saved on the cart first; if that fails, no payment starts. The payment goes out with
817
+ `express: false`, and the address (with `phone` + `phone_code`) is on the checkout's shipping
818
+ and billing address. **Apple Pay on the detail follows the same
711
819
  rule**: its sheet's amount is fixed before the tap, so it opens only on an empty cart, for the
712
820
  product × the chosen quantity; otherwise the checkout opens (`paymentMethod: 'apple-pay'`).
713
821
 
@@ -0,0 +1,3 @@
1
+
2
+ //# sourceMappingURL=chunk-QGJUBVH3.js.map
3
+ //# sourceMappingURL=chunk-QGJUBVH3.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-67QESEV2.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-QGJUBVH3.js"}