@vio-live/web-sdk 0.28.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-AFJ5M53F.js';
1
+ import { Vio, __decorateClass, primaryImageUrl, displayPrice, toCampaignId, getGlobalCurrency, ensureVippsButton, formatPrice, buyerRefOf, nexiReturnPaymentId, readNexiPending, kustomReturnOutcome, KUSTOM_RETURN_QUERY_KEYS, storageGet, ADYEN_RETURN_METHOD, adyenReturnParams, ADYEN_RETURN_QUERY_KEYS, adyenReturnOutcome, getGlobalCountryCode, isValidPhone, gatewayHasBuyerRef, rememberedCheckoutSponsor, checkoutSponsorOf, AdyenOriginError, normalizeSlotId, autoSlotDisplayName, autoSlotId, sponsorIdentityFrom, Configuration, normalizeScope, campaignCandidates } from './chunk-IR4XYTZL.js';
2
2
  import { LitElement, css, html, nothing } from 'lit';
3
3
  import { property, state, query } from 'lit/decorators.js';
4
4
 
@@ -164,6 +164,13 @@ var THEME_KEY_TO_CSS_VAR = {
164
164
  detailImageZoom: "--vio-detail-image-zoom",
165
165
  detailImageInset: "--vio-detail-image-inset",
166
166
  detailImageBg: "--vio-detail-image-bg",
167
+ detailGallery: "--vio-detail-gallery",
168
+ carouselLayout: "--vio-carousel-layout",
169
+ detailCarouselPeek: "--vio-detail-carousel-peek",
170
+ detailDescriptionMode: "--vio-detail-description-mode",
171
+ detailDescriptionPreviewLines: "--vio-detail-description-preview-lines",
172
+ detailHighlightsDisplay: "--vio-detail-highlights-display",
173
+ detailShippingReturnsDisplay: "--vio-detail-shipping-returns-display",
167
174
  detailThumbSize: "--vio-detail-thumb-size",
168
175
  detailThumbGap: "--vio-detail-thumb-gap",
169
176
  detailThumbRadius: "--vio-detail-thumb-radius",
@@ -176,6 +183,18 @@ var THEME_KEY_TO_CSS_VAR = {
176
183
  detailButtonRadius: "--vio-detail-button-radius",
177
184
  detailBuyPosition: "--vio-detail-buy-position",
178
185
  detailBuyShadow: "--vio-detail-buy-shadow",
186
+ cardFont: "--vio-card-font",
187
+ cardBorder: "--vio-card-border",
188
+ cardImageAspect: "--vio-card-image-aspect",
189
+ cardImageFit: "--vio-card-image-fit",
190
+ cardImageBg: "--vio-card-image-bg",
191
+ cardImagePadding: "--vio-card-image-padding",
192
+ cardMetaPadding: "--vio-card-meta-padding",
193
+ cardPriceFont: "--vio-card-price-font",
194
+ cardAccent: "--vio-card-accent",
195
+ cardBarRadius: "--vio-card-bar-radius",
196
+ cardDescriptionLines: "--vio-card-description-lines",
197
+ cardRetailerOrder: "--vio-card-retailer-order",
179
198
  cartWidth: "--vio-cart-width",
180
199
  checkoutWidth: "--vio-checkout-width",
181
200
  confirmLogoHeight: "--vio-confirm-logo-height",
@@ -359,6 +378,10 @@ var VioProduct = class extends LitElement {
359
378
  this.brand = "";
360
379
  this.name = "";
361
380
  this.price = "";
381
+ this.description = "";
382
+ this.showDescription = false;
383
+ this.buttonStyle = "icon";
384
+ this.buttonLabel = "Kj\xF8p";
362
385
  this.retailer = "";
363
386
  this.imageUrl = "";
364
387
  this.productId = "";
@@ -427,20 +450,29 @@ var VioProduct = class extends LitElement {
427
450
  box-sizing: border-box;
428
451
  width: 100%;
429
452
  cursor: pointer;
430
- font-family: var(--vio-font-sans, -apple-system, sans-serif);
453
+ font-family: var(--vio-card-font, var(--vio-font-sans, -apple-system, sans-serif));
431
454
  color: var(--vio-color-text, #0a0a0a);
432
455
  -webkit-tap-highlight-color: transparent;
433
456
  }
457
+ /* Every --vio-card-* below defaults to the look the card always had; a
458
+ host restyles the card with them (they cross the carousel's shadow
459
+ root, ::part would not). E.g. a framed card with a buy button:
460
+ --vio-card-border, --vio-card-meta-padding, --vio-card-image-fit: contain. */
434
461
  .card {
435
462
  display: flex;
436
463
  flex-direction: column;
437
464
  height: 100%;
465
+ box-sizing: border-box;
466
+ border: var(--vio-card-border, none);
467
+ background: var(--vio-card-bg, transparent);
468
+ transition: border-color 0.2s ease;
438
469
  }
470
+ :host(:hover) .card { border-color: var(--vio-card-border-hover-color, var(--vio-color-border, #e5e5e5)); }
439
471
  .image {
440
472
  position: relative;
441
- aspect-ratio: 1 / 1;
473
+ aspect-ratio: var(--vio-card-image-aspect, 1 / 1);
442
474
  width: 100%;
443
- background-color: var(--vio-color-surface-muted, #f2f2f2);
475
+ background-color: var(--vio-card-image-bg, var(--vio-color-surface-muted, #f2f2f2));
444
476
  display: flex;
445
477
  align-items: center;
446
478
  justify-content: center;
@@ -455,8 +487,8 @@ var VioProduct = class extends LitElement {
455
487
  position: absolute;
456
488
  right: 10px;
457
489
  bottom: 10px;
458
- width: 40px;
459
- height: 40px;
490
+ width: var(--vio-card-add-size, 40px);
491
+ height: var(--vio-card-add-size, 40px);
460
492
  border-radius: 50%;
461
493
  background: rgba(255, 255, 255, 0.96);
462
494
  border: none;
@@ -476,7 +508,7 @@ var VioProduct = class extends LitElement {
476
508
  .add-btn.in-cart { background: #0a0a0a; color: #fff; }
477
509
  .add-btn.in-cart:hover { background: #222; }
478
510
  .add-btn.pop { animation: vio-add-pop 0.42s cubic-bezier(0.34, 1.56, 0.64, 1); }
479
- .add-btn svg { width: 18px; height: 18px; }
511
+ .add-btn svg { width: var(--vio-card-add-icon-size, 18px); height: var(--vio-card-add-icon-size, 18px); }
480
512
  .add-btn .add-count { font-size: 15px; font-weight: 700; line-height: 1; }
481
513
  @keyframes vio-add-pop {
482
514
  0% { transform: scale(1); }
@@ -487,47 +519,87 @@ var VioProduct = class extends LitElement {
487
519
  .image img {
488
520
  width: 100%;
489
521
  height: 100%;
490
- object-fit: cover;
522
+ box-sizing: border-box;
523
+ padding: var(--vio-card-image-padding, 0);
524
+ object-fit: var(--vio-card-image-fit, cover);
491
525
  display: block;
492
526
  }
493
527
  .meta {
494
- padding-top: 14px;
528
+ padding: var(--vio-card-meta-padding, 14px 0 0);
495
529
  display: flex;
496
530
  flex-direction: column;
497
531
  flex: 1;
498
532
  }
499
533
  .brand {
500
- font-size: var(--vio-size-xs, 12px);
501
- letter-spacing: var(--vio-tracking-label, 0.1em);
534
+ font-size: var(--vio-card-brand-size, var(--vio-size-xs, 12px));
535
+ letter-spacing: var(--vio-card-brand-tracking, var(--vio-tracking-label, 0.1em));
502
536
  text-transform: uppercase;
503
- color: var(--vio-color-text-secondary, #666);
504
- font-weight: 500;
505
- margin-bottom: 6px;
537
+ color: var(--vio-card-brand-color, var(--vio-color-text-secondary, #666));
538
+ font-weight: var(--vio-card-brand-weight, 500);
539
+ margin-bottom: var(--vio-card-brand-gap, 6px);
506
540
  }
507
541
  .name {
508
- font-size: var(--vio-size-md, 16px);
509
- font-weight: 500;
542
+ font-family: var(--vio-card-name-font, inherit);
543
+ font-size: var(--vio-card-name-size, var(--vio-size-md, 16px));
544
+ font-weight: var(--vio-card-name-weight, 500);
510
545
  line-height: 1.3;
511
- margin: 0 0 10px;
546
+ margin: 0 0 var(--vio-card-name-gap, 10px);
512
547
  display: -webkit-box;
513
548
  -webkit-line-clamp: 2;
514
549
  line-clamp: 2;
515
550
  -webkit-box-orient: vertical;
516
551
  overflow: hidden;
517
552
  }
553
+ /* show-description: cut after --vio-card-description-lines (4). */
554
+ .description {
555
+ display: -webkit-box;
556
+ -webkit-line-clamp: var(--vio-card-description-lines, 4);
557
+ line-clamp: var(--vio-card-description-lines, 4);
558
+ -webkit-box-orient: vertical;
559
+ overflow: hidden;
560
+ font-size: var(--vio-card-description-size, 14px);
561
+ line-height: 1.45;
562
+ color: var(--vio-card-description-color, var(--vio-color-text-secondary, #737373));
563
+ margin: 0 0 var(--vio-card-description-gap, 12px);
564
+ }
518
565
  .price {
519
- font-size: var(--vio-size-md, 16px);
520
- font-weight: 700;
521
- margin-bottom: 16px;
566
+ font-family: var(--vio-card-price-font, inherit);
567
+ font-size: var(--vio-card-price-size, var(--vio-size-md, 16px));
568
+ font-weight: var(--vio-card-price-weight, 700);
569
+ margin-bottom: var(--vio-card-price-gap, 16px);
522
570
  }
571
+ /* Who sells it. --vio-card-retailer-order: -1 lifts it above the name
572
+ (where a brand line goes), for catalogues without a brand of their own. */
523
573
  .retailer {
524
- font-size: var(--vio-size-xs, 12px);
525
- letter-spacing: var(--vio-tracking-label, 0.1em);
574
+ display: var(--vio-card-retailer-display, block);
575
+ order: var(--vio-card-retailer-order, 0);
576
+ font-size: var(--vio-card-retailer-size, var(--vio-size-xs, 12px));
577
+ letter-spacing: var(--vio-card-retailer-tracking, var(--vio-tracking-label, 0.1em));
526
578
  text-transform: uppercase;
527
- color: var(--vio-color-text-tertiary, #999);
528
- font-weight: 500;
579
+ color: var(--vio-card-retailer-color, var(--vio-color-text-tertiary, #999));
580
+ font-weight: var(--vio-card-retailer-weight, 500);
581
+ margin: var(--vio-card-retailer-margin, auto 0 0);
582
+ }
583
+ /* button-style="bar": the Vev card's bar — full width under the price,
584
+ uppercase, the accent colour, 6px corners (--vio-card-bar-radius). */
585
+ .bar-btn {
586
+ width: 100%;
529
587
  margin-top: auto;
588
+ padding: 14px;
589
+ border: none;
590
+ border-radius: var(--vio-card-bar-radius, 6px);
591
+ background: var(--vio-card-accent, var(--vio-color-text, #0a0a0a));
592
+ color: var(--vio-card-accent-text, #fff);
593
+ font-family: inherit;
594
+ font-size: 13px;
595
+ font-weight: 600;
596
+ letter-spacing: 0.08em;
597
+ text-transform: uppercase;
598
+ cursor: pointer;
599
+ transition: opacity 0.15s ease;
530
600
  }
601
+ .bar-btn:hover { opacity: 0.88; }
602
+ .bar-btn.pop { animation: vio-add-pop 0.42s cubic-bezier(0.34, 1.56, 0.64, 1); }
531
603
  `;
532
604
  }
533
605
  connectedCallback() {
@@ -644,7 +716,7 @@ var VioProduct = class extends LitElement {
644
716
  >
645
717
  <div class="image">
646
718
  ${this.imageUrl ? html`<img src=${this.imageUrl} alt=${this.name || this.brand} loading="lazy" />` : ""}
647
- <button
719
+ ${this.buttonStyle === "icon" ? html`<button
648
720
  class="add-btn ${this.inCart ? "in-cart" : ""} ${this.justAdded ? "pop" : ""}"
649
721
  @click=${this.onAdd}
650
722
  aria-label=${this.inCart ? "I handlekurven \u2013 legg til en til" : "Legg i kurv"}
@@ -657,14 +729,18 @@ var VioProduct = class extends LitElement {
657
729
  <line x1="3" y1="6" x2="21" y2="6"></line>
658
730
  <path d="M16 10a4 4 0 0 1-8 0"></path>
659
731
  </svg>`}
660
- </button>
732
+ </button>` : ""}
661
733
  </div>
662
734
  ${this.hideMeta ? "" : html`
663
735
  <div class="meta">
664
736
  ${this.brand ? html`<div class="brand">${this.brand}</div>` : ""}
665
737
  ${this.name ? html`<div class="name">${this.name}</div>` : ""}
738
+ ${this.showDescription && this.description ? html`<p class="description">${this.description}</p>` : ""}
666
739
  ${this.price ? html`<div class="price">${this.price}</div>` : ""}
667
740
  ${this.retailer ? html`<div class="retailer">${this.retailer}</div>` : ""}
741
+ ${this.buttonStyle === "bar" ? html`<button class="bar-btn ${this.justAdded ? "pop" : ""}" @click=${this.onAdd}>
742
+ ${this.inCart ? `${this.buttonLabel} \xB7 ${this.cartQty}` : this.buttonLabel}
743
+ </button>` : ""}
668
744
  </div>`}
669
745
  </div>
670
746
  `;
@@ -679,6 +755,18 @@ __decorateClass([
679
755
  __decorateClass([
680
756
  property({ type: String })
681
757
  ], VioProduct.prototype, "price", 2);
758
+ __decorateClass([
759
+ property({ type: String })
760
+ ], VioProduct.prototype, "description", 2);
761
+ __decorateClass([
762
+ property({ type: Boolean, attribute: "show-description" })
763
+ ], VioProduct.prototype, "showDescription", 2);
764
+ __decorateClass([
765
+ property({ type: String, attribute: "button-style" })
766
+ ], VioProduct.prototype, "buttonStyle", 2);
767
+ __decorateClass([
768
+ property({ type: String, attribute: "button-label" })
769
+ ], VioProduct.prototype, "buttonLabel", 2);
682
770
  __decorateClass([
683
771
  property({ type: String })
684
772
  ], VioProduct.prototype, "retailer", 2);
@@ -797,6 +885,12 @@ var VioProductCarousel = class extends LitElement {
797
885
  this.sponsorId = 0;
798
886
  this.productRefs = "";
799
887
  this.currency = "NOK";
888
+ this.layout = "";
889
+ this.showDescription = false;
890
+ this.descriptions = {};
891
+ this.buttonStyle = "icon";
892
+ this.buttonLabel = "Kj\xF8p";
893
+ this.resolvedLayout = "carousel";
800
894
  this.vioSlot = "";
801
895
  this.vioSlotName = "";
802
896
  this.vioScope = "";
@@ -810,7 +904,11 @@ var VioProductCarousel = class extends LitElement {
810
904
  this.fetchError = "";
811
905
  // ── Analytics (auto) ──────────────────────────────────────────────
812
906
  // component_impression on the contract rule (≥50% visible ≥1s, once per
813
- // session) + view_item_list with the rendered products. No-ops unless
907
+ // session) with the products shown in `commerce.items` (ADR-0041: the
908
+ // collector counts per-product impressions from them) + view_item_list with
909
+ // the rendered products. While products load the impression is not
910
+ // observed: it starts once they are here (or the fetch failed — slotted
911
+ // fallback is still an impression, without items). No-ops unless
814
912
  // Vio.analytics.start() ran.
815
913
  this.unobserveImpression = null;
816
914
  this.impressed = false;
@@ -919,6 +1017,21 @@ var VioProductCarousel = class extends LitElement {
919
1017
  .scroller { padding: 0 var(--vio-space-lg, 24px) 8px; }
920
1018
  }
921
1019
  .scroller::-webkit-scrollbar { display: none; }
1020
+ /* layout="list": one card under the other, as wide as the column. */
1021
+ .viewport.list .scroller {
1022
+ flex-direction: column;
1023
+ align-items: stretch;
1024
+ gap: var(--vio-carousel-list-gap, 32px);
1025
+ max-width: var(--vio-carousel-list-max-width, 480px);
1026
+ margin: 0 auto;
1027
+ padding: 0 var(--vio-space-lg, 24px);
1028
+ overflow: visible;
1029
+ scroll-snap-type: none;
1030
+ }
1031
+ .viewport.list .scroller vio-product,
1032
+ .viewport.list ::slotted(*),
1033
+ .viewport.list .skeleton { flex: 0 0 auto; width: 100%; }
1034
+ .viewport.list .nav-btn { display: none; }
922
1035
  ::slotted(*),
923
1036
  vio-product {
924
1037
  flex: 0 0 240px;
@@ -991,13 +1104,32 @@ var VioProductCarousel = class extends LitElement {
991
1104
  }
992
1105
  `;
993
1106
  }
1107
+ overrideFor(p) {
1108
+ const own = this.descriptions?.[String(p.id)];
1109
+ return typeof own === "string" ? own : void 0;
1110
+ }
1111
+ descriptionFor(p) {
1112
+ return this.overrideFor(p) ?? cardDescription(p.description);
1113
+ }
1114
+ /** The editor's text always shows (unless empty); the catalogue's only with show-description. */
1115
+ showsDescription(p) {
1116
+ const own = this.overrideFor(p);
1117
+ return own !== void 0 ? own.trim() !== "" : this.showDescription;
1118
+ }
1119
+ willUpdate(changed) {
1120
+ if (changed.has("layout") || !this.hasUpdated) {
1121
+ const fromTheme = typeof getComputedStyle === "function" ? getComputedStyle(this).getPropertyValue("--vio-carousel-layout") : "";
1122
+ const value = (this.layout || fromTheme).trim().replace(/^["']|["']$/g, "").toLowerCase();
1123
+ this.resolvedLayout = value === "list" ? "list" : "carousel";
1124
+ }
1125
+ }
994
1126
  connectedCallback() {
995
1127
  super.connectedCallback();
996
1128
  this.slotRegistration.connect(this.slotAttributes());
997
- this.observeForAnalytics();
998
1129
  if (this.productRefs.trim() || this.productIds.trim()) {
999
1130
  void this.autoFetch();
1000
1131
  }
1132
+ this.observeForAnalytics();
1001
1133
  }
1002
1134
  disconnectedCallback() {
1003
1135
  this.unobserveImpression?.();
@@ -1031,6 +1163,10 @@ var VioProductCarousel = class extends LitElement {
1031
1163
  }
1032
1164
  this.isLoading = true;
1033
1165
  this.fetchError = "";
1166
+ if (!this.impressed) {
1167
+ this.unobserveImpression?.();
1168
+ this.unobserveImpression = null;
1169
+ }
1034
1170
  try {
1035
1171
  if (this.productRefs.trim() === "" && !this.commerceKey && this.sponsorId <= 0 && !Vio.bootstrapCache) {
1036
1172
  try {
@@ -1074,10 +1210,11 @@ var VioProductCarousel = class extends LitElement {
1074
1210
  this.fetchError = err instanceof Error ? err.message : String(err);
1075
1211
  } finally {
1076
1212
  this.isLoading = false;
1213
+ this.observeForAnalytics();
1077
1214
  }
1078
1215
  }
1079
1216
  observeForAnalytics() {
1080
- if (this.unobserveImpression) return;
1217
+ if (this.unobserveImpression || this.isLoading || !this.isConnected) return;
1081
1218
  try {
1082
1219
  this.unobserveImpression = Vio.analytics.observeImpression(
1083
1220
  this,
@@ -1092,7 +1229,8 @@ var VioProductCarousel = class extends LitElement {
1092
1229
  onImpression: () => {
1093
1230
  this.impressed = true;
1094
1231
  this.maybeTrackList();
1095
- }
1232
+ },
1233
+ commerce: () => this.listCommerce()
1096
1234
  }
1097
1235
  );
1098
1236
  } catch {
@@ -1113,19 +1251,25 @@ var VioProductCarousel = class extends LitElement {
1113
1251
  locationId: this.effectiveSlot || this.label || void 0,
1114
1252
  ...this.attribution()
1115
1253
  },
1116
- commerce: {
1117
- items: entries.map(({ product, sponsorId: sponsorId2 }) => ({
1118
- productId: product.id,
1119
- name: product.title,
1120
- price: Number(product.price?.amount_incl_taxes ?? product.price?.amount) || void 0
1121
- })),
1122
- currency: entries[0]?.product.price?.currency_code
1123
- },
1254
+ commerce: this.listCommerce() ?? void 0,
1124
1255
  props: this.label ? { list_name: this.label } : void 0
1125
1256
  });
1126
1257
  } catch {
1127
1258
  }
1128
1259
  }
1260
+ /** The products on show as analytics lines (impression and view_item_list); null before they load. */
1261
+ listCommerce() {
1262
+ const entries = this.fetched ?? [];
1263
+ if (entries.length === 0) return null;
1264
+ return {
1265
+ items: entries.map(({ product }) => ({
1266
+ productId: product.id,
1267
+ name: product.title,
1268
+ price: Number(product.price?.amount_incl_taxes ?? product.price?.amount) || void 0
1269
+ })),
1270
+ currency: entries[0]?.product.price?.currency_code
1271
+ };
1272
+ }
1129
1273
  parseRefs(raw) {
1130
1274
  return raw.split(",").map((pair) => pair.trim()).filter((s) => s.length > 0).map((pair) => {
1131
1275
  const [sidStr, pidStr] = pair.split(":");
@@ -1165,6 +1309,10 @@ var VioProductCarousel = class extends LitElement {
1165
1309
  name=${p.title}
1166
1310
  price=${displayPrice(p)}
1167
1311
  retailer=${retailer}
1312
+ description=${this.descriptionFor(p)}
1313
+ ?show-description=${this.showsDescription(p)}
1314
+ button-style=${this.buttonStyle}
1315
+ button-label=${this.buttonLabel}
1168
1316
  image-url=${img}
1169
1317
  ?has-variants=${(p.variants?.length ?? 0) > 1}
1170
1318
  .vioSlot=${this.effectiveSlot}
@@ -1190,7 +1338,7 @@ var VioProductCarousel = class extends LitElement {
1190
1338
  </div>
1191
1339
  ${this.seeAllHref ? html`<a class="see-all" href=${this.seeAllHref}>${this.seeAllLabel}</a>` : ""}
1192
1340
  </div>
1193
- <div class="viewport">
1341
+ <div class="viewport ${this.resolvedLayout}">
1194
1342
  <button
1195
1343
  class="nav-btn prev"
1196
1344
  @click=${() => this.scrollByDirection(-1)}
@@ -1241,6 +1389,24 @@ __decorateClass([
1241
1389
  __decorateClass([
1242
1390
  property({ type: String })
1243
1391
  ], VioProductCarousel.prototype, "currency", 2);
1392
+ __decorateClass([
1393
+ property({ type: String, reflect: true })
1394
+ ], VioProductCarousel.prototype, "layout", 2);
1395
+ __decorateClass([
1396
+ property({ type: Boolean, attribute: "show-description" })
1397
+ ], VioProductCarousel.prototype, "showDescription", 2);
1398
+ __decorateClass([
1399
+ property({ type: Object })
1400
+ ], VioProductCarousel.prototype, "descriptions", 2);
1401
+ __decorateClass([
1402
+ property({ type: String, attribute: "button-style" })
1403
+ ], VioProductCarousel.prototype, "buttonStyle", 2);
1404
+ __decorateClass([
1405
+ property({ type: String, attribute: "button-label" })
1406
+ ], VioProductCarousel.prototype, "buttonLabel", 2);
1407
+ __decorateClass([
1408
+ state()
1409
+ ], VioProductCarousel.prototype, "resolvedLayout", 2);
1244
1410
  __decorateClass([
1245
1411
  property({ type: String, attribute: "vio-slot" })
1246
1412
  ], VioProductCarousel.prototype, "vioSlot", 2);
@@ -1274,6 +1440,10 @@ __decorateClass([
1274
1440
  __decorateClass([
1275
1441
  state()
1276
1442
  ], VioProductCarousel.prototype, "fetchError", 2);
1443
+ function cardDescription(description) {
1444
+ if (!description) return "";
1445
+ return description.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\s+/g, " ").trim();
1446
+ }
1277
1447
 
1278
1448
  // src/ui/payment-redirect.ts
1279
1449
  var OVERLAY_CLASS = "vio-redirect-overlay";
@@ -1381,7 +1551,7 @@ var NO_EXPRESS_BUTTON_METHODS = [
1381
1551
  ...EMBEDDED_METHODS,
1382
1552
  ...FORM_FIRST_WIDGET_METHODS
1383
1553
  ];
1384
- var COLLECTS_OWN_ADDRESS = [...EMBEDDED_METHODS, "vipps"];
1554
+ var COLLECTS_OWN_ADDRESS = [...EMBEDDED_METHODS];
1385
1555
  var isEmbeddedMethod = (name) => EMBEDDED_METHODS.includes(normalizeMethodName(name));
1386
1556
  var isFormFirstWidgetMethod = (name) => FORM_FIRST_WIDGET_METHODS.includes(normalizeMethodName(name));
1387
1557
  var collectsOwnAddress = (name) => COLLECTS_OWN_ADDRESS.includes(normalizeMethodName(name));
@@ -1595,6 +1765,8 @@ var BottomSheet = class {
1595
1765
  this.locked = false;
1596
1766
  this.drag = null;
1597
1767
  this.dragEndedAt = -Infinity;
1768
+ /** A finger down in the content, not yet known to be a pull (down) or a scroll. */
1769
+ this.pull = null;
1598
1770
  this.onPointerDown = (e) => {
1599
1771
  if (!e.isPrimary || e.pointerType === "mouse" && e.button !== 0) return;
1600
1772
  if (this.drag || !isPhoneSheet()) return;
@@ -1620,45 +1792,54 @@ var BottomSheet = class {
1620
1792
  if (!drag.moving) {
1621
1793
  if (dy < -SLOP) return this.abandon();
1622
1794
  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));
1795
+ this.startMoving(drag);
1640
1796
  }
1797
+ this.follow(drag, e.clientY, e.timeStamp);
1641
1798
  if (e.cancelable) e.preventDefault();
1642
1799
  };
1643
1800
  this.onPointerUp = (e) => {
1644
1801
  const drag = this.drag;
1645
1802
  if (!drag || e.pointerId !== drag.pointerId) return;
1646
1803
  this.stopTracking();
1647
- this.drag = null;
1648
- if (!drag.moving) return;
1649
- this.dragEndedAt = performance.now();
1804
+ this.finish(drag);
1805
+ };
1806
+ // ── Pull from the content (touch) ─────────────────────────────────────────
1807
+ this.onTouchStart = (e) => {
1808
+ this.pull = null;
1809
+ if (this.drag || e.touches.length !== 1 || !isPhoneSheet()) return;
1650
1810
  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 = "";
1811
+ if (!sheet || !e.composedPath().includes(sheet)) return;
1812
+ if (inZone(e, sheet, this.opts.dragZones)) return;
1813
+ const t = e.touches[0];
1814
+ if (t) this.pull = { startX: t.clientX, startY: t.clientY };
1815
+ };
1816
+ this.onTouchMove = (e) => {
1817
+ const t = e.touches[0];
1818
+ if (!t) return;
1819
+ if (this.pull) {
1820
+ const dx = t.clientX - this.pull.startX;
1821
+ const dy = t.clientY - this.pull.startY;
1822
+ if (Math.max(Math.abs(dx), Math.abs(dy)) <= SLOP) return;
1823
+ const startY = this.pull.startY;
1824
+ this.pull = null;
1825
+ if (Math.abs(dx) >= Math.abs(dy) || dy < 0 || this.scrolledDown(e)) return;
1826
+ this.drag = { pointerId: -1, startY, lastY: startY, lastT: e.timeStamp, velocity: 0, moving: false, offset: 0 };
1827
+ this.startMoving(this.drag);
1661
1828
  }
1829
+ const drag = this.drag;
1830
+ if (!drag || drag.pointerId !== -1) return;
1831
+ this.follow(drag, t.clientY, e.timeStamp);
1832
+ if (e.cancelable) e.preventDefault();
1833
+ };
1834
+ this.onTouchEnd = (e) => {
1835
+ this.pull = null;
1836
+ const drag = this.drag;
1837
+ if (!drag || drag.pointerId !== -1 || e.touches.length > 0) return;
1838
+ this.finish(drag);
1839
+ };
1840
+ this.onTouchCancel = () => {
1841
+ this.pull = null;
1842
+ if (this.drag?.pointerId === -1) this.abandon();
1662
1843
  };
1663
1844
  this.onPointerCancel = (e) => {
1664
1845
  if (!this.drag || e.pointerId !== this.drag.pointerId) return;
@@ -1693,12 +1874,21 @@ var BottomSheet = class {
1693
1874
  }
1694
1875
  this.opts.host.addEventListener("pointerdown", this.onPointerDown);
1695
1876
  this.opts.host.addEventListener("click", this.onClickCapture, true);
1877
+ this.opts.host.addEventListener("touchstart", this.onTouchStart, { passive: true });
1878
+ this.opts.host.addEventListener("touchmove", this.onTouchMove, { passive: false });
1879
+ this.opts.host.addEventListener("touchend", this.onTouchEnd);
1880
+ this.opts.host.addEventListener("touchcancel", this.onTouchCancel);
1696
1881
  }
1697
1882
  closed() {
1698
1883
  this.opts.host.removeEventListener("pointerdown", this.onPointerDown);
1699
1884
  this.opts.host.removeEventListener("click", this.onClickCapture, true);
1885
+ this.opts.host.removeEventListener("touchstart", this.onTouchStart);
1886
+ this.opts.host.removeEventListener("touchmove", this.onTouchMove);
1887
+ this.opts.host.removeEventListener("touchend", this.onTouchEnd);
1888
+ this.opts.host.removeEventListener("touchcancel", this.onTouchCancel);
1700
1889
  this.stopTracking();
1701
1890
  this.drag = null;
1891
+ this.pull = null;
1702
1892
  const sheet = this.opts.sheet();
1703
1893
  if (sheet) {
1704
1894
  sheet.style.transform = "";
@@ -1714,6 +1904,58 @@ var BottomSheet = class {
1714
1904
  unlockPageScroll();
1715
1905
  }
1716
1906
  }
1907
+ /** Is the content under the finger scrolled away from its top (the sheet's scroller, or one inside it)? */
1908
+ scrolledDown(e) {
1909
+ const sheet = this.opts.sheet();
1910
+ const scroller = this.opts.scroller?.() ?? sheet;
1911
+ if (scroller && scroller.scrollTop > 0) return true;
1912
+ for (const node of e.composedPath()) {
1913
+ if (node === sheet) break;
1914
+ if (node instanceof HTMLElement && node.scrollTop > 0 && node.scrollHeight > node.clientHeight) return true;
1915
+ }
1916
+ return false;
1917
+ }
1918
+ // ── Shared by both ────────────────────────────────────────────────────────
1919
+ /** The press is a drag now: the sheet and backdrop follow the finger without easing. */
1920
+ startMoving(drag) {
1921
+ drag.moving = true;
1922
+ const sheet = this.opts.sheet();
1923
+ if (sheet) sheet.style.transition = "none";
1924
+ const backdrop = this.opts.backdrop?.();
1925
+ if (backdrop) backdrop.style.transition = "none";
1926
+ }
1927
+ follow(drag, y, timeStamp) {
1928
+ const dt = timeStamp - drag.lastT;
1929
+ if (dt > 0) drag.velocity = 0.8 * ((y - drag.lastY) / dt) + 0.2 * drag.velocity;
1930
+ drag.lastY = y;
1931
+ drag.lastT = timeStamp;
1932
+ drag.offset = Math.max(0, y - drag.startY);
1933
+ const sheet = this.opts.sheet();
1934
+ if (sheet) sheet.style.transform = `translateY(${drag.offset}px)`;
1935
+ const backdrop = this.opts.backdrop?.();
1936
+ if (backdrop && sheet) {
1937
+ const height = sheet.getBoundingClientRect().height || window.innerHeight;
1938
+ backdrop.style.opacity = String(Math.max(0, 1 - drag.offset / height));
1939
+ }
1940
+ }
1941
+ /** Let go: close if far or fast enough (and allowed), else spring back. */
1942
+ finish(drag) {
1943
+ this.drag = null;
1944
+ if (!drag.moving) return;
1945
+ this.dragEndedAt = performance.now();
1946
+ const sheet = this.opts.sheet();
1947
+ const height = sheet?.getBoundingClientRect().height || window.innerHeight;
1948
+ const far = drag.offset >= Math.min(CLOSE_DISTANCE, height / 4);
1949
+ const flick = drag.velocity >= FLICK_VELOCITY && drag.offset >= FLICK_MIN;
1950
+ const allowed = !this.opts.canClose || this.opts.canClose();
1951
+ this.release();
1952
+ if ((far || flick) && allowed) {
1953
+ if (sheet) sheet.style.transform = "translateY(100%)";
1954
+ this.opts.close();
1955
+ } else if (sheet) {
1956
+ sheet.style.transform = "";
1957
+ }
1958
+ }
1717
1959
  /** Give up on this press and put the sheet back where it was. */
1718
1960
  abandon() {
1719
1961
  const moving = this.drag?.moving;
@@ -1780,9 +2022,40 @@ function unlockPageScroll() {
1780
2022
  }
1781
2023
 
1782
2024
  // src/ui/components/vio-product-detail.ts
2025
+ var ADDED_TOAST_MS = 8e3;
1783
2026
  var STRIPE_LOGO_SRC = "data:image/svg+xml;utf8," + encodeURIComponent(
1784
2027
  '<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>'
1785
2028
  );
2029
+ var HIGHLIGHT_ICONS = {
2030
+ plane: "M21.5 2.5 2.5 10l7 3m12-10.5-12 10.5m12-10.5L17 21l-7.5-8m0 0V20l3-4M5 17l-2.5 2.5M7.5 18.5 6 20",
2031
+ heart: "M12 20.5s-8.5-5-8.5-11A4.75 4.75 0 0 1 12 6.75 4.75 4.75 0 0 1 20.5 9.5c0 6-8.5 11-8.5 11Z",
2032
+ shirt: "M8.5 3.5 3 6l1.75 4.25L7 9.5v11h10v-11l2.25.75L21 6l-5.5-2.5a3.5 3.5 0 0 1-7 0Z",
2033
+ truck: "M2.5 5.5h11v10h-11zM13.5 9.5h4l3 3.5v2.5h-7M6.5 18.5a2 2 0 1 0 0-.01M17 18.5a2 2 0 1 0 0-.01",
2034
+ return: "M4 9h11.5a4.5 4.5 0 0 1 0 9H8M4 9l4-4M4 9l4 4",
2035
+ leaf: "M5 19C5 10 10 4.5 20 4.5c0 10-5.5 15-14 15M5 19l7-7",
2036
+ star: "m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9L12 17l-5.2 2.7 1-5.9-4.3-4.1 5.9-.8Z",
2037
+ shield: "M12 3 4.5 6v5.5c0 4.5 3.2 8.2 7.5 9.5 4.3-1.3 7.5-5 7.5-9.5V6Z",
2038
+ mailbox: "M3 19v-8a4 4 0 0 1 8 0v8m-8 0h18v-7a4 4 0 0 0-4-4H7m8 0V4h3M6 12h2",
2039
+ home: "M3 11 12 4l9 7M5.5 9.5V20h13V9.5M10 20v-5h4v5",
2040
+ pin: "M12 21s-6.5-6-6.5-11a6.5 6.5 0 0 1 13 0c0 5-6.5 11-6.5 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z",
2041
+ clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3 2"
2042
+ };
2043
+ function rateIcon(method) {
2044
+ const m = method.toLowerCase();
2045
+ if (/postkasse|mailbox|brevkasse/.test(m)) return "mailbox";
2046
+ if (/hente|pakkeboks|automat|postkontor|pickup|utlever/.test(m)) return "pin";
2047
+ if (/dør|hjem|home|door/.test(m)) return "home";
2048
+ return "truck";
2049
+ }
2050
+ function carrierLogo(logos, carrier) {
2051
+ if (!logos || !carrier) return "";
2052
+ const want = carrier.trim().toLowerCase();
2053
+ for (const [name, url] of Object.entries(logos)) if (name.trim().toLowerCase() === want && url) return url;
2054
+ return "";
2055
+ }
2056
+ function lineIcon(name, cls = "line-icon") {
2057
+ return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true"><path d=${HIGHLIGHT_ICONS[name] ?? ""}></path></svg>`;
2058
+ }
1786
2059
  var VioProductDetail = class extends LitElement {
1787
2060
  constructor() {
1788
2061
  super(...arguments);
@@ -1798,6 +2071,12 @@ var VioProductDetail = class extends LitElement {
1798
2071
  this.vioCampaign = "";
1799
2072
  this.vioContentId = "";
1800
2073
  this.vioContentTitle = "";
2074
+ this.noAddedToast = false;
2075
+ this.gallery = "";
2076
+ this.descriptionMode = "";
2077
+ this.highlights = [];
2078
+ this.deliveryTime = "";
2079
+ this.carrierLogos = {};
1801
2080
  this.slotRegistration = new SlotRegistration(this, "detail");
1802
2081
  /** What the click that opened the detail carried ('' when nothing), and for which product. */
1803
2082
  this.eventSlot = "";
@@ -1808,18 +2087,31 @@ var VioProductDetail = class extends LitElement {
1808
2087
  this.product = null;
1809
2088
  this.sellerName = null;
1810
2089
  this.sellerSettled = false;
2090
+ this.shippingFrom = null;
2091
+ this.shippingRates = [];
1811
2092
  this.isLoading = false;
1812
2093
  this.fetchError = "";
1813
2094
  this.selectedOptions = {};
2095
+ this.selectionPrompt = "";
1814
2096
  this.quantity = 1;
1815
2097
  this.activeImageIndex = 0;
1816
2098
  this.thumbsOverflow = false;
1817
2099
  this.thumbsAtStart = true;
1818
2100
  this.thumbsAtEnd = false;
2101
+ this.descriptionOpen = false;
2102
+ this.descriptionClipped = false;
2103
+ this.galleryMode = "thumbs";
2104
+ this.resolvedDescriptionMode = "preview";
1819
2105
  this.logoSquare = false;
1820
2106
  this.adding = false;
2107
+ this.addedToast = null;
2108
+ this.addedToastTimer = null;
1821
2109
  this.applePayOk = false;
1822
2110
  this.availableMethods = null;
2111
+ this.methodsLoading = false;
2112
+ this.vippsExpress = false;
2113
+ /** Whose methods `availableMethods` are — another brand's are not shown while its own load. */
2114
+ this.methodsSponsorId = 0;
1823
2115
  this.vippsOfficial = false;
1824
2116
  /** Race guard: only the latest fetch may write state (two quick taps on
1825
2117
  * different cards would otherwise let the slow response win). */
@@ -1828,6 +2120,10 @@ var VioProductDetail = class extends LitElement {
1828
2120
  this.optMapCache = /* @__PURE__ */ new WeakMap();
1829
2121
  /** Pre-prepared Apple Pay handle — show() fires synchronously on tap. */
1830
2122
  this.applePayHandle = null;
2123
+ /** The amount `applePayHandle`'s sheet shows (product × quantity when it was prepared). */
2124
+ this.applePayAmount = null;
2125
+ /** The amount the latest preparation was asked for — a newer one supersedes it. */
2126
+ this.applePayAskedFor = null;
1831
2127
  /** Keyboard and focus while open: Escape closes, Tab stays inside, focus returns to the opener. */
1832
2128
  this.dialogFocus = new DialogFocus({
1833
2129
  host: this,
@@ -1869,6 +2165,15 @@ var VioProductDetail = class extends LitElement {
1869
2165
  this.show();
1870
2166
  void this.fetchProduct();
1871
2167
  };
2168
+ /** The photo in view is the active one (dots, Apple Pay sheet, events). */
2169
+ this.onCarouselScroll = () => {
2170
+ const track = this.carouselTrack();
2171
+ const step = this.carouselStep(track);
2172
+ if (!track || !step) return;
2173
+ const last = track.children.length - 1;
2174
+ const idx = Math.min(last, Math.max(0, Math.round(track.scrollLeft / step)));
2175
+ if (idx !== this.activeImageIndex) this.activeImageIndex = idx;
2176
+ };
1872
2177
  this.onThumbsScroll = () => {
1873
2178
  const row = this.thumbRow();
1874
2179
  if (!row) return;
@@ -2183,6 +2488,103 @@ var VioProductDetail = class extends LitElement {
2183
2488
  .thumbs.overflows .thumbs-arrow { display: flex; }
2184
2489
  }
2185
2490
 
2491
+ /* Carousel (gallery="carousel" / theme detailGallery): the same box as the
2492
+ main image — proportion, fit, background, inset — one per photo, in a
2493
+ row that snaps. --vio-detail-carousel-peek shows the edge of the next. */
2494
+ .carousel {
2495
+ position: relative;
2496
+ margin-inline: calc(
2497
+ var(--vio-detail-image-inset, var(--vio-detail-padding-x, var(--vio-space-lg, 24px))) -
2498
+ var(--vio-detail-padding-x, var(--vio-space-lg, 24px))
2499
+ );
2500
+ }
2501
+ .carousel-track {
2502
+ display: flex;
2503
+ gap: var(--vio-detail-carousel-gap, 0px);
2504
+ overflow-x: auto;
2505
+ scroll-snap-type: x mandatory;
2506
+ scroll-behavior: smooth;
2507
+ scrollbar-width: none;
2508
+ -webkit-overflow-scrolling: touch;
2509
+ overscroll-behavior-x: contain;
2510
+ /* Sideways is the carousel's, up and down the panel's; no double-tap
2511
+ zoom delay. The sheet only drags from its top bar, never from here. */
2512
+ touch-action: pan-x pan-y;
2513
+ border-radius: var(--vio-detail-image-radius, 0);
2514
+ }
2515
+ .carousel-track::-webkit-scrollbar { display: none; }
2516
+ .carousel-slide {
2517
+ flex: 0 0 calc(100% - var(--vio-detail-carousel-peek, 0px));
2518
+ aspect-ratio: var(--vio-detail-image-aspect, 4 / 5);
2519
+ max-height: var(--vio-detail-image-max-height, 85vh);
2520
+ background: var(--vio-detail-image-bg, var(--vio-color-surface-muted, #f2f2f2));
2521
+ scroll-snap-align: start;
2522
+ scroll-snap-stop: always;
2523
+ overflow: hidden;
2524
+ }
2525
+ .carousel-slide img {
2526
+ display: block;
2527
+ /* A press on a photo is a swipe: no image drag, no long-press menu, no selection. */
2528
+ pointer-events: none;
2529
+ user-select: none;
2530
+ -webkit-user-select: none;
2531
+ -webkit-user-drag: none;
2532
+ -webkit-touch-callout: none;
2533
+ width: 100%;
2534
+ height: 100%;
2535
+ object-fit: var(--vio-detail-image-fit, cover);
2536
+ object-position: var(--vio-detail-image-position, center);
2537
+ transform: scale(var(--vio-detail-image-zoom, 1));
2538
+ }
2539
+ /* One dot per photo over the bottom of the image: there is more than one,
2540
+ and which one this is. White with a shadow reads on light and dark photos. */
2541
+ .carousel-dots {
2542
+ position: absolute;
2543
+ left: 0;
2544
+ right: 0;
2545
+ bottom: 12px;
2546
+ display: flex;
2547
+ justify-content: center;
2548
+ gap: 6px;
2549
+ pointer-events: none;
2550
+ }
2551
+ .carousel-dot {
2552
+ width: 6px;
2553
+ height: 6px;
2554
+ border-radius: 50%;
2555
+ background: var(--vio-detail-dot-color, rgba(255, 255, 255, 0.6));
2556
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.35);
2557
+ transition: background 0.2s ease, transform 0.2s ease;
2558
+ }
2559
+ .carousel-dot.active {
2560
+ background: var(--vio-detail-dot-active-color, #fff);
2561
+ transform: scale(1.35);
2562
+ }
2563
+ .carousel-arrow {
2564
+ display: none;
2565
+ position: absolute;
2566
+ top: 50%;
2567
+ transform: translateY(-50%);
2568
+ width: 36px;
2569
+ height: 36px;
2570
+ border-radius: 50%;
2571
+ border: 1px solid var(--vio-color-border, #e5e5e5);
2572
+ background: var(--vio-color-surface, #fff);
2573
+ color: var(--vio-color-text, #0a0a0a);
2574
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
2575
+ cursor: pointer;
2576
+ align-items: center;
2577
+ justify-content: center;
2578
+ padding: 0;
2579
+ font: 18px/1 sans-serif;
2580
+ }
2581
+ .carousel-arrow.prev { left: 12px; }
2582
+ .carousel-arrow.next { right: 12px; }
2583
+ .carousel-arrow:disabled { visibility: hidden; }
2584
+ @media (hover: hover) and (pointer: fine) {
2585
+ .carousel-arrow { display: flex; }
2586
+ }
2587
+
2186
2588
  /* Info column */
2187
2589
  .info { display: flex; flex-direction: column; gap: var(--vio-detail-info-gap, 20px); min-width: 0; }
2188
2590
  .brand {
@@ -2200,11 +2602,88 @@ var VioProductDetail = class extends LitElement {
2200
2602
  margin: 0;
2201
2603
  letter-spacing: -0.01em;
2202
2604
  }
2605
+ /* The brand's promises: one row, an icon over a title and a line of text,
2606
+ centred like a shop's own trust strip. */
2607
+ .highlights {
2608
+ list-style: none;
2609
+ /* --vio-detail-highlights-display: none hides the strip (theme detailHighlightsDisplay). */
2610
+ margin: 0;
2611
+ padding: var(--vio-detail-highlights-padding, 16px 0 2px);
2612
+ display: var(--vio-detail-highlights-display, grid);
2613
+ grid-auto-flow: column;
2614
+ grid-auto-columns: 1fr;
2615
+ gap: 10px;
2616
+ border-top: 1px solid var(--vio-color-border, #e5e5e5);
2617
+ }
2618
+ /* Like a shop's trust strip: a thin icon, the promise in regular weight,
2619
+ a smaller line under it — nothing bold. */
2620
+ .hl { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 2px; min-width: 0; }
2621
+ .hl-icon {
2622
+ width: var(--vio-detail-highlight-icon-size, 22px);
2623
+ height: var(--vio-detail-highlight-icon-size, 22px);
2624
+ margin-bottom: 8px;
2625
+ fill: none;
2626
+ stroke: currentColor;
2627
+ stroke-width: 1.2;
2628
+ stroke-linecap: round;
2629
+ stroke-linejoin: round;
2630
+ object-fit: contain;
2631
+ }
2632
+ .hl-title { font-size: 12px; font-weight: 400; line-height: 1.3; letter-spacing: 0.01em; color: var(--vio-color-text, #0a0a0a); }
2633
+ .hl-text { font-size: 10.5px; line-height: 1.35; letter-spacing: 0.01em; color: var(--vio-color-text-secondary, #666); }
2634
+ /* «Frakt & retur»: same fold as «Beskrivelse», with a small list of rates.
2635
+ --vio-detail-shipping-returns-display: none hides it (theme detailShippingReturnsDisplay). */
2636
+ .shipping-returns { display: var(--vio-detail-shipping-returns-display, block); }
2637
+ .description.shipping-returns-body { white-space: normal; }
2638
+ .shipping-returns-body p { margin: 0 0 10px; }
2639
+ .sr-title {
2640
+ font-size: 11px;
2641
+ text-transform: uppercase;
2642
+ letter-spacing: var(--vio-tracking-label, 0.1em);
2643
+ color: var(--vio-color-text-secondary, #666);
2644
+ font-weight: 600;
2645
+ margin: 4px 0 6px;
2646
+ }
2647
+ .sr-rates { list-style: none; margin: 0 0 10px; padding: 0; }
2648
+ /* One row per carrier: «Helthjem» over «Til postkasse/dørmatte», price right. */
2649
+ .sr-rates li {
2650
+ display: flex;
2651
+ align-items: center;
2652
+ gap: 12px;
2653
+ padding: 8px 0;
2654
+ border-bottom: 1px solid var(--vio-color-border, #eee);
2655
+ }
2656
+ .sr-rates li:last-child { border-bottom: none; }
2657
+ .sr-rate { flex: 1; display: flex; flex-direction: column; min-width: 0; line-height: 1.35; }
2658
+ .line-icon { fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; }
2659
+ .sr-icon { flex: 0 0 auto; width: 20px; height: 20px; color: var(--vio-color-text, #0a0a0a); }
2660
+ .sr-line { display: flex; align-items: center; gap: 12px; }
2661
+ /* A carrier's logo in a fixed box, so rows line up whatever its shape. */
2662
+ .sr-logo { flex: 0 0 auto; width: var(--vio-detail-carrier-logo-width, 56px); height: 24px; object-fit: contain; object-position: left center; }
2663
+ .sr-carrier { font-weight: 600; color: var(--vio-color-text, #0a0a0a); }
2664
+ .sr-method { font-size: 13px; }
2665
+ .sr-time { margin: 0 0 14px; }
2666
+ .sr-price { font-weight: 600; color: var(--vio-color-text, #0a0a0a); white-space: nowrap; }
2667
+ .sr-links { display: flex; gap: 16px; padding-left: 32px; }
2668
+ .sr-links a { color: inherit; }
2669
+ /* Name and price read as one block (stock on the name's line, right);
2670
+ the sizes come after a wider gap, then shipping/returns by the buttons. */
2671
+ .headline { display: flex; flex-direction: column; gap: var(--vio-detail-headline-gap, 8px); }
2672
+ .title-row {
2673
+ display: flex;
2674
+ align-items: baseline;
2675
+ justify-content: space-between;
2676
+ gap: 16px;
2677
+ }
2678
+ .title-row .name { min-width: 0; }
2679
+ .title-row .stock { flex: 0 0 auto; white-space: nowrap; }
2203
2680
  .price-row {
2204
2681
  display: flex;
2205
2682
  align-items: baseline;
2206
2683
  gap: 16px;
2207
2684
  }
2685
+ /* The sizes follow the price closely: they are the next thing to do. */
2686
+ .headline + .purchase { margin-top: calc(var(--vio-detail-sizes-gap, 2px) - var(--vio-detail-info-gap, 20px)); }
2208
2687
  .price {
2209
2688
  font-size: var(--vio-detail-price-size, 24px);
2210
2689
  font-weight: var(--vio-detail-price-weight, 700);
@@ -2214,6 +2693,11 @@ var VioProductDetail = class extends LitElement {
2214
2693
  color: var(--vio-color-text-tertiary, #999);
2215
2694
  text-decoration: line-through;
2216
2695
  }
2696
+ .delivery-note {
2697
+ margin-top: calc(var(--vio-detail-info-gap, 20px) * -0.6);
2698
+ font-size: 13px;
2699
+ color: var(--vio-color-text-secondary, #666);
2700
+ }
2217
2701
  .stock {
2218
2702
  font-size: 12px;
2219
2703
  text-transform: uppercase;
@@ -2225,8 +2709,15 @@ var VioProductDetail = class extends LitElement {
2225
2709
  .stock.out-of-stock { color: var(--vio-color-text-tertiary, #999); }
2226
2710
 
2227
2711
  /* Option pickers */
2228
- .option-group { display: flex; flex-direction: column; gap: 6px; }
2712
+ .option-group { display: flex; flex-direction: column; gap: 8px; }
2713
+ /* The prompt sits on the label's own line: tapping a buy button before
2714
+ choosing must not push the pills down under the shopper's finger. */
2229
2715
  .option-label {
2716
+ display: flex;
2717
+ align-items: baseline;
2718
+ justify-content: space-between;
2719
+ gap: 12px;
2720
+ min-height: 1.4em;
2230
2721
  font-size: 11px;
2231
2722
  text-transform: uppercase;
2232
2723
  letter-spacing: var(--vio-tracking-label, 0.1em);
@@ -2272,6 +2763,19 @@ var VioProductDetail = class extends LitElement {
2272
2763
  background: repeating-linear-gradient(-45deg, #fafafa, #fafafa 5px, #f0f0f0 5px, #f0f0f0 10px);
2273
2764
  }
2274
2765
  .option-pill.unavailable:hover { border-color: var(--vio-color-border, #e5e5e5); }
2766
+ /* The value chosen, beside the option's name: «Størrelse: M». */
2767
+ .option-chosen { color: var(--vio-color-text, #0a0a0a); font-weight: 700; }
2768
+ /* A buy button was tapped before choosing: say what is missing. */
2769
+ .option-prompt {
2770
+ text-transform: none;
2771
+ letter-spacing: normal;
2772
+ font-size: 13px;
2773
+ font-weight: 600;
2774
+ color: var(--vio-color-accent, #c14a3b);
2775
+ }
2776
+ .option-group.needs-choice .option-pill:not(.unavailable) {
2777
+ border-color: var(--vio-color-accent, #c14a3b);
2778
+ }
2275
2779
 
2276
2780
  /* The buy zone: Vipps first, then quantity + add to cart, then the other
2277
2781
  ways to pay. --vio-detail-buy-position: sticky pins it to the bottom of
@@ -2288,6 +2792,37 @@ var VioProductDetail = class extends LitElement {
2288
2792
  box-shadow: var(--vio-detail-buy-shadow, none);
2289
2793
  }
2290
2794
  .buy > * { margin-top: 0 !important; }
2795
+ /* The buy zone and the description, apart from the name, price and stock:
2796
+ a pinned buy zone can slide up over what is in this box (the
2797
+ description) but never above it — never over the name, the price or
2798
+ the stock line (Mote & Livsstil, 2026-10-28). */
2799
+ .purchase { display: flex; flex-direction: column; gap: var(--vio-detail-info-gap, 20px); }
2800
+ /* The pickers live in the buy zone: when it is pinned to the bottom of the
2801
+ panel (desktop, or a host's sticky), it can never cover them — they
2802
+ travel with the buttons they gate (Mote & Livsstil, 2026-10-28). */
2803
+ .buy-options {
2804
+ display: flex;
2805
+ flex-direction: column;
2806
+ gap: var(--vio-detail-info-gap, 20px);
2807
+ padding-bottom: 4px;
2808
+ }
2809
+ /* Where the express buttons go while the brand's methods load: neutral,
2810
+ the height of one button, no method named. */
2811
+ .buy-pending {
2812
+ height: var(--vio-detail-button-height, 52px);
2813
+ border-radius: var(--vio-detail-button-radius, 0);
2814
+ background: linear-gradient(
2815
+ 100deg,
2816
+ var(--vio-color-surface-muted, #f1f1f1) 30%,
2817
+ rgba(255, 255, 255, 0.65) 50%,
2818
+ var(--vio-color-surface-muted, #f1f1f1) 70%
2819
+ );
2820
+ background-size: 200% 100%;
2821
+ animation: vio-skeleton-shimmer 1.4s ease-in-out infinite;
2822
+ }
2823
+ @media (prefers-reduced-motion: reduce) {
2824
+ .buy-pending { animation: none; background: var(--vio-color-surface-muted, #f1f1f1); }
2825
+ }
2291
2826
  /* Under the buttons: who the shopper is buying from. */
2292
2827
  .seller-note {
2293
2828
  margin: 2px 0 0;
@@ -2420,6 +2955,14 @@ var VioProductDetail = class extends LitElement {
2420
2955
  }
2421
2956
  .buy-vipps:hover:not(:disabled) { background: #ec4f1c; }
2422
2957
  .buy-vipps:disabled { opacity: 0.5; cursor: not-allowed; }
2958
+ /* Under the Express button: what happens in the app. */
2959
+ .vipps-note {
2960
+ margin: 0;
2961
+ font-size: 12px;
2962
+ line-height: 1.4;
2963
+ color: var(--vio-color-text-secondary, #666);
2964
+ text-align: center;
2965
+ }
2423
2966
  /* Vipps' own button (web component) — same slot, their drawing. */
2424
2967
  .buy-vipps-official { display: block; width: 100%; margin-top: 10px; cursor: pointer; }
2425
2968
  /* Apple Pay express button — black, per Apple's button guidelines. Only
@@ -2471,6 +3014,61 @@ var VioProductDetail = class extends LitElement {
2471
3014
  transition: transform 0.15s ease;
2472
3015
  }
2473
3016
  .description-block[open] > summary::after { transform: rotate(-135deg) translateY(-2px); }
3017
+ /* «Beskrivelse» with a preview: the same heading as the folds, then the
3018
+ first lines fading out, then «Les mer». */
3019
+ .desc-head {
3020
+ all: unset;
3021
+ box-sizing: border-box;
3022
+ width: 100%;
3023
+ cursor: pointer;
3024
+ display: flex;
3025
+ align-items: center;
3026
+ justify-content: space-between;
3027
+ padding: 14px 0 6px;
3028
+ font-size: 11px;
3029
+ text-transform: uppercase;
3030
+ letter-spacing: var(--vio-tracking-label, 0.1em);
3031
+ color: var(--vio-color-text-secondary, #666);
3032
+ font-weight: 600;
3033
+ font-family: inherit;
3034
+ }
3035
+ .description-preview.clipped .desc-head::after {
3036
+ content: '';
3037
+ width: 8px;
3038
+ height: 8px;
3039
+ border-right: 1.5px solid currentColor;
3040
+ border-bottom: 1.5px solid currentColor;
3041
+ transform: rotate(45deg) translateY(-2px);
3042
+ transition: transform 0.15s ease;
3043
+ }
3044
+ .description-preview.open .desc-head::after { transform: rotate(-135deg) translateY(-2px); }
3045
+ .description-preview .description { padding-bottom: 6px; }
3046
+ .description-preview.folded .desc-head, .description-preview.open:not(.clipped) .desc-head { padding-bottom: 14px; }
3047
+ .description-preview.folded.open .desc-head { padding-bottom: 6px; }
3048
+ .description-preview.folded .description { padding-bottom: 14px; }
3049
+ .description-preview:not(.clipped) .description { padding-bottom: 14px; }
3050
+ .description.collapsed {
3051
+ display: -webkit-box;
3052
+ -webkit-box-orient: vertical;
3053
+ -webkit-line-clamp: var(--vio-detail-description-preview-lines, 3);
3054
+ line-clamp: var(--vio-detail-description-preview-lines, 3);
3055
+ overflow: hidden;
3056
+ cursor: pointer;
3057
+ -webkit-mask-image: linear-gradient(to bottom, #000 45%, transparent);
3058
+ mask-image: linear-gradient(to bottom, #000 45%, transparent);
3059
+ }
3060
+ .desc-more {
3061
+ all: unset;
3062
+ cursor: pointer;
3063
+ display: inline-block;
3064
+ margin: 0 0 14px;
3065
+ font-size: 13px;
3066
+ font-weight: 600;
3067
+ color: var(--vio-color-text, #0a0a0a);
3068
+ text-decoration: underline;
3069
+ text-underline-offset: 3px;
3070
+ font-family: inherit;
3071
+ }
2474
3072
  .description {
2475
3073
  font-size: 14px;
2476
3074
  line-height: 1.6;
@@ -2478,15 +3076,13 @@ var VioProductDetail = class extends LitElement {
2478
3076
  padding-bottom: 14px;
2479
3077
  white-space: pre-line;
2480
3078
  }
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. */
3079
+ /* Same order on every screen: buy, then the description at the very
3080
+ bottom (folded). Hosts that want the buy zone pinned can still set
3081
+ --vio-detail-buy-position: sticky. */
2483
3082
  @media (min-width: 601px) {
2484
- .description-block { order: 1; }
2485
3083
  .buy {
2486
- order: 2;
2487
- position: var(--vio-detail-buy-position, sticky);
3084
+ position: var(--vio-detail-buy-position, static);
2488
3085
  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
3086
  }
2491
3087
  }
2492
3088
 
@@ -2526,6 +3122,49 @@ var VioProductDetail = class extends LitElement {
2526
3122
  background: var(--vio-color-surface-muted, #f1f1f1);
2527
3123
  }
2528
3124
  }
3125
+ /* «Lagt i handlekurven»: above the page, below where a thumb reaches. */
3126
+ .added-toast {
3127
+ position: fixed;
3128
+ left: 50%;
3129
+ bottom: max(16px, env(safe-area-inset-bottom));
3130
+ transform: translateX(-50%);
3131
+ z-index: 1102;
3132
+ width: min(420px, calc(100vw - 32px));
3133
+ box-sizing: border-box;
3134
+ padding: 14px 16px;
3135
+ background: var(--vio-color-surface, #fff);
3136
+ color: var(--vio-color-text, #0a0a0a);
3137
+ border: 1px solid var(--vio-color-border, #e5e5e5);
3138
+ border-radius: var(--vio-radius-lg, 8px);
3139
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
3140
+ font-family: var(--vio-font-sans, -apple-system, sans-serif);
3141
+ display: flex;
3142
+ flex-direction: column;
3143
+ gap: 12px;
3144
+ }
3145
+ .added-toast-text { font-size: 14px; font-weight: 600; }
3146
+ .added-toast-text::before { content: '✓ '; color: #2e7d32; }
3147
+ .added-toast-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
3148
+ .added-toast-actions button {
3149
+ min-height: 44px;
3150
+ padding: 0 12px;
3151
+ font-family: inherit;
3152
+ font-size: 13px;
3153
+ font-weight: 600;
3154
+ cursor: pointer;
3155
+ border-radius: var(--vio-detail-button-radius, 0);
3156
+ }
3157
+ .added-toast-checkout {
3158
+ background: var(--vio-color-text, #0a0a0a);
3159
+ color: var(--vio-color-text-on-primary, #fff);
3160
+ border: 1px solid var(--vio-color-text, #0a0a0a);
3161
+ }
3162
+ .added-toast-continue {
3163
+ background: transparent;
3164
+ color: var(--vio-color-text, #0a0a0a);
3165
+ border: 1px solid var(--vio-color-border, #e5e5e5);
3166
+ }
3167
+
2529
3168
  .error {
2530
3169
  padding: 80px 24px;
2531
3170
  text-align: center;
@@ -2541,6 +3180,8 @@ var VioProductDetail = class extends LitElement {
2541
3180
  document.addEventListener("vio:product-click", this.boundProductClick);
2542
3181
  }
2543
3182
  disconnectedCallback() {
3183
+ if (this.addedToastTimer) clearTimeout(this.addedToastTimer);
3184
+ this.addedToastTimer = null;
2544
3185
  document.removeEventListener("vio:product-click", this.boundProductClick);
2545
3186
  this.slotRegistration.clear();
2546
3187
  this.bottomSheet.disconnect();
@@ -2551,13 +3192,63 @@ var VioProductDetail = class extends LitElement {
2551
3192
  this.dialogFocus.update(this.open);
2552
3193
  this.bottomSheet.update(this.open);
2553
3194
  }
2554
- if (changed.has("product") || changed.has("open")) this.measureThumbs();
2555
- if (changed.has("activeImageIndex")) this.revealActiveThumb();
3195
+ if (changed.has("product") || changed.has("open")) {
3196
+ this.measureThumbs();
3197
+ this.measureDescription();
3198
+ }
3199
+ if ((changed.has("quantity") || changed.has("selectedOptions")) && this.product && this.applePayAskedFor !== null) {
3200
+ if (this.unitPrice * this.quantity !== this.applePayAskedFor) this.prepareApplePaySheet();
3201
+ }
3202
+ if (changed.has("activeImageIndex")) {
3203
+ this.revealActiveThumb();
3204
+ this.revealActiveSlide();
3205
+ }
3206
+ if (changed.has("selectedOptions") && this.galleryMode === "carousel") {
3207
+ if (this.activeImageIndex !== 0) this.activeImageIndex = 0;
3208
+ else this.revealActiveSlide();
3209
+ }
2556
3210
  this.measureLogo();
2557
3211
  if (changed.has("vioSlot") || changed.has("vioSlotName") || changed.has("vioScope") || changed.has("vioScopeName") || changed.has("vioCampaign")) {
2558
3212
  this.slotRegistration.sync(this.slotAttributes());
2559
3213
  }
2560
3214
  }
3215
+ willUpdate(changed) {
3216
+ if (changed.has("gallery") || changed.has("open") && this.open) this.galleryMode = this.resolveGalleryMode();
3217
+ if (changed.has("descriptionMode") || changed.has("open") && this.open) {
3218
+ const value = this.themeChoice(this.descriptionMode, "--vio-detail-description-mode");
3219
+ this.resolvedDescriptionMode = value === "folded" || value === "open" ? value : "preview";
3220
+ }
3221
+ }
3222
+ /** An option as the element says it, else as the theme's custom property says it (lower case, unquoted). */
3223
+ themeChoice(own, cssVar) {
3224
+ const fromTheme = typeof getComputedStyle === "function" ? getComputedStyle(this).getPropertyValue(cssVar) : "";
3225
+ return (own || fromTheme).trim().replace(/^["']|["']$/g, "").toLowerCase();
3226
+ }
3227
+ /** The attribute wins; else the theme's `--vio-detail-gallery`; else thumbnails. */
3228
+ resolveGalleryMode() {
3229
+ return this.themeChoice(this.gallery, "--vio-detail-gallery") === "carousel" ? "carousel" : "thumbs";
3230
+ }
3231
+ carouselTrack() {
3232
+ return this.renderRoot.querySelector(".carousel-track");
3233
+ }
3234
+ /** One photo's width plus the gap (with a peek a photo is narrower than the row). */
3235
+ carouselStep(track) {
3236
+ const slide = track?.firstElementChild;
3237
+ if (!track || !slide || slide.offsetWidth === 0) return 0;
3238
+ return slide.offsetWidth + (parseFloat(getComputedStyle(track).columnGap) || 0);
3239
+ }
3240
+ /** Bring the active photo into view — only when it is not already the one a swipe is on. */
3241
+ revealActiveSlide() {
3242
+ const track = this.carouselTrack();
3243
+ const step = this.carouselStep(track);
3244
+ if (!track || !step) return;
3245
+ const target = Math.min(this.activeImageIndex * step, track.scrollWidth - track.clientWidth);
3246
+ if (Math.abs(track.scrollLeft - target) > step / 2) track.scrollTo({ left: target });
3247
+ }
3248
+ stepCarousel(direction) {
3249
+ const last = this.allImages().length - 1;
3250
+ this.activeImageIndex = Math.min(last, Math.max(0, this.activeImageIndex + direction));
3251
+ }
2561
3252
  thumbRow() {
2562
3253
  return this.renderRoot.querySelector(".gallery-thumbs");
2563
3254
  }
@@ -2586,6 +3277,7 @@ var VioProductDetail = class extends LitElement {
2586
3277
  }
2587
3278
  show() {
2588
3279
  this.open = true;
3280
+ this.hideAddedToast();
2589
3281
  }
2590
3282
  close() {
2591
3283
  this.open = false;
@@ -2599,9 +3291,12 @@ var VioProductDetail = class extends LitElement {
2599
3291
  this.product = null;
2600
3292
  this.sellerName = null;
2601
3293
  this.sellerSettled = false;
3294
+ this.shippingFrom = null;
2602
3295
  this.selectedOptions = {};
3296
+ this.selectionPrompt = "";
2603
3297
  this.quantity = 1;
2604
3298
  this.activeImageIndex = 0;
3299
+ this.descriptionOpen = false;
2605
3300
  this.fetchError = "";
2606
3301
  }
2607
3302
  }, 400);
@@ -2617,6 +3312,7 @@ var VioProductDetail = class extends LitElement {
2617
3312
  this.fetchError = "";
2618
3313
  this.sellerName = null;
2619
3314
  this.sellerSettled = false;
3315
+ this.shippingFrom = null;
2620
3316
  let products = null;
2621
3317
  let lastError = null;
2622
3318
  for (let attempt = 0; attempt < 4; attempt++) {
@@ -2646,6 +3342,7 @@ var VioProductDetail = class extends LitElement {
2646
3342
  this.optMapCache = /* @__PURE__ */ new WeakMap();
2647
3343
  this.product = products?.[0] ?? null;
2648
3344
  if (this.product) void this.loadSeller(productIdNum, seq);
3345
+ if (this.product) void this.loadShippingFrom(productIdNum, seq);
2649
3346
  if (this.product) {
2650
3347
  try {
2651
3348
  const price = Number(
@@ -2678,16 +3375,11 @@ var VioProductDetail = class extends LitElement {
2678
3375
  const initial = {};
2679
3376
  for (const opt of this.product.options ?? []) {
2680
3377
  const values = normalizeValues(opt.values);
2681
- if (values.length > 0) initial[opt.name] = values[0];
3378
+ if (values.length === 1) initial[opt.name] = values[0];
2682
3379
  }
2683
3380
  this.selectedOptions = initial;
2684
- void Vio.checkout.prepareApplePayFor(this.unitPrice * this.quantity, this.currency).then((handle) => {
2685
- this.applePayHandle = handle;
2686
- this.applePayOk = handle.available;
2687
- }).catch(() => {
2688
- this.applePayHandle = null;
2689
- this.applePayOk = false;
2690
- });
3381
+ this.selectionPrompt = "";
3382
+ this.prepareApplePaySheet();
2691
3383
  void this.loadAvailablePaymentMethods();
2692
3384
  }
2693
3385
  this.isLoading = false;
@@ -2696,22 +3388,41 @@ var VioProductDetail = class extends LitElement {
2696
3388
  async loadAvailablePaymentMethods() {
2697
3389
  const spId = this.sponsorId || (typeof Vio.campaignForSlot === "function" ? Number(Vio.campaignForSlot(this.activeSlot || null, { campaignId: this.activeCampaignHint })?.primarySponsor?.id) : 0) || globalThis.__VIO_SPONSOR_ID__;
2698
3390
  if (!spId) return;
3391
+ if (spId !== this.methodsSponsorId) {
3392
+ this.availableMethods = null;
3393
+ this.vippsExpress = false;
3394
+ }
3395
+ this.methodsSponsorId = spId;
3396
+ this.methodsLoading = true;
2699
3397
  try {
2700
3398
  const methods = await Vio.checkout.getAvailablePaymentMethods(spId);
2701
3399
  if (Array.isArray(methods)) {
2702
3400
  this.availableMethods = methods.map((m) => m.name);
3401
+ const vipps = methods.find(
3402
+ (m) => String(m?.name ?? "").toLowerCase() === "vipps"
3403
+ );
3404
+ this.vippsExpress = !!vipps && vipps.config?.find((c) => c?.name === "express")?.value !== "false";
2703
3405
  } else {
2704
3406
  this.availableMethods = null;
3407
+ this.vippsExpress = false;
2705
3408
  }
2706
3409
  } catch (err) {
2707
3410
  if (typeof console !== "undefined") {
2708
3411
  console.warn("[VioProductDetail] Failed to load payment methods:", err);
2709
3412
  }
2710
3413
  this.availableMethods = null;
3414
+ this.vippsExpress = false;
3415
+ } finally {
3416
+ this.methodsLoading = false;
2711
3417
  }
2712
3418
  }
3419
+ /** The methods are still on their way: a placeholder stands where the buttons go. */
3420
+ get methodsPending() {
3421
+ return this.methodsLoading && this.availableMethods === null;
3422
+ }
2713
3423
  /** Backend names arrive as e.g. "Apple Pay" — compare letters only. */
2714
3424
  methodEnabled(...names) {
3425
+ if (this.methodsPending) return false;
2715
3426
  return isMethodEnabled(this.availableMethods, ...names);
2716
3427
  }
2717
3428
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -2900,26 +3611,92 @@ var VioProductDetail = class extends LitElement {
2900
3611
  const price = v?.price ?? this.product?.price;
2901
3612
  return price?.amount_incl_taxes ?? price?.amount ?? 0;
2902
3613
  }
3614
+ /**
3615
+ * There is something to pay for — whether the express buttons show. With
3616
+ * every option chosen, the chosen variant's price; before that, the
3617
+ * product's or any variant's: the first variant (what `unitPrice` falls
3618
+ * back to) at 0 must not hide Vipps for a size that costs 699.
3619
+ */
3620
+ get hasPrice() {
3621
+ if (!this.missingOption()) return this.unitPrice > 0;
3622
+ const priceOf = (p) => Number(p?.amount_incl_taxes ?? p?.amount ?? 0);
3623
+ return priceOf(this.product?.price) > 0 || (this.product?.variants ?? []).some((v) => priceOf(v.price) > 0);
3624
+ }
2903
3625
  get compareAt() {
2904
3626
  const v = this.selectedVariant();
2905
3627
  const price = v?.price ?? this.product?.price;
2906
3628
  return price?.compare_at_incl_taxes ?? price?.compare_at ?? null;
2907
3629
  }
3630
+ /** The options the shopper has to choose (those with values), in order. */
3631
+ choosableOptions() {
3632
+ return (this.product?.options ?? []).filter((o) => normalizeValues(o.values).length > 0);
3633
+ }
3634
+ /** The first option still without a value, or null when every one is chosen. */
3635
+ missingOption() {
3636
+ return this.choosableOptions().find((o) => !String(this.selectedOptions[o.name] ?? "").trim()) ?? null;
3637
+ }
3638
+ /**
3639
+ * Units that can be sold. With every option chosen, the chosen variant's;
3640
+ * before that, the most any variant still matching the partial choice has
3641
+ * — so the stepper and «Utsolgt» do not speak for a variant (the first
3642
+ * one) the shopper never picked.
3643
+ */
2908
3644
  get availableQuantity() {
3645
+ if (this.missingOption() && Array.isArray(this.product?.variants) && this.product.variants.length > 0) {
3646
+ const sel = this.selectedOptions;
3647
+ const qtys = this.product.variants.filter((v2) => this.variantMatchesSelection(v2, sel)).map((v2) => this.variantSellableQty(v2));
3648
+ return qtys.length > 0 ? Math.max(0, ...qtys) : 0;
3649
+ }
2909
3650
  const v = this.selectedVariant();
2910
3651
  if (v) return this.variantSellableQty(v);
2911
3652
  return this.product?.quantity ?? 0;
2912
3653
  }
3654
+ /**
3655
+ * «Få igjen — N stk» only for the variant actually chosen: before a size is
3656
+ * picked the count belonged to the first variant, which said «Få igjen» of
3657
+ * a product with plenty of other sizes.
3658
+ */
2913
3659
  stockLabel() {
2914
3660
  const qty = this.availableQuantity;
2915
3661
  if (qty <= 0) return { text: "Utsolgt", cls: "out-of-stock" };
2916
- if (qty <= 3) return { text: `F\xE5 igjen \u2014 ${qty} stk`, cls: "low-stock" };
3662
+ if (qty <= 3 && !this.missingOption()) return { text: `F\xE5 igjen \u2014 ${qty} stk`, cls: "low-stock" };
2917
3663
  return { text: "P\xE5 lager", cls: "in-stock" };
2918
3664
  }
3665
+ /** «Velg størrelse» for a size, «Velg farge» for a colour… */
3666
+ promptFor(option) {
3667
+ const name = String(option.name ?? "").trim();
3668
+ if (!name || /^(st(ø|o)rrelse|str\.?|size)$/i.test(name)) return "Velg st\xF8rrelse";
3669
+ return `Velg ${name.toLowerCase()}`;
3670
+ }
3671
+ /**
3672
+ * Every buy button goes through here first: with an option not chosen yet,
3673
+ * nothing is added or paid — the picker says what is missing and comes
3674
+ * into view (it sits right above the buttons, inside the buy zone).
3675
+ */
3676
+ requireSelection() {
3677
+ const missing = this.missingOption();
3678
+ if (!missing) {
3679
+ this.selectionPrompt = "";
3680
+ return true;
3681
+ }
3682
+ this.selectionPrompt = missing.name;
3683
+ void this.updateComplete.then(() => {
3684
+ const group = this.renderRoot.querySelector(".option-group.needs-choice");
3685
+ group?.scrollIntoView?.({ behavior: "smooth", block: "nearest" });
3686
+ });
3687
+ return false;
3688
+ }
2919
3689
  /** Pick an option value. If the exact combination doesn't exist, jump to an
2920
3690
  * in-stock variant that carries the value and adopt its full option map
2921
3691
  * (keeps the selection always resolvable to a real variant). */
2922
3692
  selectOption(optionName, value) {
3693
+ this.applyOption(optionName, value);
3694
+ if (this.selectionPrompt && !this.missingOption()) this.selectionPrompt = "";
3695
+ else if (this.selectionPrompt) this.selectionPrompt = this.missingOption()?.name ?? "";
3696
+ const max = this.availableQuantity;
3697
+ if (max > 0 && this.quantity > max) this.quantity = max;
3698
+ }
3699
+ applyOption(optionName, value) {
2923
3700
  const newSelection = { ...this.selectedOptions, [optionName]: value };
2924
3701
  const exactInStock = this.product?.variants?.find(
2925
3702
  (v) => this.variantMatchesSelection(v, newSelection) && this.variantSellableQty(v) > 0
@@ -2987,6 +3764,7 @@ var VioProductDetail = class extends LitElement {
2987
3764
  }
2988
3765
  async addToCart() {
2989
3766
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3767
+ if (!this.requireSelection()) return;
2990
3768
  this.adding = true;
2991
3769
  try {
2992
3770
  const variantId = this.addCurrentToCart();
@@ -3003,11 +3781,49 @@ var VioProductDetail = class extends LitElement {
3003
3781
  }
3004
3782
  })
3005
3783
  );
3784
+ this.showAddedToast();
3006
3785
  this.close();
3007
3786
  } finally {
3008
3787
  this.adding = false;
3009
3788
  }
3010
3789
  }
3790
+ /** The toast after an add; it goes by itself after a while. */
3791
+ showAddedToast() {
3792
+ if (this.noAddedToast) return;
3793
+ this.addedToast = {
3794
+ sponsorId: this.sponsorId,
3795
+ detail: { sponsorId: this.sponsorId, express: false, ...this.attribution(), ...sponsorDetail(this.sponsorId) }
3796
+ };
3797
+ if (this.addedToastTimer) clearTimeout(this.addedToastTimer);
3798
+ this.addedToastTimer = setTimeout(() => this.hideAddedToast(), ADDED_TOAST_MS);
3799
+ }
3800
+ hideAddedToast() {
3801
+ if (this.addedToastTimer) clearTimeout(this.addedToastTimer);
3802
+ this.addedToastTimer = null;
3803
+ this.addedToast = null;
3804
+ }
3805
+ /** «Til kassen»: the checkout of the brand just added to — the host opens it on `vio:checkout-open`, as from the cart. */
3806
+ toastToCheckout() {
3807
+ const toast = this.addedToast;
3808
+ this.hideAddedToast();
3809
+ if (!toast) return;
3810
+ const cart = Vio.cart.getCart(toast.sponsorId);
3811
+ const total = (cart?.items ?? []).reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
3812
+ if (!cart || cart.items.length === 0 || !(total > 0)) return;
3813
+ this.dispatchEvent(new CustomEvent("vio:checkout-open", { bubbles: true, composed: true, detail: toast.detail }));
3814
+ }
3815
+ renderAddedToast() {
3816
+ if (!this.addedToast) return nothing;
3817
+ return html`
3818
+ <div class="added-toast" part="added-toast" role="status" aria-live="polite">
3819
+ <div class="added-toast-text">Lagt i handlekurven</div>
3820
+ <div class="added-toast-actions">
3821
+ <button class="added-toast-checkout" part="added-toast-checkout" @click=${this.toastToCheckout}>Til kassen</button>
3822
+ <button class="added-toast-continue" part="added-toast-continue" @click=${this.hideAddedToast}>Fortsett å handle</button>
3823
+ </div>
3824
+ </div>
3825
+ `;
3826
+ }
3011
3827
  /**
3012
3828
  * Express buy: add to cart, then open the checkout overlay with Klarna
3013
3829
  * preselected. The host app handles `vio:checkout-open` (same event the cart
@@ -3016,6 +3832,7 @@ var VioProductDetail = class extends LitElement {
3016
3832
  */
3017
3833
  async buyWithKlarna() {
3018
3834
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3835
+ if (!this.requireSelection()) return;
3019
3836
  this.adding = true;
3020
3837
  try {
3021
3838
  this.addCurrentToCart();
@@ -3048,6 +3865,7 @@ var VioProductDetail = class extends LitElement {
3048
3865
  */
3049
3866
  buyNow() {
3050
3867
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3868
+ if (!this.requireSelection()) return;
3051
3869
  this.addCurrentToCart();
3052
3870
  this.close();
3053
3871
  this.dispatchEvent(
@@ -3070,11 +3888,27 @@ var VioProductDetail = class extends LitElement {
3070
3888
  }
3071
3889
  buyWithVipps() {
3072
3890
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3891
+ if (!this.requireSelection()) return;
3892
+ if (!(this.unitPrice > 0)) return;
3893
+ if ((Vio.cart.getCart(this.sponsorId)?.items.length ?? 0) > 0) {
3894
+ this.addCurrentToCart();
3895
+ this.close();
3896
+ this.openCheckoutWithVipps();
3897
+ return;
3898
+ }
3073
3899
  const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: this.sponsorId, source: "detail" });
3074
3900
  this.addCurrentToCart();
3075
3901
  this.close();
3076
3902
  void this.startVippsExpressOrCheckout(redirect);
3077
3903
  }
3904
+ /**
3905
+ * The Vipps button will open the app with Express (see buyWithVipps): the
3906
+ * brand has Express on, and this brand's cart is empty — with lines in it
3907
+ * the checkout opens first.
3908
+ */
3909
+ vippsGoesStraightToApp() {
3910
+ return this.vippsExpress && (Vio.cart.getCart(this.sponsorId)?.items.length ?? 0) === 0;
3911
+ }
3078
3912
  async startVippsExpressOrCheckout(redirect) {
3079
3913
  let fallback = true;
3080
3914
  try {
@@ -3092,17 +3926,26 @@ var VioProductDetail = class extends LitElement {
3092
3926
  redirect.fail(err instanceof Error ? err.message : String(err));
3093
3927
  }
3094
3928
  if (!fallback) return;
3929
+ this.openCheckoutWithVipps();
3930
+ }
3931
+ /** The ordinary way in, Vipps preselected: the host opens the checkout on `vio:checkout-open`. */
3932
+ openCheckoutWithVipps() {
3933
+ this.openCheckoutWith("vipps");
3934
+ }
3935
+ /** The ordinary way in with a method preselected: the host opens the checkout on `vio:checkout-open`. */
3936
+ openCheckoutWith(paymentMethod) {
3095
3937
  this.dispatchEvent(
3096
3938
  new CustomEvent("vio:checkout-open", {
3097
3939
  bubbles: true,
3098
3940
  composed: true,
3099
- detail: { sponsorId: this.sponsorId, paymentMethod: "vipps", express: false, ...this.attribution(), ...sponsorDetail(this.sponsorId) }
3941
+ detail: { sponsorId: this.sponsorId, paymentMethod, express: false, ...this.attribution(), ...sponsorDetail(this.sponsorId) }
3100
3942
  })
3101
3943
  );
3102
3944
  }
3103
3945
  /** Buy with Stripe — opens the checkout with Stripe preselected. */
3104
3946
  buyWithStripe() {
3105
3947
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3948
+ if (!this.requireSelection()) return;
3106
3949
  this.addCurrentToCart();
3107
3950
  this.close();
3108
3951
  this.dispatchEvent(
@@ -3113,14 +3956,49 @@ var VioProductDetail = class extends LitElement {
3113
3956
  })
3114
3957
  );
3115
3958
  }
3959
+ /** Prepare the Apple Pay sheet for this product × quantity; the newest preparation wins. */
3960
+ prepareApplePaySheet() {
3961
+ const amount = this.unitPrice * this.quantity;
3962
+ this.applePayAskedFor = amount;
3963
+ void Vio.checkout.prepareApplePayFor(amount, this.currency).then((handle) => {
3964
+ if (this.applePayAskedFor !== amount) return;
3965
+ this.applePayHandle = handle;
3966
+ this.applePayAmount = amount;
3967
+ this.applePayOk = handle.available;
3968
+ }).catch(() => {
3969
+ if (this.applePayAskedFor !== amount) return;
3970
+ this.applePayHandle = null;
3971
+ this.applePayAmount = null;
3972
+ this.applePayOk = false;
3973
+ });
3974
+ }
3116
3975
  /**
3117
3976
  * Express buy with Apple Pay. Runs synchronously inside the click so the
3118
3977
  * Apple Pay sheet keeps the user gesture: add to cart → open checkout (sets
3119
3978
  * state) → launch the sheet. On authorize, `payment-complete` drives the
3120
3979
  * confirmation drawer (same path as the in-checkout Apple Pay button).
3980
+ *
3981
+ * The sheet's amount is fixed before the tap (Apple blocks a sheet opened
3982
+ * after an await, so it cannot wait for the server's cart), while the
3983
+ * charge is the sponsor's WHOLE cart. So the sheet opens only when it shows
3984
+ * what will be charged: an empty cart, and a sheet prepared for this
3985
+ * product × quantity. Otherwise — lines already in the cart (as with Vipps,
3986
+ * see buyWithVipps), or a quantity whose sheet is still being prepared —
3987
+ * the product is added and the checkout opens with Apple Pay preselected:
3988
+ * every line and the total on screen, and its own Apple Pay button pays
3989
+ * that total.
3121
3990
  */
3122
3991
  buyWithApplePay() {
3123
3992
  if (!this.product || this.availableQuantity <= 0 || !this.applePayHandle) return;
3993
+ if (!this.requireSelection()) return;
3994
+ if (!(this.unitPrice > 0)) return;
3995
+ const cartHasLines = (Vio.cart.getCart(this.sponsorId)?.items.length ?? 0) > 0;
3996
+ if (cartHasLines || this.applePayAmount !== this.unitPrice * this.quantity) {
3997
+ this.addCurrentToCart();
3998
+ this.close();
3999
+ this.openCheckoutWith("apple-pay");
4000
+ return;
4001
+ }
3124
4002
  this.addCurrentToCart();
3125
4003
  try {
3126
4004
  Vio.checkout.open(this.sponsorId);
@@ -3133,6 +4011,175 @@ var VioProductDetail = class extends LitElement {
3133
4011
  void this.applePayHandle.show();
3134
4012
  this.close();
3135
4013
  }
4014
+ /**
4015
+ * The product's cheapest rate for the page's market — the rates the
4016
+ * backend cart offers for its line. A rate in another currency than the
4017
+ * product's price is not shown (never a price the shopper would not pay).
4018
+ */
4019
+ async loadShippingFrom(productId, seq) {
4020
+ let rates = null;
4021
+ try {
4022
+ const product = Vio.commerceFor(this.sponsorId).channel.product;
4023
+ rates = typeof product.getShippingRates === "function" ? await product.getShippingRates(productId, { currency: this.currency }) : null;
4024
+ } catch {
4025
+ rates = null;
4026
+ }
4027
+ if (seq !== this.fetchSeq) return;
4028
+ const currency = String(this.product?.price?.currency_code ?? this.currency ?? "").toUpperCase();
4029
+ const usable = (rates ?? []).filter((r) => !r.currency || r.currency.toUpperCase() === currency);
4030
+ const cheapest = usable[0];
4031
+ this.shippingFrom = cheapest ? { price: cheapest.price, currency: cheapest.currency || currency } : null;
4032
+ this.shippingRates = usable.map((r) => ({
4033
+ name: String(r.name ?? ""),
4034
+ price: r.price,
4035
+ currency: r.currency || currency,
4036
+ description: String(r.description ?? "").trim(),
4037
+ logoUrl: String(r.logoUrl ?? "")
4038
+ }));
4039
+ }
4040
+ /**
4041
+ * `gallery="carousel"`: every photo in one row that snaps one at a time —
4042
+ * swipe on touch, arrows with a mouse — and a dot per photo over it
4043
+ * instead of thumbnails, so the name, price and sizes come up higher.
4044
+ */
4045
+ renderCarousel(images, title) {
4046
+ const count = images.length;
4047
+ const current2 = Math.min(this.activeImageIndex, Math.max(0, count - 1));
4048
+ return html`
4049
+ <div class="gallery carousel" part="gallery">
4050
+ <div class="carousel-track" part="image" @scroll=${this.onCarouselScroll}>
4051
+ ${images.map(
4052
+ (img, idx) => html`
4053
+ <div class="carousel-slide" part="slide" aria-label=${`Bilde ${idx + 1} av ${count}`}>
4054
+ <img src=${img.url} alt=${idx === 0 ? title : ""} loading=${idx === 0 ? "eager" : "lazy"} />
4055
+ </div>
4056
+ `
4057
+ )}
4058
+ </div>
4059
+ ${count > 1 ? html`
4060
+ <button
4061
+ class="carousel-arrow prev"
4062
+ part="carousel-arrow"
4063
+ aria-label="Forrige bilde"
4064
+ ?disabled=${current2 === 0}
4065
+ @click=${() => this.stepCarousel(-1)}
4066
+ >‹</button>
4067
+ <button
4068
+ class="carousel-arrow next"
4069
+ part="carousel-arrow"
4070
+ aria-label="Neste bilde"
4071
+ ?disabled=${current2 === count - 1}
4072
+ @click=${() => this.stepCarousel(1)}
4073
+ >›</button>
4074
+ <div class="carousel-dots" part="image-dots" aria-hidden="true">
4075
+ ${images.map((_, idx) => html`<span class="carousel-dot ${idx === current2 ? "active" : ""}"></span>`)}
4076
+ </div>
4077
+ ` : ""}
4078
+ </div>
4079
+ `;
4080
+ }
4081
+ /**
4082
+ * «Beskrivelse», not shut: the first lines show, fading out, and a tap on
4083
+ * the heading, the text or «Les mer» opens the rest (Angelo, 2026-10-28).
4084
+ * A description that fits in the preview has nothing to open.
4085
+ */
4086
+ renderDescription(text) {
4087
+ const mode = this.resolvedDescriptionMode;
4088
+ if (mode === "open") {
4089
+ return html`<div class="description-block description-preview open" part="description">
4090
+ <div class="desc-head">Beskrivelse</div>
4091
+ <div class="description">${text}</div>
4092
+ </div>`;
4093
+ }
4094
+ const foldable = mode === "folded" || this.descriptionClipped;
4095
+ const open = this.descriptionOpen || !foldable;
4096
+ const toggle = () => {
4097
+ if (foldable) this.descriptionOpen = !this.descriptionOpen;
4098
+ };
4099
+ return html`<div class="description-block description-preview ${open ? "open" : ""} ${foldable ? "clipped" : ""} ${mode}" part="description">
4100
+ <button class="desc-head" type="button" aria-expanded=${open ? "true" : "false"} @click=${toggle}>Beskrivelse</button>
4101
+ ${mode === "folded" && !open ? nothing : html`<div class="description ${open ? "" : "collapsed"}" @click=${() => !this.descriptionOpen && toggle()}>${text}</div>`}
4102
+ ${mode === "preview" && this.descriptionClipped ? html`<button class="desc-more" type="button" @click=${toggle}>${this.descriptionOpen ? "Vis mindre" : "Les mer"}</button>` : nothing}
4103
+ </div>`;
4104
+ }
4105
+ /** Is the preview hiding part of the text? Measured with the preview applied. */
4106
+ measureDescription() {
4107
+ const el = this.renderRoot.querySelector(".description-preview .description");
4108
+ if (!el || this.descriptionOpen) return;
4109
+ const wasCollapsed = el.classList.contains("collapsed");
4110
+ el.classList.add("collapsed");
4111
+ const clipped = el.scrollHeight > el.clientHeight + 1;
4112
+ if (!wasCollapsed) el.classList.remove("collapsed");
4113
+ if (clipped !== this.descriptionClipped) this.descriptionClipped = clipped;
4114
+ }
4115
+ renderHighlights() {
4116
+ const own = (Array.isArray(this.highlights) ? this.highlights : []).filter((h) => h && h.title);
4117
+ const items = own.length ? own : resolveSponsorIdentity(this.sponsorId)?.highlights ?? [];
4118
+ if (items.length === 0) return nothing;
4119
+ return html`<ul class="highlights" part="highlights">
4120
+ ${items.map((h) => {
4121
+ const path = h.icon ? HIGHLIGHT_ICONS[h.icon] : void 0;
4122
+ const icon = path ? html`<svg class="hl-icon" viewBox="0 0 24 24" aria-hidden="true"><path d=${path}></path></svg>` : h.icon ? html`<img class="hl-icon" src=${h.icon} alt="" />` : nothing;
4123
+ return html`<li class="hl" part="highlight">
4124
+ ${icon}
4125
+ <span class="hl-title">${h.title}</span>
4126
+ ${h.text ? html`<span class="hl-text">${h.text}</span>` : nothing}
4127
+ </li>`;
4128
+ })}
4129
+ </ul>`;
4130
+ }
4131
+ /**
4132
+ * «Frakt & retur», folded like «Beskrivelse»: the delivery options for the
4133
+ * page's country with their price, and the right of withdrawal with the
4134
+ * brand's own terms and returns pages when it gave them.
4135
+ */
4136
+ renderShippingReturns() {
4137
+ const identity = resolveSponsorIdentity(this.sponsorId);
4138
+ const rates = this.shippingRates;
4139
+ const times = [...new Set(rates.map((r) => r.description).filter(Boolean))];
4140
+ const perRateTime = times.length > 1;
4141
+ const sharedTime = times.length === 1 ? times[0] : times.length === 0 && this.deliveryTime.trim() ? `Leveringstid: ${this.deliveryTime.trim()}` : "";
4142
+ const links = [
4143
+ identity?.refundPolicyUrl ? html`<a href=${identity.refundPolicyUrl} target="_blank" rel="noopener noreferrer">Angrerett og retur</a>` : null,
4144
+ identity?.termsUrl ? html`<a href=${identity.termsUrl} target="_blank" rel="noopener noreferrer">Kjøpsvilkår</a>` : null
4145
+ ].filter(Boolean);
4146
+ return html`<details class="description-block shipping-returns" part="shipping-returns">
4147
+ <summary>Frakt &amp; retur</summary>
4148
+ <div class="description shipping-returns-body">
4149
+ <div class="sr-title">Frakt</div>
4150
+ ${rates.length ? html`<ul class="sr-rates">
4151
+ ${rates.map((r) => {
4152
+ const { carrier, method } = splitRateName(r.name);
4153
+ const logo = r.logoUrl || carrierLogo(this.carrierLogos, carrier);
4154
+ return html`<li>
4155
+ ${logo ? html`<img class="sr-logo" src=${logo} alt=${carrier} />` : lineIcon(rateIcon(r.name ?? ""), "line-icon sr-icon")}
4156
+ <span class="sr-rate">
4157
+ <span class="sr-carrier">${carrier || method || "Frakt"}</span>
4158
+ ${carrier && method ? html`<span class="sr-method">${method}</span>` : nothing}
4159
+ ${perRateTime && r.description ? html`<span class="sr-method">${r.description}</span>` : nothing}
4160
+ </span>
4161
+ <span class="sr-price">${r.price <= 0 ? "Gratis" : formatPrice(r.price, r.currency)}</span>
4162
+ </li>`;
4163
+ })}
4164
+ </ul>` : html`<p>Fraktprisen vises i kassen.</p>`}
4165
+ ${sharedTime ? html`<p class="sr-line sr-time">${lineIcon("clock", "line-icon sr-icon")}<span>${sharedTime}</span></p>` : nothing}
4166
+ <div class="sr-title">Retur</div>
4167
+ <p class="sr-line">${lineIcon("return", "line-icon sr-icon")}<span>Du har 14 dagers angrerett fra du mottar varen.</span></p>
4168
+ ${links.length ? html`<p class="sr-links">${links}</p>` : nothing}
4169
+ </div>
4170
+ </details>`;
4171
+ }
4172
+ /** Under the price: «Frakt fra X · 14 dagers angrerett» (or «Gratis frakt · …»). */
4173
+ renderDeliveryNote() {
4174
+ const ship = this.shippingFrom;
4175
+ const shipping = ship ? ship.price <= 0 ? "Gratis frakt" : `Frakt fra ${formatPrice(ship.price, ship.currency)}` : "";
4176
+ return html`<div class="delivery-note" part="delivery-note">
4177
+ ${shipping ? html`<span part="delivery-shipping">${shipping}</span><span aria-hidden="true"> · </span>` : ""}<span
4178
+ part="delivery-withdrawal"
4179
+ >14 dagers angrerett</span
4180
+ >
4181
+ </div>`;
4182
+ }
3136
4183
  /** The seller's Company name; the brand when there is none; nothing while asking. */
3137
4184
  headerName() {
3138
4185
  if (this.sellerName) return this.sellerName;
@@ -3215,6 +4262,7 @@ var VioProductDetail = class extends LitElement {
3215
4262
 
3216
4263
  ${this.renderBody()}
3217
4264
  </div>
4265
+ ${this.renderAddedToast()}
3218
4266
  `;
3219
4267
  }
3220
4268
  renderBody() {
@@ -3242,7 +4290,7 @@ var VioProductDetail = class extends LitElement {
3242
4290
  const activeImage = this.activeImage;
3243
4291
  return html`
3244
4292
  <div class="body">
3245
- <div class="gallery" part="gallery">
4293
+ ${this.galleryMode === "carousel" ? this.renderCarousel(images, p.title) : html`<div class="gallery" part="gallery">
3246
4294
  <div class="gallery-main" part="image">
3247
4295
  ${activeImage?.url ? html`<img src=${activeImage.url} alt=${p.title} />` : ""}
3248
4296
  </div>
@@ -3279,26 +4327,30 @@ var VioProductDetail = class extends LitElement {
3279
4327
  >›</button>
3280
4328
  </div>
3281
4329
  ` : ""}
3282
- </div>
4330
+ </div>`}
3283
4331
 
3284
4332
  <div class="info" part="info">
3285
4333
  ${p.brand ? html`<div class="brand" part="brand">${p.brand}</div>` : ""}
3286
- <h2 class="name" part="title">${p.title}</h2>
3287
-
3288
- <div class="price-row">
3289
- <span class="price" part="price">${formatPrice(this.unitPrice, p.price.currency_code)}</span>
3290
- ${showCompare ? html`<span class="compare-at">${formatPrice(
4334
+ <div class="headline">
4335
+ <div class="title-row">
4336
+ <h2 class="name" part="title">${p.title}</h2>
4337
+ <div class="stock ${stock.cls}" part="stock">${stock.text}</div>
4338
+ </div>
4339
+ <div class="price-row">
4340
+ <span class="price" part="price">${formatPrice(this.unitPrice, p.price.currency_code)}</span>
4341
+ ${showCompare ? html`<span class="compare-at">${formatPrice(
3291
4342
  compare,
3292
4343
  p.price.currency_code
3293
4344
  )}</span>` : ""}
4345
+ </div>
3294
4346
  </div>
3295
4347
 
3296
- <div class="stock ${stock.cls}" part="stock">${stock.text}</div>
3297
-
3298
- ${this.renderOptionPickers(p.options)}
3299
-
4348
+ <div class="purchase">
3300
4349
  <div class="buy" part="buy">
3301
- ${this.availableQuantity > 0 && this.methodEnabled("vipps") ? this.vippsOfficial ? html`
4350
+ ${p.options.length > 0 ? html`<div class="buy-options" part="buy-options">${this.renderOptionPickers(p.options)}</div>` : ""}
4351
+ ${this.renderDeliveryNote()}
4352
+ ${this.methodsPending && this.availableQuantity > 0 ? html`<div class="buy-pending" part="buy-pending" role="status" aria-label="Laster betalingsmåter"></div>` : ""}
4353
+ ${this.availableQuantity > 0 && this.hasPrice && this.methodEnabled("vipps") ? this.vippsOfficial ? html`
3302
4354
  <vipps-mobilepay-button
3303
4355
  class="buy-vipps-official"
3304
4356
  part="buy-vipps"
@@ -3324,6 +4376,7 @@ var VioProductDetail = class extends LitElement {
3324
4376
  <span class="vipps-badge">vipps</span>
3325
4377
  </button>
3326
4378
  ` : ""}
4379
+ ${this.availableQuantity > 0 && this.hasPrice && this.methodEnabled("vipps") && this.vippsGoesStraightToApp() ? html`<p class="vipps-note" part="vipps-note">Vipps fyller inn navn og adresse – du godkjenner og betaler i appen</p>` : ""}
3327
4380
 
3328
4381
  <div class="qty-cta-row" part="quantity-row">
3329
4382
  <div class="qty-stepper">
@@ -3365,7 +4418,7 @@ var VioProductDetail = class extends LitElement {
3365
4418
  </button>
3366
4419
  ` : ""}
3367
4420
 
3368
- ${this.availableQuantity > 0 && this.applePayOk && this.methodEnabled("apple-pay", "applepay") ? html`
4421
+ ${this.availableQuantity > 0 && this.hasPrice && this.applePayOk && this.methodEnabled("apple-pay", "applepay") ? html`
3369
4422
  <button
3370
4423
  class="buy-applepay"
3371
4424
  @click=${this.buyWithApplePay}
@@ -3411,12 +4464,11 @@ var VioProductDetail = class extends LitElement {
3411
4464
  ${this.renderSellerNote()}
3412
4465
  </div>
3413
4466
 
3414
- ${p.description ? html`
3415
- <details class="description-block" part="description">
3416
- <summary>Beskrivelse</summary>
3417
- <div class="description">${this.stripHtml(p.description)}</div>
3418
- </details>
3419
- ` : ""}
4467
+ ${this.renderHighlights()}
4468
+
4469
+ ${p.description ? this.renderDescription(this.stripHtml(p.description)) : ""}
4470
+ ${this.renderShippingReturns()}
4471
+ </div>
3420
4472
  </div>
3421
4473
  </div>
3422
4474
  `;
@@ -3426,9 +4478,16 @@ var VioProductDetail = class extends LitElement {
3426
4478
  return html`${options.map((opt) => {
3427
4479
  const values = normalizeValues(opt.values);
3428
4480
  if (values.length === 0) return "";
4481
+ const chosen = String(this.selectedOptions[opt.name] ?? "").trim();
4482
+ const asked = this.selectionPrompt === opt.name && !chosen;
3429
4483
  return html`
3430
- <div class="option-group" part="option-group">
3431
- <div class="option-label" part="option-label">${opt.name}</div>
4484
+ <div class="option-group ${asked ? "needs-choice" : ""}" part="option-group">
4485
+ <div class="option-label" part="option-label">
4486
+ <span
4487
+ >${opt.name}${chosen ? html`: <span class="option-chosen" part="option-chosen">${chosen}</span>` : ""}</span
4488
+ >
4489
+ ${asked ? html`<span class="option-prompt" part="option-prompt" role="alert">${this.promptFor(opt)}</span>` : ""}
4490
+ </div>
3432
4491
  <div class="option-values" part="options">
3433
4492
  ${values.map((val) => {
3434
4493
  const available = this.isValueAvailable(opt.name, val);
@@ -3493,6 +4552,24 @@ __decorateClass([
3493
4552
  __decorateClass([
3494
4553
  property({ type: String, attribute: "vio-content-title" })
3495
4554
  ], VioProductDetail.prototype, "vioContentTitle", 2);
4555
+ __decorateClass([
4556
+ property({ type: Boolean, attribute: "no-added-toast" })
4557
+ ], VioProductDetail.prototype, "noAddedToast", 2);
4558
+ __decorateClass([
4559
+ property({ type: String })
4560
+ ], VioProductDetail.prototype, "gallery", 2);
4561
+ __decorateClass([
4562
+ property({ type: String, attribute: "description-mode" })
4563
+ ], VioProductDetail.prototype, "descriptionMode", 2);
4564
+ __decorateClass([
4565
+ property({ type: Array })
4566
+ ], VioProductDetail.prototype, "highlights", 2);
4567
+ __decorateClass([
4568
+ property({ type: String, attribute: "delivery-time" })
4569
+ ], VioProductDetail.prototype, "deliveryTime", 2);
4570
+ __decorateClass([
4571
+ property({ type: Object, attribute: "carrier-logos" })
4572
+ ], VioProductDetail.prototype, "carrierLogos", 2);
3496
4573
  __decorateClass([
3497
4574
  state()
3498
4575
  ], VioProductDetail.prototype, "product", 2);
@@ -3502,6 +4579,12 @@ __decorateClass([
3502
4579
  __decorateClass([
3503
4580
  state()
3504
4581
  ], VioProductDetail.prototype, "sellerSettled", 2);
4582
+ __decorateClass([
4583
+ state()
4584
+ ], VioProductDetail.prototype, "shippingFrom", 2);
4585
+ __decorateClass([
4586
+ state()
4587
+ ], VioProductDetail.prototype, "shippingRates", 2);
3505
4588
  __decorateClass([
3506
4589
  state()
3507
4590
  ], VioProductDetail.prototype, "isLoading", 2);
@@ -3511,6 +4594,9 @@ __decorateClass([
3511
4594
  __decorateClass([
3512
4595
  state()
3513
4596
  ], VioProductDetail.prototype, "selectedOptions", 2);
4597
+ __decorateClass([
4598
+ state()
4599
+ ], VioProductDetail.prototype, "selectionPrompt", 2);
3514
4600
  __decorateClass([
3515
4601
  state()
3516
4602
  ], VioProductDetail.prototype, "quantity", 2);
@@ -3526,18 +4612,39 @@ __decorateClass([
3526
4612
  __decorateClass([
3527
4613
  state()
3528
4614
  ], VioProductDetail.prototype, "thumbsAtEnd", 2);
4615
+ __decorateClass([
4616
+ state()
4617
+ ], VioProductDetail.prototype, "descriptionOpen", 2);
4618
+ __decorateClass([
4619
+ state()
4620
+ ], VioProductDetail.prototype, "descriptionClipped", 2);
4621
+ __decorateClass([
4622
+ state()
4623
+ ], VioProductDetail.prototype, "galleryMode", 2);
4624
+ __decorateClass([
4625
+ state()
4626
+ ], VioProductDetail.prototype, "resolvedDescriptionMode", 2);
3529
4627
  __decorateClass([
3530
4628
  state()
3531
4629
  ], VioProductDetail.prototype, "logoSquare", 2);
3532
4630
  __decorateClass([
3533
4631
  state()
3534
4632
  ], VioProductDetail.prototype, "adding", 2);
4633
+ __decorateClass([
4634
+ state()
4635
+ ], VioProductDetail.prototype, "addedToast", 2);
3535
4636
  __decorateClass([
3536
4637
  state()
3537
4638
  ], VioProductDetail.prototype, "applePayOk", 2);
3538
4639
  __decorateClass([
3539
4640
  state()
3540
4641
  ], VioProductDetail.prototype, "availableMethods", 2);
4642
+ __decorateClass([
4643
+ state()
4644
+ ], VioProductDetail.prototype, "methodsLoading", 2);
4645
+ __decorateClass([
4646
+ state()
4647
+ ], VioProductDetail.prototype, "vippsExpress", 2);
3541
4648
  __decorateClass([
3542
4649
  state()
3543
4650
  ], VioProductDetail.prototype, "vippsOfficial", 2);
@@ -3550,9 +4657,35 @@ function normalizeValues(raw) {
3550
4657
  }
3551
4658
  return [];
3552
4659
  }
4660
+ function splitRateName(name) {
4661
+ const text = String(name ?? "").trim();
4662
+ const m = text.match(/^(.+?)\s+[-–—]\s+(.+)$/);
4663
+ if (!m?.[1] || !m[2]) return { carrier: "", method: text };
4664
+ const method = m[2].trim();
4665
+ return { carrier: m[1].trim(), method: method.charAt(0).toUpperCase() + method.slice(1) };
4666
+ }
3553
4667
  var STRIPE_LOGO_SRC2 = "data:image/svg+xml;utf8," + encodeURIComponent(
3554
4668
  '<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>'
3555
4669
  );
4670
+ function ratePrice(rate, currency) {
4671
+ const price = rate?.price;
4672
+ if (!price) return null;
4673
+ const amount = typeof price.amount_incl_taxes === "number" ? price.amount_incl_taxes : price.amount;
4674
+ if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) return null;
4675
+ if (price.currency_code && currency && price.currency_code.toUpperCase() !== currency.toUpperCase()) return null;
4676
+ return amount;
4677
+ }
4678
+ function cartShipping(items, currency) {
4679
+ for (const item of items) {
4680
+ const chosen = ratePrice(item.shipping, currency);
4681
+ if (chosen !== null) return chosen;
4682
+ }
4683
+ for (const item of items) {
4684
+ const offered = (item.availableShippings ?? []).map((r) => ratePrice(r, currency)).filter((p) => p !== null);
4685
+ if (offered.length > 0) return Math.min(...offered);
4686
+ }
4687
+ return null;
4688
+ }
3556
4689
  var VioCart = class extends LitElement {
3557
4690
  constructor() {
3558
4691
  super(...arguments);
@@ -3564,6 +4697,7 @@ var VioCart = class extends LitElement {
3564
4697
  this.itemCount = 0;
3565
4698
  this.applePayAvailable = false;
3566
4699
  this.availableMethods = null;
4700
+ this.methodsLoading = false;
3567
4701
  this.vippsOfficial = false;
3568
4702
  /** Whether the method lookup has been attempted — see boundOnCartChange. */
3569
4703
  this.paymentMethodsAttempted = false;
@@ -3586,6 +4720,7 @@ var VioCart = class extends LitElement {
3586
4720
  sheet: () => this.renderRoot.querySelector(".drawer"),
3587
4721
  backdrop: () => this.renderRoot.querySelector(".backdrop"),
3588
4722
  dragZones: ".handle, .header",
4723
+ scroller: () => this.renderRoot.querySelector(".body"),
3589
4724
  close: () => this.close()
3590
4725
  });
3591
4726
  this.boundOnCartChange = (e) => {
@@ -3790,6 +4925,14 @@ var VioCart = class extends LitElement {
3790
4925
  cursor: pointer;
3791
4926
  }
3792
4927
  .checkout-btn:hover { opacity: 0.9; }
4928
+ .checkout-btn:disabled { opacity: 0.5; cursor: not-allowed; }
4929
+ /* Where the express buttons go while the brand's methods load. */
4930
+ .methods-pending {
4931
+ height: 48px;
4932
+ margin-bottom: 10px;
4933
+ border-radius: var(--vio-radius-lg, 8px);
4934
+ background: var(--vio-color-surface-muted, #f1f1f1);
4935
+ }
3793
4936
  .footer {
3794
4937
  padding: var(--vio-space-lg, 24px);
3795
4938
  border-top: 1px solid var(--vio-color-border, #e5e5e5);
@@ -3802,6 +4945,14 @@ var VioCart = class extends LitElement {
3802
4945
  font-size: 14px;
3803
4946
  }
3804
4947
  .subtotal-label { color: var(--vio-color-text-secondary, #666); }
4948
+ .shipping-row,
4949
+ .total-row {
4950
+ display: flex;
4951
+ justify-content: space-between;
4952
+ margin: calc(var(--vio-space-md, 16px) * -0.5) 0 var(--vio-space-md, 16px);
4953
+ font-size: 14px;
4954
+ }
4955
+ .total-row { font-weight: 700; font-size: 16px; margin-top: 0; }
3805
4956
  .subtotal-value { font-weight: 700; font-size: 16px; }
3806
4957
 
3807
4958
  /* Apple Pay express — pay the whole cart directly. */
@@ -3997,6 +5148,7 @@ var VioCart = class extends LitElement {
3997
5148
  this.loadingPaymentMethods = false;
3998
5149
  return;
3999
5150
  }
5151
+ this.methodsLoading = true;
4000
5152
  try {
4001
5153
  const methods = await Vio.checkout.getAvailablePaymentMethods(firstSponsorId);
4002
5154
  if (Array.isArray(methods)) {
@@ -4011,10 +5163,20 @@ var VioCart = class extends LitElement {
4011
5163
  this.availableMethods = null;
4012
5164
  } finally {
4013
5165
  this.loadingPaymentMethods = false;
5166
+ this.methodsLoading = false;
4014
5167
  }
4015
5168
  }
4016
- /** Backend names arrive as e.g. "Apple Pay" — compare letters only. */
5169
+ /** The methods are on their way and none is known yet. */
5170
+ get methodsPending() {
5171
+ return this.methodsLoading && this.availableMethods === null;
5172
+ }
5173
+ /**
5174
+ * Backend names arrive as e.g. "Apple Pay" — compare letters only. While
5175
+ * the list loads, no method's button is drawn: «unknown = all» painted
5176
+ * Stripe and Klarna for a Vipps-only brand until it came.
5177
+ */
4017
5178
  methodEnabled(...names) {
5179
+ if (this.methodsPending) return false;
4018
5180
  return isMethodEnabled(this.availableMethods, ...names);
4019
5181
  }
4020
5182
  updated(changed) {
@@ -4025,6 +5187,9 @@ var VioCart = class extends LitElement {
4025
5187
  if (changed.has("open") && this.open) {
4026
5188
  this.confirmedOrder = null;
4027
5189
  this.preparedTotal = -1;
5190
+ for (const cart of this.carts.values()) {
5191
+ if (cart.cartId) void Vio.cart.refreshFromServer(cart.sponsorId);
5192
+ }
4028
5193
  }
4029
5194
  if (this.open && this.itemCount > 0) {
4030
5195
  const total = this.totalAcrossSponsors;
@@ -4054,7 +5219,7 @@ var VioCart = class extends LitElement {
4054
5219
  /** Apple Pay express — pay the whole cart, then show the confirmation. */
4055
5220
  onApplePay() {
4056
5221
  const handle = this.applePayHandle;
4057
- if (!handle || !handle.available || this.itemCount === 0) return;
5222
+ if (!handle || !handle.available || !this.payable) return;
4058
5223
  const snapshot = this.buildConfirmation();
4059
5224
  const sponsorIds = [...this.carts.keys()];
4060
5225
  const sponsor = sponsorIds.length === 1 ? sponsorDetail(sponsorIds[0]) : {};
@@ -4098,7 +5263,7 @@ var VioCart = class extends LitElement {
4098
5263
  * (betalingsmetode). The host's checkout-open handler closes this cart and
4099
5264
  * opens <vio-checkout> in express mode with Klarna preselected. */
4100
5265
  onKlarnaExpress() {
4101
- if (this.itemCount === 0) return;
5266
+ if (!this.payable) return;
4102
5267
  const firstSponsorId = [...this.carts.keys()][0];
4103
5268
  if (firstSponsorId === void 0) return;
4104
5269
  this.dispatchEvent(
@@ -4119,7 +5284,7 @@ var VioCart = class extends LitElement {
4119
5284
  * method present the others were unreachable from here.
4120
5285
  */
4121
5286
  onCheckout() {
4122
- if (this.itemCount === 0) return;
5287
+ if (!this.payable) return;
4123
5288
  const firstSponsorId = [...this.carts.keys()][0];
4124
5289
  if (firstSponsorId === void 0) return;
4125
5290
  this.dispatchEvent(
@@ -4131,51 +5296,31 @@ var VioCart = class extends LitElement {
4131
5296
  );
4132
5297
  }
4133
5298
  /**
4134
- * Vipps — Express: the app collects address and delivery, so the cart
4135
- * goes straight to Vipps. When this cart cannot have Express (several
4136
- * suppliers, digital goods, no rate) or Vipps cannot be started, the
4137
- * checkout opens with Vipps preselected, as before.
5299
+ * Vipps from the cart: the checkout opens with Vipps preselected — the
5300
+ * standard path, where our form collects name, address, mobile and the
5301
+ * delivery (with its price) before paying (Mote & Livsstil, 2026-10-28).
5302
+ * It no longer starts Express from here: Express is the product detail's
5303
+ * «Kjøp nå med Vipps» only.
4138
5304
  */
4139
5305
  onVipps() {
4140
- if (this.itemCount === 0) return;
5306
+ if (!this.payable) return;
4141
5307
  const firstSponsorId = [...this.carts.keys()][0];
4142
5308
  if (firstSponsorId === void 0) return;
4143
- const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: firstSponsorId, source: "cart" });
4144
- void this.startVippsExpressOrCheckout(firstSponsorId, redirect);
4145
- }
4146
- /** Vipps' own button when its script answers; our orange one otherwise. */
4147
- async loadVippsButton() {
4148
- this.vippsOfficial = await ensureVippsButton();
4149
- }
4150
- async startVippsExpressOrCheckout(sponsorId, redirect) {
4151
- let fallback = true;
4152
- try {
4153
- await Vio.cart.waitForCartMutations(sponsorId);
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
- }
4161
- } catch (err) {
4162
- if (typeof console !== "undefined") {
4163
- console.warn("[VioCart] Vipps Express not started:", err);
4164
- }
4165
- redirect.fail(err instanceof Error ? err.message : String(err));
4166
- }
4167
- if (!fallback) return;
4168
5309
  this.dispatchEvent(
4169
5310
  new CustomEvent("vio:checkout-open", {
4170
5311
  bubbles: true,
4171
5312
  composed: true,
4172
- detail: { sponsorId, paymentMethod: "vipps", express: false, ...sponsorDetail(sponsorId) }
5313
+ detail: { sponsorId: firstSponsorId, paymentMethod: "vipps", express: false, ...sponsorDetail(firstSponsorId) }
4173
5314
  })
4174
5315
  );
4175
5316
  }
5317
+ /** Vipps' own button when its script answers; our orange one otherwise. */
5318
+ async loadVippsButton() {
5319
+ this.vippsOfficial = await ensureVippsButton();
5320
+ }
4176
5321
  /** Stripe — opens the checkout with Stripe preselected. */
4177
5322
  onStripe() {
4178
- if (this.itemCount === 0) return;
5323
+ if (!this.payable) return;
4179
5324
  const firstSponsorId = [...this.carts.keys()][0];
4180
5325
  if (firstSponsorId === void 0) return;
4181
5326
  this.dispatchEvent(
@@ -4190,6 +5335,10 @@ var VioCart = class extends LitElement {
4190
5335
  sponsorLabel(cart) {
4191
5336
  return resolveSponsorIdentity(cart.sponsorId)?.name || cart.sponsorName || "";
4192
5337
  }
5338
+ /** Something to pay for: lines, and a total above 0. No button opens a checkout or a payment otherwise. */
5339
+ get payable() {
5340
+ return this.itemCount > 0 && this.totalAcrossSponsors > 0;
5341
+ }
4193
5342
  get totalAcrossSponsors() {
4194
5343
  let sum = 0;
4195
5344
  for (const cart of this.carts.values()) {
@@ -4197,6 +5346,24 @@ var VioCart = class extends LitElement {
4197
5346
  }
4198
5347
  return sum;
4199
5348
  }
5349
+ /**
5350
+ * Shipping across the carts, from what the server cart says of its lines:
5351
+ * the rate chosen for them, else the cheapest one offered (the one the
5352
+ * checkout preselects). null when some cart has no known rate — the footer
5353
+ * says it is worked out in the checkout then.
5354
+ */
5355
+ get shippingAcrossSponsors() {
5356
+ let sum = 0;
5357
+ let any = false;
5358
+ for (const cart of this.carts.values()) {
5359
+ if (cart.items.length === 0) continue;
5360
+ const rate = cartShipping(cart.items, cart.currency);
5361
+ if (rate === null) return null;
5362
+ sum += rate;
5363
+ any = true;
5364
+ }
5365
+ return any ? sum : null;
5366
+ }
4200
5367
  get primaryCurrency() {
4201
5368
  for (const cart of this.carts.values()) return cart.currency;
4202
5369
  return "NOK";
@@ -4288,13 +5455,16 @@ var VioCart = class extends LitElement {
4288
5455
  ${formatPrice(this.totalAcrossSponsors, this.primaryCurrency)}
4289
5456
  </span>
4290
5457
  </div>
5458
+ ${this.renderShippingRows()}
4291
5459
  <button
4292
5460
  class="checkout-btn"
4293
5461
  @click=${this.onCheckout}
5462
+ ?disabled=${!this.payable}
4294
5463
  aria-label="Til kassen"
4295
5464
  >
4296
5465
  Til kassen
4297
5466
  </button>
5467
+ ${this.methodsPending ? html`<div class="methods-pending" role="status" aria-label="Laster betalingsmåter"></div>` : ""}
4298
5468
  ${this.methodEnabled("apple-pay") && this.applePayAvailable ? html`
4299
5469
  <button
4300
5470
  class="applepay-btn"
@@ -4355,6 +5525,20 @@ var VioCart = class extends LitElement {
4355
5525
  </aside>
4356
5526
  `;
4357
5527
  }
5528
+ /** «Frakt» (the chosen or cheapest rate) and «Totalt» with it; «Beregnes i kassen» when unknown. */
5529
+ renderShippingRows() {
5530
+ const shipping = this.shippingAcrossSponsors;
5531
+ return html`
5532
+ <div class="shipping-row" part="cart-shipping">
5533
+ <span class="subtotal-label">Frakt</span>
5534
+ <span>${shipping === null ? "Beregnes i kassen" : shipping <= 0 ? "Gratis" : formatPrice(shipping, this.primaryCurrency)}</span>
5535
+ </div>
5536
+ ${shipping !== null ? html`<div class="total-row" part="cart-total">
5537
+ <span>Totalt</span>
5538
+ <span>${formatPrice(this.totalAcrossSponsors + shipping, this.primaryCurrency)}</span>
5539
+ </div>` : ""}
5540
+ `;
5541
+ }
4358
5542
  renderConfirmation() {
4359
5543
  const o = this.confirmedOrder;
4360
5544
  if (!o) return html``;
@@ -4406,6 +5590,9 @@ __decorateClass([
4406
5590
  __decorateClass([
4407
5591
  state()
4408
5592
  ], VioCart.prototype, "availableMethods", 2);
5593
+ __decorateClass([
5594
+ state()
5595
+ ], VioCart.prototype, "methodsLoading", 2);
4409
5596
  __decorateClass([
4410
5597
  state()
4411
5598
  ], VioCart.prototype, "vippsOfficial", 2);
@@ -4447,6 +5634,8 @@ function errorText(err) {
4447
5634
  var STRIPE_LOGO_SRC3 = "data:image/svg+xml;utf8," + encodeURIComponent(
4448
5635
  '<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>'
4449
5636
  );
5637
+ var EMPTY_CART_MESSAGE2 = "Handlekurven er tom.";
5638
+ var SHIPPING_NOT_SAVED_MESSAGE = "Kunne ikke lagre fraktmetoden. Pr\xF8v igjen.";
4450
5639
  var _VioCheckout = class _VioCheckout extends LitElement {
4451
5640
  constructor() {
4452
5641
  super(...arguments);
@@ -4552,6 +5741,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4552
5741
  firstName: "",
4553
5742
  lastName: "",
4554
5743
  email: "",
5744
+ phone: "",
4555
5745
  address: "",
4556
5746
  postalCode: "",
4557
5747
  city: ""
@@ -4912,6 +6102,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4912
6102
  transition: border-color 0.15s;
4913
6103
  }
4914
6104
  .field input:focus { border-bottom-color: var(--vio-color-text, #0a0a0a); }
6105
+ .field input[aria-invalid='true'] { border-bottom-color: var(--vio-color-accent, #c14a3b); }
6106
+ .field-error { margin-top: 4px; font-size: 12px; color: var(--vio-color-accent, #c14a3b); }
4915
6107
 
4916
6108
  .payment-grid {
4917
6109
  display: grid;
@@ -5600,7 +6792,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5600
6792
  this.cleanReturnQueryParams(["checkout_id", "payment_processor"]);
5601
6793
  return;
5602
6794
  }
5603
- if (vioMethod === "klarna" && typeof sessionStorage !== "undefined" && sessionStorage.getItem("vio.klarna.pending.v1")) {
6795
+ if (vioMethod === "klarna" && storageGet("session", "vio.klarna.pending.v1")) {
5604
6796
  this.cleanReturnQueryParams(["vio_payment", "vio_method", "vio_sponsor", "checkout_id"]);
5605
6797
  return;
5606
6798
  }
@@ -5766,6 +6958,9 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5766
6958
  if (changed.has("open") && this.open) {
5767
6959
  this.autoSelectSoleMethod();
5768
6960
  }
6961
+ if (changed.has("open") && this.open && this.checkoutState?.sponsorId && !this.orderConfirmed) {
6962
+ void Vio.cart.refreshFromServer(this.checkoutState.sponsorId);
6963
+ }
5769
6964
  if ((changed.has("klarnaAvailable") && this.klarnaAvailable || changed.has("applePayAvailable") && this.applePayAvailable) && this.paymentMethodsResolved && !this.checkoutState?.paymentMethod) {
5770
6965
  this.autoSelectAttempted = false;
5771
6966
  this.autoSelectSoleMethod();
@@ -5864,12 +7059,34 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5864
7059
  (i) => Array.isArray(i.availableShippings) && i.availableShippings.length > 0
5865
7060
  );
5866
7061
  }
5867
- /** Whether all mandatory Leveringsadresse fields are filled. */
7062
+ /** The shipping country the mobile's calling code defaults to (no +prefix typed). */
7063
+ get phoneCountry() {
7064
+ return String(this.form.country || getGlobalCountryCode());
7065
+ }
7066
+ /**
7067
+ * The mobile is required only for the standard Vipps payment — shopcart
7068
+ * does not ask Vipps for the profile there. Every other method takes it
7069
+ * when given and never waits for it (a host that pre-fills the form with
7070
+ * `setAddress()` and no phone keeps working).
7071
+ */
7072
+ get phoneRequired() {
7073
+ return this.checkoutState?.paymentMethod === "vipps";
7074
+ }
7075
+ /** A number typed that is not usable (nothing said while the field is empty). */
7076
+ get phoneError() {
7077
+ const raw = String(this.form.phone ?? "").trim();
7078
+ return raw.length > 0 && !isValidPhone(raw, this.phoneCountry);
7079
+ }
7080
+ /** The mobile as the chosen method needs it: valid when required, else anything. */
7081
+ get phoneReady() {
7082
+ return !this.phoneRequired || isValidPhone(this.form.phone, this.phoneCountry);
7083
+ }
7084
+ /** Whether all mandatory Leveringsadresse fields are filled (the mobile only for Vipps). */
5868
7085
  get isAddressValid() {
5869
7086
  const f = this.form;
5870
7087
  if (!f) return false;
5871
7088
  return Boolean(
5872
- f.firstName && String(f.firstName).trim().length > 0 && f.lastName && String(f.lastName).trim().length > 0 && f.email && String(f.email).trim().length > 0 && String(f.email).includes("@") && f.address && String(f.address).trim().length > 0 && f.postalCode && String(f.postalCode).trim().length > 0 && f.city && String(f.city).trim().length > 0
7089
+ f.firstName && String(f.firstName).trim().length > 0 && f.lastName && String(f.lastName).trim().length > 0 && f.email && String(f.email).trim().length > 0 && String(f.email).includes("@") && f.address && String(f.address).trim().length > 0 && f.postalCode && String(f.postalCode).trim().length > 0 && f.city && String(f.city).trim().length > 0 && this.phoneReady
5873
7090
  );
5874
7091
  }
5875
7092
  /** Whether a valid shipping option is selected (when shippings exist). */
@@ -6109,21 +7326,44 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6109
7326
  });
6110
7327
  }
6111
7328
  /**
6112
- * Vipps from the checkout: Express when the channel allows it, so the
6113
- * shopper picks the delivery inside the app — our rate on the cart is the
6114
- * one preselected there — and never silently keeps the default our form
6115
- * chose for them. When the cart cannot have Express (several suppliers)
6116
- * the backend says so without creating a payment, and the plain payment
6117
- * follows: profile shared, the rate already on the cart.
7329
+ * Vipps from the checkout: the STANDARD payment, always (Mote & Livsstil,
7330
+ * 2026-10-28). The shopper filled our form — name, address, mobile — and
7331
+ * chose the delivery with its price here; the payment carries that address
7332
+ * and the rate on the cart, and Vipps only approves and pays. Express (the
7333
+ * app filling in name and address) is the product detail's «Kjøp nå med
7334
+ * Vipps» alone, even when the brand has Express on.
6118
7335
  */
6119
7336
  async startVippsFromCheckout() {
6120
7337
  const sponsorId = this.checkoutState?.sponsorId;
6121
- const express = await Vio.checkout.getVippsExpressEnabled(sponsorId);
6122
- if (express) {
6123
- const res = await Vio.checkout.startVippsPayment(sponsorId, this.form, { express: true });
6124
- if (res?.payment_url) return;
7338
+ await Vio.checkout.startVippsPayment(sponsorId, this.form, { express: false });
7339
+ }
7340
+ /**
7341
+ * The chosen delivery on the cart before a hosted payment is minted (its
7342
+ * total depends on it). False — and the payment must not start — when it
7343
+ * could not be saved: the selected rate has no supplier to save it under,
7344
+ * or the backend refused. A cart with no rates at all has nothing to save.
7345
+ */
7346
+ async persistSelectedShipping(method) {
7347
+ if (this.availableShippingsList.length === 0) return true;
7348
+ const selectedOpt = this.shippingList.find((s) => s.id === this.selectedShipping);
7349
+ if (!selectedOpt?.supplierId) {
7350
+ if (typeof console !== "undefined") {
7351
+ console.warn(`[VioCheckout] no supplier for the selected shipping \u2014 ${method} not started`);
7352
+ }
7353
+ return false;
7354
+ }
7355
+ try {
7356
+ const updated = await Vio.checkout.updateShippingsBySupplier(
7357
+ [{ shipping_id: selectedOpt.id, supplier_id: Number(selectedOpt.supplierId) }],
7358
+ this.checkoutState?.sponsorId
7359
+ );
7360
+ return updated != null;
7361
+ } catch (err) {
7362
+ if (typeof console !== "undefined") {
7363
+ console.warn(`[VioCheckout] updateShippingsBySupplier on ${method} pay failed:`, err);
7364
+ }
7365
+ return false;
6125
7366
  }
6126
- await Vio.checkout.startVippsPayment(sponsorId, this.form);
6127
7367
  }
6128
7368
  async onApplePay() {
6129
7369
  if (this.applePayInProgress) return;
@@ -6142,6 +7382,10 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6142
7382
  if (this.shippingBlocked) return;
6143
7383
  this.paymentError = null;
6144
7384
  this.paymentNotice = null;
7385
+ if (!this.cartPayable) {
7386
+ this.paymentError = EMPTY_CART_MESSAGE2;
7387
+ return;
7388
+ }
6145
7389
  if (method === "apple-pay") {
6146
7390
  void this.onApplePay();
6147
7391
  return;
@@ -6162,8 +7406,9 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6162
7406
  Vio.checkout.selectPaymentMethod("nexi");
6163
7407
  return;
6164
7408
  }
6165
- if (method !== "vipps") {
6166
- const missing = !this.isAddressValid ? "Vennligst fyll ut alle feltene i leveringsadresse." : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode." : null;
7409
+ {
7410
+ const phoneMissing = method === "vipps" && !isValidPhone(this.form.phone, this.phoneCountry);
7411
+ const missing = !this.isAddressValid || phoneMissing ? "Vennligst fyll ut alle feltene i leveringsadresse." : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode." : null;
6167
7412
  if (missing) {
6168
7413
  if (this.checkoutState?.paymentMethod !== method) {
6169
7414
  Vio.checkout.selectPaymentMethod(method);
@@ -6187,20 +7432,12 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6187
7432
  this.stripeRequested = true;
6188
7433
  return;
6189
7434
  }
6190
- const selectedOpt = this.shippingList.find((s) => s.id === this.selectedShipping);
6191
- if (selectedOpt?.supplierId) {
6192
- try {
6193
- await Vio.checkout.updateShippingsBySupplier(
6194
- [{ shipping_id: selectedOpt.id, supplier_id: Number(selectedOpt.supplierId) }],
6195
- this.checkoutState?.sponsorId
6196
- );
6197
- } catch (err) {
6198
- if (typeof console !== "undefined") {
6199
- console.warn("[VioCheckout] updateShippingsBySupplier on stripe pay failed:", err);
6200
- }
6201
- }
6202
- }
6203
7435
  this.stripeLoading = true;
7436
+ if (!await this.persistSelectedShipping("stripe")) {
7437
+ this.stripeLoading = false;
7438
+ this.paymentError = SHIPPING_NOT_SAVED_MESSAGE;
7439
+ return;
7440
+ }
6204
7441
  void Vio.checkout.startStripePayment(this.checkoutState?.sponsorId, this.form).catch((err) => {
6205
7442
  if (typeof console !== "undefined") {
6206
7443
  console.warn("[VioCheckout] Stripe payment could not start:", err);
@@ -6212,6 +7449,11 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6212
7449
  }
6213
7450
  if (method === "vipps") {
6214
7451
  this.vippsLoading = true;
7452
+ if (!await this.persistSelectedShipping("vipps")) {
7453
+ this.vippsLoading = false;
7454
+ this.paymentError = SHIPPING_NOT_SAVED_MESSAGE;
7455
+ return;
7456
+ }
6215
7457
  const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: this.checkoutState?.sponsorId, source: "checkout" });
6216
7458
  void this.startVippsFromCheckout().catch((err) => {
6217
7459
  if (typeof console !== "undefined") {
@@ -6233,7 +7475,11 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6233
7475
  const s = this.checkoutState;
6234
7476
  if (!s || !s.paymentMethod) return;
6235
7477
  if (this.shippingBlocked) return;
6236
- if (s.paymentMethod !== "vipps" && !this.isAddressValid) {
7478
+ if (!this.cartPayable) {
7479
+ this.paymentError = EMPTY_CART_MESSAGE2;
7480
+ return;
7481
+ }
7482
+ if (!this.isAddressValid) {
6237
7483
  this.paymentError = "Vennligst fyll ut alle feltene i leveringsadresse.";
6238
7484
  return;
6239
7485
  }
@@ -6458,6 +7704,14 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6458
7704
  return method === "apple-pay" || method === "klarna" || isEmbeddedMethod(method ?? "") || // Adyen's Drop-in brings its own Pay button.
6459
7705
  isFormFirstWidgetMethod(method ?? "");
6460
7706
  }
7707
+ /**
7708
+ * A total above 0 — every pay button needs it. The state's subtotal follows
7709
+ * the cart (CheckoutManager.onCartChange): a cart emptied or paid in
7710
+ * another tab drops it to 0 here.
7711
+ */
7712
+ get cartPayable() {
7713
+ return (this.checkoutState?.subtotal ?? 0) > 0;
7714
+ }
6461
7715
  orderTotal() {
6462
7716
  const s = this.checkoutState;
6463
7717
  if (!s) return "";
@@ -7603,7 +8857,6 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7603
8857
  renderCheckoutBody() {
7604
8858
  const method = this.checkoutState?.paymentMethod;
7605
8859
  const showCompleteCta = !!method && !this.isExpressMethod(method);
7606
- const isVipps = method === "vipps";
7607
8860
  const needsOurForm = !!method && !collectsOwnAddress(method);
7608
8861
  return html`
7609
8862
  ${needsOurForm ? html`
@@ -7642,6 +8895,22 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7642
8895
  @input=${(e) => this.onFieldChange("email", e.target.value)}
7643
8896
  />
7644
8897
  </div>
8898
+ <div class="field">
8899
+ <label for="vio-checkout-phone">${this.phoneRequired ? "Mobilnummer *" : "Mobilnummer (valgfritt)"}</label>
8900
+ <input
8901
+ id="vio-checkout-phone"
8902
+ class="phone-input"
8903
+ type="tel"
8904
+ ?required=${this.phoneRequired}
8905
+ inputmode="tel"
8906
+ autocomplete="tel"
8907
+ placeholder=${this.phoneCountry.toUpperCase() === "NO" ? "8 siffer" : "+46 70 123 45 67"}
8908
+ aria-invalid=${this.phoneError ? "true" : "false"}
8909
+ .value=${this.form.phone ?? ""}
8910
+ @input=${(e) => this.onFieldChange("phone", e.target.value)}
8911
+ />
8912
+ ${this.phoneError ? html`<div class="field-error" role="alert">Skriv et gyldig mobilnummer (med landskode, f.eks. +46, hvis det ikke er norsk)</div>` : ""}
8913
+ </div>
7645
8914
  <div class="field">
7646
8915
  <label>Adresse *</label>
7647
8916
  <input
@@ -7706,22 +8975,6 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7706
8975
  </section>
7707
8976
  ` : ""}
7708
8977
 
7709
- ${isVipps ? html`
7710
- <section class="section">
7711
- <div class="section-label">Kontakt</div>
7712
- <div class="field">
7713
- <label>E-post * <span style="font-weight:400;opacity:0.6">(for kvittering — adresse fylles ut i Vipps)</span></label>
7714
- <input
7715
- type="email"
7716
- required
7717
- autocomplete="email"
7718
- .value=${this.form.email}
7719
- @input=${(e) => this.onFieldChange("email", e.target.value)}
7720
- />
7721
- </div>
7722
- </section>
7723
- ` : ""}
7724
-
7725
8978
  <section class="section">
7726
8979
  <div class="section-label">
7727
8980
  ${needsOurForm ? "Steg 2" : "Betaling"}
@@ -7761,7 +9014,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7761
9014
  <button
7762
9015
  class="payment-btn primary complete-cta"
7763
9016
  @click=${() => void this.onPay("stripe")}
7764
- ?disabled=${this.stripeLoading || !this.isAddressValid || !this.hasSelectedShipping}
9017
+ ?disabled=${this.stripeLoading || !this.cartPayable || !this.isAddressValid || !this.hasSelectedShipping}
7765
9018
  title=${!this.isAddressValid ? "Vennligst fyll ut leveringsadresse" : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode" : ""}
7766
9019
  >
7767
9020
  ${this.stripeLoading ? "G\xE5r til Stripe\u2026" : `Betal ${this.payTotalLabel()} med Stripe`}
@@ -7771,7 +9024,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7771
9024
  <button
7772
9025
  class="payment-btn primary complete-cta"
7773
9026
  @click=${() => void this.onPay("vipps")}
7774
- ?disabled=${this.vippsLoading}
9027
+ ?disabled=${this.vippsLoading || !this.cartPayable || !this.isAddressValid || !this.hasSelectedShipping}
9028
+ title=${!this.isAddressValid ? "Vennligst fyll ut leveringsadresse" : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode" : ""}
7775
9029
  >
7776
9030
  ${this.vippsLoading ? "G\xE5r til Vipps\u2026" : `Betal ${this.payTotalLabel()} med Vipps`}
7777
9031
  </button>
@@ -7780,7 +9034,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7780
9034
  <button
7781
9035
  class="payment-btn primary complete-cta"
7782
9036
  @click=${() => void this.onPay("apple-pay")}
7783
- ?disabled=${this.applePayInProgress || !this.isAddressValid || !this.hasSelectedShipping}
9037
+ ?disabled=${this.applePayInProgress || !this.cartPayable || !this.isAddressValid || !this.hasSelectedShipping}
7784
9038
  title=${!this.isAddressValid ? "Vennligst fyll ut leveringsadresse" : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode" : ""}
7785
9039
  >
7786
9040
  ${this.applePayInProgress ? "\xC5pner\u2026" : `Betal ${this.payTotalLabel()} med Apple Pay`}
@@ -7790,7 +9044,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7790
9044
  <button
7791
9045
  class="payment-btn primary complete-cta"
7792
9046
  @click=${this.onCompleteOrder}
7793
- ?disabled=${!this.isAddressValid || !this.hasSelectedShipping}
9047
+ ?disabled=${!this.cartPayable || !this.isAddressValid || !this.hasSelectedShipping}
7794
9048
  title=${!this.isAddressValid ? "Vennligst fyll ut leveringsadresse" : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode" : ""}
7795
9049
  >
7796
9050
  Betal ${this.payTotalLabel()} med ${this.methodLabel(method ?? null)}
@@ -8099,5 +9353,5 @@ function registerVioElements() {
8099
9353
  }
8100
9354
 
8101
9355
  export { VioCart, VioCheckout, VioProduct, VioProductCarousel, VioProductDetail, VioTokens, applyVioTheme, registerVioElements };
8102
- //# sourceMappingURL=chunk-Q2NALS7P.js.map
8103
- //# sourceMappingURL=chunk-Q2NALS7P.js.map
9356
+ //# sourceMappingURL=chunk-C5Q6W4B2.js.map
9357
+ //# sourceMappingURL=chunk-C5Q6W4B2.js.map