@vio-live/web-sdk 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-AG4G2S2M.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;
@@ -1496,12 +1828,24 @@ var VioProductDetail = class extends LitElement {
1496
1828
  this.optMapCache = /* @__PURE__ */ new WeakMap();
1497
1829
  /** Pre-prepared Apple Pay handle — show() fires synchronously on tap. */
1498
1830
  this.applePayHandle = null;
1831
+ /** The amount `applePayHandle`'s sheet shows (product × quantity when it was prepared). */
1832
+ this.applePayAmount = null;
1833
+ /** The amount the latest preparation was asked for — a newer one supersedes it. */
1834
+ this.applePayAskedFor = null;
1499
1835
  /** Keyboard and focus while open: Escape closes, Tab stays inside, focus returns to the opener. */
1500
1836
  this.dialogFocus = new DialogFocus({
1501
1837
  host: this,
1502
1838
  container: () => this.renderRoot.querySelector(".modal"),
1503
1839
  close: () => this.close()
1504
1840
  });
1841
+ /** Phones: the page behind is locked, and the sheet closes by pulling the bar or the top bar down. */
1842
+ this.bottomSheet = new BottomSheet({
1843
+ host: this,
1844
+ sheet: () => this.renderRoot.querySelector(".modal"),
1845
+ backdrop: () => this.renderRoot.querySelector(".backdrop"),
1846
+ dragZones: ".handle, .topbar",
1847
+ close: () => this.close()
1848
+ });
1505
1849
  this.boundProductClick = (e) => {
1506
1850
  if (!this.autoOpen) return;
1507
1851
  const ev = e;
@@ -1529,6 +1873,14 @@ var VioProductDetail = class extends LitElement {
1529
1873
  this.show();
1530
1874
  void this.fetchProduct();
1531
1875
  };
1876
+ this.onThumbsScroll = () => {
1877
+ const row = this.thumbRow();
1878
+ if (!row) return;
1879
+ const atStart = row.scrollLeft <= 1;
1880
+ const atEnd = row.scrollLeft + row.clientWidth >= row.scrollWidth - 1;
1881
+ if (atStart !== this.thumbsAtStart) this.thumbsAtStart = atStart;
1882
+ if (atEnd !== this.thumbsAtEnd) this.thumbsAtEnd = atEnd;
1883
+ };
1532
1884
  }
1533
1885
  /** The card's context applies to the product it opened — not to one the host opened by hand later. */
1534
1886
  get fromCard() {
@@ -1616,7 +1968,10 @@ var VioProductDetail = class extends LitElement {
1616
1968
  height: 92vh;
1617
1969
  border-radius: var(--vio-radius-xl, 16px) var(--vio-radius-xl, 16px) 0 0;
1618
1970
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
1971
+ overscroll-behavior: contain;
1619
1972
  }
1973
+ /* Pulled down to close (bottom-sheet.ts): the browser must not take the gesture for a scroll. */
1974
+ .handle, .topbar { touch-action: none; }
1620
1975
  .handle {
1621
1976
  display: flex;
1622
1977
  justify-content: center;
@@ -1637,8 +1992,8 @@ var VioProductDetail = class extends LitElement {
1637
1992
  @media (min-width: 601px) {
1638
1993
  .modal {
1639
1994
  inset: 0 0 0 auto;
1640
- width: min(440px, 100%);
1641
- max-width: 440px;
1995
+ width: min(var(--vio-detail-width, 440px), 100%);
1996
+ max-width: var(--vio-detail-width, 440px);
1642
1997
  border-radius: 0;
1643
1998
  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.1);
1644
1999
  transform: translateX(100%);
@@ -1652,29 +2007,59 @@ var VioProductDetail = class extends LitElement {
1652
2007
  max-width: none;
1653
2008
  grid-template-columns: 1fr;
1654
2009
  gap: 20px;
1655
- padding: 20px 24px 40px;
2010
+ padding: var(--vio-detail-padding-top, 0px) var(--vio-detail-padding-x, var(--vio-space-lg, 24px)) 40px;
1656
2011
  }
1657
- .modal .name { font-size: 26px; line-height: 1.2; }
1658
- .modal .gallery-main { width: 100%; }
2012
+ .modal .name { font-size: var(--vio-detail-title-size, 26px); line-height: 1.2; }
2013
+ .modal .gallery-main { width: auto; }
1659
2014
  }
1660
2015
 
1661
2016
  .topbar {
1662
2017
  display: flex;
1663
2018
  justify-content: space-between;
1664
2019
  align-items: center;
1665
- padding: 20px var(--vio-space-lg, 24px);
2020
+ gap: 16px;
2021
+ min-height: var(--vio-detail-topbar-height, 64px);
2022
+ box-sizing: border-box;
2023
+ padding: 10px var(--vio-detail-padding-x, var(--vio-space-lg, 24px));
1666
2024
  position: sticky;
1667
2025
  top: 0;
1668
2026
  background: var(--vio-color-surface, #fff);
1669
2027
  border-bottom: 1px solid var(--vio-color-border, #e5e5e5);
1670
2028
  z-index: 1;
1671
2029
  }
1672
- .topbar-brand {
1673
- font-size: 12px;
2030
+ /* Who sells it, top left beside the close: the sponsor's logo, else its name. */
2031
+ .topbar-sponsor { flex: 1 1 auto; display: flex; align-items: center; gap: 10px; min-width: 0; }
2032
+ /* The logo box clips, so --vio-detail-logo-zoom can crop the white a
2033
+ square file carries around its mark. */
2034
+ .topbar-logo-box {
2035
+ flex: 0 0 auto;
2036
+ display: flex;
2037
+ align-items: center;
2038
+ height: var(--vio-detail-logo-height, 44px);
2039
+ max-width: var(--vio-detail-logo-max-width, 220px);
2040
+ overflow: hidden;
2041
+ }
2042
+ .topbar-logo {
2043
+ display: block;
2044
+ width: auto;
2045
+ height: 100%;
2046
+ max-width: var(--vio-detail-logo-max-width, 220px);
2047
+ object-fit: contain;
2048
+ object-position: left center;
2049
+ transform: scale(var(--vio-detail-logo-zoom, 1));
2050
+ transform-origin: center;
2051
+ }
2052
+ /* A square (or near) logo is a mark, not a wordmark: say whose it is. */
2053
+ .topbar-sponsor:not(.is-square) .topbar-mark-name { display: none; }
2054
+ .topbar-name {
2055
+ font-size: var(--vio-detail-logo-name-size, 13px);
1674
2056
  text-transform: uppercase;
1675
2057
  letter-spacing: var(--vio-tracking-label, 0.1em);
1676
2058
  color: var(--vio-color-text-secondary, #666);
1677
- font-weight: 500;
2059
+ font-weight: 600;
2060
+ white-space: nowrap;
2061
+ overflow: hidden;
2062
+ text-overflow: ellipsis;
1678
2063
  }
1679
2064
  .close {
1680
2065
  background: none;
@@ -1700,74 +2085,110 @@ var VioProductDetail = class extends LitElement {
1700
2085
  gap: 64px;
1701
2086
  }
1702
2087
  @media (max-width: 800px) {
1703
- .body { grid-template-columns: 1fr; gap: 32px; padding: 24px var(--vio-space-lg, 24px) 64px; }
2088
+ .body {
2089
+ grid-template-columns: 1fr;
2090
+ gap: 24px;
2091
+ padding: var(--vio-detail-padding-top, 0px) var(--vio-detail-padding-x, var(--vio-space-lg, 24px)) 64px;
2092
+ }
1704
2093
  }
1705
2094
 
1706
- /* Gallery */
1707
- .gallery { display: flex; flex-direction: column; gap: 16px; }
2095
+ /* Gallery. One fixed proportion for every image (4:5 by default) that the
2096
+ image fills (cover): no white bars, and the panel never jumps between
2097
+ photos of different shapes (Angelo, 2026-10-22). Its side margin is
2098
+ --vio-detail-image-inset (the panel's padding by default; 0 = edge to
2099
+ edge). Fit, proportion, a height cap and a zoom (to crop a white margin
2100
+ baked into the file) are the host's to set. */
2101
+ .gallery { display: flex; flex-direction: column; gap: var(--vio-detail-gallery-gap, 12px); min-width: 0; }
1708
2102
  .gallery-main {
1709
- aspect-ratio: 1;
1710
- background: var(--vio-color-surface-muted, #f2f2f2);
2103
+ position: relative;
2104
+ margin-inline: calc(
2105
+ var(--vio-detail-image-inset, var(--vio-detail-padding-x, var(--vio-space-lg, 24px))) -
2106
+ var(--vio-detail-padding-x, var(--vio-space-lg, 24px))
2107
+ );
2108
+ aspect-ratio: var(--vio-detail-image-aspect, 4 / 5);
2109
+ max-height: var(--vio-detail-image-max-height, 85vh);
2110
+ background: var(--vio-detail-image-bg, var(--vio-color-surface-muted, #f2f2f2));
2111
+ border-radius: var(--vio-detail-image-radius, 0);
1711
2112
  display: flex;
1712
2113
  align-items: center;
1713
2114
  justify-content: center;
1714
2115
  overflow: hidden;
1715
2116
  }
1716
2117
  .gallery-main img {
2118
+ display: block;
1717
2119
  width: 100%;
1718
2120
  height: 100%;
1719
- object-fit: contain;
1720
- padding: 24px;
1721
- box-sizing: border-box;
1722
- }
2121
+ max-height: var(--vio-detail-image-max-height, 85vh);
2122
+ object-fit: var(--vio-detail-image-fit, cover);
2123
+ object-position: var(--vio-detail-image-position, center);
2124
+ transform: scale(var(--vio-detail-image-zoom, 1));
2125
+ }
2126
+ /* Thumbnails: one row, never a second line — swipe on touch, arrows with a
2127
+ mouse; the selected one scrolls into view by itself. */
2128
+ .thumbs { position: relative; min-width: 0; }
1723
2129
  .gallery-thumbs {
1724
2130
  display: flex;
1725
- gap: 8px;
1726
- flex-wrap: wrap;
2131
+ flex-wrap: nowrap;
2132
+ gap: var(--vio-detail-thumb-gap, 8px);
2133
+ overflow-x: auto;
2134
+ scroll-snap-type: x proximity;
2135
+ scroll-behavior: smooth;
2136
+ scrollbar-width: none;
2137
+ -webkit-overflow-scrolling: touch;
1727
2138
  }
2139
+ .gallery-thumbs::-webkit-scrollbar { display: none; }
1728
2140
  .gallery-thumb {
1729
- width: 72px;
1730
- height: 72px;
2141
+ flex: 0 0 auto;
2142
+ width: var(--vio-detail-thumb-size, 64px);
2143
+ height: var(--vio-detail-thumb-size, 64px);
1731
2144
  background: var(--vio-color-surface-muted, #f2f2f2);
1732
- border: 1px solid transparent;
2145
+ border: var(--vio-detail-thumb-border-width, 1px) solid transparent;
2146
+ border-radius: var(--vio-detail-thumb-radius, 0);
1733
2147
  cursor: pointer;
1734
2148
  padding: 0;
1735
2149
  overflow: hidden;
2150
+ scroll-snap-align: start;
2151
+ box-sizing: border-box;
2152
+ opacity: var(--vio-detail-thumb-opacity, 1);
1736
2153
  }
1737
2154
  .gallery-thumb img {
2155
+ display: block;
1738
2156
  width: 100%;
1739
2157
  height: 100%;
1740
- object-fit: contain;
1741
- padding: 6px;
1742
- box-sizing: border-box;
2158
+ object-fit: var(--vio-detail-thumb-fit, cover);
1743
2159
  }
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;
2160
+ .gallery-thumb.active {
2161
+ border-color: var(--vio-detail-thumb-active-color, var(--vio-color-text, #0a0a0a));
2162
+ opacity: 1;
1752
2163
  }
1753
- .sponsor-logo {
1754
- display: block;
1755
- width: auto;
1756
- height: auto;
1757
- max-height: 28px;
1758
- max-width: 160px;
1759
- object-fit: contain;
2164
+ .thumbs-arrow {
2165
+ display: none;
2166
+ position: absolute;
2167
+ top: 50%;
2168
+ transform: translateY(-50%);
2169
+ z-index: 1;
2170
+ width: 28px;
2171
+ height: 28px;
2172
+ border-radius: 50%;
2173
+ border: 1px solid var(--vio-color-border, #e5e5e5);
2174
+ background: var(--vio-color-surface, #fff);
2175
+ color: var(--vio-color-text, #0a0a0a);
2176
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
2177
+ cursor: pointer;
2178
+ align-items: center;
2179
+ justify-content: center;
2180
+ padding: 0;
2181
+ font: 16px/1 sans-serif;
1760
2182
  }
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;
2183
+ .thumbs-arrow.prev { left: -6px; }
2184
+ .thumbs-arrow.next { right: -6px; }
2185
+ .thumbs-arrow:disabled { visibility: hidden; }
2186
+ @media (hover: hover) and (pointer: fine) {
2187
+ .thumbs.overflows .thumbs-arrow { display: flex; }
1767
2188
  }
1768
2189
 
1769
2190
  /* Info column */
1770
- .info { display: flex; flex-direction: column; gap: 24px; }
2191
+ .info { display: flex; flex-direction: column; gap: var(--vio-detail-info-gap, 20px); min-width: 0; }
1771
2192
  .brand {
1772
2193
  font-size: 12px;
1773
2194
  text-transform: uppercase;
@@ -1777,8 +2198,8 @@ var VioProductDetail = class extends LitElement {
1777
2198
  }
1778
2199
  .name {
1779
2200
  font-family: var(--vio-font-serif, Georgia, serif);
1780
- font-size: clamp(28px, 4vw, 40px);
1781
- font-weight: 400;
2201
+ font-size: var(--vio-detail-title-size, clamp(28px, 4vw, 40px));
2202
+ font-weight: var(--vio-detail-title-weight, 400);
1782
2203
  line-height: 1.15;
1783
2204
  margin: 0;
1784
2205
  letter-spacing: -0.01em;
@@ -1789,8 +2210,8 @@ var VioProductDetail = class extends LitElement {
1789
2210
  gap: 16px;
1790
2211
  }
1791
2212
  .price {
1792
- font-size: 24px;
1793
- font-weight: 700;
2213
+ font-size: var(--vio-detail-price-size, 24px);
2214
+ font-weight: var(--vio-detail-price-weight, 700);
1794
2215
  }
1795
2216
  .compare-at {
1796
2217
  font-size: 16px;
@@ -1808,7 +2229,7 @@ var VioProductDetail = class extends LitElement {
1808
2229
  .stock.out-of-stock { color: var(--vio-color-text-tertiary, #999); }
1809
2230
 
1810
2231
  /* Option pickers */
1811
- .option-group { display: flex; flex-direction: column; gap: 8px; }
2232
+ .option-group { display: flex; flex-direction: column; gap: 6px; }
1812
2233
  .option-label {
1813
2234
  font-size: 11px;
1814
2235
  text-transform: uppercase;
@@ -1816,14 +2237,28 @@ var VioProductDetail = class extends LitElement {
1816
2237
  color: var(--vio-color-text-secondary, #666);
1817
2238
  font-weight: 500;
1818
2239
  }
1819
- .option-values { display: flex; flex-wrap: wrap; gap: 8px; }
2240
+ /* Sizes: compact, still comfortable to tap. wrap → several lines;
2241
+ nowrap → one line that scrolls sideways. */
2242
+ .option-values {
2243
+ display: flex;
2244
+ flex-wrap: var(--vio-detail-option-wrap, wrap);
2245
+ gap: var(--vio-detail-option-gap, 6px);
2246
+ overflow-x: auto;
2247
+ scrollbar-width: none;
2248
+ }
2249
+ .option-values::-webkit-scrollbar { display: none; }
1820
2250
  .option-pill {
1821
- padding: 10px 18px;
2251
+ flex: 0 0 auto;
2252
+ box-sizing: border-box;
2253
+ min-height: var(--vio-detail-option-height, 36px);
2254
+ min-width: var(--vio-detail-option-height, 36px);
2255
+ padding: 0 var(--vio-detail-option-padding-x, 12px);
2256
+ border-radius: var(--vio-detail-option-radius, 0);
1822
2257
  background: #fff;
1823
2258
  border: 1px solid var(--vio-color-border, #e5e5e5);
1824
2259
  cursor: pointer;
1825
2260
  font-family: inherit;
1826
- font-size: 13px;
2261
+ font-size: var(--vio-detail-option-font-size, 13px);
1827
2262
  font-weight: 500;
1828
2263
  color: inherit;
1829
2264
  transition: border-color 0.15s, background 0.15s;
@@ -1842,6 +2277,34 @@ var VioProductDetail = class extends LitElement {
1842
2277
  }
1843
2278
  .option-pill.unavailable:hover { border-color: var(--vio-color-border, #e5e5e5); }
1844
2279
 
2280
+ /* The buy zone: Vipps first, then quantity + add to cart, then the other
2281
+ ways to pay. --vio-detail-buy-position: sticky pins it to the bottom of
2282
+ the panel while the shopper scrolls (mobile, mostly). */
2283
+ .buy {
2284
+ display: flex;
2285
+ flex-direction: column;
2286
+ gap: var(--vio-detail-buy-gap, 10px);
2287
+ position: var(--vio-detail-buy-position, static);
2288
+ bottom: 0;
2289
+ z-index: 2;
2290
+ background: var(--vio-detail-buy-bg, var(--vio-color-surface, #fff));
2291
+ padding: var(--vio-detail-buy-padding, 0);
2292
+ box-shadow: var(--vio-detail-buy-shadow, none);
2293
+ }
2294
+ .buy > * { margin-top: 0 !important; }
2295
+ /* Under the buttons: who the shopper is buying from. */
2296
+ .seller-note {
2297
+ margin: 2px 0 0;
2298
+ font-size: var(--vio-detail-seller-note-size, 12px);
2299
+ line-height: 1.4;
2300
+ color: var(--vio-color-text-secondary, #666);
2301
+ text-align: var(--vio-detail-seller-note-align, center);
2302
+ }
2303
+ .seller-note strong { font-weight: 600; color: var(--vio-color-text, #0a0a0a); }
2304
+ .seller-links { display: block; margin-top: 2px; }
2305
+ .seller-links a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
2306
+ .seller-links a + a::before { content: '·'; display: inline-block; margin: 0 6px; text-decoration: none; }
2307
+
1845
2308
  /* Qty + CTA row */
1846
2309
  .qty-cta-row {
1847
2310
  display: grid;
@@ -1856,7 +2319,7 @@ var VioProductDetail = class extends LitElement {
1856
2319
  }
1857
2320
  .qty-btn {
1858
2321
  width: 44px;
1859
- height: 56px;
2322
+ height: var(--vio-detail-button-height, 52px);
1860
2323
  background: #fff;
1861
2324
  border: none;
1862
2325
  cursor: pointer;
@@ -1874,7 +2337,9 @@ var VioProductDetail = class extends LitElement {
1874
2337
  background: var(--vio-color-text, #0a0a0a);
1875
2338
  color: var(--vio-color-text-on-primary, #fff);
1876
2339
  border: none;
1877
- padding: 18px 24px;
2340
+ min-height: var(--vio-detail-button-height, 52px);
2341
+ border-radius: var(--vio-detail-button-radius, 0);
2342
+ padding: 0 24px;
1878
2343
  font-size: 12px;
1879
2344
  text-transform: uppercase;
1880
2345
  letter-spacing: var(--vio-tracking-label, 0.1em);
@@ -1984,19 +2449,49 @@ var VioProductDetail = class extends LitElement {
1984
2449
  .buy-applepay:disabled { opacity: 0.5; cursor: not-allowed; }
1985
2450
 
1986
2451
  /* Description */
2452
+ /* The description folds: a tap opens it, so it does not take the page. */
2453
+ .description-block { border-top: 1px solid var(--vio-color-border, #e5e5e5); }
2454
+ .description-block > summary {
2455
+ list-style: none;
2456
+ cursor: pointer;
2457
+ display: flex;
2458
+ align-items: center;
2459
+ justify-content: space-between;
2460
+ padding: 14px 0;
2461
+ font-size: 11px;
2462
+ text-transform: uppercase;
2463
+ letter-spacing: var(--vio-tracking-label, 0.1em);
2464
+ color: var(--vio-color-text-secondary, #666);
2465
+ font-weight: 600;
2466
+ }
2467
+ .description-block > summary::-webkit-details-marker { display: none; }
2468
+ .description-block > summary::after {
2469
+ content: '';
2470
+ width: 8px;
2471
+ height: 8px;
2472
+ border-right: 1.5px solid currentColor;
2473
+ border-bottom: 1.5px solid currentColor;
2474
+ transform: rotate(45deg) translateY(-2px);
2475
+ transition: transform 0.15s ease;
2476
+ }
2477
+ .description-block[open] > summary::after { transform: rotate(-135deg) translateY(-2px); }
1987
2478
  .description {
1988
2479
  font-size: 14px;
1989
2480
  line-height: 1.6;
1990
2481
  color: var(--vio-color-text-secondary, #444);
1991
- margin-top: 8px;
2482
+ padding-bottom: 14px;
2483
+ white-space: pre-line;
1992
2484
  }
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;
2485
+ /* Desktop: the description above, the buy zone at the bottom of the panel
2486
+ (sticky). Phones keep the order as it is: buy, then the description. */
2487
+ @media (min-width: 601px) {
2488
+ .description-block { order: 1; }
2489
+ .buy {
2490
+ order: 2;
2491
+ position: var(--vio-detail-buy-position, sticky);
2492
+ padding: var(--vio-detail-buy-padding, 12px 0 16px);
2493
+ box-shadow: var(--vio-detail-buy-shadow, 0 -1px 0 var(--vio-color-border, #e5e5e5));
2494
+ }
2000
2495
  }
2001
2496
 
2002
2497
  /* Skeleton loader: shimmer placeholders mirroring the product layout
@@ -2052,14 +2547,50 @@ var VioProductDetail = class extends LitElement {
2052
2547
  disconnectedCallback() {
2053
2548
  document.removeEventListener("vio:product-click", this.boundProductClick);
2054
2549
  this.slotRegistration.clear();
2550
+ this.bottomSheet.disconnect();
2055
2551
  super.disconnectedCallback();
2056
2552
  }
2057
2553
  updated(changed) {
2058
- if (changed.has("open")) this.dialogFocus.update(this.open);
2554
+ if (changed.has("open")) {
2555
+ this.dialogFocus.update(this.open);
2556
+ this.bottomSheet.update(this.open);
2557
+ }
2558
+ if (changed.has("product") || changed.has("open")) this.measureThumbs();
2559
+ if ((changed.has("quantity") || changed.has("selectedOptions")) && this.product && this.applePayAskedFor !== null) {
2560
+ if (this.unitPrice * this.quantity !== this.applePayAskedFor) this.prepareApplePaySheet();
2561
+ }
2562
+ if (changed.has("activeImageIndex")) this.revealActiveThumb();
2563
+ this.measureLogo();
2059
2564
  if (changed.has("vioSlot") || changed.has("vioSlotName") || changed.has("vioScope") || changed.has("vioScopeName") || changed.has("vioCampaign")) {
2060
2565
  this.slotRegistration.sync(this.slotAttributes());
2061
2566
  }
2062
2567
  }
2568
+ thumbRow() {
2569
+ return this.renderRoot.querySelector(".gallery-thumbs");
2570
+ }
2571
+ /** Arrows only when the row overflows; each one off at its own end. */
2572
+ measureThumbs() {
2573
+ const row = this.thumbRow();
2574
+ if (!row) return;
2575
+ const overflow = row.scrollWidth > row.clientWidth + 1;
2576
+ if (overflow !== this.thumbsOverflow) this.thumbsOverflow = overflow;
2577
+ this.onThumbsScroll();
2578
+ }
2579
+ /** One arrow press moves most of a visible row. */
2580
+ scrollThumbs(direction) {
2581
+ const row = this.thumbRow();
2582
+ if (row) row.scrollBy({ left: direction * row.clientWidth * 0.8 });
2583
+ }
2584
+ /** The selected thumbnail is always visible: the row scrolls to it, the page never does. */
2585
+ revealActiveThumb() {
2586
+ const row = this.thumbRow();
2587
+ const thumb = row?.querySelectorAll(".gallery-thumb")[this.activeImageIndex];
2588
+ if (!row || !thumb) return;
2589
+ const left = thumb.offsetLeft - row.offsetLeft;
2590
+ if (left < row.scrollLeft || left + thumb.offsetWidth > row.scrollLeft + row.clientWidth) {
2591
+ row.scrollTo({ left: Math.max(0, left - (row.clientWidth - thumb.offsetWidth) / 2) });
2592
+ }
2593
+ }
2063
2594
  show() {
2064
2595
  this.open = true;
2065
2596
  }
@@ -2157,13 +2688,7 @@ var VioProductDetail = class extends LitElement {
2157
2688
  if (values.length > 0) initial[opt.name] = values[0];
2158
2689
  }
2159
2690
  this.selectedOptions = initial;
2160
- void Vio.checkout.prepareApplePayFor(this.unitPrice * this.quantity, this.currency).then((handle) => {
2161
- this.applePayHandle = handle;
2162
- this.applePayOk = handle.available;
2163
- }).catch(() => {
2164
- this.applePayHandle = null;
2165
- this.applePayOk = false;
2166
- });
2691
+ this.prepareApplePaySheet();
2167
2692
  void this.loadAvailablePaymentMethods();
2168
2693
  }
2169
2694
  this.isLoading = false;
@@ -2546,27 +3071,47 @@ var VioProductDetail = class extends LitElement {
2546
3071
  }
2547
3072
  buyWithVipps() {
2548
3073
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3074
+ if ((Vio.cart.getCart(this.sponsorId)?.items.length ?? 0) > 0) {
3075
+ this.addCurrentToCart();
3076
+ this.close();
3077
+ this.openCheckoutWithVipps();
3078
+ return;
3079
+ }
3080
+ const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: this.sponsorId, source: "detail" });
2549
3081
  this.addCurrentToCart();
2550
3082
  this.close();
2551
- void this.startVippsExpressOrCheckout();
3083
+ void this.startVippsExpressOrCheckout(redirect);
2552
3084
  }
2553
- async startVippsExpressOrCheckout() {
3085
+ async startVippsExpressOrCheckout(redirect) {
2554
3086
  let fallback = true;
2555
3087
  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;
3088
+ if (!await Vio.checkout.getVippsExpressEnabled(this.sponsorId)) {
3089
+ redirect.abort("express-off");
3090
+ } else {
3091
+ const res = await Vio.checkout.startVippsExpress(this.sponsorId);
3092
+ fallback = !res?.payment_url;
3093
+ if (fallback) redirect.abort("not-started");
3094
+ }
2559
3095
  } catch (err) {
2560
3096
  if (typeof console !== "undefined") {
2561
3097
  console.warn("[VioProductDetail] Vipps Express not started:", err);
2562
3098
  }
3099
+ redirect.fail(err instanceof Error ? err.message : String(err));
2563
3100
  }
2564
3101
  if (!fallback) return;
3102
+ this.openCheckoutWithVipps();
3103
+ }
3104
+ /** The ordinary way in, Vipps preselected: the host opens the checkout on `vio:checkout-open`. */
3105
+ openCheckoutWithVipps() {
3106
+ this.openCheckoutWith("vipps");
3107
+ }
3108
+ /** The ordinary way in with a method preselected: the host opens the checkout on `vio:checkout-open`. */
3109
+ openCheckoutWith(paymentMethod) {
2565
3110
  this.dispatchEvent(
2566
3111
  new CustomEvent("vio:checkout-open", {
2567
3112
  bubbles: true,
2568
3113
  composed: true,
2569
- detail: { sponsorId: this.sponsorId, paymentMethod: "vipps", express: false, ...this.attribution(), ...sponsorDetail(this.sponsorId) }
3114
+ detail: { sponsorId: this.sponsorId, paymentMethod, express: false, ...this.attribution(), ...sponsorDetail(this.sponsorId) }
2570
3115
  })
2571
3116
  );
2572
3117
  }
@@ -2583,14 +3128,47 @@ var VioProductDetail = class extends LitElement {
2583
3128
  })
2584
3129
  );
2585
3130
  }
3131
+ /** Prepare the Apple Pay sheet for this product × quantity; the newest preparation wins. */
3132
+ prepareApplePaySheet() {
3133
+ const amount = this.unitPrice * this.quantity;
3134
+ this.applePayAskedFor = amount;
3135
+ void Vio.checkout.prepareApplePayFor(amount, this.currency).then((handle) => {
3136
+ if (this.applePayAskedFor !== amount) return;
3137
+ this.applePayHandle = handle;
3138
+ this.applePayAmount = amount;
3139
+ this.applePayOk = handle.available;
3140
+ }).catch(() => {
3141
+ if (this.applePayAskedFor !== amount) return;
3142
+ this.applePayHandle = null;
3143
+ this.applePayAmount = null;
3144
+ this.applePayOk = false;
3145
+ });
3146
+ }
2586
3147
  /**
2587
3148
  * Express buy with Apple Pay. Runs synchronously inside the click so the
2588
3149
  * Apple Pay sheet keeps the user gesture: add to cart → open checkout (sets
2589
3150
  * state) → launch the sheet. On authorize, `payment-complete` drives the
2590
3151
  * confirmation drawer (same path as the in-checkout Apple Pay button).
3152
+ *
3153
+ * The sheet's amount is fixed before the tap (Apple blocks a sheet opened
3154
+ * after an await, so it cannot wait for the server's cart), while the
3155
+ * charge is the sponsor's WHOLE cart. So the sheet opens only when it shows
3156
+ * what will be charged: an empty cart, and a sheet prepared for this
3157
+ * product × quantity. Otherwise — lines already in the cart (as with Vipps,
3158
+ * see buyWithVipps), or a quantity whose sheet is still being prepared —
3159
+ * the product is added and the checkout opens with Apple Pay preselected:
3160
+ * every line and the total on screen, and its own Apple Pay button pays
3161
+ * that total.
2591
3162
  */
2592
3163
  buyWithApplePay() {
2593
3164
  if (!this.product || this.availableQuantity <= 0 || !this.applePayHandle) return;
3165
+ const cartHasLines = (Vio.cart.getCart(this.sponsorId)?.items.length ?? 0) > 0;
3166
+ if (cartHasLines || this.applePayAmount !== this.unitPrice * this.quantity) {
3167
+ this.addCurrentToCart();
3168
+ this.close();
3169
+ this.openCheckoutWith("apple-pay");
3170
+ return;
3171
+ }
2594
3172
  this.addCurrentToCart();
2595
3173
  try {
2596
3174
  Vio.checkout.open(this.sponsorId);
@@ -2620,28 +3198,67 @@ var VioProductDetail = class extends LitElement {
2620
3198
  this.sellerSettled = true;
2621
3199
  }
2622
3200
  /**
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.
3201
+ * Who sells the product, top left beside the close (Mote & Livsstil,
3202
+ * 2026-10-22): the logo of the sponsor the product was fetched with — the
3203
+ * product's brand, never the article's — with its name as `alt`; without a
3204
+ * logo that loads, its name; without an identity, the seller's Company name
3205
+ * (or the product's brand) once known. Size: --vio-detail-logo-height.
2628
3206
  */
2629
- renderSponsorHeader() {
3207
+ renderTopbarSponsor() {
2630
3208
  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>`;
3209
+ const mark = identity ? renderSponsorMark(this, identity, { logo: "topbar-logo", name: "topbar-name" }) : nothing;
3210
+ if (mark !== nothing) {
3211
+ if (identity && usableLogo(identity)) {
3212
+ return html`<span class="topbar-logo-box">${mark}</span
3213
+ ><span class="topbar-name topbar-mark-name">${identity.name}</span>`;
3214
+ }
3215
+ return mark;
3216
+ }
3217
+ const name = this.headerName();
3218
+ return name ? html`<span class="topbar-name">${name}</span>` : nothing;
3219
+ }
3220
+ /**
3221
+ * Under the buy buttons: «Du handler direkte hos {brand}.» — the brand the
3222
+ * product was fetched with (the sponsor; its Company name or the product's
3223
+ * brand when no identity is known), never the article's. Nothing when no
3224
+ * name is known.
3225
+ */
3226
+ renderSellerNote() {
3227
+ const identity = resolveSponsorIdentity(this.sponsorId);
3228
+ const name = identity?.name || this.headerName();
3229
+ if (!name) return nothing;
3230
+ const links = [
3231
+ identity?.termsUrl ? html`<a href=${identity.termsUrl} target="_blank" rel="noopener noreferrer" part="seller-terms">Kjøpsvilkår</a>` : null,
3232
+ identity?.refundPolicyUrl ? html`<a href=${identity.refundPolicyUrl} target="_blank" rel="noopener noreferrer" part="seller-refund">Angrerett og retur</a>` : null
3233
+ ].filter(Boolean);
3234
+ return html`<p class="seller-note" part="seller-note">
3235
+ Du handler direkte hos <strong>${name}</strong>.${links.length ? html`<span class="seller-links">${links}</span>` : nothing}
3236
+ </p>`;
3237
+ }
3238
+ /** Square-ish logo (4:5 to 5:4) → the top bar shows the name beside it. */
3239
+ measureLogo() {
3240
+ const img = this.renderRoot.querySelector(".topbar-logo");
3241
+ if (!img) {
3242
+ if (this.logoSquare) this.logoSquare = false;
3243
+ return;
3244
+ }
3245
+ const decide = () => {
3246
+ if (!img.naturalWidth || !img.naturalHeight) return;
3247
+ const ratio = img.naturalWidth / img.naturalHeight;
3248
+ const square = ratio >= 0.8 && ratio <= 1.25;
3249
+ if (square !== this.logoSquare) this.logoSquare = square;
3250
+ };
3251
+ if (img.complete) decide();
3252
+ else img.addEventListener("load", decide, { once: true });
2636
3253
  }
2637
3254
  render() {
2638
3255
  return html`
2639
3256
  <div class="backdrop" @click=${this.close}></div>
2640
3257
  <div class="modal" role="dialog" aria-modal="true" aria-label="Produkt" tabindex="-1">
2641
3258
  <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>
3259
+ <div class="topbar" part="topbar">
3260
+ <div class="topbar-sponsor ${this.logoSquare ? "is-square" : ""}" part="sponsor">${this.renderTopbarSponsor()}</div>
3261
+ <button class="close" part="close" @click=${this.close} aria-label="Lukk">×</button>
2645
3262
  </div>
2646
3263
 
2647
3264
  ${this.renderBody()}
@@ -2673,45 +3290,90 @@ var VioProductDetail = class extends LitElement {
2673
3290
  const activeImage = this.activeImage;
2674
3291
  return html`
2675
3292
  <div class="body">
2676
- ${this.renderSponsorHeader()}
2677
- <div class="gallery">
2678
- <div class="gallery-main">
3293
+ <div class="gallery" part="gallery">
3294
+ <div class="gallery-main" part="image">
2679
3295
  ${activeImage?.url ? html`<img src=${activeImage.url} alt=${p.title} />` : ""}
2680
3296
  </div>
2681
3297
  ${images.length > 1 ? html`
2682
- <div class="gallery-thumbs">
2683
- ${images.map(
3298
+ <div class="thumbs ${this.thumbsOverflow ? "overflows" : ""}">
3299
+ <button
3300
+ class="thumbs-arrow prev"
3301
+ part="thumbs-arrow"
3302
+ aria-label="Forrige bilder"
3303
+ ?disabled=${this.thumbsAtStart}
3304
+ @click=${() => this.scrollThumbs(-1)}
3305
+ >‹</button>
3306
+ <div class="gallery-thumbs" part="thumbs" @scroll=${this.onThumbsScroll}>
3307
+ ${images.map(
2684
3308
  (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
- `
3309
+ <button
3310
+ class="gallery-thumb ${idx === this.activeImageIndex ? "active" : ""}"
3311
+ part=${idx === this.activeImageIndex ? "thumb thumb-active" : "thumb"}
3312
+ @click=${() => this.activeImageIndex = idx}
3313
+ aria-label=${`Bilde ${idx + 1}`}
3314
+ aria-current=${idx === this.activeImageIndex ? "true" : "false"}
3315
+ >
3316
+ <img src=${img.url} alt="" loading="lazy" />
3317
+ </button>
3318
+ `
2693
3319
  )}
3320
+ </div>
3321
+ <button
3322
+ class="thumbs-arrow next"
3323
+ part="thumbs-arrow"
3324
+ aria-label="Neste bilder"
3325
+ ?disabled=${this.thumbsAtEnd}
3326
+ @click=${() => this.scrollThumbs(1)}
3327
+ >›</button>
2694
3328
  </div>
2695
3329
  ` : ""}
2696
3330
  </div>
2697
3331
 
2698
- <div class="info">
2699
- ${p.brand ? html`<div class="brand">${p.brand}</div>` : ""}
2700
- <h2 class="name">${p.title}</h2>
3332
+ <div class="info" part="info">
3333
+ ${p.brand ? html`<div class="brand" part="brand">${p.brand}</div>` : ""}
3334
+ <h2 class="name" part="title">${p.title}</h2>
2701
3335
 
2702
3336
  <div class="price-row">
2703
- <span class="price">${formatPrice(this.unitPrice, p.price.currency_code)}</span>
3337
+ <span class="price" part="price">${formatPrice(this.unitPrice, p.price.currency_code)}</span>
2704
3338
  ${showCompare ? html`<span class="compare-at">${formatPrice(
2705
3339
  compare,
2706
3340
  p.price.currency_code
2707
3341
  )}</span>` : ""}
2708
3342
  </div>
2709
3343
 
2710
- <div class="stock ${stock.cls}">${stock.text}</div>
3344
+ <div class="stock ${stock.cls}" part="stock">${stock.text}</div>
2711
3345
 
2712
3346
  ${this.renderOptionPickers(p.options)}
2713
3347
 
2714
- <div class="qty-cta-row">
3348
+ <div class="buy" part="buy">
3349
+ ${this.availableQuantity > 0 && this.methodEnabled("vipps") ? this.vippsOfficial ? html`
3350
+ <vipps-mobilepay-button
3351
+ class="buy-vipps-official"
3352
+ part="buy-vipps"
3353
+ brand="vipps"
3354
+ language="no"
3355
+ verb="buy"
3356
+ variant="primary"
3357
+ rounded="true"
3358
+ stretched="true"
3359
+ role="button"
3360
+ aria-label="Kjøp nå med Vipps"
3361
+ @click=${this.buyWithVipps}
3362
+ ></vipps-mobilepay-button>
3363
+ ` : html`
3364
+ <button
3365
+ class="buy-vipps"
3366
+ part="buy-vipps"
3367
+ @click=${this.buyWithVipps}
3368
+ ?disabled=${this.adding}
3369
+ aria-label="Kjøp nå med Vipps"
3370
+ >
3371
+ <span>Kjøp nå med</span>
3372
+ <span class="vipps-badge">vipps</span>
3373
+ </button>
3374
+ ` : ""}
3375
+
3376
+ <div class="qty-cta-row" part="quantity-row">
2715
3377
  <div class="qty-stepper">
2716
3378
  <button
2717
3379
  class="qty-btn"
@@ -2732,6 +3394,7 @@ var VioProductDetail = class extends LitElement {
2732
3394
  </div>
2733
3395
  <button
2734
3396
  class="add-to-cart"
3397
+ part="add-to-cart"
2735
3398
  @click=${this.addToCart}
2736
3399
  ?disabled=${this.availableQuantity <= 0 || this.adding}
2737
3400
  >
@@ -2793,36 +3456,14 @@ var VioProductDetail = class extends LitElement {
2793
3456
  </button>
2794
3457
  ` : ""}
2795
3458
 
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
- ` : ""}
3459
+ ${this.renderSellerNote()}
3460
+ </div>
2820
3461
 
2821
3462
  ${p.description ? html`
2822
- <div>
2823
- <div class="description-label">Beskrivelse</div>
3463
+ <details class="description-block" part="description">
3464
+ <summary>Beskrivelse</summary>
2824
3465
  <div class="description">${this.stripHtml(p.description)}</div>
2825
- </div>
3466
+ </details>
2826
3467
  ` : ""}
2827
3468
  </div>
2828
3469
  </div>
@@ -2834,9 +3475,9 @@ var VioProductDetail = class extends LitElement {
2834
3475
  const values = normalizeValues(opt.values);
2835
3476
  if (values.length === 0) return "";
2836
3477
  return html`
2837
- <div class="option-group">
2838
- <div class="option-label">${opt.name}</div>
2839
- <div class="option-values">
3478
+ <div class="option-group" part="option-group">
3479
+ <div class="option-label" part="option-label">${opt.name}</div>
3480
+ <div class="option-values" part="options">
2840
3481
  ${values.map((val) => {
2841
3482
  const available = this.isValueAvailable(opt.name, val);
2842
3483
  const currentVal = this.selectedOptions[opt.name] ?? "";
@@ -2844,6 +3485,7 @@ var VioProductDetail = class extends LitElement {
2844
3485
  return html`
2845
3486
  <button
2846
3487
  class="option-pill ${available ? "" : "unavailable"}"
3488
+ part=${isSelected ? "option option-selected" : "option"}
2847
3489
  @click=${() => this.selectOption(opt.name, val)}
2848
3490
  aria-pressed=${isSelected}
2849
3491
  ?disabled=${!available}
@@ -2923,6 +3565,18 @@ __decorateClass([
2923
3565
  __decorateClass([
2924
3566
  state()
2925
3567
  ], VioProductDetail.prototype, "activeImageIndex", 2);
3568
+ __decorateClass([
3569
+ state()
3570
+ ], VioProductDetail.prototype, "thumbsOverflow", 2);
3571
+ __decorateClass([
3572
+ state()
3573
+ ], VioProductDetail.prototype, "thumbsAtStart", 2);
3574
+ __decorateClass([
3575
+ state()
3576
+ ], VioProductDetail.prototype, "thumbsAtEnd", 2);
3577
+ __decorateClass([
3578
+ state()
3579
+ ], VioProductDetail.prototype, "logoSquare", 2);
2926
3580
  __decorateClass([
2927
3581
  state()
2928
3582
  ], VioProductDetail.prototype, "adding", 2);
@@ -2974,6 +3628,14 @@ var VioCart = class extends LitElement {
2974
3628
  container: () => this.renderRoot.querySelector(".drawer"),
2975
3629
  close: () => this.close()
2976
3630
  });
3631
+ /** Phones: the page behind is locked, and the sheet closes by pulling the bar or the header down. */
3632
+ this.bottomSheet = new BottomSheet({
3633
+ host: this,
3634
+ sheet: () => this.renderRoot.querySelector(".drawer"),
3635
+ backdrop: () => this.renderRoot.querySelector(".backdrop"),
3636
+ dragZones: ".handle, .header",
3637
+ close: () => this.close()
3638
+ });
2977
3639
  this.boundOnCartChange = (e) => {
2978
3640
  const detail = e.detail;
2979
3641
  this.carts = detail.cartsBySponsor;
@@ -3004,7 +3666,7 @@ var VioCart = class extends LitElement {
3004
3666
  top: 0;
3005
3667
  right: 0;
3006
3668
  bottom: 0;
3007
- width: min(440px, 100%);
3669
+ width: min(var(--vio-cart-width, 440px), 100%);
3008
3670
  background: var(--vio-color-surface, #fff);
3009
3671
  transform: translateX(100%);
3010
3672
  transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
@@ -3033,8 +3695,11 @@ var VioCart = class extends LitElement {
3033
3695
  border-radius: var(--vio-radius-xl, 16px) var(--vio-radius-xl, 16px) 0 0;
3034
3696
  transform: translateY(100%);
3035
3697
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
3698
+ overscroll-behavior: contain;
3036
3699
  }
3037
3700
  :host([open]) .drawer { transform: translateY(0); }
3701
+ /* Pulled down to close (bottom-sheet.ts): the browser must not take the gesture for a scroll. */
3702
+ .handle, .header { touch-action: none; }
3038
3703
  .handle {
3039
3704
  display: flex;
3040
3705
  justify-content: center;
@@ -3354,6 +4019,7 @@ var VioCart = class extends LitElement {
3354
4019
  }
3355
4020
  disconnectedCallback() {
3356
4021
  Vio.cart.removeEventListener("change", this.boundOnCartChange);
4022
+ this.bottomSheet.disconnect();
3357
4023
  super.disconnectedCallback();
3358
4024
  }
3359
4025
  close() {
@@ -3400,10 +4066,16 @@ var VioCart = class extends LitElement {
3400
4066
  return isMethodEnabled(this.availableMethods, ...names);
3401
4067
  }
3402
4068
  updated(changed) {
3403
- if (changed.has("open")) this.dialogFocus.update(this.open);
4069
+ if (changed.has("open")) {
4070
+ this.dialogFocus.update(this.open);
4071
+ this.bottomSheet.update(this.open);
4072
+ }
3404
4073
  if (changed.has("open") && this.open) {
3405
4074
  this.confirmedOrder = null;
3406
4075
  this.preparedTotal = -1;
4076
+ for (const cart of this.carts.values()) {
4077
+ if (cart.cartId) void Vio.cart.refreshFromServer(cart.sponsorId);
4078
+ }
3407
4079
  }
3408
4080
  if (this.open && this.itemCount > 0) {
3409
4081
  const total = this.totalAcrossSponsors;
@@ -3519,23 +4191,29 @@ var VioCart = class extends LitElement {
3519
4191
  if (this.itemCount === 0) return;
3520
4192
  const firstSponsorId = [...this.carts.keys()][0];
3521
4193
  if (firstSponsorId === void 0) return;
3522
- void this.startVippsExpressOrCheckout(firstSponsorId);
4194
+ const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: firstSponsorId, source: "cart" });
4195
+ void this.startVippsExpressOrCheckout(firstSponsorId, redirect);
3523
4196
  }
3524
4197
  /** Vipps' own button when its script answers; our orange one otherwise. */
3525
4198
  async loadVippsButton() {
3526
4199
  this.vippsOfficial = await ensureVippsButton();
3527
4200
  }
3528
- async startVippsExpressOrCheckout(sponsorId) {
4201
+ async startVippsExpressOrCheckout(sponsorId, redirect) {
3529
4202
  let fallback = true;
3530
4203
  try {
3531
4204
  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;
4205
+ if (!await Vio.checkout.getVippsExpressEnabled(sponsorId)) {
4206
+ redirect.abort("express-off");
4207
+ } else {
4208
+ const res = await Vio.checkout.startVippsExpress(sponsorId);
4209
+ fallback = !res?.payment_url;
4210
+ if (fallback) redirect.abort("not-started");
4211
+ }
3535
4212
  } catch (err) {
3536
4213
  if (typeof console !== "undefined") {
3537
4214
  console.warn("[VioCart] Vipps Express not started:", err);
3538
4215
  }
4216
+ redirect.fail(err instanceof Error ? err.message : String(err));
3539
4217
  }
3540
4218
  if (!fallback) return;
3541
4219
  this.dispatchEvent(
@@ -3826,6 +4504,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
3826
4504
  this.open = false;
3827
4505
  this.heading = "Kasse";
3828
4506
  this.shippingLabel = "Beregnes ved kasse";
4507
+ this.vippsShipsInApp = false;
3829
4508
  this.express = false;
3830
4509
  this.checkoutState = null;
3831
4510
  this.items = [];
@@ -3941,6 +4620,15 @@ var _VioCheckout = class _VioCheckout extends LitElement {
3941
4620
  close: () => this.close(),
3942
4621
  canClose: () => !this.paymentInFlight
3943
4622
  });
4623
+ /** Phones: the page behind is locked, and the sheet closes by pulling the bar or the top bar down — not mid-payment. */
4624
+ this.bottomSheet = new BottomSheet({
4625
+ host: this,
4626
+ sheet: () => this.renderRoot.querySelector(".modal"),
4627
+ backdrop: () => this.renderRoot.querySelector(".backdrop"),
4628
+ dragZones: ".handle, .topbar",
4629
+ close: () => this.close(),
4630
+ canClose: () => !this.paymentInFlight
4631
+ });
3944
4632
  this.paymentMethodsResolved = false;
3945
4633
  /** Guards `autoSelectSoleMethod` to ONE attempt per opening — see there. */
3946
4634
  this.autoSelectAttempted = false;
@@ -4093,8 +4781,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4093
4781
  from the right instead of the full-screen overlay. */
4094
4782
  .modal.as-side {
4095
4783
  inset: 0 0 0 auto;
4096
- width: min(440px, 100%);
4097
- max-width: 440px;
4784
+ width: min(var(--vio-checkout-width, 440px), 100%);
4785
+ max-width: var(--vio-checkout-width, 440px);
4098
4786
  transform: translateX(100%);
4099
4787
  border-radius: 0;
4100
4788
  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.1);
@@ -4109,8 +4797,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4109
4797
  @media (min-width: 601px) {
4110
4798
  .modal {
4111
4799
  inset: 0 0 0 auto;
4112
- width: min(440px, 100%);
4113
- max-width: 440px;
4800
+ width: min(var(--vio-checkout-width, 440px), 100%);
4801
+ max-width: var(--vio-checkout-width, 440px);
4114
4802
  transform: translateX(100%);
4115
4803
  border-radius: 0;
4116
4804
  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.1);
@@ -4147,7 +4835,10 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4147
4835
  height: 92vh;
4148
4836
  border-radius: var(--vio-radius-xl, 16px) var(--vio-radius-xl, 16px) 0 0;
4149
4837
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
4838
+ overscroll-behavior: contain;
4150
4839
  }
4840
+ /* Pulled down to close (bottom-sheet.ts): the browser must not take the gesture for a scroll. */
4841
+ .handle, .topbar { touch-action: none; }
4151
4842
  .handle {
4152
4843
  display: flex;
4153
4844
  justify-content: center;
@@ -4522,7 +5213,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4522
5213
  width: 48px;
4523
5214
  height: 48px;
4524
5215
  border-radius: 50%;
4525
- background: #2e7d32;
5216
+ background: var(--vio-confirm-check-bg, #2e7d32);
4526
5217
  color: #fff;
4527
5218
  display: flex;
4528
5219
  align-items: center;
@@ -4532,8 +5223,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4532
5223
  }
4533
5224
  .confirm-title {
4534
5225
  font-family: var(--vio-font-serif, Georgia, serif);
4535
- font-size: 24px;
4536
- font-weight: 400;
5226
+ font-size: var(--vio-confirm-title-size, 24px);
5227
+ font-weight: var(--vio-confirm-title-weight, 400);
4537
5228
  margin: 0;
4538
5229
  }
4539
5230
  /* The brand the order is with: its logo over the thanks, its name in it. */
@@ -4541,13 +5232,12 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4541
5232
  .confirm-brand-logo {
4542
5233
  display: block;
4543
5234
  width: auto;
4544
- height: auto;
4545
- max-height: 32px;
4546
- max-width: 160px;
5235
+ height: var(--vio-confirm-logo-height, 40px);
5236
+ max-width: var(--vio-confirm-logo-max-width, 220px);
4547
5237
  object-fit: contain;
4548
5238
  }
4549
5239
  .confirm-sponsor {
4550
- font-size: 14px;
5240
+ font-size: var(--vio-confirm-text-size, 14px);
4551
5241
  line-height: 1.4;
4552
5242
  color: var(--vio-color-text, #0a0a0a);
4553
5243
  margin: 4px 0 0;
@@ -4771,6 +5461,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4771
5461
  } else if (outcome === "failed") {
4772
5462
  this.paymentError = "Betalingen ble avbrutt eller feilet. Vennligst pr\xF8v igjen.";
4773
5463
  this.open = true;
5464
+ returnCancelled({ method: vioMethod, sponsorId, source: "checkout", checkoutId });
4774
5465
  } else if (sawStatus) {
4775
5466
  this.paymentNotice = "Betalingen behandles fortsatt. Du f\xE5r ordrebekreftelse p\xE5 e-post n\xE5r den er gjennomf\xF8rt. Handlekurven er uendret.";
4776
5467
  this.open = true;
@@ -5113,16 +5804,23 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5113
5804
  this.unmountNexi();
5114
5805
  this.unmountAdyen();
5115
5806
  for (const settle of [...this.pendingSuccessEmits]) settle();
5807
+ this.bottomSheet.disconnect();
5116
5808
  super.disconnectedCallback();
5117
5809
  }
5118
5810
  updated(changed) {
5119
- if (changed.has("open")) this.dialogFocus.update(this.open);
5811
+ if (changed.has("open")) {
5812
+ this.dialogFocus.update(this.open);
5813
+ this.bottomSheet.update(this.open);
5814
+ }
5120
5815
  if (changed.has("open") && this.open && this.items.length > 0 && this.availableShippingsList.length === 0) {
5121
5816
  void this.loadAvailableShippings();
5122
5817
  }
5123
5818
  if (changed.has("open") && this.open) {
5124
5819
  this.autoSelectSoleMethod();
5125
5820
  }
5821
+ if (changed.has("open") && this.open && this.checkoutState?.sponsorId && !this.orderConfirmed) {
5822
+ void Vio.cart.refreshFromServer(this.checkoutState.sponsorId);
5823
+ }
5126
5824
  if ((changed.has("klarnaAvailable") && this.klarnaAvailable || changed.has("applePayAvailable") && this.applePayAvailable) && this.paymentMethodsResolved && !this.checkoutState?.paymentMethod) {
5127
5825
  this.autoSelectAttempted = false;
5128
5826
  this.autoSelectSoleMethod();
@@ -5249,6 +5947,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5249
5947
  if (Array.isArray(methods)) {
5250
5948
  this.availableMethods = methods.map((m) => m.name);
5251
5949
  this.stripeMode = await Vio.checkout.getStripeMode(spId);
5950
+ this.vippsShipsInApp = this.availableMethods.some((m) => String(m).toLowerCase() === "vipps") ? await Vio.checkout.getVippsExpressEnabled(spId) : false;
5252
5951
  } else {
5253
5952
  this.availableMethods = null;
5254
5953
  }
@@ -5569,10 +6268,12 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5569
6268
  }
5570
6269
  if (method === "vipps") {
5571
6270
  this.vippsLoading = true;
6271
+ const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: this.checkoutState?.sponsorId, source: "checkout" });
5572
6272
  void this.startVippsFromCheckout().catch((err) => {
5573
6273
  if (typeof console !== "undefined") {
5574
6274
  console.warn("[VioCheckout] Vipps payment could not start:", err);
5575
6275
  }
6276
+ redirect.fail(errorText(err));
5576
6277
  this.paymentError = friendlyPaymentError(errorText(err));
5577
6278
  }).finally(() => {
5578
6279
  this.vippsLoading = false;
@@ -5657,12 +6358,14 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5657
6358
  * `visibilitychange`) or the element leaves, the event goes out at once
5658
6359
  * without the reference. Exactly one event per call. `afterwards` runs
5659
6360
  * right after the event, so a path keeps the order it always had (the cart
5660
- * is cleared after the event).
6361
+ * is cleared after the event). When that answer already has Vio's order
6362
+ * number, the event carries it too (`orderNumber`): it is the purchase's
6363
+ * `order_id`, and analytics need not ask again.
5661
6364
  */
5662
6365
  async emitPaymentSuccess(detail, checkoutId, sponsorId, afterwards) {
5663
6366
  let done = false;
5664
6367
  const sponsorAtStart = this.sponsorOfOrder(sponsorId, checkoutId);
5665
- const finish = (buyerRef) => {
6368
+ const finish = (found = {}) => {
5666
6369
  if (done) return;
5667
6370
  done = true;
5668
6371
  const named = this.sponsorOfOrder(sponsorId, checkoutId);
@@ -5671,7 +6374,11 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5671
6374
  const event = new CustomEvent("vio:payment-success", {
5672
6375
  bubbles: true,
5673
6376
  composed: true,
5674
- detail: buyerRef ? { ...withSponsor, buyerRef } : withSponsor
6377
+ detail: found.buyerRef || found.orderNumber ? {
6378
+ ...withSponsor,
6379
+ ...found.buyerRef ? { buyerRef: found.buyerRef } : {},
6380
+ ...found.orderNumber ? { orderNumber: found.orderNumber } : {}
6381
+ } : withSponsor
5675
6382
  });
5676
6383
  this.dispatchEvent(event);
5677
6384
  if (!this.isConnected && typeof window !== "undefined") {
@@ -5684,7 +6391,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5684
6391
  };
5685
6392
  const known = buyerRefOf(detail);
5686
6393
  if (known || !checkoutId || !this.shouldAskBuyerRef(detail)) {
5687
- finish(known);
6394
+ finish({ buyerRef: known });
5688
6395
  return;
5689
6396
  }
5690
6397
  const settleEarly = () => finish();
@@ -5716,8 +6423,9 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5716
6423
  * failed or has neither the reference nor an order yet (the webhook has not
5717
6424
  * written the checkout), again at 1.2 s — all within 2.5 s. An order
5718
6425
  * 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.
6426
+ * again cannot help. Whatever the answer said of the order number comes
6427
+ * back with it. `{}` when the time is up, when the answer stopped mattering
6428
+ * (`stopped()`) or on failure.
5721
6429
  */
5722
6430
  async askBuyerRef(checkoutId, sponsorId, stopped) {
5723
6431
  const started = Date.now();
@@ -5730,19 +6438,20 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5730
6438
  for (const at of [0, _VioCheckout.BUYER_REF_RETRY_AT_MS]) {
5731
6439
  const wait = Math.min(at - (Date.now() - started), remaining());
5732
6440
  if (wait > 0) await Promise.race([new Promise((resolve) => setTimeout(resolve, wait)), timeUp]);
5733
- if (stopped() || remaining() <= 0) return void 0;
6441
+ if (stopped() || remaining() <= 0) return {};
5734
6442
  try {
5735
6443
  const reference = await Promise.race([
5736
6444
  Vio.checkout.getOrderReference(checkoutId, sponsorId || void 0),
5737
6445
  timeUp
5738
6446
  ]);
5739
- if (reference === null) return void 0;
5740
- if (reference.buyerRef) return reference.buyerRef;
5741
- if (reference.orderNumber !== null) return void 0;
6447
+ if (reference === null) return {};
6448
+ const order = reference.orderNumber ? { orderNumber: reference.orderNumber } : {};
6449
+ if (reference.buyerRef) return { buyerRef: reference.buyerRef, ...order };
6450
+ if (reference.orderNumber !== null) return order;
5742
6451
  } catch {
5743
6452
  }
5744
6453
  }
5745
- return void 0;
6454
+ return {};
5746
6455
  } finally {
5747
6456
  clearTimeout(deadline);
5748
6457
  }
@@ -5814,6 +6523,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5814
6523
  * express always has one; the normal flow has one once the backend
5815
6524
  * shippings loaded (the choice IS persisted on the cart and charged). */
5816
6525
  shippingMajor() {
6526
+ if (this.vippsChoosesShipping()) return 0;
5817
6527
  if (this.checkoutState?.paymentMethod === "qliro" && typeof this.qliroShipping?.price === "number") {
5818
6528
  return this.qliroShipping.price;
5819
6529
  }
@@ -5828,6 +6538,10 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5828
6538
  if (!o) return 0;
5829
6539
  return o.priceMajor ?? o.price / 100;
5830
6540
  }
6541
+ /** Vipps is the method and it runs as Express: the delivery is chosen in Vipps. */
6542
+ vippsChoosesShipping() {
6543
+ return this.checkoutState?.paymentMethod === "vipps" && this.vippsShipsInApp;
6544
+ }
5831
6545
  /** The rate the summary names — Qliro's own pick while Qliro is the method. */
5832
6546
  summaryShippingName() {
5833
6547
  if (this.checkoutState?.paymentMethod === "qliro" && this.qliroShipping) {
@@ -6006,22 +6720,22 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6006
6720
  if (email) details.push(["Kvittering til", email]);
6007
6721
  const sponsor = this.checkoutSponsorIdentity();
6008
6722
  return html`
6009
- <section class="confirmation">
6723
+ <section class="confirmation" part="confirmation">
6010
6724
  ${usableLogo(sponsor) ? html`<div class="confirm-brand" part="sponsor">
6011
6725
  ${renderSponsorMark(this, sponsor, { logo: "confirm-brand-logo", name: "confirm-brand-name" })}
6012
6726
  </div>` : ""}
6013
6727
  <header class="confirm-head">
6014
6728
  <div class="confirm-check" aria-hidden="true">✓</div>
6015
6729
  <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>` : ""}
6730
+ <h3 class="confirm-title" part="confirm-title">Takk for bestillingen!</h3>
6731
+ ${sponsor?.name ? html`<p class="confirm-sponsor" part="confirm-sponsor">Du har handlet hos <strong>${sponsor.name}</strong>.</p>` : ""}
6018
6732
  <p class="confirm-text">
6019
6733
  Betalingen er bekreftet.${email ? html` Du får en ordrebekreftelse på e-post.` : ""}
6020
6734
  </p>
6021
6735
  </div>
6022
6736
  </header>
6023
6737
 
6024
- <dl class="confirm-details">
6738
+ <dl class="confirm-details" part="confirm-details">
6025
6739
  ${details.map(
6026
6740
  ([k, v]) => html`<div><dt>${k}</dt><dd class=${k === o?.providerRef?.label ? "ref" : ""}>${v}</dd></div>`
6027
6741
  )}
@@ -6431,12 +7145,12 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6431
7145
  for (const delay of _VioCheckout.NEXI_DETAILS_DELAYS_MS) {
6432
7146
  if (delay) await new Promise((r) => setTimeout(r, delay));
6433
7147
  const o = await Vio.checkout.getNexiOrder(checkoutId, sponsorId).catch(() => null);
6434
- const current = this.confirmedOrder;
6435
- if (!current || current.providerRef?.value !== paymentId) return;
7148
+ const current2 = this.confirmedOrder;
7149
+ if (!current2 || current2.providerRef?.value !== paymentId) return;
6436
7150
  if (!o) continue;
6437
7151
  const paidWith = o.payment_method ? o.card_last4 ? `${o.payment_method} \u2022\u2022\u2022\u2022 ${o.card_last4}` : o.payment_method : void 0;
6438
7152
  this.confirmedOrder = {
6439
- ...current,
7153
+ ...current2,
6440
7154
  ...paidWith ? { paidWith } : {},
6441
7155
  ...o.email ? { email: o.email } : {}
6442
7156
  };
@@ -6464,15 +7178,15 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6464
7178
  const options = this.nexiShipping?.options ?? [];
6465
7179
  if (options.length < 2) return "";
6466
7180
  const currency = this.checkoutState?.currency || getGlobalCurrency();
6467
- const current = this.nexiShipping?.shipping_id;
7181
+ const current2 = this.nexiShipping?.shipping_id;
6468
7182
  return html`
6469
7183
  <div class="ship-select nexi-ship" role="radiogroup" aria-label="Frakt">
6470
7184
  ${options.map(
6471
7185
  (o) => html`
6472
7186
  <button
6473
- class="ship-opt ${current === o.id ? "active" : ""}"
7187
+ class="ship-opt ${current2 === o.id ? "active" : ""}"
6474
7188
  role="radio"
6475
- aria-checked=${current === o.id}
7189
+ aria-checked=${current2 === o.id}
6476
7190
  ?disabled=${this.nexiPicking}
6477
7191
  @click=${() => void this.onPickNexiShipping(o.id)}
6478
7192
  >
@@ -7250,7 +7964,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7250
7964
  </div>
7251
7965
  <div class="order-row">
7252
7966
  <span>Frakt${this.shippingMajor() > 0 && this.summaryShippingName() ? ` \u2013 ${this.summaryShippingName()}` : ""}</span>
7253
- <span>${this.shippingMajor() > 0 ? formatPrice(this.shippingMajor(), this.checkoutState?.currency) : this.shippingLabel}</span>
7967
+ <span>${this.vippsChoosesShipping() ? "Velges i Vipps" : this.shippingMajor() > 0 ? formatPrice(this.shippingMajor(), this.checkoutState?.currency) : this.shippingLabel}</span>
7254
7968
  </div>
7255
7969
  <div class="order-row total">
7256
7970
  <span>Totalt</span><span>${this.payTotalLabel()}</span>
@@ -7271,6 +7985,9 @@ __decorateClass([
7271
7985
  __decorateClass([
7272
7986
  property({ type: String, attribute: "shipping-label" })
7273
7987
  ], _VioCheckout.prototype, "shippingLabel", 2);
7988
+ __decorateClass([
7989
+ state()
7990
+ ], _VioCheckout.prototype, "vippsShipsInApp", 2);
7274
7991
  __decorateClass([
7275
7992
  property({ type: Boolean, reflect: true })
7276
7993
  ], _VioCheckout.prototype, "express", 2);
@@ -7446,5 +8163,5 @@ function registerVioElements() {
7446
8163
  }
7447
8164
 
7448
8165
  export { VioCart, VioCheckout, VioProduct, VioProductCarousel, VioProductDetail, VioTokens, applyVioTheme, registerVioElements };
7449
- //# sourceMappingURL=chunk-GLAKUYQA.js.map
7450
- //# sourceMappingURL=chunk-GLAKUYQA.js.map
8166
+ //# sourceMappingURL=chunk-L6YYYKQK.js.map
8167
+ //# sourceMappingURL=chunk-L6YYYKQK.js.map