@vio-live/web-sdk 0.27.0 → 0.28.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.
@@ -1,4 +1,4 @@
1
- import { Vio, __decorateClass, primaryImageUrl, displayPrice, toCampaignId, getGlobalCurrency, ensureVippsButton, formatPrice, buyerRefOf, nexiReturnPaymentId, readNexiPending, kustomReturnOutcome, KUSTOM_RETURN_QUERY_KEYS, ADYEN_RETURN_METHOD, adyenReturnParams, ADYEN_RETURN_QUERY_KEYS, adyenReturnOutcome, gatewayHasBuyerRef, rememberedCheckoutSponsor, checkoutSponsorOf, AdyenOriginError, normalizeSlotId, autoSlotDisplayName, autoSlotId, sponsorIdentityFrom, Configuration, normalizeScope, campaignCandidates } from './chunk-HJRIPCWB.js';
1
+ import { Vio, __decorateClass, primaryImageUrl, displayPrice, toCampaignId, getGlobalCurrency, ensureVippsButton, formatPrice, buyerRefOf, nexiReturnPaymentId, readNexiPending, kustomReturnOutcome, KUSTOM_RETURN_QUERY_KEYS, ADYEN_RETURN_METHOD, adyenReturnParams, ADYEN_RETURN_QUERY_KEYS, adyenReturnOutcome, gatewayHasBuyerRef, rememberedCheckoutSponsor, checkoutSponsorOf, AdyenOriginError, normalizeSlotId, autoSlotDisplayName, autoSlotId, sponsorIdentityFrom, Configuration, normalizeScope, campaignCandidates } from './chunk-AFJ5M53F.js';
2
2
  import { LitElement, css, html, nothing } from 'lit';
3
3
  import { property, state, query } from 'lit/decorators.js';
4
4
 
@@ -151,7 +151,38 @@ var THEME_KEY_TO_CSS_VAR = {
151
151
  spaceSm: "--vio-space-sm",
152
152
  spaceMd: "--vio-space-md",
153
153
  spaceLg: "--vio-space-lg",
154
- spaceXl: "--vio-space-xl"
154
+ spaceXl: "--vio-space-xl",
155
+ detailWidth: "--vio-detail-width",
156
+ detailPaddingX: "--vio-detail-padding-x",
157
+ detailLogoHeight: "--vio-detail-logo-height",
158
+ detailLogoZoom: "--vio-detail-logo-zoom",
159
+ detailTitleSize: "--vio-detail-title-size",
160
+ detailPriceSize: "--vio-detail-price-size",
161
+ detailImageFit: "--vio-detail-image-fit",
162
+ detailImageAspect: "--vio-detail-image-aspect",
163
+ detailImageMaxHeight: "--vio-detail-image-max-height",
164
+ detailImageZoom: "--vio-detail-image-zoom",
165
+ detailImageInset: "--vio-detail-image-inset",
166
+ detailImageBg: "--vio-detail-image-bg",
167
+ detailThumbSize: "--vio-detail-thumb-size",
168
+ detailThumbGap: "--vio-detail-thumb-gap",
169
+ detailThumbRadius: "--vio-detail-thumb-radius",
170
+ detailThumbActiveColor: "--vio-detail-thumb-active-color",
171
+ detailOptionHeight: "--vio-detail-option-height",
172
+ detailOptionGap: "--vio-detail-option-gap",
173
+ detailOptionRadius: "--vio-detail-option-radius",
174
+ detailOptionWrap: "--vio-detail-option-wrap",
175
+ detailButtonHeight: "--vio-detail-button-height",
176
+ detailButtonRadius: "--vio-detail-button-radius",
177
+ detailBuyPosition: "--vio-detail-buy-position",
178
+ detailBuyShadow: "--vio-detail-buy-shadow",
179
+ cartWidth: "--vio-cart-width",
180
+ checkoutWidth: "--vio-checkout-width",
181
+ confirmLogoHeight: "--vio-confirm-logo-height",
182
+ confirmTitleSize: "--vio-confirm-title-size",
183
+ confirmCheckBg: "--vio-confirm-check-bg",
184
+ redirectBg: "--vio-redirect-bg",
185
+ redirectSpinner: "--vio-redirect-spinner"
155
186
  };
156
187
  function applyVioTheme(overrides, target = typeof document !== "undefined" ? document.documentElement : null) {
157
188
  if (!target) return;
@@ -1244,6 +1275,102 @@ __decorateClass([
1244
1275
  state()
1245
1276
  ], VioProductCarousel.prototype, "fetchError", 2);
1246
1277
 
1278
+ // src/ui/payment-redirect.ts
1279
+ var OVERLAY_CLASS = "vio-redirect-overlay";
1280
+ var STYLE_ID = "vio-redirect-overlay-style";
1281
+ var LABEL = { vipps: "Vipps", stripe: "Stripe", klarna: "Klarna" };
1282
+ function emit(name, detail, cancelable = false) {
1283
+ if (typeof document === "undefined") return true;
1284
+ return document.dispatchEvent(new CustomEvent(name, { detail, cancelable }));
1285
+ }
1286
+ function injectStyle() {
1287
+ if (document.getElementById(STYLE_ID)) return;
1288
+ const style = document.createElement("style");
1289
+ style.id = STYLE_ID;
1290
+ style.textContent = `
1291
+ .${OVERLAY_CLASS} {
1292
+ position: fixed; inset: 0; z-index: 2147483000;
1293
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;
1294
+ background: var(--vio-redirect-bg, rgba(255, 255, 255, 0.94));
1295
+ color: var(--vio-redirect-color, var(--vio-color-text, #0a0a0a));
1296
+ font: 600 16px/1.3 var(--vio-redirect-font, var(--vio-font-sans, -apple-system, sans-serif));
1297
+ opacity: 0; transition: opacity 0.18s ease; cursor: progress;
1298
+ }
1299
+ .${OVERLAY_CLASS}.is-shown { opacity: 1; }
1300
+ .${OVERLAY_CLASS} .vio-redirect-spinner {
1301
+ width: 36px; height: 36px; border-radius: 50%;
1302
+ border: 3px solid rgba(0, 0, 0, 0.1); border-top-color: var(--vio-redirect-spinner, #ff5b24);
1303
+ animation: vio-redirect-spin 0.8s linear infinite;
1304
+ }
1305
+ @keyframes vio-redirect-spin { to { transform: rotate(360deg); } }
1306
+ @media (prefers-reduced-motion: reduce) {
1307
+ .${OVERLAY_CLASS} { transition: none; }
1308
+ .${OVERLAY_CLASS} .vio-redirect-spinner { animation: none; }
1309
+ }`;
1310
+ document.head.appendChild(style);
1311
+ }
1312
+ var current = null;
1313
+ function removeOverlay() {
1314
+ const el = current?.el;
1315
+ if (!el) return;
1316
+ el.classList.remove("is-shown");
1317
+ setTimeout(() => el.remove(), 200);
1318
+ current.el = null;
1319
+ }
1320
+ function onPageShow(e) {
1321
+ if (!e.persisted || !current || current.settled) return;
1322
+ current.settled = true;
1323
+ removeOverlay();
1324
+ emit("vio:payment-redirect-cancel", { ...current.detail, reason: "back" });
1325
+ current = null;
1326
+ }
1327
+ function beginPaymentRedirect(detail) {
1328
+ if (current && !current.settled) {
1329
+ current.settled = true;
1330
+ removeOverlay();
1331
+ }
1332
+ const hostTookOver = !emit("vio:payment-redirect-start", detail, true);
1333
+ const state6 = { el: null, detail, settled: false };
1334
+ current = state6;
1335
+ if (!hostTookOver && typeof document !== "undefined" && document.body) {
1336
+ injectStyle();
1337
+ const el = document.createElement("div");
1338
+ el.className = OVERLAY_CLASS;
1339
+ el.setAttribute("role", "status");
1340
+ el.setAttribute("aria-live", "polite");
1341
+ const spinner = document.createElement("div");
1342
+ spinner.className = "vio-redirect-spinner";
1343
+ spinner.setAttribute("aria-hidden", "true");
1344
+ const text = document.createElement("div");
1345
+ text.className = "vio-redirect-text";
1346
+ text.textContent = `\xC5pner ${LABEL[detail.method] ?? detail.method} \u2026`;
1347
+ el.append(spinner, text);
1348
+ el.addEventListener("click", (e) => e.stopPropagation());
1349
+ document.body.appendChild(el);
1350
+ requestAnimationFrame(() => el.classList.add("is-shown"));
1351
+ state6.el = el;
1352
+ }
1353
+ if (typeof window !== "undefined") window.addEventListener("pageshow", onPageShow);
1354
+ const settle = (event, reason) => {
1355
+ if (state6.settled) return;
1356
+ state6.settled = true;
1357
+ if (current === state6) {
1358
+ removeOverlay();
1359
+ current = null;
1360
+ } else if (state6.el) {
1361
+ state6.el.remove();
1362
+ }
1363
+ emit(event, { ...detail, reason });
1364
+ };
1365
+ return {
1366
+ fail: (reason) => settle("vio:payment-redirect-error", reason),
1367
+ abort: (reason = "not-started") => settle("vio:payment-redirect-cancel", reason)
1368
+ };
1369
+ }
1370
+ function returnCancelled(detail) {
1371
+ emit("vio:payment-redirect-cancel", { ...detail, reason: "cancelled" });
1372
+ }
1373
+
1247
1374
  // src/core/checkout/method-taxonomy.ts
1248
1375
  function normalizeMethodName(name) {
1249
1376
  return String(name ?? "").toLowerCase().replace(/[^a-z]/g, "");
@@ -1451,6 +1578,207 @@ function isWithin(node, ancestor) {
1451
1578
  return false;
1452
1579
  }
1453
1580
 
1581
+ // src/ui/bottom-sheet.ts
1582
+ var PHONE = "(max-width: 600px)";
1583
+ var SLOP = 6;
1584
+ var CLOSE_DISTANCE = 120;
1585
+ var FLICK_VELOCITY = 0.5;
1586
+ var FLICK_MIN = 24;
1587
+ var CLICK_AFTER_DRAG_MS = 400;
1588
+ function isPhoneSheet() {
1589
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(PHONE).matches;
1590
+ }
1591
+ var BottomSheet = class {
1592
+ constructor(opts) {
1593
+ this.opts = opts;
1594
+ this.active = false;
1595
+ this.locked = false;
1596
+ this.drag = null;
1597
+ this.dragEndedAt = -Infinity;
1598
+ this.onPointerDown = (e) => {
1599
+ if (!e.isPrimary || e.pointerType === "mouse" && e.button !== 0) return;
1600
+ if (this.drag || !isPhoneSheet()) return;
1601
+ const sheet = this.opts.sheet();
1602
+ if (!sheet || !inZone(e, sheet, this.opts.dragZones)) return;
1603
+ this.drag = {
1604
+ pointerId: e.pointerId,
1605
+ startY: e.clientY,
1606
+ lastY: e.clientY,
1607
+ lastT: e.timeStamp,
1608
+ velocity: 0,
1609
+ moving: false,
1610
+ offset: 0
1611
+ };
1612
+ window.addEventListener("pointermove", this.onPointerMove);
1613
+ window.addEventListener("pointerup", this.onPointerUp);
1614
+ window.addEventListener("pointercancel", this.onPointerCancel);
1615
+ };
1616
+ this.onPointerMove = (e) => {
1617
+ const drag = this.drag;
1618
+ if (!drag || e.pointerId !== drag.pointerId) return;
1619
+ const dy = e.clientY - drag.startY;
1620
+ if (!drag.moving) {
1621
+ if (dy < -SLOP) return this.abandon();
1622
+ if (dy <= SLOP) return;
1623
+ drag.moving = true;
1624
+ const sheet2 = this.opts.sheet();
1625
+ if (sheet2) sheet2.style.transition = "none";
1626
+ const backdrop2 = this.opts.backdrop?.();
1627
+ if (backdrop2) backdrop2.style.transition = "none";
1628
+ }
1629
+ const dt = e.timeStamp - drag.lastT;
1630
+ if (dt > 0) drag.velocity = 0.8 * ((e.clientY - drag.lastY) / dt) + 0.2 * drag.velocity;
1631
+ drag.lastY = e.clientY;
1632
+ drag.lastT = e.timeStamp;
1633
+ drag.offset = Math.max(0, dy);
1634
+ const sheet = this.opts.sheet();
1635
+ if (sheet) sheet.style.transform = `translateY(${drag.offset}px)`;
1636
+ const backdrop = this.opts.backdrop?.();
1637
+ if (backdrop && sheet) {
1638
+ const height = sheet.getBoundingClientRect().height || window.innerHeight;
1639
+ backdrop.style.opacity = String(Math.max(0, 1 - drag.offset / height));
1640
+ }
1641
+ if (e.cancelable) e.preventDefault();
1642
+ };
1643
+ this.onPointerUp = (e) => {
1644
+ const drag = this.drag;
1645
+ if (!drag || e.pointerId !== drag.pointerId) return;
1646
+ this.stopTracking();
1647
+ this.drag = null;
1648
+ if (!drag.moving) return;
1649
+ this.dragEndedAt = performance.now();
1650
+ const sheet = this.opts.sheet();
1651
+ const height = sheet?.getBoundingClientRect().height || window.innerHeight;
1652
+ const far = drag.offset >= Math.min(CLOSE_DISTANCE, height / 4);
1653
+ const flick = drag.velocity >= FLICK_VELOCITY && drag.offset >= FLICK_MIN;
1654
+ const allowed = !this.opts.canClose || this.opts.canClose();
1655
+ this.release();
1656
+ if ((far || flick) && allowed) {
1657
+ if (sheet) sheet.style.transform = "translateY(100%)";
1658
+ this.opts.close();
1659
+ } else if (sheet) {
1660
+ sheet.style.transform = "";
1661
+ }
1662
+ };
1663
+ this.onPointerCancel = (e) => {
1664
+ if (!this.drag || e.pointerId !== this.drag.pointerId) return;
1665
+ this.abandon();
1666
+ };
1667
+ /** The press that ended a drag also fires a click on ✕ or the grab bar: swallow it. */
1668
+ this.onClickCapture = (e) => {
1669
+ if (performance.now() - this.dragEndedAt > CLICK_AFTER_DRAG_MS) return;
1670
+ this.dragEndedAt = -Infinity;
1671
+ e.stopPropagation();
1672
+ e.preventDefault();
1673
+ };
1674
+ }
1675
+ /** Call from `updated()` when `open` changed. Same value twice is a no-op. */
1676
+ update(open) {
1677
+ if (open === this.active) return;
1678
+ this.active = open;
1679
+ if (open) this.opened();
1680
+ else this.closed();
1681
+ }
1682
+ /** Call from `disconnectedCallback()`: a sheet torn down while open must not keep the page locked. */
1683
+ disconnect() {
1684
+ if (this.active) {
1685
+ this.active = false;
1686
+ this.closed();
1687
+ }
1688
+ }
1689
+ opened() {
1690
+ if (isPhoneSheet()) {
1691
+ lockPageScroll();
1692
+ this.locked = true;
1693
+ }
1694
+ this.opts.host.addEventListener("pointerdown", this.onPointerDown);
1695
+ this.opts.host.addEventListener("click", this.onClickCapture, true);
1696
+ }
1697
+ closed() {
1698
+ this.opts.host.removeEventListener("pointerdown", this.onPointerDown);
1699
+ this.opts.host.removeEventListener("click", this.onClickCapture, true);
1700
+ this.stopTracking();
1701
+ this.drag = null;
1702
+ const sheet = this.opts.sheet();
1703
+ if (sheet) {
1704
+ sheet.style.transform = "";
1705
+ sheet.style.transition = "";
1706
+ }
1707
+ const backdrop = this.opts.backdrop?.();
1708
+ if (backdrop) {
1709
+ backdrop.style.opacity = "";
1710
+ backdrop.style.transition = "";
1711
+ }
1712
+ if (this.locked) {
1713
+ this.locked = false;
1714
+ unlockPageScroll();
1715
+ }
1716
+ }
1717
+ /** Give up on this press and put the sheet back where it was. */
1718
+ abandon() {
1719
+ const moving = this.drag?.moving;
1720
+ this.stopTracking();
1721
+ this.drag = null;
1722
+ if (!moving) return;
1723
+ this.release();
1724
+ const sheet = this.opts.sheet();
1725
+ if (sheet) sheet.style.transform = "";
1726
+ }
1727
+ /** Hand the sheet and backdrop back to their CSS transitions. */
1728
+ release() {
1729
+ const sheet = this.opts.sheet();
1730
+ if (sheet) sheet.style.transition = "";
1731
+ const backdrop = this.opts.backdrop?.();
1732
+ if (backdrop) {
1733
+ backdrop.style.transition = "";
1734
+ backdrop.style.opacity = "";
1735
+ }
1736
+ }
1737
+ stopTracking() {
1738
+ window.removeEventListener("pointermove", this.onPointerMove);
1739
+ window.removeEventListener("pointerup", this.onPointerUp);
1740
+ window.removeEventListener("pointercancel", this.onPointerCancel);
1741
+ }
1742
+ };
1743
+ function inZone(e, sheet, zones) {
1744
+ for (const node of e.composedPath()) {
1745
+ if (node === sheet) return false;
1746
+ if (node instanceof Element && node.matches(zones)) return sheet.contains(node);
1747
+ }
1748
+ return false;
1749
+ }
1750
+ var locks = 0;
1751
+ var saved = null;
1752
+ var LOCKED = ["position", "top", "left", "right", "width", "overflow"];
1753
+ function lockPageScroll() {
1754
+ if (typeof document === "undefined") return;
1755
+ locks += 1;
1756
+ if (locks > 1) return;
1757
+ const body = document.body;
1758
+ const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
1759
+ saved = { scrollY, body: {} };
1760
+ for (const p of LOCKED) saved.body[p] = body.style[p];
1761
+ body.style.position = "fixed";
1762
+ body.style.top = `-${scrollY}px`;
1763
+ body.style.left = "0";
1764
+ body.style.right = "0";
1765
+ body.style.width = "100%";
1766
+ body.style.overflow = "hidden";
1767
+ }
1768
+ function unlockPageScroll() {
1769
+ if (typeof document === "undefined" || locks === 0) return;
1770
+ locks -= 1;
1771
+ if (locks > 0 || !saved) return;
1772
+ const { scrollY, body } = saved;
1773
+ saved = null;
1774
+ for (const p of LOCKED) document.body.style[p] = body[p] ?? "";
1775
+ const root = document.documentElement;
1776
+ const behavior = root.style.scrollBehavior;
1777
+ root.style.scrollBehavior = "auto";
1778
+ window.scrollTo(0, scrollY);
1779
+ root.style.scrollBehavior = behavior;
1780
+ }
1781
+
1454
1782
  // src/ui/components/vio-product-detail.ts
1455
1783
  var STRIPE_LOGO_SRC = "data:image/svg+xml;utf8," + encodeURIComponent(
1456
1784
  '<svg width="360" height="150" viewBox="0 0 360 150" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M360 77.4001C360 51.8001 347.6 31.6001 323.9 31.6001C300.1 31.6001 285.7 51.8001 285.7 77.2001C285.7 107.3 302.7 122.5 327.1 122.5C339 122.5 348 119.8 354.8 116V96.0001C348 99.4001 340.2 101.5 330.3 101.5C320.6 101.5 312 98.1001 310.9 86.3001H359.8C359.8 85.0001 360 79.8001 360 77.4001ZM310.6 67.9001C310.6 56.6001 317.5 51.9001 323.8 51.9001C329.9 51.9001 336.4 56.6001 336.4 67.9001H310.6Z" fill="#533AFD"/><path fill-rule="evenodd" clip-rule="evenodd" d="M247.1 31.6001C237.3 31.6001 231 36.2001 227.5 39.4001L226.2 33.2001H204.2V149.8L229.2 144.5L229.3 116.2C232.9 118.8 238.2 122.5 247 122.5C264.9 122.5 281.2 108.1 281.2 76.4001C281.1 47.4001 264.6 31.6001 247.1 31.6001ZM241.1 100.5C235.2 100.5 231.7 98.4001 229.3 95.8001L229.2 58.7001C231.8 55.8001 235.4 53.8001 241.1 53.8001C250.2 53.8001 256.5 64.0001 256.5 77.1001C256.5 90.5001 250.3 100.5 241.1 100.5Z" fill="#533AFD"/><path fill-rule="evenodd" clip-rule="evenodd" d="M169.8 25.7L194.9 20.3V0L169.8 5.3V25.7Z" fill="#533AFD"/><path d="M194.9 33.3H169.8V120.8H194.9V33.3Z" fill="#533AFD"/><path fill-rule="evenodd" clip-rule="evenodd" d="M142.9 40.7L141.3 33.3H119.7V120.8H144.7V61.5C150.6 53.8 160.6 55.2 163.7 56.3V33.3C160.5 32.1 148.8 29.9 142.9 40.7Z" fill="#533AFD"/><path fill-rule="evenodd" clip-rule="evenodd" d="M92.8999 11.6001L68.4999 16.8001L68.3999 96.9001C68.3999 111.7 79.4999 122.6 94.2999 122.6C102.5 122.6 108.5 121.1 111.8 119.3V99.0001C108.6 100.3 92.7999 104.9 92.7999 90.1001V54.6001H111.8V33.3001H92.7999L92.8999 11.6001Z" fill="#533AFD"/><path fill-rule="evenodd" clip-rule="evenodd" d="M25.3 58.7001C25.3 54.8001 28.5 53.3001 33.8 53.3001C41.4 53.3001 51 55.6001 58.6 59.7001V36.2001C50.3 32.9001 42.1 31.6001 33.8 31.6001C13.5 31.6001 0 42.2001 0 59.9001C0 87.5001 38 83.1001 38 95.0001C38 99.6001 34 101.1 28.4 101.1C20.1 101.1 9.5 97.7001 1.1 93.1001V116.9C10.4 120.9 19.8 122.6 28.4 122.6C49.2 122.6 63.5 112.3 63.5 94.4001C63.4 64.6001 25.3 69.9001 25.3 58.7001Z" fill="#533AFD"/></svg>'
@@ -1485,6 +1813,10 @@ var VioProductDetail = class extends LitElement {
1485
1813
  this.selectedOptions = {};
1486
1814
  this.quantity = 1;
1487
1815
  this.activeImageIndex = 0;
1816
+ this.thumbsOverflow = false;
1817
+ this.thumbsAtStart = true;
1818
+ this.thumbsAtEnd = false;
1819
+ this.logoSquare = false;
1488
1820
  this.adding = false;
1489
1821
  this.applePayOk = false;
1490
1822
  this.availableMethods = null;
@@ -1502,6 +1834,14 @@ var VioProductDetail = class extends LitElement {
1502
1834
  container: () => this.renderRoot.querySelector(".modal"),
1503
1835
  close: () => this.close()
1504
1836
  });
1837
+ /** Phones: the page behind is locked, and the sheet closes by pulling the bar or the top bar down. */
1838
+ this.bottomSheet = new BottomSheet({
1839
+ host: this,
1840
+ sheet: () => this.renderRoot.querySelector(".modal"),
1841
+ backdrop: () => this.renderRoot.querySelector(".backdrop"),
1842
+ dragZones: ".handle, .topbar",
1843
+ close: () => this.close()
1844
+ });
1505
1845
  this.boundProductClick = (e) => {
1506
1846
  if (!this.autoOpen) return;
1507
1847
  const ev = e;
@@ -1529,6 +1869,14 @@ var VioProductDetail = class extends LitElement {
1529
1869
  this.show();
1530
1870
  void this.fetchProduct();
1531
1871
  };
1872
+ this.onThumbsScroll = () => {
1873
+ const row = this.thumbRow();
1874
+ if (!row) return;
1875
+ const atStart = row.scrollLeft <= 1;
1876
+ const atEnd = row.scrollLeft + row.clientWidth >= row.scrollWidth - 1;
1877
+ if (atStart !== this.thumbsAtStart) this.thumbsAtStart = atStart;
1878
+ if (atEnd !== this.thumbsAtEnd) this.thumbsAtEnd = atEnd;
1879
+ };
1532
1880
  }
1533
1881
  /** The card's context applies to the product it opened — not to one the host opened by hand later. */
1534
1882
  get fromCard() {
@@ -1616,7 +1964,10 @@ var VioProductDetail = class extends LitElement {
1616
1964
  height: 92vh;
1617
1965
  border-radius: var(--vio-radius-xl, 16px) var(--vio-radius-xl, 16px) 0 0;
1618
1966
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
1967
+ overscroll-behavior: contain;
1619
1968
  }
1969
+ /* Pulled down to close (bottom-sheet.ts): the browser must not take the gesture for a scroll. */
1970
+ .handle, .topbar { touch-action: none; }
1620
1971
  .handle {
1621
1972
  display: flex;
1622
1973
  justify-content: center;
@@ -1637,8 +1988,8 @@ var VioProductDetail = class extends LitElement {
1637
1988
  @media (min-width: 601px) {
1638
1989
  .modal {
1639
1990
  inset: 0 0 0 auto;
1640
- width: min(440px, 100%);
1641
- max-width: 440px;
1991
+ width: min(var(--vio-detail-width, 440px), 100%);
1992
+ max-width: var(--vio-detail-width, 440px);
1642
1993
  border-radius: 0;
1643
1994
  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.1);
1644
1995
  transform: translateX(100%);
@@ -1652,29 +2003,59 @@ var VioProductDetail = class extends LitElement {
1652
2003
  max-width: none;
1653
2004
  grid-template-columns: 1fr;
1654
2005
  gap: 20px;
1655
- padding: 20px 24px 40px;
2006
+ padding: var(--vio-detail-padding-top, 0px) var(--vio-detail-padding-x, var(--vio-space-lg, 24px)) 40px;
1656
2007
  }
1657
- .modal .name { font-size: 26px; line-height: 1.2; }
1658
- .modal .gallery-main { width: 100%; }
2008
+ .modal .name { font-size: var(--vio-detail-title-size, 26px); line-height: 1.2; }
2009
+ .modal .gallery-main { width: auto; }
1659
2010
  }
1660
2011
 
1661
2012
  .topbar {
1662
2013
  display: flex;
1663
2014
  justify-content: space-between;
1664
2015
  align-items: center;
1665
- padding: 20px var(--vio-space-lg, 24px);
2016
+ gap: 16px;
2017
+ min-height: var(--vio-detail-topbar-height, 64px);
2018
+ box-sizing: border-box;
2019
+ padding: 10px var(--vio-detail-padding-x, var(--vio-space-lg, 24px));
1666
2020
  position: sticky;
1667
2021
  top: 0;
1668
2022
  background: var(--vio-color-surface, #fff);
1669
2023
  border-bottom: 1px solid var(--vio-color-border, #e5e5e5);
1670
2024
  z-index: 1;
1671
2025
  }
1672
- .topbar-brand {
1673
- font-size: 12px;
2026
+ /* Who sells it, top left beside the close: the sponsor's logo, else its name. */
2027
+ .topbar-sponsor { flex: 1 1 auto; display: flex; align-items: center; gap: 10px; min-width: 0; }
2028
+ /* The logo box clips, so --vio-detail-logo-zoom can crop the white a
2029
+ square file carries around its mark. */
2030
+ .topbar-logo-box {
2031
+ flex: 0 0 auto;
2032
+ display: flex;
2033
+ align-items: center;
2034
+ height: var(--vio-detail-logo-height, 44px);
2035
+ max-width: var(--vio-detail-logo-max-width, 220px);
2036
+ overflow: hidden;
2037
+ }
2038
+ .topbar-logo {
2039
+ display: block;
2040
+ width: auto;
2041
+ height: 100%;
2042
+ max-width: var(--vio-detail-logo-max-width, 220px);
2043
+ object-fit: contain;
2044
+ object-position: left center;
2045
+ transform: scale(var(--vio-detail-logo-zoom, 1));
2046
+ transform-origin: center;
2047
+ }
2048
+ /* A square (or near) logo is a mark, not a wordmark: say whose it is. */
2049
+ .topbar-sponsor:not(.is-square) .topbar-mark-name { display: none; }
2050
+ .topbar-name {
2051
+ font-size: var(--vio-detail-logo-name-size, 13px);
1674
2052
  text-transform: uppercase;
1675
2053
  letter-spacing: var(--vio-tracking-label, 0.1em);
1676
2054
  color: var(--vio-color-text-secondary, #666);
1677
- font-weight: 500;
2055
+ font-weight: 600;
2056
+ white-space: nowrap;
2057
+ overflow: hidden;
2058
+ text-overflow: ellipsis;
1678
2059
  }
1679
2060
  .close {
1680
2061
  background: none;
@@ -1700,74 +2081,110 @@ var VioProductDetail = class extends LitElement {
1700
2081
  gap: 64px;
1701
2082
  }
1702
2083
  @media (max-width: 800px) {
1703
- .body { grid-template-columns: 1fr; gap: 32px; padding: 24px var(--vio-space-lg, 24px) 64px; }
2084
+ .body {
2085
+ grid-template-columns: 1fr;
2086
+ gap: 24px;
2087
+ padding: var(--vio-detail-padding-top, 0px) var(--vio-detail-padding-x, var(--vio-space-lg, 24px)) 64px;
2088
+ }
1704
2089
  }
1705
2090
 
1706
- /* Gallery */
1707
- .gallery { display: flex; flex-direction: column; gap: 16px; }
2091
+ /* Gallery. One fixed proportion for every image (4:5 by default) that the
2092
+ image fills (cover): no white bars, and the panel never jumps between
2093
+ photos of different shapes (Angelo, 2026-10-22). Its side margin is
2094
+ --vio-detail-image-inset (the panel's padding by default; 0 = edge to
2095
+ edge). Fit, proportion, a height cap and a zoom (to crop a white margin
2096
+ baked into the file) are the host's to set. */
2097
+ .gallery { display: flex; flex-direction: column; gap: var(--vio-detail-gallery-gap, 12px); min-width: 0; }
1708
2098
  .gallery-main {
1709
- aspect-ratio: 1;
1710
- background: var(--vio-color-surface-muted, #f2f2f2);
2099
+ position: relative;
2100
+ margin-inline: calc(
2101
+ var(--vio-detail-image-inset, var(--vio-detail-padding-x, var(--vio-space-lg, 24px))) -
2102
+ var(--vio-detail-padding-x, var(--vio-space-lg, 24px))
2103
+ );
2104
+ aspect-ratio: var(--vio-detail-image-aspect, 4 / 5);
2105
+ max-height: var(--vio-detail-image-max-height, 85vh);
2106
+ background: var(--vio-detail-image-bg, var(--vio-color-surface-muted, #f2f2f2));
2107
+ border-radius: var(--vio-detail-image-radius, 0);
1711
2108
  display: flex;
1712
2109
  align-items: center;
1713
2110
  justify-content: center;
1714
2111
  overflow: hidden;
1715
2112
  }
1716
2113
  .gallery-main img {
2114
+ display: block;
1717
2115
  width: 100%;
1718
2116
  height: 100%;
1719
- object-fit: contain;
1720
- padding: 24px;
1721
- box-sizing: border-box;
1722
- }
2117
+ max-height: var(--vio-detail-image-max-height, 85vh);
2118
+ object-fit: var(--vio-detail-image-fit, cover);
2119
+ object-position: var(--vio-detail-image-position, center);
2120
+ transform: scale(var(--vio-detail-image-zoom, 1));
2121
+ }
2122
+ /* Thumbnails: one row, never a second line — swipe on touch, arrows with a
2123
+ mouse; the selected one scrolls into view by itself. */
2124
+ .thumbs { position: relative; min-width: 0; }
1723
2125
  .gallery-thumbs {
1724
2126
  display: flex;
1725
- gap: 8px;
1726
- flex-wrap: wrap;
2127
+ flex-wrap: nowrap;
2128
+ gap: var(--vio-detail-thumb-gap, 8px);
2129
+ overflow-x: auto;
2130
+ scroll-snap-type: x proximity;
2131
+ scroll-behavior: smooth;
2132
+ scrollbar-width: none;
2133
+ -webkit-overflow-scrolling: touch;
1727
2134
  }
2135
+ .gallery-thumbs::-webkit-scrollbar { display: none; }
1728
2136
  .gallery-thumb {
1729
- width: 72px;
1730
- height: 72px;
2137
+ flex: 0 0 auto;
2138
+ width: var(--vio-detail-thumb-size, 64px);
2139
+ height: var(--vio-detail-thumb-size, 64px);
1731
2140
  background: var(--vio-color-surface-muted, #f2f2f2);
1732
- border: 1px solid transparent;
2141
+ border: var(--vio-detail-thumb-border-width, 1px) solid transparent;
2142
+ border-radius: var(--vio-detail-thumb-radius, 0);
1733
2143
  cursor: pointer;
1734
2144
  padding: 0;
1735
2145
  overflow: hidden;
2146
+ scroll-snap-align: start;
2147
+ box-sizing: border-box;
2148
+ opacity: var(--vio-detail-thumb-opacity, 1);
1736
2149
  }
1737
2150
  .gallery-thumb img {
2151
+ display: block;
1738
2152
  width: 100%;
1739
2153
  height: 100%;
1740
- object-fit: contain;
1741
- padding: 6px;
1742
- box-sizing: border-box;
2154
+ object-fit: var(--vio-detail-thumb-fit, cover);
1743
2155
  }
1744
- .gallery-thumb.active { border-color: var(--vio-color-text, #0a0a0a); }
1745
-
1746
- /* Who sells it: the sponsor's logo, or its name — small, above everything. */
1747
- .sponsor-header {
1748
- grid-column: 1 / -1;
1749
- display: flex;
1750
- align-items: center;
1751
- min-height: 24px;
2156
+ .gallery-thumb.active {
2157
+ border-color: var(--vio-detail-thumb-active-color, var(--vio-color-text, #0a0a0a));
2158
+ opacity: 1;
1752
2159
  }
1753
- .sponsor-logo {
1754
- display: block;
1755
- width: auto;
1756
- height: auto;
1757
- max-height: 28px;
1758
- max-width: 160px;
1759
- object-fit: contain;
2160
+ .thumbs-arrow {
2161
+ display: none;
2162
+ position: absolute;
2163
+ top: 50%;
2164
+ transform: translateY(-50%);
2165
+ z-index: 1;
2166
+ width: 28px;
2167
+ height: 28px;
2168
+ border-radius: 50%;
2169
+ border: 1px solid var(--vio-color-border, #e5e5e5);
2170
+ background: var(--vio-color-surface, #fff);
2171
+ color: var(--vio-color-text, #0a0a0a);
2172
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
2173
+ cursor: pointer;
2174
+ align-items: center;
2175
+ justify-content: center;
2176
+ padding: 0;
2177
+ font: 16px/1 sans-serif;
1760
2178
  }
1761
- .sponsor-name {
1762
- font-size: 12px;
1763
- text-transform: uppercase;
1764
- letter-spacing: var(--vio-tracking-label, 0.1em);
1765
- color: var(--vio-color-text-secondary, #666);
1766
- font-weight: 600;
2179
+ .thumbs-arrow.prev { left: -6px; }
2180
+ .thumbs-arrow.next { right: -6px; }
2181
+ .thumbs-arrow:disabled { visibility: hidden; }
2182
+ @media (hover: hover) and (pointer: fine) {
2183
+ .thumbs.overflows .thumbs-arrow { display: flex; }
1767
2184
  }
1768
2185
 
1769
2186
  /* Info column */
1770
- .info { display: flex; flex-direction: column; gap: 24px; }
2187
+ .info { display: flex; flex-direction: column; gap: var(--vio-detail-info-gap, 20px); min-width: 0; }
1771
2188
  .brand {
1772
2189
  font-size: 12px;
1773
2190
  text-transform: uppercase;
@@ -1777,8 +2194,8 @@ var VioProductDetail = class extends LitElement {
1777
2194
  }
1778
2195
  .name {
1779
2196
  font-family: var(--vio-font-serif, Georgia, serif);
1780
- font-size: clamp(28px, 4vw, 40px);
1781
- font-weight: 400;
2197
+ font-size: var(--vio-detail-title-size, clamp(28px, 4vw, 40px));
2198
+ font-weight: var(--vio-detail-title-weight, 400);
1782
2199
  line-height: 1.15;
1783
2200
  margin: 0;
1784
2201
  letter-spacing: -0.01em;
@@ -1789,8 +2206,8 @@ var VioProductDetail = class extends LitElement {
1789
2206
  gap: 16px;
1790
2207
  }
1791
2208
  .price {
1792
- font-size: 24px;
1793
- font-weight: 700;
2209
+ font-size: var(--vio-detail-price-size, 24px);
2210
+ font-weight: var(--vio-detail-price-weight, 700);
1794
2211
  }
1795
2212
  .compare-at {
1796
2213
  font-size: 16px;
@@ -1808,7 +2225,7 @@ var VioProductDetail = class extends LitElement {
1808
2225
  .stock.out-of-stock { color: var(--vio-color-text-tertiary, #999); }
1809
2226
 
1810
2227
  /* Option pickers */
1811
- .option-group { display: flex; flex-direction: column; gap: 8px; }
2228
+ .option-group { display: flex; flex-direction: column; gap: 6px; }
1812
2229
  .option-label {
1813
2230
  font-size: 11px;
1814
2231
  text-transform: uppercase;
@@ -1816,14 +2233,28 @@ var VioProductDetail = class extends LitElement {
1816
2233
  color: var(--vio-color-text-secondary, #666);
1817
2234
  font-weight: 500;
1818
2235
  }
1819
- .option-values { display: flex; flex-wrap: wrap; gap: 8px; }
2236
+ /* Sizes: compact, still comfortable to tap. wrap → several lines;
2237
+ nowrap → one line that scrolls sideways. */
2238
+ .option-values {
2239
+ display: flex;
2240
+ flex-wrap: var(--vio-detail-option-wrap, wrap);
2241
+ gap: var(--vio-detail-option-gap, 6px);
2242
+ overflow-x: auto;
2243
+ scrollbar-width: none;
2244
+ }
2245
+ .option-values::-webkit-scrollbar { display: none; }
1820
2246
  .option-pill {
1821
- padding: 10px 18px;
2247
+ flex: 0 0 auto;
2248
+ box-sizing: border-box;
2249
+ min-height: var(--vio-detail-option-height, 36px);
2250
+ min-width: var(--vio-detail-option-height, 36px);
2251
+ padding: 0 var(--vio-detail-option-padding-x, 12px);
2252
+ border-radius: var(--vio-detail-option-radius, 0);
1822
2253
  background: #fff;
1823
2254
  border: 1px solid var(--vio-color-border, #e5e5e5);
1824
2255
  cursor: pointer;
1825
2256
  font-family: inherit;
1826
- font-size: 13px;
2257
+ font-size: var(--vio-detail-option-font-size, 13px);
1827
2258
  font-weight: 500;
1828
2259
  color: inherit;
1829
2260
  transition: border-color 0.15s, background 0.15s;
@@ -1842,6 +2273,34 @@ var VioProductDetail = class extends LitElement {
1842
2273
  }
1843
2274
  .option-pill.unavailable:hover { border-color: var(--vio-color-border, #e5e5e5); }
1844
2275
 
2276
+ /* The buy zone: Vipps first, then quantity + add to cart, then the other
2277
+ ways to pay. --vio-detail-buy-position: sticky pins it to the bottom of
2278
+ the panel while the shopper scrolls (mobile, mostly). */
2279
+ .buy {
2280
+ display: flex;
2281
+ flex-direction: column;
2282
+ gap: var(--vio-detail-buy-gap, 10px);
2283
+ position: var(--vio-detail-buy-position, static);
2284
+ bottom: 0;
2285
+ z-index: 2;
2286
+ background: var(--vio-detail-buy-bg, var(--vio-color-surface, #fff));
2287
+ padding: var(--vio-detail-buy-padding, 0);
2288
+ box-shadow: var(--vio-detail-buy-shadow, none);
2289
+ }
2290
+ .buy > * { margin-top: 0 !important; }
2291
+ /* Under the buttons: who the shopper is buying from. */
2292
+ .seller-note {
2293
+ margin: 2px 0 0;
2294
+ font-size: var(--vio-detail-seller-note-size, 12px);
2295
+ line-height: 1.4;
2296
+ color: var(--vio-color-text-secondary, #666);
2297
+ text-align: var(--vio-detail-seller-note-align, center);
2298
+ }
2299
+ .seller-note strong { font-weight: 600; color: var(--vio-color-text, #0a0a0a); }
2300
+ .seller-links { display: block; margin-top: 2px; }
2301
+ .seller-links a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
2302
+ .seller-links a + a::before { content: '·'; display: inline-block; margin: 0 6px; text-decoration: none; }
2303
+
1845
2304
  /* Qty + CTA row */
1846
2305
  .qty-cta-row {
1847
2306
  display: grid;
@@ -1856,7 +2315,7 @@ var VioProductDetail = class extends LitElement {
1856
2315
  }
1857
2316
  .qty-btn {
1858
2317
  width: 44px;
1859
- height: 56px;
2318
+ height: var(--vio-detail-button-height, 52px);
1860
2319
  background: #fff;
1861
2320
  border: none;
1862
2321
  cursor: pointer;
@@ -1874,7 +2333,9 @@ var VioProductDetail = class extends LitElement {
1874
2333
  background: var(--vio-color-text, #0a0a0a);
1875
2334
  color: var(--vio-color-text-on-primary, #fff);
1876
2335
  border: none;
1877
- padding: 18px 24px;
2336
+ min-height: var(--vio-detail-button-height, 52px);
2337
+ border-radius: var(--vio-detail-button-radius, 0);
2338
+ padding: 0 24px;
1878
2339
  font-size: 12px;
1879
2340
  text-transform: uppercase;
1880
2341
  letter-spacing: var(--vio-tracking-label, 0.1em);
@@ -1984,19 +2445,49 @@ var VioProductDetail = class extends LitElement {
1984
2445
  .buy-applepay:disabled { opacity: 0.5; cursor: not-allowed; }
1985
2446
 
1986
2447
  /* Description */
2448
+ /* The description folds: a tap opens it, so it does not take the page. */
2449
+ .description-block { border-top: 1px solid var(--vio-color-border, #e5e5e5); }
2450
+ .description-block > summary {
2451
+ list-style: none;
2452
+ cursor: pointer;
2453
+ display: flex;
2454
+ align-items: center;
2455
+ justify-content: space-between;
2456
+ padding: 14px 0;
2457
+ font-size: 11px;
2458
+ text-transform: uppercase;
2459
+ letter-spacing: var(--vio-tracking-label, 0.1em);
2460
+ color: var(--vio-color-text-secondary, #666);
2461
+ font-weight: 600;
2462
+ }
2463
+ .description-block > summary::-webkit-details-marker { display: none; }
2464
+ .description-block > summary::after {
2465
+ content: '';
2466
+ width: 8px;
2467
+ height: 8px;
2468
+ border-right: 1.5px solid currentColor;
2469
+ border-bottom: 1.5px solid currentColor;
2470
+ transform: rotate(45deg) translateY(-2px);
2471
+ transition: transform 0.15s ease;
2472
+ }
2473
+ .description-block[open] > summary::after { transform: rotate(-135deg) translateY(-2px); }
1987
2474
  .description {
1988
2475
  font-size: 14px;
1989
2476
  line-height: 1.6;
1990
2477
  color: var(--vio-color-text-secondary, #444);
1991
- margin-top: 8px;
2478
+ padding-bottom: 14px;
2479
+ white-space: pre-line;
1992
2480
  }
1993
- .description-label {
1994
- font-size: 11px;
1995
- text-transform: uppercase;
1996
- letter-spacing: var(--vio-tracking-label, 0.1em);
1997
- color: var(--vio-color-text-secondary, #666);
1998
- font-weight: 500;
1999
- margin-bottom: 8px;
2481
+ /* Desktop: the description above, the buy zone at the bottom of the panel
2482
+ (sticky). Phones keep the order as it is: buy, then the description. */
2483
+ @media (min-width: 601px) {
2484
+ .description-block { order: 1; }
2485
+ .buy {
2486
+ order: 2;
2487
+ position: var(--vio-detail-buy-position, sticky);
2488
+ padding: var(--vio-detail-buy-padding, 12px 0 16px);
2489
+ box-shadow: var(--vio-detail-buy-shadow, 0 -1px 0 var(--vio-color-border, #e5e5e5));
2490
+ }
2000
2491
  }
2001
2492
 
2002
2493
  /* Skeleton loader: shimmer placeholders mirroring the product layout
@@ -2052,14 +2543,47 @@ var VioProductDetail = class extends LitElement {
2052
2543
  disconnectedCallback() {
2053
2544
  document.removeEventListener("vio:product-click", this.boundProductClick);
2054
2545
  this.slotRegistration.clear();
2546
+ this.bottomSheet.disconnect();
2055
2547
  super.disconnectedCallback();
2056
2548
  }
2057
2549
  updated(changed) {
2058
- if (changed.has("open")) this.dialogFocus.update(this.open);
2550
+ if (changed.has("open")) {
2551
+ this.dialogFocus.update(this.open);
2552
+ this.bottomSheet.update(this.open);
2553
+ }
2554
+ if (changed.has("product") || changed.has("open")) this.measureThumbs();
2555
+ if (changed.has("activeImageIndex")) this.revealActiveThumb();
2556
+ this.measureLogo();
2059
2557
  if (changed.has("vioSlot") || changed.has("vioSlotName") || changed.has("vioScope") || changed.has("vioScopeName") || changed.has("vioCampaign")) {
2060
2558
  this.slotRegistration.sync(this.slotAttributes());
2061
2559
  }
2062
2560
  }
2561
+ thumbRow() {
2562
+ return this.renderRoot.querySelector(".gallery-thumbs");
2563
+ }
2564
+ /** Arrows only when the row overflows; each one off at its own end. */
2565
+ measureThumbs() {
2566
+ const row = this.thumbRow();
2567
+ if (!row) return;
2568
+ const overflow = row.scrollWidth > row.clientWidth + 1;
2569
+ if (overflow !== this.thumbsOverflow) this.thumbsOverflow = overflow;
2570
+ this.onThumbsScroll();
2571
+ }
2572
+ /** One arrow press moves most of a visible row. */
2573
+ scrollThumbs(direction) {
2574
+ const row = this.thumbRow();
2575
+ if (row) row.scrollBy({ left: direction * row.clientWidth * 0.8 });
2576
+ }
2577
+ /** The selected thumbnail is always visible: the row scrolls to it, the page never does. */
2578
+ revealActiveThumb() {
2579
+ const row = this.thumbRow();
2580
+ const thumb = row?.querySelectorAll(".gallery-thumb")[this.activeImageIndex];
2581
+ if (!row || !thumb) return;
2582
+ const left = thumb.offsetLeft - row.offsetLeft;
2583
+ if (left < row.scrollLeft || left + thumb.offsetWidth > row.scrollLeft + row.clientWidth) {
2584
+ row.scrollTo({ left: Math.max(0, left - (row.clientWidth - thumb.offsetWidth) / 2) });
2585
+ }
2586
+ }
2063
2587
  show() {
2064
2588
  this.open = true;
2065
2589
  }
@@ -2546,20 +3070,26 @@ var VioProductDetail = class extends LitElement {
2546
3070
  }
2547
3071
  buyWithVipps() {
2548
3072
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3073
+ const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: this.sponsorId, source: "detail" });
2549
3074
  this.addCurrentToCart();
2550
3075
  this.close();
2551
- void this.startVippsExpressOrCheckout();
3076
+ void this.startVippsExpressOrCheckout(redirect);
2552
3077
  }
2553
- async startVippsExpressOrCheckout() {
3078
+ async startVippsExpressOrCheckout(redirect) {
2554
3079
  let fallback = true;
2555
3080
  try {
2556
- if (!await Vio.checkout.getVippsExpressEnabled(this.sponsorId)) throw new Error("express off");
2557
- const res = await Vio.checkout.startVippsExpress(this.sponsorId);
2558
- fallback = !res?.payment_url;
3081
+ if (!await Vio.checkout.getVippsExpressEnabled(this.sponsorId)) {
3082
+ redirect.abort("express-off");
3083
+ } else {
3084
+ const res = await Vio.checkout.startVippsExpress(this.sponsorId);
3085
+ fallback = !res?.payment_url;
3086
+ if (fallback) redirect.abort("not-started");
3087
+ }
2559
3088
  } catch (err) {
2560
3089
  if (typeof console !== "undefined") {
2561
3090
  console.warn("[VioProductDetail] Vipps Express not started:", err);
2562
3091
  }
3092
+ redirect.fail(err instanceof Error ? err.message : String(err));
2563
3093
  }
2564
3094
  if (!fallback) return;
2565
3095
  this.dispatchEvent(
@@ -2620,28 +3150,67 @@ var VioProductDetail = class extends LitElement {
2620
3150
  this.sellerSettled = true;
2621
3151
  }
2622
3152
  /**
2623
- * Who sells the product: the brand of the sponsor it was fetched with —
2624
- * its logo, with the name as `alt`. Without a logo that loads, the topbar
2625
- * already names the seller (its Company name, else the brand), so the name
2626
- * goes here only when the topbar names nobody once the seller is known —
2627
- * never the same name twice. Nothing at all when no identity is known.
3153
+ * Who sells the product, top left beside the close (Mote & Livsstil,
3154
+ * 2026-10-22): the logo of the sponsor the product was fetched with — the
3155
+ * product's brand, never the article's — with its name as `alt`; without a
3156
+ * logo that loads, its name; without an identity, the seller's Company name
3157
+ * (or the product's brand) once known. Size: --vio-detail-logo-height.
2628
3158
  */
2629
- renderSponsorHeader() {
3159
+ renderTopbarSponsor() {
2630
3160
  const identity = resolveSponsorIdentity(this.sponsorId);
2631
- if (!identity) return nothing;
2632
- if (!usableLogo(identity) && (!this.sellerSettled || this.headerName())) return nothing;
2633
- const mark = renderSponsorMark(this, identity, { logo: "sponsor-logo", name: "sponsor-name" });
2634
- if (mark === nothing) return nothing;
2635
- return html`<div class="sponsor-header" part="sponsor">${mark}</div>`;
3161
+ const mark = identity ? renderSponsorMark(this, identity, { logo: "topbar-logo", name: "topbar-name" }) : nothing;
3162
+ if (mark !== nothing) {
3163
+ if (identity && usableLogo(identity)) {
3164
+ return html`<span class="topbar-logo-box">${mark}</span
3165
+ ><span class="topbar-name topbar-mark-name">${identity.name}</span>`;
3166
+ }
3167
+ return mark;
3168
+ }
3169
+ const name = this.headerName();
3170
+ return name ? html`<span class="topbar-name">${name}</span>` : nothing;
3171
+ }
3172
+ /**
3173
+ * Under the buy buttons: «Du handler direkte hos {brand}.» — the brand the
3174
+ * product was fetched with (the sponsor; its Company name or the product's
3175
+ * brand when no identity is known), never the article's. Nothing when no
3176
+ * name is known.
3177
+ */
3178
+ renderSellerNote() {
3179
+ const identity = resolveSponsorIdentity(this.sponsorId);
3180
+ const name = identity?.name || this.headerName();
3181
+ if (!name) return nothing;
3182
+ const links = [
3183
+ identity?.termsUrl ? html`<a href=${identity.termsUrl} target="_blank" rel="noopener noreferrer" part="seller-terms">Kjøpsvilkår</a>` : null,
3184
+ identity?.refundPolicyUrl ? html`<a href=${identity.refundPolicyUrl} target="_blank" rel="noopener noreferrer" part="seller-refund">Angrerett og retur</a>` : null
3185
+ ].filter(Boolean);
3186
+ return html`<p class="seller-note" part="seller-note">
3187
+ Du handler direkte hos <strong>${name}</strong>.${links.length ? html`<span class="seller-links">${links}</span>` : nothing}
3188
+ </p>`;
3189
+ }
3190
+ /** Square-ish logo (4:5 to 5:4) → the top bar shows the name beside it. */
3191
+ measureLogo() {
3192
+ const img = this.renderRoot.querySelector(".topbar-logo");
3193
+ if (!img) {
3194
+ if (this.logoSquare) this.logoSquare = false;
3195
+ return;
3196
+ }
3197
+ const decide = () => {
3198
+ if (!img.naturalWidth || !img.naturalHeight) return;
3199
+ const ratio = img.naturalWidth / img.naturalHeight;
3200
+ const square = ratio >= 0.8 && ratio <= 1.25;
3201
+ if (square !== this.logoSquare) this.logoSquare = square;
3202
+ };
3203
+ if (img.complete) decide();
3204
+ else img.addEventListener("load", decide, { once: true });
2636
3205
  }
2637
3206
  render() {
2638
3207
  return html`
2639
3208
  <div class="backdrop" @click=${this.close}></div>
2640
3209
  <div class="modal" role="dialog" aria-modal="true" aria-label="Produkt" tabindex="-1">
2641
3210
  <div class="handle" @click=${this.close} aria-hidden="true"></div>
2642
- <div class="topbar">
2643
- <span class="topbar-brand">${this.headerName()}</span>
2644
- <button class="close" @click=${this.close} aria-label="Lukk">×</button>
3211
+ <div class="topbar" part="topbar">
3212
+ <div class="topbar-sponsor ${this.logoSquare ? "is-square" : ""}" part="sponsor">${this.renderTopbarSponsor()}</div>
3213
+ <button class="close" part="close" @click=${this.close} aria-label="Lukk">×</button>
2645
3214
  </div>
2646
3215
 
2647
3216
  ${this.renderBody()}
@@ -2673,45 +3242,90 @@ var VioProductDetail = class extends LitElement {
2673
3242
  const activeImage = this.activeImage;
2674
3243
  return html`
2675
3244
  <div class="body">
2676
- ${this.renderSponsorHeader()}
2677
- <div class="gallery">
2678
- <div class="gallery-main">
3245
+ <div class="gallery" part="gallery">
3246
+ <div class="gallery-main" part="image">
2679
3247
  ${activeImage?.url ? html`<img src=${activeImage.url} alt=${p.title} />` : ""}
2680
3248
  </div>
2681
3249
  ${images.length > 1 ? html`
2682
- <div class="gallery-thumbs">
2683
- ${images.map(
3250
+ <div class="thumbs ${this.thumbsOverflow ? "overflows" : ""}">
3251
+ <button
3252
+ class="thumbs-arrow prev"
3253
+ part="thumbs-arrow"
3254
+ aria-label="Forrige bilder"
3255
+ ?disabled=${this.thumbsAtStart}
3256
+ @click=${() => this.scrollThumbs(-1)}
3257
+ >‹</button>
3258
+ <div class="gallery-thumbs" part="thumbs" @scroll=${this.onThumbsScroll}>
3259
+ ${images.map(
2684
3260
  (img, idx) => html`
2685
- <button
2686
- class="gallery-thumb ${idx === this.activeImageIndex ? "active" : ""}"
2687
- @click=${() => this.activeImageIndex = idx}
2688
- aria-label=${`Bilde ${idx + 1}`}
2689
- >
2690
- <img src=${img.url} alt="" />
2691
- </button>
2692
- `
3261
+ <button
3262
+ class="gallery-thumb ${idx === this.activeImageIndex ? "active" : ""}"
3263
+ part=${idx === this.activeImageIndex ? "thumb thumb-active" : "thumb"}
3264
+ @click=${() => this.activeImageIndex = idx}
3265
+ aria-label=${`Bilde ${idx + 1}`}
3266
+ aria-current=${idx === this.activeImageIndex ? "true" : "false"}
3267
+ >
3268
+ <img src=${img.url} alt="" loading="lazy" />
3269
+ </button>
3270
+ `
2693
3271
  )}
3272
+ </div>
3273
+ <button
3274
+ class="thumbs-arrow next"
3275
+ part="thumbs-arrow"
3276
+ aria-label="Neste bilder"
3277
+ ?disabled=${this.thumbsAtEnd}
3278
+ @click=${() => this.scrollThumbs(1)}
3279
+ >›</button>
2694
3280
  </div>
2695
3281
  ` : ""}
2696
3282
  </div>
2697
3283
 
2698
- <div class="info">
2699
- ${p.brand ? html`<div class="brand">${p.brand}</div>` : ""}
2700
- <h2 class="name">${p.title}</h2>
3284
+ <div class="info" part="info">
3285
+ ${p.brand ? html`<div class="brand" part="brand">${p.brand}</div>` : ""}
3286
+ <h2 class="name" part="title">${p.title}</h2>
2701
3287
 
2702
3288
  <div class="price-row">
2703
- <span class="price">${formatPrice(this.unitPrice, p.price.currency_code)}</span>
3289
+ <span class="price" part="price">${formatPrice(this.unitPrice, p.price.currency_code)}</span>
2704
3290
  ${showCompare ? html`<span class="compare-at">${formatPrice(
2705
3291
  compare,
2706
3292
  p.price.currency_code
2707
3293
  )}</span>` : ""}
2708
3294
  </div>
2709
3295
 
2710
- <div class="stock ${stock.cls}">${stock.text}</div>
3296
+ <div class="stock ${stock.cls}" part="stock">${stock.text}</div>
2711
3297
 
2712
3298
  ${this.renderOptionPickers(p.options)}
2713
3299
 
2714
- <div class="qty-cta-row">
3300
+ <div class="buy" part="buy">
3301
+ ${this.availableQuantity > 0 && this.methodEnabled("vipps") ? this.vippsOfficial ? html`
3302
+ <vipps-mobilepay-button
3303
+ class="buy-vipps-official"
3304
+ part="buy-vipps"
3305
+ brand="vipps"
3306
+ language="no"
3307
+ verb="buy"
3308
+ variant="primary"
3309
+ rounded="true"
3310
+ stretched="true"
3311
+ role="button"
3312
+ aria-label="Kjøp nå med Vipps"
3313
+ @click=${this.buyWithVipps}
3314
+ ></vipps-mobilepay-button>
3315
+ ` : html`
3316
+ <button
3317
+ class="buy-vipps"
3318
+ part="buy-vipps"
3319
+ @click=${this.buyWithVipps}
3320
+ ?disabled=${this.adding}
3321
+ aria-label="Kjøp nå med Vipps"
3322
+ >
3323
+ <span>Kjøp nå med</span>
3324
+ <span class="vipps-badge">vipps</span>
3325
+ </button>
3326
+ ` : ""}
3327
+
3328
+ <div class="qty-cta-row" part="quantity-row">
2715
3329
  <div class="qty-stepper">
2716
3330
  <button
2717
3331
  class="qty-btn"
@@ -2732,6 +3346,7 @@ var VioProductDetail = class extends LitElement {
2732
3346
  </div>
2733
3347
  <button
2734
3348
  class="add-to-cart"
3349
+ part="add-to-cart"
2735
3350
  @click=${this.addToCart}
2736
3351
  ?disabled=${this.availableQuantity <= 0 || this.adding}
2737
3352
  >
@@ -2793,36 +3408,14 @@ var VioProductDetail = class extends LitElement {
2793
3408
  </button>
2794
3409
  ` : ""}
2795
3410
 
2796
- ${this.availableQuantity > 0 && this.methodEnabled("vipps") ? this.vippsOfficial ? html`
2797
- <vipps-mobilepay-button
2798
- class="buy-vipps-official"
2799
- brand="vipps"
2800
- language="no"
2801
- verb="buy"
2802
- variant="primary"
2803
- rounded="true"
2804
- stretched="true"
2805
- role="button"
2806
- aria-label="Kjøp nå med Vipps"
2807
- @click=${this.buyWithVipps}
2808
- ></vipps-mobilepay-button>
2809
- ` : html`
2810
- <button
2811
- class="buy-vipps"
2812
- @click=${this.buyWithVipps}
2813
- ?disabled=${this.adding}
2814
- aria-label="Kjøp nå med Vipps"
2815
- >
2816
- <span>Kjøp nå med</span>
2817
- <span class="vipps-badge">vipps</span>
2818
- </button>
2819
- ` : ""}
3411
+ ${this.renderSellerNote()}
3412
+ </div>
2820
3413
 
2821
3414
  ${p.description ? html`
2822
- <div>
2823
- <div class="description-label">Beskrivelse</div>
3415
+ <details class="description-block" part="description">
3416
+ <summary>Beskrivelse</summary>
2824
3417
  <div class="description">${this.stripHtml(p.description)}</div>
2825
- </div>
3418
+ </details>
2826
3419
  ` : ""}
2827
3420
  </div>
2828
3421
  </div>
@@ -2834,9 +3427,9 @@ var VioProductDetail = class extends LitElement {
2834
3427
  const values = normalizeValues(opt.values);
2835
3428
  if (values.length === 0) return "";
2836
3429
  return html`
2837
- <div class="option-group">
2838
- <div class="option-label">${opt.name}</div>
2839
- <div class="option-values">
3430
+ <div class="option-group" part="option-group">
3431
+ <div class="option-label" part="option-label">${opt.name}</div>
3432
+ <div class="option-values" part="options">
2840
3433
  ${values.map((val) => {
2841
3434
  const available = this.isValueAvailable(opt.name, val);
2842
3435
  const currentVal = this.selectedOptions[opt.name] ?? "";
@@ -2844,6 +3437,7 @@ var VioProductDetail = class extends LitElement {
2844
3437
  return html`
2845
3438
  <button
2846
3439
  class="option-pill ${available ? "" : "unavailable"}"
3440
+ part=${isSelected ? "option option-selected" : "option"}
2847
3441
  @click=${() => this.selectOption(opt.name, val)}
2848
3442
  aria-pressed=${isSelected}
2849
3443
  ?disabled=${!available}
@@ -2923,6 +3517,18 @@ __decorateClass([
2923
3517
  __decorateClass([
2924
3518
  state()
2925
3519
  ], VioProductDetail.prototype, "activeImageIndex", 2);
3520
+ __decorateClass([
3521
+ state()
3522
+ ], VioProductDetail.prototype, "thumbsOverflow", 2);
3523
+ __decorateClass([
3524
+ state()
3525
+ ], VioProductDetail.prototype, "thumbsAtStart", 2);
3526
+ __decorateClass([
3527
+ state()
3528
+ ], VioProductDetail.prototype, "thumbsAtEnd", 2);
3529
+ __decorateClass([
3530
+ state()
3531
+ ], VioProductDetail.prototype, "logoSquare", 2);
2926
3532
  __decorateClass([
2927
3533
  state()
2928
3534
  ], VioProductDetail.prototype, "adding", 2);
@@ -2974,6 +3580,14 @@ var VioCart = class extends LitElement {
2974
3580
  container: () => this.renderRoot.querySelector(".drawer"),
2975
3581
  close: () => this.close()
2976
3582
  });
3583
+ /** Phones: the page behind is locked, and the sheet closes by pulling the bar or the header down. */
3584
+ this.bottomSheet = new BottomSheet({
3585
+ host: this,
3586
+ sheet: () => this.renderRoot.querySelector(".drawer"),
3587
+ backdrop: () => this.renderRoot.querySelector(".backdrop"),
3588
+ dragZones: ".handle, .header",
3589
+ close: () => this.close()
3590
+ });
2977
3591
  this.boundOnCartChange = (e) => {
2978
3592
  const detail = e.detail;
2979
3593
  this.carts = detail.cartsBySponsor;
@@ -3004,7 +3618,7 @@ var VioCart = class extends LitElement {
3004
3618
  top: 0;
3005
3619
  right: 0;
3006
3620
  bottom: 0;
3007
- width: min(440px, 100%);
3621
+ width: min(var(--vio-cart-width, 440px), 100%);
3008
3622
  background: var(--vio-color-surface, #fff);
3009
3623
  transform: translateX(100%);
3010
3624
  transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
@@ -3033,8 +3647,11 @@ var VioCart = class extends LitElement {
3033
3647
  border-radius: var(--vio-radius-xl, 16px) var(--vio-radius-xl, 16px) 0 0;
3034
3648
  transform: translateY(100%);
3035
3649
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
3650
+ overscroll-behavior: contain;
3036
3651
  }
3037
3652
  :host([open]) .drawer { transform: translateY(0); }
3653
+ /* Pulled down to close (bottom-sheet.ts): the browser must not take the gesture for a scroll. */
3654
+ .handle, .header { touch-action: none; }
3038
3655
  .handle {
3039
3656
  display: flex;
3040
3657
  justify-content: center;
@@ -3354,6 +3971,7 @@ var VioCart = class extends LitElement {
3354
3971
  }
3355
3972
  disconnectedCallback() {
3356
3973
  Vio.cart.removeEventListener("change", this.boundOnCartChange);
3974
+ this.bottomSheet.disconnect();
3357
3975
  super.disconnectedCallback();
3358
3976
  }
3359
3977
  close() {
@@ -3400,7 +4018,10 @@ var VioCart = class extends LitElement {
3400
4018
  return isMethodEnabled(this.availableMethods, ...names);
3401
4019
  }
3402
4020
  updated(changed) {
3403
- if (changed.has("open")) this.dialogFocus.update(this.open);
4021
+ if (changed.has("open")) {
4022
+ this.dialogFocus.update(this.open);
4023
+ this.bottomSheet.update(this.open);
4024
+ }
3404
4025
  if (changed.has("open") && this.open) {
3405
4026
  this.confirmedOrder = null;
3406
4027
  this.preparedTotal = -1;
@@ -3519,23 +4140,29 @@ var VioCart = class extends LitElement {
3519
4140
  if (this.itemCount === 0) return;
3520
4141
  const firstSponsorId = [...this.carts.keys()][0];
3521
4142
  if (firstSponsorId === void 0) return;
3522
- void this.startVippsExpressOrCheckout(firstSponsorId);
4143
+ const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: firstSponsorId, source: "cart" });
4144
+ void this.startVippsExpressOrCheckout(firstSponsorId, redirect);
3523
4145
  }
3524
4146
  /** Vipps' own button when its script answers; our orange one otherwise. */
3525
4147
  async loadVippsButton() {
3526
4148
  this.vippsOfficial = await ensureVippsButton();
3527
4149
  }
3528
- async startVippsExpressOrCheckout(sponsorId) {
4150
+ async startVippsExpressOrCheckout(sponsorId, redirect) {
3529
4151
  let fallback = true;
3530
4152
  try {
3531
4153
  await Vio.cart.waitForCartMutations(sponsorId);
3532
- if (!await Vio.checkout.getVippsExpressEnabled(sponsorId)) throw new Error("express off");
3533
- const res = await Vio.checkout.startVippsExpress(sponsorId);
3534
- fallback = !res?.payment_url;
4154
+ if (!await Vio.checkout.getVippsExpressEnabled(sponsorId)) {
4155
+ redirect.abort("express-off");
4156
+ } else {
4157
+ const res = await Vio.checkout.startVippsExpress(sponsorId);
4158
+ fallback = !res?.payment_url;
4159
+ if (fallback) redirect.abort("not-started");
4160
+ }
3535
4161
  } catch (err) {
3536
4162
  if (typeof console !== "undefined") {
3537
4163
  console.warn("[VioCart] Vipps Express not started:", err);
3538
4164
  }
4165
+ redirect.fail(err instanceof Error ? err.message : String(err));
3539
4166
  }
3540
4167
  if (!fallback) return;
3541
4168
  this.dispatchEvent(
@@ -3941,6 +4568,15 @@ var _VioCheckout = class _VioCheckout extends LitElement {
3941
4568
  close: () => this.close(),
3942
4569
  canClose: () => !this.paymentInFlight
3943
4570
  });
4571
+ /** Phones: the page behind is locked, and the sheet closes by pulling the bar or the top bar down — not mid-payment. */
4572
+ this.bottomSheet = new BottomSheet({
4573
+ host: this,
4574
+ sheet: () => this.renderRoot.querySelector(".modal"),
4575
+ backdrop: () => this.renderRoot.querySelector(".backdrop"),
4576
+ dragZones: ".handle, .topbar",
4577
+ close: () => this.close(),
4578
+ canClose: () => !this.paymentInFlight
4579
+ });
3944
4580
  this.paymentMethodsResolved = false;
3945
4581
  /** Guards `autoSelectSoleMethod` to ONE attempt per opening — see there. */
3946
4582
  this.autoSelectAttempted = false;
@@ -4093,8 +4729,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4093
4729
  from the right instead of the full-screen overlay. */
4094
4730
  .modal.as-side {
4095
4731
  inset: 0 0 0 auto;
4096
- width: min(440px, 100%);
4097
- max-width: 440px;
4732
+ width: min(var(--vio-checkout-width, 440px), 100%);
4733
+ max-width: var(--vio-checkout-width, 440px);
4098
4734
  transform: translateX(100%);
4099
4735
  border-radius: 0;
4100
4736
  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.1);
@@ -4109,8 +4745,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4109
4745
  @media (min-width: 601px) {
4110
4746
  .modal {
4111
4747
  inset: 0 0 0 auto;
4112
- width: min(440px, 100%);
4113
- max-width: 440px;
4748
+ width: min(var(--vio-checkout-width, 440px), 100%);
4749
+ max-width: var(--vio-checkout-width, 440px);
4114
4750
  transform: translateX(100%);
4115
4751
  border-radius: 0;
4116
4752
  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.1);
@@ -4147,7 +4783,10 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4147
4783
  height: 92vh;
4148
4784
  border-radius: var(--vio-radius-xl, 16px) var(--vio-radius-xl, 16px) 0 0;
4149
4785
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
4786
+ overscroll-behavior: contain;
4150
4787
  }
4788
+ /* Pulled down to close (bottom-sheet.ts): the browser must not take the gesture for a scroll. */
4789
+ .handle, .topbar { touch-action: none; }
4151
4790
  .handle {
4152
4791
  display: flex;
4153
4792
  justify-content: center;
@@ -4522,7 +5161,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4522
5161
  width: 48px;
4523
5162
  height: 48px;
4524
5163
  border-radius: 50%;
4525
- background: #2e7d32;
5164
+ background: var(--vio-confirm-check-bg, #2e7d32);
4526
5165
  color: #fff;
4527
5166
  display: flex;
4528
5167
  align-items: center;
@@ -4532,8 +5171,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4532
5171
  }
4533
5172
  .confirm-title {
4534
5173
  font-family: var(--vio-font-serif, Georgia, serif);
4535
- font-size: 24px;
4536
- font-weight: 400;
5174
+ font-size: var(--vio-confirm-title-size, 24px);
5175
+ font-weight: var(--vio-confirm-title-weight, 400);
4537
5176
  margin: 0;
4538
5177
  }
4539
5178
  /* The brand the order is with: its logo over the thanks, its name in it. */
@@ -4541,13 +5180,12 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4541
5180
  .confirm-brand-logo {
4542
5181
  display: block;
4543
5182
  width: auto;
4544
- height: auto;
4545
- max-height: 32px;
4546
- max-width: 160px;
5183
+ height: var(--vio-confirm-logo-height, 40px);
5184
+ max-width: var(--vio-confirm-logo-max-width, 220px);
4547
5185
  object-fit: contain;
4548
5186
  }
4549
5187
  .confirm-sponsor {
4550
- font-size: 14px;
5188
+ font-size: var(--vio-confirm-text-size, 14px);
4551
5189
  line-height: 1.4;
4552
5190
  color: var(--vio-color-text, #0a0a0a);
4553
5191
  margin: 4px 0 0;
@@ -4771,6 +5409,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4771
5409
  } else if (outcome === "failed") {
4772
5410
  this.paymentError = "Betalingen ble avbrutt eller feilet. Vennligst pr\xF8v igjen.";
4773
5411
  this.open = true;
5412
+ returnCancelled({ method: vioMethod, sponsorId, source: "checkout", checkoutId });
4774
5413
  } else if (sawStatus) {
4775
5414
  this.paymentNotice = "Betalingen behandles fortsatt. Du f\xE5r ordrebekreftelse p\xE5 e-post n\xE5r den er gjennomf\xF8rt. Handlekurven er uendret.";
4776
5415
  this.open = true;
@@ -5113,10 +5752,14 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5113
5752
  this.unmountNexi();
5114
5753
  this.unmountAdyen();
5115
5754
  for (const settle of [...this.pendingSuccessEmits]) settle();
5755
+ this.bottomSheet.disconnect();
5116
5756
  super.disconnectedCallback();
5117
5757
  }
5118
5758
  updated(changed) {
5119
- if (changed.has("open")) this.dialogFocus.update(this.open);
5759
+ if (changed.has("open")) {
5760
+ this.dialogFocus.update(this.open);
5761
+ this.bottomSheet.update(this.open);
5762
+ }
5120
5763
  if (changed.has("open") && this.open && this.items.length > 0 && this.availableShippingsList.length === 0) {
5121
5764
  void this.loadAvailableShippings();
5122
5765
  }
@@ -5569,10 +6212,12 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5569
6212
  }
5570
6213
  if (method === "vipps") {
5571
6214
  this.vippsLoading = true;
6215
+ const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: this.checkoutState?.sponsorId, source: "checkout" });
5572
6216
  void this.startVippsFromCheckout().catch((err) => {
5573
6217
  if (typeof console !== "undefined") {
5574
6218
  console.warn("[VioCheckout] Vipps payment could not start:", err);
5575
6219
  }
6220
+ redirect.fail(errorText(err));
5576
6221
  this.paymentError = friendlyPaymentError(errorText(err));
5577
6222
  }).finally(() => {
5578
6223
  this.vippsLoading = false;
@@ -5657,12 +6302,14 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5657
6302
  * `visibilitychange`) or the element leaves, the event goes out at once
5658
6303
  * without the reference. Exactly one event per call. `afterwards` runs
5659
6304
  * right after the event, so a path keeps the order it always had (the cart
5660
- * is cleared after the event).
6305
+ * is cleared after the event). When that answer already has Vio's order
6306
+ * number, the event carries it too (`orderNumber`): it is the purchase's
6307
+ * `order_id`, and analytics need not ask again.
5661
6308
  */
5662
6309
  async emitPaymentSuccess(detail, checkoutId, sponsorId, afterwards) {
5663
6310
  let done = false;
5664
6311
  const sponsorAtStart = this.sponsorOfOrder(sponsorId, checkoutId);
5665
- const finish = (buyerRef) => {
6312
+ const finish = (found = {}) => {
5666
6313
  if (done) return;
5667
6314
  done = true;
5668
6315
  const named = this.sponsorOfOrder(sponsorId, checkoutId);
@@ -5671,7 +6318,11 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5671
6318
  const event = new CustomEvent("vio:payment-success", {
5672
6319
  bubbles: true,
5673
6320
  composed: true,
5674
- detail: buyerRef ? { ...withSponsor, buyerRef } : withSponsor
6321
+ detail: found.buyerRef || found.orderNumber ? {
6322
+ ...withSponsor,
6323
+ ...found.buyerRef ? { buyerRef: found.buyerRef } : {},
6324
+ ...found.orderNumber ? { orderNumber: found.orderNumber } : {}
6325
+ } : withSponsor
5675
6326
  });
5676
6327
  this.dispatchEvent(event);
5677
6328
  if (!this.isConnected && typeof window !== "undefined") {
@@ -5684,7 +6335,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5684
6335
  };
5685
6336
  const known = buyerRefOf(detail);
5686
6337
  if (known || !checkoutId || !this.shouldAskBuyerRef(detail)) {
5687
- finish(known);
6338
+ finish({ buyerRef: known });
5688
6339
  return;
5689
6340
  }
5690
6341
  const settleEarly = () => finish();
@@ -5716,8 +6367,9 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5716
6367
  * failed or has neither the reference nor an order yet (the webhook has not
5717
6368
  * written the checkout), again at 1.2 s — all within 2.5 s. An order
5718
6369
  * without a reference is a settled answer: the backend has none, asking
5719
- * again cannot help. `undefined` when the time is up, when the answer
5720
- * stopped mattering (`stopped()`) or on failure.
6370
+ * again cannot help. Whatever the answer said of the order number comes
6371
+ * back with it. `{}` when the time is up, when the answer stopped mattering
6372
+ * (`stopped()`) or on failure.
5721
6373
  */
5722
6374
  async askBuyerRef(checkoutId, sponsorId, stopped) {
5723
6375
  const started = Date.now();
@@ -5730,19 +6382,20 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5730
6382
  for (const at of [0, _VioCheckout.BUYER_REF_RETRY_AT_MS]) {
5731
6383
  const wait = Math.min(at - (Date.now() - started), remaining());
5732
6384
  if (wait > 0) await Promise.race([new Promise((resolve) => setTimeout(resolve, wait)), timeUp]);
5733
- if (stopped() || remaining() <= 0) return void 0;
6385
+ if (stopped() || remaining() <= 0) return {};
5734
6386
  try {
5735
6387
  const reference = await Promise.race([
5736
6388
  Vio.checkout.getOrderReference(checkoutId, sponsorId || void 0),
5737
6389
  timeUp
5738
6390
  ]);
5739
- if (reference === null) return void 0;
5740
- if (reference.buyerRef) return reference.buyerRef;
5741
- if (reference.orderNumber !== null) return void 0;
6391
+ if (reference === null) return {};
6392
+ const order = reference.orderNumber ? { orderNumber: reference.orderNumber } : {};
6393
+ if (reference.buyerRef) return { buyerRef: reference.buyerRef, ...order };
6394
+ if (reference.orderNumber !== null) return order;
5742
6395
  } catch {
5743
6396
  }
5744
6397
  }
5745
- return void 0;
6398
+ return {};
5746
6399
  } finally {
5747
6400
  clearTimeout(deadline);
5748
6401
  }
@@ -6006,22 +6659,22 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6006
6659
  if (email) details.push(["Kvittering til", email]);
6007
6660
  const sponsor = this.checkoutSponsorIdentity();
6008
6661
  return html`
6009
- <section class="confirmation">
6662
+ <section class="confirmation" part="confirmation">
6010
6663
  ${usableLogo(sponsor) ? html`<div class="confirm-brand" part="sponsor">
6011
6664
  ${renderSponsorMark(this, sponsor, { logo: "confirm-brand-logo", name: "confirm-brand-name" })}
6012
6665
  </div>` : ""}
6013
6666
  <header class="confirm-head">
6014
6667
  <div class="confirm-check" aria-hidden="true">✓</div>
6015
6668
  <div>
6016
- <h3 class="confirm-title">Takk for bestillingen!</h3>
6017
- ${sponsor?.name ? html`<p class="confirm-sponsor">Du har handlet hos <strong>${sponsor.name}</strong>.</p>` : ""}
6669
+ <h3 class="confirm-title" part="confirm-title">Takk for bestillingen!</h3>
6670
+ ${sponsor?.name ? html`<p class="confirm-sponsor" part="confirm-sponsor">Du har handlet hos <strong>${sponsor.name}</strong>.</p>` : ""}
6018
6671
  <p class="confirm-text">
6019
6672
  Betalingen er bekreftet.${email ? html` Du får en ordrebekreftelse på e-post.` : ""}
6020
6673
  </p>
6021
6674
  </div>
6022
6675
  </header>
6023
6676
 
6024
- <dl class="confirm-details">
6677
+ <dl class="confirm-details" part="confirm-details">
6025
6678
  ${details.map(
6026
6679
  ([k, v]) => html`<div><dt>${k}</dt><dd class=${k === o?.providerRef?.label ? "ref" : ""}>${v}</dd></div>`
6027
6680
  )}
@@ -6431,12 +7084,12 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6431
7084
  for (const delay of _VioCheckout.NEXI_DETAILS_DELAYS_MS) {
6432
7085
  if (delay) await new Promise((r) => setTimeout(r, delay));
6433
7086
  const o = await Vio.checkout.getNexiOrder(checkoutId, sponsorId).catch(() => null);
6434
- const current = this.confirmedOrder;
6435
- if (!current || current.providerRef?.value !== paymentId) return;
7087
+ const current2 = this.confirmedOrder;
7088
+ if (!current2 || current2.providerRef?.value !== paymentId) return;
6436
7089
  if (!o) continue;
6437
7090
  const paidWith = o.payment_method ? o.card_last4 ? `${o.payment_method} \u2022\u2022\u2022\u2022 ${o.card_last4}` : o.payment_method : void 0;
6438
7091
  this.confirmedOrder = {
6439
- ...current,
7092
+ ...current2,
6440
7093
  ...paidWith ? { paidWith } : {},
6441
7094
  ...o.email ? { email: o.email } : {}
6442
7095
  };
@@ -6464,15 +7117,15 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6464
7117
  const options = this.nexiShipping?.options ?? [];
6465
7118
  if (options.length < 2) return "";
6466
7119
  const currency = this.checkoutState?.currency || getGlobalCurrency();
6467
- const current = this.nexiShipping?.shipping_id;
7120
+ const current2 = this.nexiShipping?.shipping_id;
6468
7121
  return html`
6469
7122
  <div class="ship-select nexi-ship" role="radiogroup" aria-label="Frakt">
6470
7123
  ${options.map(
6471
7124
  (o) => html`
6472
7125
  <button
6473
- class="ship-opt ${current === o.id ? "active" : ""}"
7126
+ class="ship-opt ${current2 === o.id ? "active" : ""}"
6474
7127
  role="radio"
6475
- aria-checked=${current === o.id}
7128
+ aria-checked=${current2 === o.id}
6476
7129
  ?disabled=${this.nexiPicking}
6477
7130
  @click=${() => void this.onPickNexiShipping(o.id)}
6478
7131
  >
@@ -7446,5 +8099,5 @@ function registerVioElements() {
7446
8099
  }
7447
8100
 
7448
8101
  export { VioCart, VioCheckout, VioProduct, VioProductCarousel, VioProductDetail, VioTokens, applyVioTheme, registerVioElements };
7449
- //# sourceMappingURL=chunk-GLAKUYQA.js.map
7450
- //# sourceMappingURL=chunk-GLAKUYQA.js.map
8102
+ //# sourceMappingURL=chunk-Q2NALS7P.js.map
8103
+ //# sourceMappingURL=chunk-Q2NALS7P.js.map