@vio-live/web-sdk 0.29.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-AG4G2S2M.js';
1
+ import { Vio, __decorateClass, primaryImageUrl, displayPrice, toCampaignId, getGlobalCurrency, ensureVippsButton, formatPrice, shippingDescriptionOf, 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-ZORYG4W3.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?.();
@@ -1019,6 +1151,21 @@ var VioProductCarousel = class extends LitElement {
1019
1151
  * sponsor of the campaign the slot shows (ADR-0034 — "the campaign of the
1020
1152
  * block is its sponsor"), else 0 (unknown).
1021
1153
  */
1154
+ /**
1155
+ * The campaign whose channel sells a sponsor's products here (one article =
1156
+ * one campaign = one channel): the campaign this slot credits for that
1157
+ * sponsor (`Vio.attribution`), then the element's own (`vio-campaign`, an
1158
+ * ancestor's `data-vio-campaign`).
1159
+ */
1160
+ commerceCampaigns(sponsorId) {
1161
+ const own = this.slotRegistration.campaignHint;
1162
+ try {
1163
+ const resolved = typeof Vio.attribution === "function" ? Vio.attribution(this.effectiveSlot || null, { sponsorId, campaignId: own }).campaignId : void 0;
1164
+ return resolved !== void 0 ? [resolved, ...own] : own;
1165
+ } catch {
1166
+ return own;
1167
+ }
1168
+ }
1022
1169
  effectiveSponsorId() {
1023
1170
  if (this.sponsorId > 0) return this.sponsorId;
1024
1171
  const fromSlot = typeof Vio.campaignForSlot === "function" ? Number(Vio.campaignForSlot(this.effectiveSlot || null, { campaignId: this.slotRegistration.campaignHint })?.primarySponsor?.id) : NaN;
@@ -1031,6 +1178,10 @@ var VioProductCarousel = class extends LitElement {
1031
1178
  }
1032
1179
  this.isLoading = true;
1033
1180
  this.fetchError = "";
1181
+ if (!this.impressed) {
1182
+ this.unobserveImpression?.();
1183
+ this.unobserveImpression = null;
1184
+ }
1034
1185
  try {
1035
1186
  if (this.productRefs.trim() === "" && !this.commerceKey && this.sponsorId <= 0 && !Vio.bootstrapCache) {
1036
1187
  try {
@@ -1052,7 +1203,7 @@ var VioProductCarousel = class extends LitElement {
1052
1203
  }
1053
1204
  const fetched = await Promise.all(
1054
1205
  Array.from(bySponsor.entries()).map(async ([sponsorId2, ids]) => {
1055
- const commerce = this.commerceKey && this.productRefs.trim() === "" ? Vio.commerceWithKey(this.commerceKey) : Vio.commerceFor(sponsorId2);
1206
+ const commerce = this.commerceKey && this.productRefs.trim() === "" ? Vio.commerceWithKey(this.commerceKey) : Vio.commerceFor(sponsorId2, { campaignId: this.commerceCampaigns(sponsorId2) });
1056
1207
  const products = await commerce.channel.product.getByIds({
1057
1208
  product_ids: ids,
1058
1209
  currency: this.currency,
@@ -1074,10 +1225,11 @@ var VioProductCarousel = class extends LitElement {
1074
1225
  this.fetchError = err instanceof Error ? err.message : String(err);
1075
1226
  } finally {
1076
1227
  this.isLoading = false;
1228
+ this.observeForAnalytics();
1077
1229
  }
1078
1230
  }
1079
1231
  observeForAnalytics() {
1080
- if (this.unobserveImpression) return;
1232
+ if (this.unobserveImpression || this.isLoading || !this.isConnected) return;
1081
1233
  try {
1082
1234
  this.unobserveImpression = Vio.analytics.observeImpression(
1083
1235
  this,
@@ -1092,7 +1244,8 @@ var VioProductCarousel = class extends LitElement {
1092
1244
  onImpression: () => {
1093
1245
  this.impressed = true;
1094
1246
  this.maybeTrackList();
1095
- }
1247
+ },
1248
+ commerce: () => this.listCommerce()
1096
1249
  }
1097
1250
  );
1098
1251
  } catch {
@@ -1113,19 +1266,25 @@ var VioProductCarousel = class extends LitElement {
1113
1266
  locationId: this.effectiveSlot || this.label || void 0,
1114
1267
  ...this.attribution()
1115
1268
  },
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
- },
1269
+ commerce: this.listCommerce() ?? void 0,
1124
1270
  props: this.label ? { list_name: this.label } : void 0
1125
1271
  });
1126
1272
  } catch {
1127
1273
  }
1128
1274
  }
1275
+ /** The products on show as analytics lines (impression and view_item_list); null before they load. */
1276
+ listCommerce() {
1277
+ const entries = this.fetched ?? [];
1278
+ if (entries.length === 0) return null;
1279
+ return {
1280
+ items: entries.map(({ product }) => ({
1281
+ productId: product.id,
1282
+ name: product.title,
1283
+ price: Number(product.price?.amount_incl_taxes ?? product.price?.amount) || void 0
1284
+ })),
1285
+ currency: entries[0]?.product.price?.currency_code
1286
+ };
1287
+ }
1129
1288
  parseRefs(raw) {
1130
1289
  return raw.split(",").map((pair) => pair.trim()).filter((s) => s.length > 0).map((pair) => {
1131
1290
  const [sidStr, pidStr] = pair.split(":");
@@ -1165,6 +1324,10 @@ var VioProductCarousel = class extends LitElement {
1165
1324
  name=${p.title}
1166
1325
  price=${displayPrice(p)}
1167
1326
  retailer=${retailer}
1327
+ description=${this.descriptionFor(p)}
1328
+ ?show-description=${this.showsDescription(p)}
1329
+ button-style=${this.buttonStyle}
1330
+ button-label=${this.buttonLabel}
1168
1331
  image-url=${img}
1169
1332
  ?has-variants=${(p.variants?.length ?? 0) > 1}
1170
1333
  .vioSlot=${this.effectiveSlot}
@@ -1190,7 +1353,7 @@ var VioProductCarousel = class extends LitElement {
1190
1353
  </div>
1191
1354
  ${this.seeAllHref ? html`<a class="see-all" href=${this.seeAllHref}>${this.seeAllLabel}</a>` : ""}
1192
1355
  </div>
1193
- <div class="viewport">
1356
+ <div class="viewport ${this.resolvedLayout}">
1194
1357
  <button
1195
1358
  class="nav-btn prev"
1196
1359
  @click=${() => this.scrollByDirection(-1)}
@@ -1241,6 +1404,24 @@ __decorateClass([
1241
1404
  __decorateClass([
1242
1405
  property({ type: String })
1243
1406
  ], VioProductCarousel.prototype, "currency", 2);
1407
+ __decorateClass([
1408
+ property({ type: String, reflect: true })
1409
+ ], VioProductCarousel.prototype, "layout", 2);
1410
+ __decorateClass([
1411
+ property({ type: Boolean, attribute: "show-description" })
1412
+ ], VioProductCarousel.prototype, "showDescription", 2);
1413
+ __decorateClass([
1414
+ property({ type: Object })
1415
+ ], VioProductCarousel.prototype, "descriptions", 2);
1416
+ __decorateClass([
1417
+ property({ type: String, attribute: "button-style" })
1418
+ ], VioProductCarousel.prototype, "buttonStyle", 2);
1419
+ __decorateClass([
1420
+ property({ type: String, attribute: "button-label" })
1421
+ ], VioProductCarousel.prototype, "buttonLabel", 2);
1422
+ __decorateClass([
1423
+ state()
1424
+ ], VioProductCarousel.prototype, "resolvedLayout", 2);
1244
1425
  __decorateClass([
1245
1426
  property({ type: String, attribute: "vio-slot" })
1246
1427
  ], VioProductCarousel.prototype, "vioSlot", 2);
@@ -1274,6 +1455,10 @@ __decorateClass([
1274
1455
  __decorateClass([
1275
1456
  state()
1276
1457
  ], VioProductCarousel.prototype, "fetchError", 2);
1458
+ function cardDescription(description) {
1459
+ if (!description) return "";
1460
+ return description.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\s+/g, " ").trim();
1461
+ }
1277
1462
 
1278
1463
  // src/ui/payment-redirect.ts
1279
1464
  var OVERLAY_CLASS = "vio-redirect-overlay";
@@ -1381,7 +1566,7 @@ var NO_EXPRESS_BUTTON_METHODS = [
1381
1566
  ...EMBEDDED_METHODS,
1382
1567
  ...FORM_FIRST_WIDGET_METHODS
1383
1568
  ];
1384
- var COLLECTS_OWN_ADDRESS = [...EMBEDDED_METHODS, "vipps"];
1569
+ var COLLECTS_OWN_ADDRESS = [...EMBEDDED_METHODS];
1385
1570
  var isEmbeddedMethod = (name) => EMBEDDED_METHODS.includes(normalizeMethodName(name));
1386
1571
  var isFormFirstWidgetMethod = (name) => FORM_FIRST_WIDGET_METHODS.includes(normalizeMethodName(name));
1387
1572
  var collectsOwnAddress = (name) => COLLECTS_OWN_ADDRESS.includes(normalizeMethodName(name));
@@ -1595,6 +1780,8 @@ var BottomSheet = class {
1595
1780
  this.locked = false;
1596
1781
  this.drag = null;
1597
1782
  this.dragEndedAt = -Infinity;
1783
+ /** A finger down in the content, not yet known to be a pull (down) or a scroll. */
1784
+ this.pull = null;
1598
1785
  this.onPointerDown = (e) => {
1599
1786
  if (!e.isPrimary || e.pointerType === "mouse" && e.button !== 0) return;
1600
1787
  if (this.drag || !isPhoneSheet()) return;
@@ -1620,45 +1807,54 @@ var BottomSheet = class {
1620
1807
  if (!drag.moving) {
1621
1808
  if (dy < -SLOP) return this.abandon();
1622
1809
  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));
1810
+ this.startMoving(drag);
1640
1811
  }
1812
+ this.follow(drag, e.clientY, e.timeStamp);
1641
1813
  if (e.cancelable) e.preventDefault();
1642
1814
  };
1643
1815
  this.onPointerUp = (e) => {
1644
1816
  const drag = this.drag;
1645
1817
  if (!drag || e.pointerId !== drag.pointerId) return;
1646
1818
  this.stopTracking();
1647
- this.drag = null;
1648
- if (!drag.moving) return;
1649
- this.dragEndedAt = performance.now();
1819
+ this.finish(drag);
1820
+ };
1821
+ // ── Pull from the content (touch) ─────────────────────────────────────────
1822
+ this.onTouchStart = (e) => {
1823
+ this.pull = null;
1824
+ if (this.drag || e.touches.length !== 1 || !isPhoneSheet()) return;
1650
1825
  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 = "";
1826
+ if (!sheet || !e.composedPath().includes(sheet)) return;
1827
+ if (inZone(e, sheet, this.opts.dragZones)) return;
1828
+ const t = e.touches[0];
1829
+ if (t) this.pull = { startX: t.clientX, startY: t.clientY };
1830
+ };
1831
+ this.onTouchMove = (e) => {
1832
+ const t = e.touches[0];
1833
+ if (!t) return;
1834
+ if (this.pull) {
1835
+ const dx = t.clientX - this.pull.startX;
1836
+ const dy = t.clientY - this.pull.startY;
1837
+ if (Math.max(Math.abs(dx), Math.abs(dy)) <= SLOP) return;
1838
+ const startY = this.pull.startY;
1839
+ this.pull = null;
1840
+ if (Math.abs(dx) >= Math.abs(dy) || dy < 0 || this.scrolledDown(e)) return;
1841
+ this.drag = { pointerId: -1, startY, lastY: startY, lastT: e.timeStamp, velocity: 0, moving: false, offset: 0 };
1842
+ this.startMoving(this.drag);
1661
1843
  }
1844
+ const drag = this.drag;
1845
+ if (!drag || drag.pointerId !== -1) return;
1846
+ this.follow(drag, t.clientY, e.timeStamp);
1847
+ if (e.cancelable) e.preventDefault();
1848
+ };
1849
+ this.onTouchEnd = (e) => {
1850
+ this.pull = null;
1851
+ const drag = this.drag;
1852
+ if (!drag || drag.pointerId !== -1 || e.touches.length > 0) return;
1853
+ this.finish(drag);
1854
+ };
1855
+ this.onTouchCancel = () => {
1856
+ this.pull = null;
1857
+ if (this.drag?.pointerId === -1) this.abandon();
1662
1858
  };
1663
1859
  this.onPointerCancel = (e) => {
1664
1860
  if (!this.drag || e.pointerId !== this.drag.pointerId) return;
@@ -1693,12 +1889,21 @@ var BottomSheet = class {
1693
1889
  }
1694
1890
  this.opts.host.addEventListener("pointerdown", this.onPointerDown);
1695
1891
  this.opts.host.addEventListener("click", this.onClickCapture, true);
1892
+ this.opts.host.addEventListener("touchstart", this.onTouchStart, { passive: true });
1893
+ this.opts.host.addEventListener("touchmove", this.onTouchMove, { passive: false });
1894
+ this.opts.host.addEventListener("touchend", this.onTouchEnd);
1895
+ this.opts.host.addEventListener("touchcancel", this.onTouchCancel);
1696
1896
  }
1697
1897
  closed() {
1698
1898
  this.opts.host.removeEventListener("pointerdown", this.onPointerDown);
1699
1899
  this.opts.host.removeEventListener("click", this.onClickCapture, true);
1900
+ this.opts.host.removeEventListener("touchstart", this.onTouchStart);
1901
+ this.opts.host.removeEventListener("touchmove", this.onTouchMove);
1902
+ this.opts.host.removeEventListener("touchend", this.onTouchEnd);
1903
+ this.opts.host.removeEventListener("touchcancel", this.onTouchCancel);
1700
1904
  this.stopTracking();
1701
1905
  this.drag = null;
1906
+ this.pull = null;
1702
1907
  const sheet = this.opts.sheet();
1703
1908
  if (sheet) {
1704
1909
  sheet.style.transform = "";
@@ -1714,6 +1919,58 @@ var BottomSheet = class {
1714
1919
  unlockPageScroll();
1715
1920
  }
1716
1921
  }
1922
+ /** Is the content under the finger scrolled away from its top (the sheet's scroller, or one inside it)? */
1923
+ scrolledDown(e) {
1924
+ const sheet = this.opts.sheet();
1925
+ const scroller = this.opts.scroller?.() ?? sheet;
1926
+ if (scroller && scroller.scrollTop > 0) return true;
1927
+ for (const node of e.composedPath()) {
1928
+ if (node === sheet) break;
1929
+ if (node instanceof HTMLElement && node.scrollTop > 0 && node.scrollHeight > node.clientHeight) return true;
1930
+ }
1931
+ return false;
1932
+ }
1933
+ // ── Shared by both ────────────────────────────────────────────────────────
1934
+ /** The press is a drag now: the sheet and backdrop follow the finger without easing. */
1935
+ startMoving(drag) {
1936
+ drag.moving = true;
1937
+ const sheet = this.opts.sheet();
1938
+ if (sheet) sheet.style.transition = "none";
1939
+ const backdrop = this.opts.backdrop?.();
1940
+ if (backdrop) backdrop.style.transition = "none";
1941
+ }
1942
+ follow(drag, y, timeStamp) {
1943
+ const dt = timeStamp - drag.lastT;
1944
+ if (dt > 0) drag.velocity = 0.8 * ((y - drag.lastY) / dt) + 0.2 * drag.velocity;
1945
+ drag.lastY = y;
1946
+ drag.lastT = timeStamp;
1947
+ drag.offset = Math.max(0, y - drag.startY);
1948
+ const sheet = this.opts.sheet();
1949
+ if (sheet) sheet.style.transform = `translateY(${drag.offset}px)`;
1950
+ const backdrop = this.opts.backdrop?.();
1951
+ if (backdrop && sheet) {
1952
+ const height = sheet.getBoundingClientRect().height || window.innerHeight;
1953
+ backdrop.style.opacity = String(Math.max(0, 1 - drag.offset / height));
1954
+ }
1955
+ }
1956
+ /** Let go: close if far or fast enough (and allowed), else spring back. */
1957
+ finish(drag) {
1958
+ this.drag = null;
1959
+ if (!drag.moving) return;
1960
+ this.dragEndedAt = performance.now();
1961
+ const sheet = this.opts.sheet();
1962
+ const height = sheet?.getBoundingClientRect().height || window.innerHeight;
1963
+ const far = drag.offset >= Math.min(CLOSE_DISTANCE, height / 4);
1964
+ const flick = drag.velocity >= FLICK_VELOCITY && drag.offset >= FLICK_MIN;
1965
+ const allowed = !this.opts.canClose || this.opts.canClose();
1966
+ this.release();
1967
+ if ((far || flick) && allowed) {
1968
+ if (sheet) sheet.style.transform = "translateY(100%)";
1969
+ this.opts.close();
1970
+ } else if (sheet) {
1971
+ sheet.style.transform = "";
1972
+ }
1973
+ }
1717
1974
  /** Give up on this press and put the sheet back where it was. */
1718
1975
  abandon() {
1719
1976
  const moving = this.drag?.moving;
@@ -1780,9 +2037,40 @@ function unlockPageScroll() {
1780
2037
  }
1781
2038
 
1782
2039
  // src/ui/components/vio-product-detail.ts
2040
+ var ADDED_TOAST_MS = 8e3;
1783
2041
  var STRIPE_LOGO_SRC = "data:image/svg+xml;utf8," + encodeURIComponent(
1784
2042
  '<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
2043
  );
2044
+ var HIGHLIGHT_ICONS = {
2045
+ 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",
2046
+ 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",
2047
+ 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",
2048
+ 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",
2049
+ return: "M4 9h11.5a4.5 4.5 0 0 1 0 9H8M4 9l4-4M4 9l4 4",
2050
+ leaf: "M5 19C5 10 10 4.5 20 4.5c0 10-5.5 15-14 15M5 19l7-7",
2051
+ 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",
2052
+ 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",
2053
+ mailbox: "M3 19v-8a4 4 0 0 1 8 0v8m-8 0h18v-7a4 4 0 0 0-4-4H7m8 0V4h3M6 12h2",
2054
+ home: "M3 11 12 4l9 7M5.5 9.5V20h13V9.5M10 20v-5h4v5",
2055
+ 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",
2056
+ clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3 2"
2057
+ };
2058
+ function rateIcon(method) {
2059
+ const m = method.toLowerCase();
2060
+ if (/postkasse|mailbox|brevkasse/.test(m)) return "mailbox";
2061
+ if (/hente|pakkeboks|automat|postkontor|pickup|utlever/.test(m)) return "pin";
2062
+ if (/dør|hjem|home|door/.test(m)) return "home";
2063
+ return "truck";
2064
+ }
2065
+ function carrierLogo(logos, carrier) {
2066
+ if (!logos || !carrier) return "";
2067
+ const want = carrier.trim().toLowerCase();
2068
+ for (const [name, url] of Object.entries(logos)) if (name.trim().toLowerCase() === want && url) return url;
2069
+ return "";
2070
+ }
2071
+ function lineIcon(name, cls = "line-icon") {
2072
+ return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true"><path d=${HIGHLIGHT_ICONS[name] ?? ""}></path></svg>`;
2073
+ }
1786
2074
  var VioProductDetail = class extends LitElement {
1787
2075
  constructor() {
1788
2076
  super(...arguments);
@@ -1798,6 +2086,12 @@ var VioProductDetail = class extends LitElement {
1798
2086
  this.vioCampaign = "";
1799
2087
  this.vioContentId = "";
1800
2088
  this.vioContentTitle = "";
2089
+ this.noAddedToast = false;
2090
+ this.gallery = "";
2091
+ this.descriptionMode = "";
2092
+ this.highlights = [];
2093
+ this.deliveryTime = "";
2094
+ this.carrierLogos = {};
1801
2095
  this.slotRegistration = new SlotRegistration(this, "detail");
1802
2096
  /** What the click that opened the detail carried ('' when nothing), and for which product. */
1803
2097
  this.eventSlot = "";
@@ -1808,18 +2102,31 @@ var VioProductDetail = class extends LitElement {
1808
2102
  this.product = null;
1809
2103
  this.sellerName = null;
1810
2104
  this.sellerSettled = false;
2105
+ this.shippingFrom = null;
2106
+ this.shippingRates = [];
1811
2107
  this.isLoading = false;
1812
2108
  this.fetchError = "";
1813
2109
  this.selectedOptions = {};
2110
+ this.selectionPrompt = "";
1814
2111
  this.quantity = 1;
1815
2112
  this.activeImageIndex = 0;
1816
2113
  this.thumbsOverflow = false;
1817
2114
  this.thumbsAtStart = true;
1818
2115
  this.thumbsAtEnd = false;
2116
+ this.descriptionOpen = false;
2117
+ this.descriptionClipped = false;
2118
+ this.galleryMode = "thumbs";
2119
+ this.resolvedDescriptionMode = "preview";
1819
2120
  this.logoSquare = false;
1820
2121
  this.adding = false;
2122
+ this.addedToast = null;
2123
+ this.addedToastTimer = null;
1821
2124
  this.applePayOk = false;
1822
2125
  this.availableMethods = null;
2126
+ this.methodsLoading = false;
2127
+ this.vippsExpress = false;
2128
+ /** Whose methods `availableMethods` are — another brand's are not shown while its own load. */
2129
+ this.methodsSponsorId = 0;
1823
2130
  this.vippsOfficial = false;
1824
2131
  /** Race guard: only the latest fetch may write state (two quick taps on
1825
2132
  * different cards would otherwise let the slow response win). */
@@ -1873,6 +2180,15 @@ var VioProductDetail = class extends LitElement {
1873
2180
  this.show();
1874
2181
  void this.fetchProduct();
1875
2182
  };
2183
+ /** The photo in view is the active one (dots, Apple Pay sheet, events). */
2184
+ this.onCarouselScroll = () => {
2185
+ const track = this.carouselTrack();
2186
+ const step = this.carouselStep(track);
2187
+ if (!track || !step) return;
2188
+ const last = track.children.length - 1;
2189
+ const idx = Math.min(last, Math.max(0, Math.round(track.scrollLeft / step)));
2190
+ if (idx !== this.activeImageIndex) this.activeImageIndex = idx;
2191
+ };
1876
2192
  this.onThumbsScroll = () => {
1877
2193
  const row = this.thumbRow();
1878
2194
  if (!row) return;
@@ -1903,6 +2219,21 @@ var VioProductDetail = class extends LitElement {
1903
2219
  const own = this.slotRegistration.campaignHint;
1904
2220
  return this.fromCard && this.eventCampaignId !== void 0 ? [this.eventCampaignId, ...own] : own;
1905
2221
  }
2222
+ /**
2223
+ * The campaign whose channel sells the open product (one article = one
2224
+ * campaign = one channel): the campaign its events credit, then the opening
2225
+ * click's and this element's own — so the product, its rates and its
2226
+ * seller are read with that channel's key, the one the cart is created on.
2227
+ */
2228
+ commerceCampaigns() {
2229
+ const hint = this.activeCampaignHint;
2230
+ try {
2231
+ const resolved = this.attribution().campaignId;
2232
+ return typeof resolved === "number" ? [resolved, ...hint] : hint;
2233
+ } catch {
2234
+ return hint;
2235
+ }
2236
+ }
1906
2237
  slotAttributes() {
1907
2238
  return {
1908
2239
  vioSlot: this.vioSlot,
@@ -2187,6 +2518,103 @@ var VioProductDetail = class extends LitElement {
2187
2518
  .thumbs.overflows .thumbs-arrow { display: flex; }
2188
2519
  }
2189
2520
 
2521
+ /* Carousel (gallery="carousel" / theme detailGallery): the same box as the
2522
+ main image — proportion, fit, background, inset — one per photo, in a
2523
+ row that snaps. --vio-detail-carousel-peek shows the edge of the next. */
2524
+ .carousel {
2525
+ position: relative;
2526
+ margin-inline: calc(
2527
+ var(--vio-detail-image-inset, var(--vio-detail-padding-x, var(--vio-space-lg, 24px))) -
2528
+ var(--vio-detail-padding-x, var(--vio-space-lg, 24px))
2529
+ );
2530
+ }
2531
+ .carousel-track {
2532
+ display: flex;
2533
+ gap: var(--vio-detail-carousel-gap, 0px);
2534
+ overflow-x: auto;
2535
+ scroll-snap-type: x mandatory;
2536
+ scroll-behavior: smooth;
2537
+ scrollbar-width: none;
2538
+ -webkit-overflow-scrolling: touch;
2539
+ overscroll-behavior-x: contain;
2540
+ /* Sideways is the carousel's, up and down the panel's; no double-tap
2541
+ zoom delay. The sheet only drags from its top bar, never from here. */
2542
+ touch-action: pan-x pan-y;
2543
+ border-radius: var(--vio-detail-image-radius, 0);
2544
+ }
2545
+ .carousel-track::-webkit-scrollbar { display: none; }
2546
+ .carousel-slide {
2547
+ flex: 0 0 calc(100% - var(--vio-detail-carousel-peek, 0px));
2548
+ aspect-ratio: var(--vio-detail-image-aspect, 4 / 5);
2549
+ max-height: var(--vio-detail-image-max-height, 85vh);
2550
+ background: var(--vio-detail-image-bg, var(--vio-color-surface-muted, #f2f2f2));
2551
+ scroll-snap-align: start;
2552
+ scroll-snap-stop: always;
2553
+ overflow: hidden;
2554
+ }
2555
+ .carousel-slide img {
2556
+ display: block;
2557
+ /* A press on a photo is a swipe: no image drag, no long-press menu, no selection. */
2558
+ pointer-events: none;
2559
+ user-select: none;
2560
+ -webkit-user-select: none;
2561
+ -webkit-user-drag: none;
2562
+ -webkit-touch-callout: none;
2563
+ width: 100%;
2564
+ height: 100%;
2565
+ object-fit: var(--vio-detail-image-fit, cover);
2566
+ object-position: var(--vio-detail-image-position, center);
2567
+ transform: scale(var(--vio-detail-image-zoom, 1));
2568
+ }
2569
+ /* One dot per photo over the bottom of the image: there is more than one,
2570
+ and which one this is. White with a shadow reads on light and dark photos. */
2571
+ .carousel-dots {
2572
+ position: absolute;
2573
+ left: 0;
2574
+ right: 0;
2575
+ bottom: 12px;
2576
+ display: flex;
2577
+ justify-content: center;
2578
+ gap: 6px;
2579
+ pointer-events: none;
2580
+ }
2581
+ .carousel-dot {
2582
+ width: 6px;
2583
+ height: 6px;
2584
+ border-radius: 50%;
2585
+ background: var(--vio-detail-dot-color, rgba(255, 255, 255, 0.6));
2586
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.35);
2587
+ transition: background 0.2s ease, transform 0.2s ease;
2588
+ }
2589
+ .carousel-dot.active {
2590
+ background: var(--vio-detail-dot-active-color, #fff);
2591
+ transform: scale(1.35);
2592
+ }
2593
+ .carousel-arrow {
2594
+ display: none;
2595
+ position: absolute;
2596
+ top: 50%;
2597
+ transform: translateY(-50%);
2598
+ width: 36px;
2599
+ height: 36px;
2600
+ border-radius: 50%;
2601
+ border: 1px solid var(--vio-color-border, #e5e5e5);
2602
+ background: var(--vio-color-surface, #fff);
2603
+ color: var(--vio-color-text, #0a0a0a);
2604
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
2605
+ cursor: pointer;
2606
+ align-items: center;
2607
+ justify-content: center;
2608
+ padding: 0;
2609
+ font: 18px/1 sans-serif;
2610
+ }
2611
+ .carousel-arrow.prev { left: 12px; }
2612
+ .carousel-arrow.next { right: 12px; }
2613
+ .carousel-arrow:disabled { visibility: hidden; }
2614
+ @media (hover: hover) and (pointer: fine) {
2615
+ .carousel-arrow { display: flex; }
2616
+ }
2617
+
2190
2618
  /* Info column */
2191
2619
  .info { display: flex; flex-direction: column; gap: var(--vio-detail-info-gap, 20px); min-width: 0; }
2192
2620
  .brand {
@@ -2204,11 +2632,88 @@ var VioProductDetail = class extends LitElement {
2204
2632
  margin: 0;
2205
2633
  letter-spacing: -0.01em;
2206
2634
  }
2635
+ /* The brand's promises: one row, an icon over a title and a line of text,
2636
+ centred like a shop's own trust strip. */
2637
+ .highlights {
2638
+ list-style: none;
2639
+ /* --vio-detail-highlights-display: none hides the strip (theme detailHighlightsDisplay). */
2640
+ margin: 0;
2641
+ padding: var(--vio-detail-highlights-padding, 16px 0 2px);
2642
+ display: var(--vio-detail-highlights-display, grid);
2643
+ grid-auto-flow: column;
2644
+ grid-auto-columns: 1fr;
2645
+ gap: 10px;
2646
+ border-top: 1px solid var(--vio-color-border, #e5e5e5);
2647
+ }
2648
+ /* Like a shop's trust strip: a thin icon, the promise in regular weight,
2649
+ a smaller line under it — nothing bold. */
2650
+ .hl { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 2px; min-width: 0; }
2651
+ .hl-icon {
2652
+ width: var(--vio-detail-highlight-icon-size, 22px);
2653
+ height: var(--vio-detail-highlight-icon-size, 22px);
2654
+ margin-bottom: 8px;
2655
+ fill: none;
2656
+ stroke: currentColor;
2657
+ stroke-width: 1.2;
2658
+ stroke-linecap: round;
2659
+ stroke-linejoin: round;
2660
+ object-fit: contain;
2661
+ }
2662
+ .hl-title { font-size: 12px; font-weight: 400; line-height: 1.3; letter-spacing: 0.01em; color: var(--vio-color-text, #0a0a0a); }
2663
+ .hl-text { font-size: 10.5px; line-height: 1.35; letter-spacing: 0.01em; color: var(--vio-color-text-secondary, #666); }
2664
+ /* «Frakt & retur»: same fold as «Beskrivelse», with a small list of rates.
2665
+ --vio-detail-shipping-returns-display: none hides it (theme detailShippingReturnsDisplay). */
2666
+ .shipping-returns { display: var(--vio-detail-shipping-returns-display, block); }
2667
+ .description.shipping-returns-body { white-space: normal; }
2668
+ .shipping-returns-body p { margin: 0 0 10px; }
2669
+ .sr-title {
2670
+ font-size: 11px;
2671
+ text-transform: uppercase;
2672
+ letter-spacing: var(--vio-tracking-label, 0.1em);
2673
+ color: var(--vio-color-text-secondary, #666);
2674
+ font-weight: 600;
2675
+ margin: 4px 0 6px;
2676
+ }
2677
+ .sr-rates { list-style: none; margin: 0 0 10px; padding: 0; }
2678
+ /* One row per carrier: «Helthjem» over «Til postkasse/dørmatte», price right. */
2679
+ .sr-rates li {
2680
+ display: flex;
2681
+ align-items: center;
2682
+ gap: 12px;
2683
+ padding: 8px 0;
2684
+ border-bottom: 1px solid var(--vio-color-border, #eee);
2685
+ }
2686
+ .sr-rates li:last-child { border-bottom: none; }
2687
+ .sr-rate { flex: 1; display: flex; flex-direction: column; min-width: 0; line-height: 1.35; }
2688
+ .line-icon { fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; }
2689
+ .sr-icon { flex: 0 0 auto; width: 20px; height: 20px; color: var(--vio-color-text, #0a0a0a); }
2690
+ .sr-line { display: flex; align-items: center; gap: 12px; }
2691
+ /* A carrier's logo in a fixed box, so rows line up whatever its shape. */
2692
+ .sr-logo { flex: 0 0 auto; width: var(--vio-detail-carrier-logo-width, 56px); height: 24px; object-fit: contain; object-position: left center; }
2693
+ .sr-carrier { font-weight: 600; color: var(--vio-color-text, #0a0a0a); }
2694
+ .sr-method { font-size: 13px; }
2695
+ .sr-time { margin: 0 0 14px; }
2696
+ .sr-price { font-weight: 600; color: var(--vio-color-text, #0a0a0a); white-space: nowrap; }
2697
+ .sr-links { display: flex; gap: 16px; padding-left: 32px; }
2698
+ .sr-links a { color: inherit; }
2699
+ /* Name and price read as one block (stock on the name's line, right);
2700
+ the sizes come after a wider gap, then shipping/returns by the buttons. */
2701
+ .headline { display: flex; flex-direction: column; gap: var(--vio-detail-headline-gap, 8px); }
2702
+ .title-row {
2703
+ display: flex;
2704
+ align-items: baseline;
2705
+ justify-content: space-between;
2706
+ gap: 16px;
2707
+ }
2708
+ .title-row .name { min-width: 0; }
2709
+ .title-row .stock { flex: 0 0 auto; white-space: nowrap; }
2207
2710
  .price-row {
2208
2711
  display: flex;
2209
2712
  align-items: baseline;
2210
2713
  gap: 16px;
2211
2714
  }
2715
+ /* The sizes follow the price closely: they are the next thing to do. */
2716
+ .headline + .purchase { margin-top: calc(var(--vio-detail-sizes-gap, 2px) - var(--vio-detail-info-gap, 20px)); }
2212
2717
  .price {
2213
2718
  font-size: var(--vio-detail-price-size, 24px);
2214
2719
  font-weight: var(--vio-detail-price-weight, 700);
@@ -2218,6 +2723,11 @@ var VioProductDetail = class extends LitElement {
2218
2723
  color: var(--vio-color-text-tertiary, #999);
2219
2724
  text-decoration: line-through;
2220
2725
  }
2726
+ .delivery-note {
2727
+ margin-top: calc(var(--vio-detail-info-gap, 20px) * -0.6);
2728
+ font-size: 13px;
2729
+ color: var(--vio-color-text-secondary, #666);
2730
+ }
2221
2731
  .stock {
2222
2732
  font-size: 12px;
2223
2733
  text-transform: uppercase;
@@ -2229,8 +2739,15 @@ var VioProductDetail = class extends LitElement {
2229
2739
  .stock.out-of-stock { color: var(--vio-color-text-tertiary, #999); }
2230
2740
 
2231
2741
  /* Option pickers */
2232
- .option-group { display: flex; flex-direction: column; gap: 6px; }
2742
+ .option-group { display: flex; flex-direction: column; gap: 8px; }
2743
+ /* The prompt sits on the label's own line: tapping a buy button before
2744
+ choosing must not push the pills down under the shopper's finger. */
2233
2745
  .option-label {
2746
+ display: flex;
2747
+ align-items: baseline;
2748
+ justify-content: space-between;
2749
+ gap: 12px;
2750
+ min-height: 1.4em;
2234
2751
  font-size: 11px;
2235
2752
  text-transform: uppercase;
2236
2753
  letter-spacing: var(--vio-tracking-label, 0.1em);
@@ -2276,6 +2793,19 @@ var VioProductDetail = class extends LitElement {
2276
2793
  background: repeating-linear-gradient(-45deg, #fafafa, #fafafa 5px, #f0f0f0 5px, #f0f0f0 10px);
2277
2794
  }
2278
2795
  .option-pill.unavailable:hover { border-color: var(--vio-color-border, #e5e5e5); }
2796
+ /* The value chosen, beside the option's name: «Størrelse: M». */
2797
+ .option-chosen { color: var(--vio-color-text, #0a0a0a); font-weight: 700; }
2798
+ /* A buy button was tapped before choosing: say what is missing. */
2799
+ .option-prompt {
2800
+ text-transform: none;
2801
+ letter-spacing: normal;
2802
+ font-size: 13px;
2803
+ font-weight: 600;
2804
+ color: var(--vio-color-accent, #c14a3b);
2805
+ }
2806
+ .option-group.needs-choice .option-pill:not(.unavailable) {
2807
+ border-color: var(--vio-color-accent, #c14a3b);
2808
+ }
2279
2809
 
2280
2810
  /* The buy zone: Vipps first, then quantity + add to cart, then the other
2281
2811
  ways to pay. --vio-detail-buy-position: sticky pins it to the bottom of
@@ -2292,6 +2822,37 @@ var VioProductDetail = class extends LitElement {
2292
2822
  box-shadow: var(--vio-detail-buy-shadow, none);
2293
2823
  }
2294
2824
  .buy > * { margin-top: 0 !important; }
2825
+ /* The buy zone and the description, apart from the name, price and stock:
2826
+ a pinned buy zone can slide up over what is in this box (the
2827
+ description) but never above it — never over the name, the price or
2828
+ the stock line (Mote & Livsstil, 2026-10-28). */
2829
+ .purchase { display: flex; flex-direction: column; gap: var(--vio-detail-info-gap, 20px); }
2830
+ /* The pickers live in the buy zone: when it is pinned to the bottom of the
2831
+ panel (desktop, or a host's sticky), it can never cover them — they
2832
+ travel with the buttons they gate (Mote & Livsstil, 2026-10-28). */
2833
+ .buy-options {
2834
+ display: flex;
2835
+ flex-direction: column;
2836
+ gap: var(--vio-detail-info-gap, 20px);
2837
+ padding-bottom: 4px;
2838
+ }
2839
+ /* Where the express buttons go while the brand's methods load: neutral,
2840
+ the height of one button, no method named. */
2841
+ .buy-pending {
2842
+ height: var(--vio-detail-button-height, 52px);
2843
+ border-radius: var(--vio-detail-button-radius, 0);
2844
+ background: linear-gradient(
2845
+ 100deg,
2846
+ var(--vio-color-surface-muted, #f1f1f1) 30%,
2847
+ rgba(255, 255, 255, 0.65) 50%,
2848
+ var(--vio-color-surface-muted, #f1f1f1) 70%
2849
+ );
2850
+ background-size: 200% 100%;
2851
+ animation: vio-skeleton-shimmer 1.4s ease-in-out infinite;
2852
+ }
2853
+ @media (prefers-reduced-motion: reduce) {
2854
+ .buy-pending { animation: none; background: var(--vio-color-surface-muted, #f1f1f1); }
2855
+ }
2295
2856
  /* Under the buttons: who the shopper is buying from. */
2296
2857
  .seller-note {
2297
2858
  margin: 2px 0 0;
@@ -2424,6 +2985,14 @@ var VioProductDetail = class extends LitElement {
2424
2985
  }
2425
2986
  .buy-vipps:hover:not(:disabled) { background: #ec4f1c; }
2426
2987
  .buy-vipps:disabled { opacity: 0.5; cursor: not-allowed; }
2988
+ /* Under the Express button: what happens in the app. */
2989
+ .vipps-note {
2990
+ margin: 0;
2991
+ font-size: 12px;
2992
+ line-height: 1.4;
2993
+ color: var(--vio-color-text-secondary, #666);
2994
+ text-align: center;
2995
+ }
2427
2996
  /* Vipps' own button (web component) — same slot, their drawing. */
2428
2997
  .buy-vipps-official { display: block; width: 100%; margin-top: 10px; cursor: pointer; }
2429
2998
  /* Apple Pay express button — black, per Apple's button guidelines. Only
@@ -2475,6 +3044,61 @@ var VioProductDetail = class extends LitElement {
2475
3044
  transition: transform 0.15s ease;
2476
3045
  }
2477
3046
  .description-block[open] > summary::after { transform: rotate(-135deg) translateY(-2px); }
3047
+ /* «Beskrivelse» with a preview: the same heading as the folds, then the
3048
+ first lines fading out, then «Les mer». */
3049
+ .desc-head {
3050
+ all: unset;
3051
+ box-sizing: border-box;
3052
+ width: 100%;
3053
+ cursor: pointer;
3054
+ display: flex;
3055
+ align-items: center;
3056
+ justify-content: space-between;
3057
+ padding: 14px 0 6px;
3058
+ font-size: 11px;
3059
+ text-transform: uppercase;
3060
+ letter-spacing: var(--vio-tracking-label, 0.1em);
3061
+ color: var(--vio-color-text-secondary, #666);
3062
+ font-weight: 600;
3063
+ font-family: inherit;
3064
+ }
3065
+ .description-preview.clipped .desc-head::after {
3066
+ content: '';
3067
+ width: 8px;
3068
+ height: 8px;
3069
+ border-right: 1.5px solid currentColor;
3070
+ border-bottom: 1.5px solid currentColor;
3071
+ transform: rotate(45deg) translateY(-2px);
3072
+ transition: transform 0.15s ease;
3073
+ }
3074
+ .description-preview.open .desc-head::after { transform: rotate(-135deg) translateY(-2px); }
3075
+ .description-preview .description { padding-bottom: 6px; }
3076
+ .description-preview.folded .desc-head, .description-preview.open:not(.clipped) .desc-head { padding-bottom: 14px; }
3077
+ .description-preview.folded.open .desc-head { padding-bottom: 6px; }
3078
+ .description-preview.folded .description { padding-bottom: 14px; }
3079
+ .description-preview:not(.clipped) .description { padding-bottom: 14px; }
3080
+ .description.collapsed {
3081
+ display: -webkit-box;
3082
+ -webkit-box-orient: vertical;
3083
+ -webkit-line-clamp: var(--vio-detail-description-preview-lines, 3);
3084
+ line-clamp: var(--vio-detail-description-preview-lines, 3);
3085
+ overflow: hidden;
3086
+ cursor: pointer;
3087
+ -webkit-mask-image: linear-gradient(to bottom, #000 45%, transparent);
3088
+ mask-image: linear-gradient(to bottom, #000 45%, transparent);
3089
+ }
3090
+ .desc-more {
3091
+ all: unset;
3092
+ cursor: pointer;
3093
+ display: inline-block;
3094
+ margin: 0 0 14px;
3095
+ font-size: 13px;
3096
+ font-weight: 600;
3097
+ color: var(--vio-color-text, #0a0a0a);
3098
+ text-decoration: underline;
3099
+ text-underline-offset: 3px;
3100
+ font-family: inherit;
3101
+ }
2478
3102
  .description {
2479
3103
  font-size: 14px;
2480
3104
  line-height: 1.6;
@@ -2482,15 +3106,13 @@ var VioProductDetail = class extends LitElement {
2482
3106
  padding-bottom: 14px;
2483
3107
  white-space: pre-line;
2484
3108
  }
2485
- /* Desktop: the description above, the buy zone at the bottom of the panel
2486
- (sticky). Phones keep the order as it is: buy, then the description. */
3109
+ /* Same order on every screen: buy, then the description at the very
3110
+ bottom (folded). Hosts that want the buy zone pinned can still set
3111
+ --vio-detail-buy-position: sticky. */
2487
3112
  @media (min-width: 601px) {
2488
- .description-block { order: 1; }
2489
3113
  .buy {
2490
- order: 2;
2491
- position: var(--vio-detail-buy-position, sticky);
3114
+ position: var(--vio-detail-buy-position, static);
2492
3115
  padding: var(--vio-detail-buy-padding, 12px 0 16px);
2493
- box-shadow: var(--vio-detail-buy-shadow, 0 -1px 0 var(--vio-color-border, #e5e5e5));
2494
3116
  }
2495
3117
  }
2496
3118
 
@@ -2530,6 +3152,49 @@ var VioProductDetail = class extends LitElement {
2530
3152
  background: var(--vio-color-surface-muted, #f1f1f1);
2531
3153
  }
2532
3154
  }
3155
+ /* «Lagt i handlekurven»: above the page, below where a thumb reaches. */
3156
+ .added-toast {
3157
+ position: fixed;
3158
+ left: 50%;
3159
+ bottom: max(16px, env(safe-area-inset-bottom));
3160
+ transform: translateX(-50%);
3161
+ z-index: 1102;
3162
+ width: min(420px, calc(100vw - 32px));
3163
+ box-sizing: border-box;
3164
+ padding: 14px 16px;
3165
+ background: var(--vio-color-surface, #fff);
3166
+ color: var(--vio-color-text, #0a0a0a);
3167
+ border: 1px solid var(--vio-color-border, #e5e5e5);
3168
+ border-radius: var(--vio-radius-lg, 8px);
3169
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
3170
+ font-family: var(--vio-font-sans, -apple-system, sans-serif);
3171
+ display: flex;
3172
+ flex-direction: column;
3173
+ gap: 12px;
3174
+ }
3175
+ .added-toast-text { font-size: 14px; font-weight: 600; }
3176
+ .added-toast-text::before { content: '✓ '; color: #2e7d32; }
3177
+ .added-toast-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
3178
+ .added-toast-actions button {
3179
+ min-height: 44px;
3180
+ padding: 0 12px;
3181
+ font-family: inherit;
3182
+ font-size: 13px;
3183
+ font-weight: 600;
3184
+ cursor: pointer;
3185
+ border-radius: var(--vio-detail-button-radius, 0);
3186
+ }
3187
+ .added-toast-checkout {
3188
+ background: var(--vio-color-text, #0a0a0a);
3189
+ color: var(--vio-color-text-on-primary, #fff);
3190
+ border: 1px solid var(--vio-color-text, #0a0a0a);
3191
+ }
3192
+ .added-toast-continue {
3193
+ background: transparent;
3194
+ color: var(--vio-color-text, #0a0a0a);
3195
+ border: 1px solid var(--vio-color-border, #e5e5e5);
3196
+ }
3197
+
2533
3198
  .error {
2534
3199
  padding: 80px 24px;
2535
3200
  text-align: center;
@@ -2545,6 +3210,8 @@ var VioProductDetail = class extends LitElement {
2545
3210
  document.addEventListener("vio:product-click", this.boundProductClick);
2546
3211
  }
2547
3212
  disconnectedCallback() {
3213
+ if (this.addedToastTimer) clearTimeout(this.addedToastTimer);
3214
+ this.addedToastTimer = null;
2548
3215
  document.removeEventListener("vio:product-click", this.boundProductClick);
2549
3216
  this.slotRegistration.clear();
2550
3217
  this.bottomSheet.disconnect();
@@ -2555,16 +3222,63 @@ var VioProductDetail = class extends LitElement {
2555
3222
  this.dialogFocus.update(this.open);
2556
3223
  this.bottomSheet.update(this.open);
2557
3224
  }
2558
- if (changed.has("product") || changed.has("open")) this.measureThumbs();
3225
+ if (changed.has("product") || changed.has("open")) {
3226
+ this.measureThumbs();
3227
+ this.measureDescription();
3228
+ }
2559
3229
  if ((changed.has("quantity") || changed.has("selectedOptions")) && this.product && this.applePayAskedFor !== null) {
2560
3230
  if (this.unitPrice * this.quantity !== this.applePayAskedFor) this.prepareApplePaySheet();
2561
3231
  }
2562
- if (changed.has("activeImageIndex")) this.revealActiveThumb();
3232
+ if (changed.has("activeImageIndex")) {
3233
+ this.revealActiveThumb();
3234
+ this.revealActiveSlide();
3235
+ }
3236
+ if (changed.has("selectedOptions") && this.galleryMode === "carousel") {
3237
+ if (this.activeImageIndex !== 0) this.activeImageIndex = 0;
3238
+ else this.revealActiveSlide();
3239
+ }
2563
3240
  this.measureLogo();
2564
3241
  if (changed.has("vioSlot") || changed.has("vioSlotName") || changed.has("vioScope") || changed.has("vioScopeName") || changed.has("vioCampaign")) {
2565
3242
  this.slotRegistration.sync(this.slotAttributes());
2566
3243
  }
2567
3244
  }
3245
+ willUpdate(changed) {
3246
+ if (changed.has("gallery") || changed.has("open") && this.open) this.galleryMode = this.resolveGalleryMode();
3247
+ if (changed.has("descriptionMode") || changed.has("open") && this.open) {
3248
+ const value = this.themeChoice(this.descriptionMode, "--vio-detail-description-mode");
3249
+ this.resolvedDescriptionMode = value === "folded" || value === "open" ? value : "preview";
3250
+ }
3251
+ }
3252
+ /** An option as the element says it, else as the theme's custom property says it (lower case, unquoted). */
3253
+ themeChoice(own, cssVar) {
3254
+ const fromTheme = typeof getComputedStyle === "function" ? getComputedStyle(this).getPropertyValue(cssVar) : "";
3255
+ return (own || fromTheme).trim().replace(/^["']|["']$/g, "").toLowerCase();
3256
+ }
3257
+ /** The attribute wins; else the theme's `--vio-detail-gallery`; else thumbnails. */
3258
+ resolveGalleryMode() {
3259
+ return this.themeChoice(this.gallery, "--vio-detail-gallery") === "carousel" ? "carousel" : "thumbs";
3260
+ }
3261
+ carouselTrack() {
3262
+ return this.renderRoot.querySelector(".carousel-track");
3263
+ }
3264
+ /** One photo's width plus the gap (with a peek a photo is narrower than the row). */
3265
+ carouselStep(track) {
3266
+ const slide = track?.firstElementChild;
3267
+ if (!track || !slide || slide.offsetWidth === 0) return 0;
3268
+ return slide.offsetWidth + (parseFloat(getComputedStyle(track).columnGap) || 0);
3269
+ }
3270
+ /** Bring the active photo into view — only when it is not already the one a swipe is on. */
3271
+ revealActiveSlide() {
3272
+ const track = this.carouselTrack();
3273
+ const step = this.carouselStep(track);
3274
+ if (!track || !step) return;
3275
+ const target = Math.min(this.activeImageIndex * step, track.scrollWidth - track.clientWidth);
3276
+ if (Math.abs(track.scrollLeft - target) > step / 2) track.scrollTo({ left: target });
3277
+ }
3278
+ stepCarousel(direction) {
3279
+ const last = this.allImages().length - 1;
3280
+ this.activeImageIndex = Math.min(last, Math.max(0, this.activeImageIndex + direction));
3281
+ }
2568
3282
  thumbRow() {
2569
3283
  return this.renderRoot.querySelector(".gallery-thumbs");
2570
3284
  }
@@ -2593,6 +3307,7 @@ var VioProductDetail = class extends LitElement {
2593
3307
  }
2594
3308
  show() {
2595
3309
  this.open = true;
3310
+ this.hideAddedToast();
2596
3311
  }
2597
3312
  close() {
2598
3313
  this.open = false;
@@ -2606,9 +3321,12 @@ var VioProductDetail = class extends LitElement {
2606
3321
  this.product = null;
2607
3322
  this.sellerName = null;
2608
3323
  this.sellerSettled = false;
3324
+ this.shippingFrom = null;
2609
3325
  this.selectedOptions = {};
3326
+ this.selectionPrompt = "";
2610
3327
  this.quantity = 1;
2611
3328
  this.activeImageIndex = 0;
3329
+ this.descriptionOpen = false;
2612
3330
  this.fetchError = "";
2613
3331
  }
2614
3332
  }, 400);
@@ -2624,13 +3342,14 @@ var VioProductDetail = class extends LitElement {
2624
3342
  this.fetchError = "";
2625
3343
  this.sellerName = null;
2626
3344
  this.sellerSettled = false;
3345
+ this.shippingFrom = null;
2627
3346
  let products = null;
2628
3347
  let lastError = null;
2629
3348
  for (let attempt = 0; attempt < 4; attempt++) {
2630
3349
  try {
2631
3350
  const activeCurrency = getGlobalCurrency() || this.currency;
2632
3351
  this.currency = activeCurrency;
2633
- const commerce = Vio.commerceFor(this.sponsorId);
3352
+ const commerce = Vio.commerceFor(this.sponsorId, { campaignId: this.commerceCampaigns() });
2634
3353
  products = await commerce.channel.product.getByIds({
2635
3354
  product_ids: [productIdNum],
2636
3355
  currency: activeCurrency,
@@ -2653,6 +3372,7 @@ var VioProductDetail = class extends LitElement {
2653
3372
  this.optMapCache = /* @__PURE__ */ new WeakMap();
2654
3373
  this.product = products?.[0] ?? null;
2655
3374
  if (this.product) void this.loadSeller(productIdNum, seq);
3375
+ if (this.product) void this.loadShippingFrom(productIdNum, seq);
2656
3376
  if (this.product) {
2657
3377
  try {
2658
3378
  const price = Number(
@@ -2685,9 +3405,10 @@ var VioProductDetail = class extends LitElement {
2685
3405
  const initial = {};
2686
3406
  for (const opt of this.product.options ?? []) {
2687
3407
  const values = normalizeValues(opt.values);
2688
- if (values.length > 0) initial[opt.name] = values[0];
3408
+ if (values.length === 1) initial[opt.name] = values[0];
2689
3409
  }
2690
3410
  this.selectedOptions = initial;
3411
+ this.selectionPrompt = "";
2691
3412
  this.prepareApplePaySheet();
2692
3413
  void this.loadAvailablePaymentMethods();
2693
3414
  }
@@ -2697,22 +3418,41 @@ var VioProductDetail = class extends LitElement {
2697
3418
  async loadAvailablePaymentMethods() {
2698
3419
  const spId = this.sponsorId || (typeof Vio.campaignForSlot === "function" ? Number(Vio.campaignForSlot(this.activeSlot || null, { campaignId: this.activeCampaignHint })?.primarySponsor?.id) : 0) || globalThis.__VIO_SPONSOR_ID__;
2699
3420
  if (!spId) return;
3421
+ if (spId !== this.methodsSponsorId) {
3422
+ this.availableMethods = null;
3423
+ this.vippsExpress = false;
3424
+ }
3425
+ this.methodsSponsorId = spId;
3426
+ this.methodsLoading = true;
2700
3427
  try {
2701
3428
  const methods = await Vio.checkout.getAvailablePaymentMethods(spId);
2702
3429
  if (Array.isArray(methods)) {
2703
3430
  this.availableMethods = methods.map((m) => m.name);
3431
+ const vipps = methods.find(
3432
+ (m) => String(m?.name ?? "").toLowerCase() === "vipps"
3433
+ );
3434
+ this.vippsExpress = !!vipps && vipps.config?.find((c) => c?.name === "express")?.value !== "false";
2704
3435
  } else {
2705
3436
  this.availableMethods = null;
3437
+ this.vippsExpress = false;
2706
3438
  }
2707
3439
  } catch (err) {
2708
3440
  if (typeof console !== "undefined") {
2709
3441
  console.warn("[VioProductDetail] Failed to load payment methods:", err);
2710
3442
  }
2711
3443
  this.availableMethods = null;
3444
+ this.vippsExpress = false;
3445
+ } finally {
3446
+ this.methodsLoading = false;
2712
3447
  }
2713
3448
  }
3449
+ /** The methods are still on their way: a placeholder stands where the buttons go. */
3450
+ get methodsPending() {
3451
+ return this.methodsLoading && this.availableMethods === null;
3452
+ }
2714
3453
  /** Backend names arrive as e.g. "Apple Pay" — compare letters only. */
2715
3454
  methodEnabled(...names) {
3455
+ if (this.methodsPending) return false;
2716
3456
  return isMethodEnabled(this.availableMethods, ...names);
2717
3457
  }
2718
3458
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -2901,26 +3641,92 @@ var VioProductDetail = class extends LitElement {
2901
3641
  const price = v?.price ?? this.product?.price;
2902
3642
  return price?.amount_incl_taxes ?? price?.amount ?? 0;
2903
3643
  }
3644
+ /**
3645
+ * There is something to pay for — whether the express buttons show. With
3646
+ * every option chosen, the chosen variant's price; before that, the
3647
+ * product's or any variant's: the first variant (what `unitPrice` falls
3648
+ * back to) at 0 must not hide Vipps for a size that costs 699.
3649
+ */
3650
+ get hasPrice() {
3651
+ if (!this.missingOption()) return this.unitPrice > 0;
3652
+ const priceOf = (p) => Number(p?.amount_incl_taxes ?? p?.amount ?? 0);
3653
+ return priceOf(this.product?.price) > 0 || (this.product?.variants ?? []).some((v) => priceOf(v.price) > 0);
3654
+ }
2904
3655
  get compareAt() {
2905
3656
  const v = this.selectedVariant();
2906
3657
  const price = v?.price ?? this.product?.price;
2907
3658
  return price?.compare_at_incl_taxes ?? price?.compare_at ?? null;
2908
3659
  }
3660
+ /** The options the shopper has to choose (those with values), in order. */
3661
+ choosableOptions() {
3662
+ return (this.product?.options ?? []).filter((o) => normalizeValues(o.values).length > 0);
3663
+ }
3664
+ /** The first option still without a value, or null when every one is chosen. */
3665
+ missingOption() {
3666
+ return this.choosableOptions().find((o) => !String(this.selectedOptions[o.name] ?? "").trim()) ?? null;
3667
+ }
3668
+ /**
3669
+ * Units that can be sold. With every option chosen, the chosen variant's;
3670
+ * before that, the most any variant still matching the partial choice has
3671
+ * — so the stepper and «Utsolgt» do not speak for a variant (the first
3672
+ * one) the shopper never picked.
3673
+ */
2909
3674
  get availableQuantity() {
3675
+ if (this.missingOption() && Array.isArray(this.product?.variants) && this.product.variants.length > 0) {
3676
+ const sel = this.selectedOptions;
3677
+ const qtys = this.product.variants.filter((v2) => this.variantMatchesSelection(v2, sel)).map((v2) => this.variantSellableQty(v2));
3678
+ return qtys.length > 0 ? Math.max(0, ...qtys) : 0;
3679
+ }
2910
3680
  const v = this.selectedVariant();
2911
3681
  if (v) return this.variantSellableQty(v);
2912
3682
  return this.product?.quantity ?? 0;
2913
3683
  }
3684
+ /**
3685
+ * «Få igjen — N stk» only for the variant actually chosen: before a size is
3686
+ * picked the count belonged to the first variant, which said «Få igjen» of
3687
+ * a product with plenty of other sizes.
3688
+ */
2914
3689
  stockLabel() {
2915
3690
  const qty = this.availableQuantity;
2916
3691
  if (qty <= 0) return { text: "Utsolgt", cls: "out-of-stock" };
2917
- if (qty <= 3) return { text: `F\xE5 igjen \u2014 ${qty} stk`, cls: "low-stock" };
3692
+ if (qty <= 3 && !this.missingOption()) return { text: `F\xE5 igjen \u2014 ${qty} stk`, cls: "low-stock" };
2918
3693
  return { text: "P\xE5 lager", cls: "in-stock" };
2919
3694
  }
3695
+ /** «Velg størrelse» for a size, «Velg farge» for a colour… */
3696
+ promptFor(option) {
3697
+ const name = String(option.name ?? "").trim();
3698
+ if (!name || /^(st(ø|o)rrelse|str\.?|size)$/i.test(name)) return "Velg st\xF8rrelse";
3699
+ return `Velg ${name.toLowerCase()}`;
3700
+ }
3701
+ /**
3702
+ * Every buy button goes through here first: with an option not chosen yet,
3703
+ * nothing is added or paid — the picker says what is missing and comes
3704
+ * into view (it sits right above the buttons, inside the buy zone).
3705
+ */
3706
+ requireSelection() {
3707
+ const missing = this.missingOption();
3708
+ if (!missing) {
3709
+ this.selectionPrompt = "";
3710
+ return true;
3711
+ }
3712
+ this.selectionPrompt = missing.name;
3713
+ void this.updateComplete.then(() => {
3714
+ const group = this.renderRoot.querySelector(".option-group.needs-choice");
3715
+ group?.scrollIntoView?.({ behavior: "smooth", block: "nearest" });
3716
+ });
3717
+ return false;
3718
+ }
2920
3719
  /** Pick an option value. If the exact combination doesn't exist, jump to an
2921
3720
  * in-stock variant that carries the value and adopt its full option map
2922
3721
  * (keeps the selection always resolvable to a real variant). */
2923
3722
  selectOption(optionName, value) {
3723
+ this.applyOption(optionName, value);
3724
+ if (this.selectionPrompt && !this.missingOption()) this.selectionPrompt = "";
3725
+ else if (this.selectionPrompt) this.selectionPrompt = this.missingOption()?.name ?? "";
3726
+ const max = this.availableQuantity;
3727
+ if (max > 0 && this.quantity > max) this.quantity = max;
3728
+ }
3729
+ applyOption(optionName, value) {
2924
3730
  const newSelection = { ...this.selectedOptions, [optionName]: value };
2925
3731
  const exactInStock = this.product?.variants?.find(
2926
3732
  (v) => this.variantMatchesSelection(v, newSelection) && this.variantSellableQty(v) > 0
@@ -2988,6 +3794,7 @@ var VioProductDetail = class extends LitElement {
2988
3794
  }
2989
3795
  async addToCart() {
2990
3796
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3797
+ if (!this.requireSelection()) return;
2991
3798
  this.adding = true;
2992
3799
  try {
2993
3800
  const variantId = this.addCurrentToCart();
@@ -3004,11 +3811,49 @@ var VioProductDetail = class extends LitElement {
3004
3811
  }
3005
3812
  })
3006
3813
  );
3814
+ this.showAddedToast();
3007
3815
  this.close();
3008
3816
  } finally {
3009
3817
  this.adding = false;
3010
3818
  }
3011
3819
  }
3820
+ /** The toast after an add; it goes by itself after a while. */
3821
+ showAddedToast() {
3822
+ if (this.noAddedToast) return;
3823
+ this.addedToast = {
3824
+ sponsorId: this.sponsorId,
3825
+ detail: { sponsorId: this.sponsorId, express: false, ...this.attribution(), ...sponsorDetail(this.sponsorId) }
3826
+ };
3827
+ if (this.addedToastTimer) clearTimeout(this.addedToastTimer);
3828
+ this.addedToastTimer = setTimeout(() => this.hideAddedToast(), ADDED_TOAST_MS);
3829
+ }
3830
+ hideAddedToast() {
3831
+ if (this.addedToastTimer) clearTimeout(this.addedToastTimer);
3832
+ this.addedToastTimer = null;
3833
+ this.addedToast = null;
3834
+ }
3835
+ /** «Til kassen»: the checkout of the brand just added to — the host opens it on `vio:checkout-open`, as from the cart. */
3836
+ toastToCheckout() {
3837
+ const toast = this.addedToast;
3838
+ this.hideAddedToast();
3839
+ if (!toast) return;
3840
+ const cart = Vio.cart.getCart(toast.sponsorId);
3841
+ const total = (cart?.items ?? []).reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
3842
+ if (!cart || cart.items.length === 0 || !(total > 0)) return;
3843
+ this.dispatchEvent(new CustomEvent("vio:checkout-open", { bubbles: true, composed: true, detail: toast.detail }));
3844
+ }
3845
+ renderAddedToast() {
3846
+ if (!this.addedToast) return nothing;
3847
+ return html`
3848
+ <div class="added-toast" part="added-toast" role="status" aria-live="polite">
3849
+ <div class="added-toast-text">Lagt i handlekurven</div>
3850
+ <div class="added-toast-actions">
3851
+ <button class="added-toast-checkout" part="added-toast-checkout" @click=${this.toastToCheckout}>Til kassen</button>
3852
+ <button class="added-toast-continue" part="added-toast-continue" @click=${this.hideAddedToast}>Fortsett å handle</button>
3853
+ </div>
3854
+ </div>
3855
+ `;
3856
+ }
3012
3857
  /**
3013
3858
  * Express buy: add to cart, then open the checkout overlay with Klarna
3014
3859
  * preselected. The host app handles `vio:checkout-open` (same event the cart
@@ -3017,6 +3862,7 @@ var VioProductDetail = class extends LitElement {
3017
3862
  */
3018
3863
  async buyWithKlarna() {
3019
3864
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3865
+ if (!this.requireSelection()) return;
3020
3866
  this.adding = true;
3021
3867
  try {
3022
3868
  this.addCurrentToCart();
@@ -3049,6 +3895,7 @@ var VioProductDetail = class extends LitElement {
3049
3895
  */
3050
3896
  buyNow() {
3051
3897
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3898
+ if (!this.requireSelection()) return;
3052
3899
  this.addCurrentToCart();
3053
3900
  this.close();
3054
3901
  this.dispatchEvent(
@@ -3071,6 +3918,8 @@ var VioProductDetail = class extends LitElement {
3071
3918
  }
3072
3919
  buyWithVipps() {
3073
3920
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3921
+ if (!this.requireSelection()) return;
3922
+ if (!(this.unitPrice > 0)) return;
3074
3923
  if ((Vio.cart.getCart(this.sponsorId)?.items.length ?? 0) > 0) {
3075
3924
  this.addCurrentToCart();
3076
3925
  this.close();
@@ -3082,6 +3931,14 @@ var VioProductDetail = class extends LitElement {
3082
3931
  this.close();
3083
3932
  void this.startVippsExpressOrCheckout(redirect);
3084
3933
  }
3934
+ /**
3935
+ * The Vipps button will open the app with Express (see buyWithVipps): the
3936
+ * brand has Express on, and this brand's cart is empty — with lines in it
3937
+ * the checkout opens first.
3938
+ */
3939
+ vippsGoesStraightToApp() {
3940
+ return this.vippsExpress && (Vio.cart.getCart(this.sponsorId)?.items.length ?? 0) === 0;
3941
+ }
3085
3942
  async startVippsExpressOrCheckout(redirect) {
3086
3943
  let fallback = true;
3087
3944
  try {
@@ -3118,6 +3975,7 @@ var VioProductDetail = class extends LitElement {
3118
3975
  /** Buy with Stripe — opens the checkout with Stripe preselected. */
3119
3976
  buyWithStripe() {
3120
3977
  if (!this.product || this.availableQuantity <= 0 || this.adding) return;
3978
+ if (!this.requireSelection()) return;
3121
3979
  this.addCurrentToCart();
3122
3980
  this.close();
3123
3981
  this.dispatchEvent(
@@ -3162,6 +4020,8 @@ var VioProductDetail = class extends LitElement {
3162
4020
  */
3163
4021
  buyWithApplePay() {
3164
4022
  if (!this.product || this.availableQuantity <= 0 || !this.applePayHandle) return;
4023
+ if (!this.requireSelection()) return;
4024
+ if (!(this.unitPrice > 0)) return;
3165
4025
  const cartHasLines = (Vio.cart.getCart(this.sponsorId)?.items.length ?? 0) > 0;
3166
4026
  if (cartHasLines || this.applePayAmount !== this.unitPrice * this.quantity) {
3167
4027
  this.addCurrentToCart();
@@ -3181,6 +4041,180 @@ var VioProductDetail = class extends LitElement {
3181
4041
  void this.applePayHandle.show();
3182
4042
  this.close();
3183
4043
  }
4044
+ /**
4045
+ * The product's cheapest rate for the page's market — the rates the
4046
+ * backend cart offers for its line. A rate in another currency than the
4047
+ * product's price is not shown (never a price the shopper would not pay).
4048
+ */
4049
+ async loadShippingFrom(productId, seq) {
4050
+ let rates = null;
4051
+ try {
4052
+ const product = Vio.commerceFor(this.sponsorId, { campaignId: this.commerceCampaigns() }).channel.product;
4053
+ rates = typeof product.getShippingRates === "function" ? await product.getShippingRates(productId, { currency: this.currency }) : null;
4054
+ } catch {
4055
+ rates = null;
4056
+ }
4057
+ if (seq !== this.fetchSeq) return;
4058
+ const currency = String(this.product?.price?.currency_code ?? this.currency ?? "").toUpperCase();
4059
+ const usable = (rates ?? []).filter((r) => !r.currency || r.currency.toUpperCase() === currency);
4060
+ const cheapest = usable[0];
4061
+ this.shippingFrom = cheapest ? { price: cheapest.price, currency: cheapest.currency || currency } : null;
4062
+ this.shippingRates = usable.map((r) => ({
4063
+ name: String(r.name ?? ""),
4064
+ price: r.price,
4065
+ currency: r.currency || currency,
4066
+ description: String(r.description ?? "").trim(),
4067
+ logoUrl: String(r.logoUrl ?? "")
4068
+ }));
4069
+ }
4070
+ /**
4071
+ * `gallery="carousel"`: every photo in one row that snaps one at a time —
4072
+ * swipe on touch, arrows with a mouse — and a dot per photo over it
4073
+ * instead of thumbnails, so the name, price and sizes come up higher.
4074
+ */
4075
+ renderCarousel(images, title) {
4076
+ const count = images.length;
4077
+ const current2 = Math.min(this.activeImageIndex, Math.max(0, count - 1));
4078
+ return html`
4079
+ <div class="gallery carousel" part="gallery">
4080
+ <div class="carousel-track" part="image" @scroll=${this.onCarouselScroll}>
4081
+ ${images.map(
4082
+ (img, idx) => html`
4083
+ <div class="carousel-slide" part="slide" aria-label=${`Bilde ${idx + 1} av ${count}`}>
4084
+ <img src=${img.url} alt=${idx === 0 ? title : ""} loading=${idx === 0 ? "eager" : "lazy"} />
4085
+ </div>
4086
+ `
4087
+ )}
4088
+ </div>
4089
+ ${count > 1 ? html`
4090
+ <button
4091
+ class="carousel-arrow prev"
4092
+ part="carousel-arrow"
4093
+ aria-label="Forrige bilde"
4094
+ ?disabled=${current2 === 0}
4095
+ @click=${() => this.stepCarousel(-1)}
4096
+ >‹</button>
4097
+ <button
4098
+ class="carousel-arrow next"
4099
+ part="carousel-arrow"
4100
+ aria-label="Neste bilde"
4101
+ ?disabled=${current2 === count - 1}
4102
+ @click=${() => this.stepCarousel(1)}
4103
+ >›</button>
4104
+ <div class="carousel-dots" part="image-dots" aria-hidden="true">
4105
+ ${images.map((_, idx) => html`<span class="carousel-dot ${idx === current2 ? "active" : ""}"></span>`)}
4106
+ </div>
4107
+ ` : ""}
4108
+ </div>
4109
+ `;
4110
+ }
4111
+ /**
4112
+ * «Beskrivelse», not shut: the first lines show, fading out, and a tap on
4113
+ * the heading, the text or «Les mer» opens the rest (Angelo, 2026-10-28).
4114
+ * A description that fits in the preview has nothing to open.
4115
+ */
4116
+ renderDescription(text) {
4117
+ const mode = this.resolvedDescriptionMode;
4118
+ if (mode === "open") {
4119
+ return html`<div class="description-block description-preview open" part="description">
4120
+ <div class="desc-head">Beskrivelse</div>
4121
+ <div class="description">${text}</div>
4122
+ </div>`;
4123
+ }
4124
+ const foldable = mode === "folded" || this.descriptionClipped;
4125
+ const open = this.descriptionOpen || !foldable;
4126
+ const toggle = () => {
4127
+ if (foldable) this.descriptionOpen = !this.descriptionOpen;
4128
+ };
4129
+ return html`<div class="description-block description-preview ${open ? "open" : ""} ${foldable ? "clipped" : ""} ${mode}" part="description">
4130
+ <button class="desc-head" type="button" aria-expanded=${open ? "true" : "false"} @click=${toggle}>Beskrivelse</button>
4131
+ ${mode === "folded" && !open ? nothing : html`<div class="description ${open ? "" : "collapsed"}" @click=${() => !this.descriptionOpen && toggle()}>${text}</div>`}
4132
+ ${mode === "preview" && this.descriptionClipped ? html`<button class="desc-more" type="button" @click=${toggle}>${this.descriptionOpen ? "Vis mindre" : "Les mer"}</button>` : nothing}
4133
+ </div>`;
4134
+ }
4135
+ /** Is the preview hiding part of the text? Measured with the preview applied. */
4136
+ measureDescription() {
4137
+ const el = this.renderRoot.querySelector(".description-preview .description");
4138
+ if (!el || this.descriptionOpen) return;
4139
+ const wasCollapsed = el.classList.contains("collapsed");
4140
+ el.classList.add("collapsed");
4141
+ const clipped = el.scrollHeight > el.clientHeight + 1;
4142
+ if (!wasCollapsed) el.classList.remove("collapsed");
4143
+ if (clipped !== this.descriptionClipped) this.descriptionClipped = clipped;
4144
+ }
4145
+ renderHighlights() {
4146
+ const own = (Array.isArray(this.highlights) ? this.highlights : []).filter((h) => h && h.title);
4147
+ const items = own.length ? own : resolveSponsorIdentity(this.sponsorId)?.highlights ?? [];
4148
+ if (items.length === 0) return nothing;
4149
+ return html`<ul class="highlights" part="highlights">
4150
+ ${items.map((h) => {
4151
+ const path = h.icon ? HIGHLIGHT_ICONS[h.icon] : void 0;
4152
+ 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;
4153
+ return html`<li class="hl" part="highlight">
4154
+ ${icon}
4155
+ <span class="hl-title">${h.title}</span>
4156
+ ${h.text ? html`<span class="hl-text">${h.text}</span>` : nothing}
4157
+ </li>`;
4158
+ })}
4159
+ </ul>`;
4160
+ }
4161
+ /**
4162
+ * «Frakt & retur», folded like «Beskrivelse»: the delivery options for the
4163
+ * page's country with their price, and the right of withdrawal with the
4164
+ * brand's own terms and returns pages when it gave them.
4165
+ */
4166
+ renderShippingReturns() {
4167
+ const identity = resolveSponsorIdentity(this.sponsorId);
4168
+ const rates = shippingRatesShown(this.shippingRates, [
4169
+ this.product?.title,
4170
+ this.product?.brand,
4171
+ this.sellerName,
4172
+ identity?.name
4173
+ ]);
4174
+ const times = [...new Set(rates.map((r) => r.description).filter(Boolean))];
4175
+ const perRateTime = times.length > 1;
4176
+ const sharedTime = times.length === 1 ? times[0] : times.length === 0 && this.deliveryTime.trim() ? `Leveringstid: ${this.deliveryTime.trim()}` : "";
4177
+ const links = [
4178
+ identity?.refundPolicyUrl ? html`<a href=${identity.refundPolicyUrl} target="_blank" rel="noopener noreferrer">Angrerett og retur</a>` : null,
4179
+ identity?.termsUrl ? html`<a href=${identity.termsUrl} target="_blank" rel="noopener noreferrer">Kjøpsvilkår</a>` : null
4180
+ ].filter(Boolean);
4181
+ return html`<details class="description-block shipping-returns" part="shipping-returns">
4182
+ <summary>Frakt &amp; retur</summary>
4183
+ <div class="description shipping-returns-body">
4184
+ <div class="sr-title">Frakt</div>
4185
+ ${rates.length ? html`<ul class="sr-rates">
4186
+ ${rates.map((r) => {
4187
+ const { carrier, method } = splitRateName(r.name);
4188
+ const logo = r.logoUrl || carrierLogo(this.carrierLogos, carrier);
4189
+ return html`<li>
4190
+ ${logo ? html`<img class="sr-logo" src=${logo} alt=${carrier} />` : lineIcon(rateIcon(r.name ?? ""), "line-icon sr-icon")}
4191
+ <span class="sr-rate">
4192
+ <span class="sr-carrier">${carrier || method || "Frakt"}</span>
4193
+ ${carrier && method ? html`<span class="sr-method">${method}</span>` : nothing}
4194
+ ${perRateTime && r.description ? html`<span class="sr-method">${r.description}</span>` : nothing}
4195
+ </span>
4196
+ <span class="sr-price">${r.price <= 0 ? "Gratis" : formatPrice(r.price, r.currency)}</span>
4197
+ </li>`;
4198
+ })}
4199
+ </ul>` : html`<p>Fraktprisen vises i kassen.</p>`}
4200
+ ${sharedTime ? html`<p class="sr-line sr-time">${lineIcon("clock", "line-icon sr-icon")}<span>${sharedTime}</span></p>` : nothing}
4201
+ <div class="sr-title">Retur</div>
4202
+ <p class="sr-line">${lineIcon("return", "line-icon sr-icon")}<span>Du har 14 dagers angrerett fra du mottar varen.</span></p>
4203
+ ${links.length ? html`<p class="sr-links">${links}</p>` : nothing}
4204
+ </div>
4205
+ </details>`;
4206
+ }
4207
+ /** Under the price: «Frakt fra X · 14 dagers angrerett» (or «Gratis frakt · …»). */
4208
+ renderDeliveryNote() {
4209
+ const ship = this.shippingFrom;
4210
+ const shipping = ship ? ship.price <= 0 ? "Gratis frakt" : `Frakt fra ${formatPrice(ship.price, ship.currency)}` : "";
4211
+ return html`<div class="delivery-note" part="delivery-note">
4212
+ ${shipping ? html`<span part="delivery-shipping">${shipping}</span><span aria-hidden="true"> · </span>` : ""}<span
4213
+ part="delivery-withdrawal"
4214
+ >14 dagers angrerett</span
4215
+ >
4216
+ </div>`;
4217
+ }
3184
4218
  /** The seller's Company name; the brand when there is none; nothing while asking. */
3185
4219
  headerName() {
3186
4220
  if (this.sellerName) return this.sellerName;
@@ -3189,7 +4223,7 @@ var VioProductDetail = class extends LitElement {
3189
4223
  async loadSeller(productId, seq) {
3190
4224
  let name = null;
3191
4225
  try {
3192
- name = await Vio.commerceFor(this.sponsorId).channel.product.getSellerCompany(productId);
4226
+ name = await Vio.commerceFor(this.sponsorId, { campaignId: this.commerceCampaigns() }).channel.product.getSellerCompany(productId);
3193
4227
  } catch {
3194
4228
  name = null;
3195
4229
  }
@@ -3263,6 +4297,7 @@ var VioProductDetail = class extends LitElement {
3263
4297
 
3264
4298
  ${this.renderBody()}
3265
4299
  </div>
4300
+ ${this.renderAddedToast()}
3266
4301
  `;
3267
4302
  }
3268
4303
  renderBody() {
@@ -3290,7 +4325,7 @@ var VioProductDetail = class extends LitElement {
3290
4325
  const activeImage = this.activeImage;
3291
4326
  return html`
3292
4327
  <div class="body">
3293
- <div class="gallery" part="gallery">
4328
+ ${this.galleryMode === "carousel" ? this.renderCarousel(images, p.title) : html`<div class="gallery" part="gallery">
3294
4329
  <div class="gallery-main" part="image">
3295
4330
  ${activeImage?.url ? html`<img src=${activeImage.url} alt=${p.title} />` : ""}
3296
4331
  </div>
@@ -3327,26 +4362,30 @@ var VioProductDetail = class extends LitElement {
3327
4362
  >›</button>
3328
4363
  </div>
3329
4364
  ` : ""}
3330
- </div>
4365
+ </div>`}
3331
4366
 
3332
4367
  <div class="info" part="info">
3333
4368
  ${p.brand ? html`<div class="brand" part="brand">${p.brand}</div>` : ""}
3334
- <h2 class="name" part="title">${p.title}</h2>
3335
-
3336
- <div class="price-row">
3337
- <span class="price" part="price">${formatPrice(this.unitPrice, p.price.currency_code)}</span>
3338
- ${showCompare ? html`<span class="compare-at">${formatPrice(
4369
+ <div class="headline">
4370
+ <div class="title-row">
4371
+ <h2 class="name" part="title">${p.title}</h2>
4372
+ <div class="stock ${stock.cls}" part="stock">${stock.text}</div>
4373
+ </div>
4374
+ <div class="price-row">
4375
+ <span class="price" part="price">${formatPrice(this.unitPrice, p.price.currency_code)}</span>
4376
+ ${showCompare ? html`<span class="compare-at">${formatPrice(
3339
4377
  compare,
3340
4378
  p.price.currency_code
3341
4379
  )}</span>` : ""}
4380
+ </div>
3342
4381
  </div>
3343
4382
 
3344
- <div class="stock ${stock.cls}" part="stock">${stock.text}</div>
3345
-
3346
- ${this.renderOptionPickers(p.options)}
3347
-
4383
+ <div class="purchase">
3348
4384
  <div class="buy" part="buy">
3349
- ${this.availableQuantity > 0 && this.methodEnabled("vipps") ? this.vippsOfficial ? html`
4385
+ ${p.options.length > 0 ? html`<div class="buy-options" part="buy-options">${this.renderOptionPickers(p.options)}</div>` : ""}
4386
+ ${this.renderDeliveryNote()}
4387
+ ${this.methodsPending && this.availableQuantity > 0 ? html`<div class="buy-pending" part="buy-pending" role="status" aria-label="Laster betalingsmåter"></div>` : ""}
4388
+ ${this.availableQuantity > 0 && this.hasPrice && this.methodEnabled("vipps") ? this.vippsOfficial ? html`
3350
4389
  <vipps-mobilepay-button
3351
4390
  class="buy-vipps-official"
3352
4391
  part="buy-vipps"
@@ -3372,6 +4411,7 @@ var VioProductDetail = class extends LitElement {
3372
4411
  <span class="vipps-badge">vipps</span>
3373
4412
  </button>
3374
4413
  ` : ""}
4414
+ ${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>` : ""}
3375
4415
 
3376
4416
  <div class="qty-cta-row" part="quantity-row">
3377
4417
  <div class="qty-stepper">
@@ -3413,7 +4453,7 @@ var VioProductDetail = class extends LitElement {
3413
4453
  </button>
3414
4454
  ` : ""}
3415
4455
 
3416
- ${this.availableQuantity > 0 && this.applePayOk && this.methodEnabled("apple-pay", "applepay") ? html`
4456
+ ${this.availableQuantity > 0 && this.hasPrice && this.applePayOk && this.methodEnabled("apple-pay", "applepay") ? html`
3417
4457
  <button
3418
4458
  class="buy-applepay"
3419
4459
  @click=${this.buyWithApplePay}
@@ -3459,12 +4499,11 @@ var VioProductDetail = class extends LitElement {
3459
4499
  ${this.renderSellerNote()}
3460
4500
  </div>
3461
4501
 
3462
- ${p.description ? html`
3463
- <details class="description-block" part="description">
3464
- <summary>Beskrivelse</summary>
3465
- <div class="description">${this.stripHtml(p.description)}</div>
3466
- </details>
3467
- ` : ""}
4502
+ ${this.renderHighlights()}
4503
+
4504
+ ${p.description ? this.renderDescription(this.stripHtml(p.description)) : ""}
4505
+ ${this.renderShippingReturns()}
4506
+ </div>
3468
4507
  </div>
3469
4508
  </div>
3470
4509
  `;
@@ -3474,9 +4513,16 @@ var VioProductDetail = class extends LitElement {
3474
4513
  return html`${options.map((opt) => {
3475
4514
  const values = normalizeValues(opt.values);
3476
4515
  if (values.length === 0) return "";
4516
+ const chosen = String(this.selectedOptions[opt.name] ?? "").trim();
4517
+ const asked = this.selectionPrompt === opt.name && !chosen;
3477
4518
  return html`
3478
- <div class="option-group" part="option-group">
3479
- <div class="option-label" part="option-label">${opt.name}</div>
4519
+ <div class="option-group ${asked ? "needs-choice" : ""}" part="option-group">
4520
+ <div class="option-label" part="option-label">
4521
+ <span
4522
+ >${opt.name}${chosen ? html`: <span class="option-chosen" part="option-chosen">${chosen}</span>` : ""}</span
4523
+ >
4524
+ ${asked ? html`<span class="option-prompt" part="option-prompt" role="alert">${this.promptFor(opt)}</span>` : ""}
4525
+ </div>
3480
4526
  <div class="option-values" part="options">
3481
4527
  ${values.map((val) => {
3482
4528
  const available = this.isValueAvailable(opt.name, val);
@@ -3541,6 +4587,24 @@ __decorateClass([
3541
4587
  __decorateClass([
3542
4588
  property({ type: String, attribute: "vio-content-title" })
3543
4589
  ], VioProductDetail.prototype, "vioContentTitle", 2);
4590
+ __decorateClass([
4591
+ property({ type: Boolean, attribute: "no-added-toast" })
4592
+ ], VioProductDetail.prototype, "noAddedToast", 2);
4593
+ __decorateClass([
4594
+ property({ type: String })
4595
+ ], VioProductDetail.prototype, "gallery", 2);
4596
+ __decorateClass([
4597
+ property({ type: String, attribute: "description-mode" })
4598
+ ], VioProductDetail.prototype, "descriptionMode", 2);
4599
+ __decorateClass([
4600
+ property({ type: Array })
4601
+ ], VioProductDetail.prototype, "highlights", 2);
4602
+ __decorateClass([
4603
+ property({ type: String, attribute: "delivery-time" })
4604
+ ], VioProductDetail.prototype, "deliveryTime", 2);
4605
+ __decorateClass([
4606
+ property({ type: Object, attribute: "carrier-logos" })
4607
+ ], VioProductDetail.prototype, "carrierLogos", 2);
3544
4608
  __decorateClass([
3545
4609
  state()
3546
4610
  ], VioProductDetail.prototype, "product", 2);
@@ -3550,6 +4614,12 @@ __decorateClass([
3550
4614
  __decorateClass([
3551
4615
  state()
3552
4616
  ], VioProductDetail.prototype, "sellerSettled", 2);
4617
+ __decorateClass([
4618
+ state()
4619
+ ], VioProductDetail.prototype, "shippingFrom", 2);
4620
+ __decorateClass([
4621
+ state()
4622
+ ], VioProductDetail.prototype, "shippingRates", 2);
3553
4623
  __decorateClass([
3554
4624
  state()
3555
4625
  ], VioProductDetail.prototype, "isLoading", 2);
@@ -3559,6 +4629,9 @@ __decorateClass([
3559
4629
  __decorateClass([
3560
4630
  state()
3561
4631
  ], VioProductDetail.prototype, "selectedOptions", 2);
4632
+ __decorateClass([
4633
+ state()
4634
+ ], VioProductDetail.prototype, "selectionPrompt", 2);
3562
4635
  __decorateClass([
3563
4636
  state()
3564
4637
  ], VioProductDetail.prototype, "quantity", 2);
@@ -3574,18 +4647,39 @@ __decorateClass([
3574
4647
  __decorateClass([
3575
4648
  state()
3576
4649
  ], VioProductDetail.prototype, "thumbsAtEnd", 2);
4650
+ __decorateClass([
4651
+ state()
4652
+ ], VioProductDetail.prototype, "descriptionOpen", 2);
4653
+ __decorateClass([
4654
+ state()
4655
+ ], VioProductDetail.prototype, "descriptionClipped", 2);
4656
+ __decorateClass([
4657
+ state()
4658
+ ], VioProductDetail.prototype, "galleryMode", 2);
4659
+ __decorateClass([
4660
+ state()
4661
+ ], VioProductDetail.prototype, "resolvedDescriptionMode", 2);
3577
4662
  __decorateClass([
3578
4663
  state()
3579
4664
  ], VioProductDetail.prototype, "logoSquare", 2);
3580
4665
  __decorateClass([
3581
4666
  state()
3582
4667
  ], VioProductDetail.prototype, "adding", 2);
4668
+ __decorateClass([
4669
+ state()
4670
+ ], VioProductDetail.prototype, "addedToast", 2);
3583
4671
  __decorateClass([
3584
4672
  state()
3585
4673
  ], VioProductDetail.prototype, "applePayOk", 2);
3586
4674
  __decorateClass([
3587
4675
  state()
3588
4676
  ], VioProductDetail.prototype, "availableMethods", 2);
4677
+ __decorateClass([
4678
+ state()
4679
+ ], VioProductDetail.prototype, "methodsLoading", 2);
4680
+ __decorateClass([
4681
+ state()
4682
+ ], VioProductDetail.prototype, "vippsExpress", 2);
3589
4683
  __decorateClass([
3590
4684
  state()
3591
4685
  ], VioProductDetail.prototype, "vippsOfficial", 2);
@@ -3598,9 +4692,38 @@ function normalizeValues(raw) {
3598
4692
  }
3599
4693
  return [];
3600
4694
  }
4695
+ function shippingRatesShown(rates, repeats = []) {
4696
+ return rates.map((r) => ({ ...r, description: shippingDescriptionOf(r.description, [r.name, ...repeats]) }));
4697
+ }
4698
+ function splitRateName(name) {
4699
+ const text = String(name ?? "").trim();
4700
+ const m = text.match(/^(.+?)\s+[-–—]\s+(.+)$/);
4701
+ if (!m?.[1] || !m[2]) return { carrier: "", method: text };
4702
+ const method = m[2].trim();
4703
+ return { carrier: m[1].trim(), method: method.charAt(0).toUpperCase() + method.slice(1) };
4704
+ }
3601
4705
  var STRIPE_LOGO_SRC2 = "data:image/svg+xml;utf8," + encodeURIComponent(
3602
4706
  '<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>'
3603
4707
  );
4708
+ function ratePrice(rate, currency) {
4709
+ const price = rate?.price;
4710
+ if (!price) return null;
4711
+ const amount = typeof price.amount_incl_taxes === "number" ? price.amount_incl_taxes : price.amount;
4712
+ if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) return null;
4713
+ if (price.currency_code && currency && price.currency_code.toUpperCase() !== currency.toUpperCase()) return null;
4714
+ return amount;
4715
+ }
4716
+ function cartShipping(items, currency) {
4717
+ for (const item of items) {
4718
+ const chosen = ratePrice(item.shipping, currency);
4719
+ if (chosen !== null) return chosen;
4720
+ }
4721
+ for (const item of items) {
4722
+ const offered = (item.availableShippings ?? []).map((r) => ratePrice(r, currency)).filter((p) => p !== null);
4723
+ if (offered.length > 0) return Math.min(...offered);
4724
+ }
4725
+ return null;
4726
+ }
3604
4727
  var VioCart = class extends LitElement {
3605
4728
  constructor() {
3606
4729
  super(...arguments);
@@ -3612,6 +4735,7 @@ var VioCart = class extends LitElement {
3612
4735
  this.itemCount = 0;
3613
4736
  this.applePayAvailable = false;
3614
4737
  this.availableMethods = null;
4738
+ this.methodsLoading = false;
3615
4739
  this.vippsOfficial = false;
3616
4740
  /** Whether the method lookup has been attempted — see boundOnCartChange. */
3617
4741
  this.paymentMethodsAttempted = false;
@@ -3634,6 +4758,7 @@ var VioCart = class extends LitElement {
3634
4758
  sheet: () => this.renderRoot.querySelector(".drawer"),
3635
4759
  backdrop: () => this.renderRoot.querySelector(".backdrop"),
3636
4760
  dragZones: ".handle, .header",
4761
+ scroller: () => this.renderRoot.querySelector(".body"),
3637
4762
  close: () => this.close()
3638
4763
  });
3639
4764
  this.boundOnCartChange = (e) => {
@@ -3838,6 +4963,14 @@ var VioCart = class extends LitElement {
3838
4963
  cursor: pointer;
3839
4964
  }
3840
4965
  .checkout-btn:hover { opacity: 0.9; }
4966
+ .checkout-btn:disabled { opacity: 0.5; cursor: not-allowed; }
4967
+ /* Where the express buttons go while the brand's methods load. */
4968
+ .methods-pending {
4969
+ height: 48px;
4970
+ margin-bottom: 10px;
4971
+ border-radius: var(--vio-radius-lg, 8px);
4972
+ background: var(--vio-color-surface-muted, #f1f1f1);
4973
+ }
3841
4974
  .footer {
3842
4975
  padding: var(--vio-space-lg, 24px);
3843
4976
  border-top: 1px solid var(--vio-color-border, #e5e5e5);
@@ -3850,6 +4983,14 @@ var VioCart = class extends LitElement {
3850
4983
  font-size: 14px;
3851
4984
  }
3852
4985
  .subtotal-label { color: var(--vio-color-text-secondary, #666); }
4986
+ .shipping-row,
4987
+ .total-row {
4988
+ display: flex;
4989
+ justify-content: space-between;
4990
+ margin: calc(var(--vio-space-md, 16px) * -0.5) 0 var(--vio-space-md, 16px);
4991
+ font-size: 14px;
4992
+ }
4993
+ .total-row { font-weight: 700; font-size: 16px; margin-top: 0; }
3853
4994
  .subtotal-value { font-weight: 700; font-size: 16px; }
3854
4995
 
3855
4996
  /* Apple Pay express — pay the whole cart directly. */
@@ -4045,6 +5186,7 @@ var VioCart = class extends LitElement {
4045
5186
  this.loadingPaymentMethods = false;
4046
5187
  return;
4047
5188
  }
5189
+ this.methodsLoading = true;
4048
5190
  try {
4049
5191
  const methods = await Vio.checkout.getAvailablePaymentMethods(firstSponsorId);
4050
5192
  if (Array.isArray(methods)) {
@@ -4059,10 +5201,20 @@ var VioCart = class extends LitElement {
4059
5201
  this.availableMethods = null;
4060
5202
  } finally {
4061
5203
  this.loadingPaymentMethods = false;
5204
+ this.methodsLoading = false;
4062
5205
  }
4063
5206
  }
4064
- /** Backend names arrive as e.g. "Apple Pay" — compare letters only. */
5207
+ /** The methods are on their way and none is known yet. */
5208
+ get methodsPending() {
5209
+ return this.methodsLoading && this.availableMethods === null;
5210
+ }
5211
+ /**
5212
+ * Backend names arrive as e.g. "Apple Pay" — compare letters only. While
5213
+ * the list loads, no method's button is drawn: «unknown = all» painted
5214
+ * Stripe and Klarna for a Vipps-only brand until it came.
5215
+ */
4065
5216
  methodEnabled(...names) {
5217
+ if (this.methodsPending) return false;
4066
5218
  return isMethodEnabled(this.availableMethods, ...names);
4067
5219
  }
4068
5220
  updated(changed) {
@@ -4105,7 +5257,7 @@ var VioCart = class extends LitElement {
4105
5257
  /** Apple Pay express — pay the whole cart, then show the confirmation. */
4106
5258
  onApplePay() {
4107
5259
  const handle = this.applePayHandle;
4108
- if (!handle || !handle.available || this.itemCount === 0) return;
5260
+ if (!handle || !handle.available || !this.payable) return;
4109
5261
  const snapshot = this.buildConfirmation();
4110
5262
  const sponsorIds = [...this.carts.keys()];
4111
5263
  const sponsor = sponsorIds.length === 1 ? sponsorDetail(sponsorIds[0]) : {};
@@ -4149,7 +5301,7 @@ var VioCart = class extends LitElement {
4149
5301
  * (betalingsmetode). The host's checkout-open handler closes this cart and
4150
5302
  * opens <vio-checkout> in express mode with Klarna preselected. */
4151
5303
  onKlarnaExpress() {
4152
- if (this.itemCount === 0) return;
5304
+ if (!this.payable) return;
4153
5305
  const firstSponsorId = [...this.carts.keys()][0];
4154
5306
  if (firstSponsorId === void 0) return;
4155
5307
  this.dispatchEvent(
@@ -4170,7 +5322,7 @@ var VioCart = class extends LitElement {
4170
5322
  * method present the others were unreachable from here.
4171
5323
  */
4172
5324
  onCheckout() {
4173
- if (this.itemCount === 0) return;
5325
+ if (!this.payable) return;
4174
5326
  const firstSponsorId = [...this.carts.keys()][0];
4175
5327
  if (firstSponsorId === void 0) return;
4176
5328
  this.dispatchEvent(
@@ -4182,51 +5334,31 @@ var VioCart = class extends LitElement {
4182
5334
  );
4183
5335
  }
4184
5336
  /**
4185
- * Vipps — Express: the app collects address and delivery, so the cart
4186
- * goes straight to Vipps. When this cart cannot have Express (several
4187
- * suppliers, digital goods, no rate) or Vipps cannot be started, the
4188
- * checkout opens with Vipps preselected, as before.
5337
+ * Vipps from the cart: the checkout opens with Vipps preselected — the
5338
+ * standard path, where our form collects name, address, mobile and the
5339
+ * delivery (with its price) before paying (Mote & Livsstil, 2026-10-28).
5340
+ * It no longer starts Express from here: Express is the product detail's
5341
+ * «Kjøp nå med Vipps» only.
4189
5342
  */
4190
5343
  onVipps() {
4191
- if (this.itemCount === 0) return;
5344
+ if (!this.payable) return;
4192
5345
  const firstSponsorId = [...this.carts.keys()][0];
4193
5346
  if (firstSponsorId === void 0) return;
4194
- const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: firstSponsorId, source: "cart" });
4195
- void this.startVippsExpressOrCheckout(firstSponsorId, redirect);
4196
- }
4197
- /** Vipps' own button when its script answers; our orange one otherwise. */
4198
- async loadVippsButton() {
4199
- this.vippsOfficial = await ensureVippsButton();
4200
- }
4201
- async startVippsExpressOrCheckout(sponsorId, redirect) {
4202
- let fallback = true;
4203
- try {
4204
- await Vio.cart.waitForCartMutations(sponsorId);
4205
- if (!await Vio.checkout.getVippsExpressEnabled(sponsorId)) {
4206
- redirect.abort("express-off");
4207
- } else {
4208
- const res = await Vio.checkout.startVippsExpress(sponsorId);
4209
- fallback = !res?.payment_url;
4210
- if (fallback) redirect.abort("not-started");
4211
- }
4212
- } catch (err) {
4213
- if (typeof console !== "undefined") {
4214
- console.warn("[VioCart] Vipps Express not started:", err);
4215
- }
4216
- redirect.fail(err instanceof Error ? err.message : String(err));
4217
- }
4218
- if (!fallback) return;
4219
5347
  this.dispatchEvent(
4220
5348
  new CustomEvent("vio:checkout-open", {
4221
5349
  bubbles: true,
4222
5350
  composed: true,
4223
- detail: { sponsorId, paymentMethod: "vipps", express: false, ...sponsorDetail(sponsorId) }
5351
+ detail: { sponsorId: firstSponsorId, paymentMethod: "vipps", express: false, ...sponsorDetail(firstSponsorId) }
4224
5352
  })
4225
5353
  );
4226
5354
  }
5355
+ /** Vipps' own button when its script answers; our orange one otherwise. */
5356
+ async loadVippsButton() {
5357
+ this.vippsOfficial = await ensureVippsButton();
5358
+ }
4227
5359
  /** Stripe — opens the checkout with Stripe preselected. */
4228
5360
  onStripe() {
4229
- if (this.itemCount === 0) return;
5361
+ if (!this.payable) return;
4230
5362
  const firstSponsorId = [...this.carts.keys()][0];
4231
5363
  if (firstSponsorId === void 0) return;
4232
5364
  this.dispatchEvent(
@@ -4241,6 +5373,10 @@ var VioCart = class extends LitElement {
4241
5373
  sponsorLabel(cart) {
4242
5374
  return resolveSponsorIdentity(cart.sponsorId)?.name || cart.sponsorName || "";
4243
5375
  }
5376
+ /** Something to pay for: lines, and a total above 0. No button opens a checkout or a payment otherwise. */
5377
+ get payable() {
5378
+ return this.itemCount > 0 && this.totalAcrossSponsors > 0;
5379
+ }
4244
5380
  get totalAcrossSponsors() {
4245
5381
  let sum = 0;
4246
5382
  for (const cart of this.carts.values()) {
@@ -4248,6 +5384,24 @@ var VioCart = class extends LitElement {
4248
5384
  }
4249
5385
  return sum;
4250
5386
  }
5387
+ /**
5388
+ * Shipping across the carts, from what the server cart says of its lines:
5389
+ * the rate chosen for them, else the cheapest one offered (the one the
5390
+ * checkout preselects). null when some cart has no known rate — the footer
5391
+ * says it is worked out in the checkout then.
5392
+ */
5393
+ get shippingAcrossSponsors() {
5394
+ let sum = 0;
5395
+ let any = false;
5396
+ for (const cart of this.carts.values()) {
5397
+ if (cart.items.length === 0) continue;
5398
+ const rate = cartShipping(cart.items, cart.currency);
5399
+ if (rate === null) return null;
5400
+ sum += rate;
5401
+ any = true;
5402
+ }
5403
+ return any ? sum : null;
5404
+ }
4251
5405
  get primaryCurrency() {
4252
5406
  for (const cart of this.carts.values()) return cart.currency;
4253
5407
  return "NOK";
@@ -4339,13 +5493,16 @@ var VioCart = class extends LitElement {
4339
5493
  ${formatPrice(this.totalAcrossSponsors, this.primaryCurrency)}
4340
5494
  </span>
4341
5495
  </div>
5496
+ ${this.renderShippingRows()}
4342
5497
  <button
4343
5498
  class="checkout-btn"
4344
5499
  @click=${this.onCheckout}
5500
+ ?disabled=${!this.payable}
4345
5501
  aria-label="Til kassen"
4346
5502
  >
4347
5503
  Til kassen
4348
5504
  </button>
5505
+ ${this.methodsPending ? html`<div class="methods-pending" role="status" aria-label="Laster betalingsmåter"></div>` : ""}
4349
5506
  ${this.methodEnabled("apple-pay") && this.applePayAvailable ? html`
4350
5507
  <button
4351
5508
  class="applepay-btn"
@@ -4406,6 +5563,20 @@ var VioCart = class extends LitElement {
4406
5563
  </aside>
4407
5564
  `;
4408
5565
  }
5566
+ /** «Frakt» (the chosen or cheapest rate) and «Totalt» with it; «Beregnes i kassen» when unknown. */
5567
+ renderShippingRows() {
5568
+ const shipping = this.shippingAcrossSponsors;
5569
+ return html`
5570
+ <div class="shipping-row" part="cart-shipping">
5571
+ <span class="subtotal-label">Frakt</span>
5572
+ <span>${shipping === null ? "Beregnes i kassen" : shipping <= 0 ? "Gratis" : formatPrice(shipping, this.primaryCurrency)}</span>
5573
+ </div>
5574
+ ${shipping !== null ? html`<div class="total-row" part="cart-total">
5575
+ <span>Totalt</span>
5576
+ <span>${formatPrice(this.totalAcrossSponsors + shipping, this.primaryCurrency)}</span>
5577
+ </div>` : ""}
5578
+ `;
5579
+ }
4409
5580
  renderConfirmation() {
4410
5581
  const o = this.confirmedOrder;
4411
5582
  if (!o) return html``;
@@ -4457,6 +5628,9 @@ __decorateClass([
4457
5628
  __decorateClass([
4458
5629
  state()
4459
5630
  ], VioCart.prototype, "availableMethods", 2);
5631
+ __decorateClass([
5632
+ state()
5633
+ ], VioCart.prototype, "methodsLoading", 2);
4460
5634
  __decorateClass([
4461
5635
  state()
4462
5636
  ], VioCart.prototype, "vippsOfficial", 2);
@@ -4498,13 +5672,14 @@ function errorText(err) {
4498
5672
  var STRIPE_LOGO_SRC3 = "data:image/svg+xml;utf8," + encodeURIComponent(
4499
5673
  '<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>'
4500
5674
  );
5675
+ var EMPTY_CART_MESSAGE2 = "Handlekurven er tom.";
5676
+ var SHIPPING_NOT_SAVED_MESSAGE = "Kunne ikke lagre fraktmetoden. Pr\xF8v igjen.";
4501
5677
  var _VioCheckout = class _VioCheckout extends LitElement {
4502
5678
  constructor() {
4503
5679
  super(...arguments);
4504
5680
  this.open = false;
4505
5681
  this.heading = "Kasse";
4506
5682
  this.shippingLabel = "Beregnes ved kasse";
4507
- this.vippsShipsInApp = false;
4508
5683
  this.express = false;
4509
5684
  this.checkoutState = null;
4510
5685
  this.items = [];
@@ -4604,6 +5779,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4604
5779
  firstName: "",
4605
5780
  lastName: "",
4606
5781
  email: "",
5782
+ phone: "",
4607
5783
  address: "",
4608
5784
  postalCode: "",
4609
5785
  city: ""
@@ -4964,6 +6140,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
4964
6140
  transition: border-color 0.15s;
4965
6141
  }
4966
6142
  .field input:focus { border-bottom-color: var(--vio-color-text, #0a0a0a); }
6143
+ .field input[aria-invalid='true'] { border-bottom-color: var(--vio-color-accent, #c14a3b); }
6144
+ .field-error { margin-top: 4px; font-size: 12px; color: var(--vio-color-accent, #c14a3b); }
4967
6145
 
4968
6146
  .payment-grid {
4969
6147
  display: grid;
@@ -5652,7 +6830,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5652
6830
  this.cleanReturnQueryParams(["checkout_id", "payment_processor"]);
5653
6831
  return;
5654
6832
  }
5655
- if (vioMethod === "klarna" && typeof sessionStorage !== "undefined" && sessionStorage.getItem("vio.klarna.pending.v1")) {
6833
+ if (vioMethod === "klarna" && storageGet("session", "vio.klarna.pending.v1")) {
5656
6834
  this.cleanReturnQueryParams(["vio_payment", "vio_method", "vio_sponsor", "checkout_id"]);
5657
6835
  return;
5658
6836
  }
@@ -5919,12 +7097,34 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5919
7097
  (i) => Array.isArray(i.availableShippings) && i.availableShippings.length > 0
5920
7098
  );
5921
7099
  }
5922
- /** Whether all mandatory Leveringsadresse fields are filled. */
7100
+ /** The shipping country the mobile's calling code defaults to (no +prefix typed). */
7101
+ get phoneCountry() {
7102
+ return String(this.form.country || getGlobalCountryCode());
7103
+ }
7104
+ /**
7105
+ * The mobile is required only for the standard Vipps payment — shopcart
7106
+ * does not ask Vipps for the profile there. Every other method takes it
7107
+ * when given and never waits for it (a host that pre-fills the form with
7108
+ * `setAddress()` and no phone keeps working).
7109
+ */
7110
+ get phoneRequired() {
7111
+ return this.checkoutState?.paymentMethod === "vipps";
7112
+ }
7113
+ /** A number typed that is not usable (nothing said while the field is empty). */
7114
+ get phoneError() {
7115
+ const raw = String(this.form.phone ?? "").trim();
7116
+ return raw.length > 0 && !isValidPhone(raw, this.phoneCountry);
7117
+ }
7118
+ /** The mobile as the chosen method needs it: valid when required, else anything. */
7119
+ get phoneReady() {
7120
+ return !this.phoneRequired || isValidPhone(this.form.phone, this.phoneCountry);
7121
+ }
7122
+ /** Whether all mandatory Leveringsadresse fields are filled (the mobile only for Vipps). */
5923
7123
  get isAddressValid() {
5924
7124
  const f = this.form;
5925
7125
  if (!f) return false;
5926
7126
  return Boolean(
5927
- 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
7127
+ 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
5928
7128
  );
5929
7129
  }
5930
7130
  /** Whether a valid shipping option is selected (when shippings exist). */
@@ -5947,7 +7147,6 @@ var _VioCheckout = class _VioCheckout extends LitElement {
5947
7147
  if (Array.isArray(methods)) {
5948
7148
  this.availableMethods = methods.map((m) => m.name);
5949
7149
  this.stripeMode = await Vio.checkout.getStripeMode(spId);
5950
- this.vippsShipsInApp = this.availableMethods.some((m) => String(m).toLowerCase() === "vipps") ? await Vio.checkout.getVippsExpressEnabled(spId) : false;
5951
7150
  } else {
5952
7151
  this.availableMethods = null;
5953
7152
  }
@@ -6165,21 +7364,44 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6165
7364
  });
6166
7365
  }
6167
7366
  /**
6168
- * Vipps from the checkout: Express when the channel allows it, so the
6169
- * shopper picks the delivery inside the app — our rate on the cart is the
6170
- * one preselected there — and never silently keeps the default our form
6171
- * chose for them. When the cart cannot have Express (several suppliers)
6172
- * the backend says so without creating a payment, and the plain payment
6173
- * follows: profile shared, the rate already on the cart.
7367
+ * Vipps from the checkout: the STANDARD payment, always (Mote & Livsstil,
7368
+ * 2026-10-28). The shopper filled our form — name, address, mobile — and
7369
+ * chose the delivery with its price here; the payment carries that address
7370
+ * and the rate on the cart, and Vipps only approves and pays. Express (the
7371
+ * app filling in name and address) is the product detail's «Kjøp nå med
7372
+ * Vipps» alone, even when the brand has Express on.
6174
7373
  */
6175
7374
  async startVippsFromCheckout() {
6176
7375
  const sponsorId = this.checkoutState?.sponsorId;
6177
- const express = await Vio.checkout.getVippsExpressEnabled(sponsorId);
6178
- if (express) {
6179
- const res = await Vio.checkout.startVippsPayment(sponsorId, this.form, { express: true });
6180
- if (res?.payment_url) return;
7376
+ await Vio.checkout.startVippsPayment(sponsorId, this.form, { express: false });
7377
+ }
7378
+ /**
7379
+ * The chosen delivery on the cart before a hosted payment is minted (its
7380
+ * total depends on it). False — and the payment must not start — when it
7381
+ * could not be saved: the selected rate has no supplier to save it under,
7382
+ * or the backend refused. A cart with no rates at all has nothing to save.
7383
+ */
7384
+ async persistSelectedShipping(method) {
7385
+ if (this.availableShippingsList.length === 0) return true;
7386
+ const selectedOpt = this.shippingList.find((s) => s.id === this.selectedShipping);
7387
+ if (!selectedOpt?.supplierId) {
7388
+ if (typeof console !== "undefined") {
7389
+ console.warn(`[VioCheckout] no supplier for the selected shipping \u2014 ${method} not started`);
7390
+ }
7391
+ return false;
7392
+ }
7393
+ try {
7394
+ const updated = await Vio.checkout.updateShippingsBySupplier(
7395
+ [{ shipping_id: selectedOpt.id, supplier_id: Number(selectedOpt.supplierId) }],
7396
+ this.checkoutState?.sponsorId
7397
+ );
7398
+ return updated != null;
7399
+ } catch (err) {
7400
+ if (typeof console !== "undefined") {
7401
+ console.warn(`[VioCheckout] updateShippingsBySupplier on ${method} pay failed:`, err);
7402
+ }
7403
+ return false;
6181
7404
  }
6182
- await Vio.checkout.startVippsPayment(sponsorId, this.form);
6183
7405
  }
6184
7406
  async onApplePay() {
6185
7407
  if (this.applePayInProgress) return;
@@ -6198,6 +7420,10 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6198
7420
  if (this.shippingBlocked) return;
6199
7421
  this.paymentError = null;
6200
7422
  this.paymentNotice = null;
7423
+ if (!this.cartPayable) {
7424
+ this.paymentError = EMPTY_CART_MESSAGE2;
7425
+ return;
7426
+ }
6201
7427
  if (method === "apple-pay") {
6202
7428
  void this.onApplePay();
6203
7429
  return;
@@ -6218,8 +7444,9 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6218
7444
  Vio.checkout.selectPaymentMethod("nexi");
6219
7445
  return;
6220
7446
  }
6221
- if (method !== "vipps") {
6222
- const missing = !this.isAddressValid ? "Vennligst fyll ut alle feltene i leveringsadresse." : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode." : null;
7447
+ {
7448
+ const phoneMissing = method === "vipps" && !isValidPhone(this.form.phone, this.phoneCountry);
7449
+ const missing = !this.isAddressValid || phoneMissing ? "Vennligst fyll ut alle feltene i leveringsadresse." : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode." : null;
6223
7450
  if (missing) {
6224
7451
  if (this.checkoutState?.paymentMethod !== method) {
6225
7452
  Vio.checkout.selectPaymentMethod(method);
@@ -6243,20 +7470,12 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6243
7470
  this.stripeRequested = true;
6244
7471
  return;
6245
7472
  }
6246
- const selectedOpt = this.shippingList.find((s) => s.id === this.selectedShipping);
6247
- if (selectedOpt?.supplierId) {
6248
- try {
6249
- await Vio.checkout.updateShippingsBySupplier(
6250
- [{ shipping_id: selectedOpt.id, supplier_id: Number(selectedOpt.supplierId) }],
6251
- this.checkoutState?.sponsorId
6252
- );
6253
- } catch (err) {
6254
- if (typeof console !== "undefined") {
6255
- console.warn("[VioCheckout] updateShippingsBySupplier on stripe pay failed:", err);
6256
- }
6257
- }
6258
- }
6259
7473
  this.stripeLoading = true;
7474
+ if (!await this.persistSelectedShipping("stripe")) {
7475
+ this.stripeLoading = false;
7476
+ this.paymentError = SHIPPING_NOT_SAVED_MESSAGE;
7477
+ return;
7478
+ }
6260
7479
  void Vio.checkout.startStripePayment(this.checkoutState?.sponsorId, this.form).catch((err) => {
6261
7480
  if (typeof console !== "undefined") {
6262
7481
  console.warn("[VioCheckout] Stripe payment could not start:", err);
@@ -6268,6 +7487,11 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6268
7487
  }
6269
7488
  if (method === "vipps") {
6270
7489
  this.vippsLoading = true;
7490
+ if (!await this.persistSelectedShipping("vipps")) {
7491
+ this.vippsLoading = false;
7492
+ this.paymentError = SHIPPING_NOT_SAVED_MESSAGE;
7493
+ return;
7494
+ }
6271
7495
  const redirect = beginPaymentRedirect({ method: "vipps", sponsorId: this.checkoutState?.sponsorId, source: "checkout" });
6272
7496
  void this.startVippsFromCheckout().catch((err) => {
6273
7497
  if (typeof console !== "undefined") {
@@ -6289,7 +7513,11 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6289
7513
  const s = this.checkoutState;
6290
7514
  if (!s || !s.paymentMethod) return;
6291
7515
  if (this.shippingBlocked) return;
6292
- if (s.paymentMethod !== "vipps" && !this.isAddressValid) {
7516
+ if (!this.cartPayable) {
7517
+ this.paymentError = EMPTY_CART_MESSAGE2;
7518
+ return;
7519
+ }
7520
+ if (!this.isAddressValid) {
6293
7521
  this.paymentError = "Vennligst fyll ut alle feltene i leveringsadresse.";
6294
7522
  return;
6295
7523
  }
@@ -6514,6 +7742,14 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6514
7742
  return method === "apple-pay" || method === "klarna" || isEmbeddedMethod(method ?? "") || // Adyen's Drop-in brings its own Pay button.
6515
7743
  isFormFirstWidgetMethod(method ?? "");
6516
7744
  }
7745
+ /**
7746
+ * A total above 0 — every pay button needs it. The state's subtotal follows
7747
+ * the cart (CheckoutManager.onCartChange): a cart emptied or paid in
7748
+ * another tab drops it to 0 here.
7749
+ */
7750
+ get cartPayable() {
7751
+ return (this.checkoutState?.subtotal ?? 0) > 0;
7752
+ }
6517
7753
  orderTotal() {
6518
7754
  const s = this.checkoutState;
6519
7755
  if (!s) return "";
@@ -6523,7 +7759,6 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6523
7759
  * express always has one; the normal flow has one once the backend
6524
7760
  * shippings loaded (the choice IS persisted on the cart and charged). */
6525
7761
  shippingMajor() {
6526
- if (this.vippsChoosesShipping()) return 0;
6527
7762
  if (this.checkoutState?.paymentMethod === "qliro" && typeof this.qliroShipping?.price === "number") {
6528
7763
  return this.qliroShipping.price;
6529
7764
  }
@@ -6538,10 +7773,6 @@ var _VioCheckout = class _VioCheckout extends LitElement {
6538
7773
  if (!o) return 0;
6539
7774
  return o.priceMajor ?? o.price / 100;
6540
7775
  }
6541
- /** Vipps is the method and it runs as Express: the delivery is chosen in Vipps. */
6542
- vippsChoosesShipping() {
6543
- return this.checkoutState?.paymentMethod === "vipps" && this.vippsShipsInApp;
6544
- }
6545
7776
  /** The rate the summary names — Qliro's own pick while Qliro is the method. */
6546
7777
  summaryShippingName() {
6547
7778
  if (this.checkoutState?.paymentMethod === "qliro" && this.qliroShipping) {
@@ -7664,7 +8895,6 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7664
8895
  renderCheckoutBody() {
7665
8896
  const method = this.checkoutState?.paymentMethod;
7666
8897
  const showCompleteCta = !!method && !this.isExpressMethod(method);
7667
- const isVipps = method === "vipps";
7668
8898
  const needsOurForm = !!method && !collectsOwnAddress(method);
7669
8899
  return html`
7670
8900
  ${needsOurForm ? html`
@@ -7703,6 +8933,22 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7703
8933
  @input=${(e) => this.onFieldChange("email", e.target.value)}
7704
8934
  />
7705
8935
  </div>
8936
+ <div class="field">
8937
+ <label for="vio-checkout-phone">${this.phoneRequired ? "Mobilnummer *" : "Mobilnummer (valgfritt)"}</label>
8938
+ <input
8939
+ id="vio-checkout-phone"
8940
+ class="phone-input"
8941
+ type="tel"
8942
+ ?required=${this.phoneRequired}
8943
+ inputmode="tel"
8944
+ autocomplete="tel"
8945
+ placeholder=${this.phoneCountry.toUpperCase() === "NO" ? "8 siffer" : "+46 70 123 45 67"}
8946
+ aria-invalid=${this.phoneError ? "true" : "false"}
8947
+ .value=${this.form.phone ?? ""}
8948
+ @input=${(e) => this.onFieldChange("phone", e.target.value)}
8949
+ />
8950
+ ${this.phoneError ? html`<div class="field-error" role="alert">Skriv et gyldig mobilnummer (med landskode, f.eks. +46, hvis det ikke er norsk)</div>` : ""}
8951
+ </div>
7706
8952
  <div class="field">
7707
8953
  <label>Adresse *</label>
7708
8954
  <input
@@ -7767,22 +9013,6 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7767
9013
  </section>
7768
9014
  ` : ""}
7769
9015
 
7770
- ${isVipps ? html`
7771
- <section class="section">
7772
- <div class="section-label">Kontakt</div>
7773
- <div class="field">
7774
- <label>E-post * <span style="font-weight:400;opacity:0.6">(for kvittering — adresse fylles ut i Vipps)</span></label>
7775
- <input
7776
- type="email"
7777
- required
7778
- autocomplete="email"
7779
- .value=${this.form.email}
7780
- @input=${(e) => this.onFieldChange("email", e.target.value)}
7781
- />
7782
- </div>
7783
- </section>
7784
- ` : ""}
7785
-
7786
9016
  <section class="section">
7787
9017
  <div class="section-label">
7788
9018
  ${needsOurForm ? "Steg 2" : "Betaling"}
@@ -7822,7 +9052,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7822
9052
  <button
7823
9053
  class="payment-btn primary complete-cta"
7824
9054
  @click=${() => void this.onPay("stripe")}
7825
- ?disabled=${this.stripeLoading || !this.isAddressValid || !this.hasSelectedShipping}
9055
+ ?disabled=${this.stripeLoading || !this.cartPayable || !this.isAddressValid || !this.hasSelectedShipping}
7826
9056
  title=${!this.isAddressValid ? "Vennligst fyll ut leveringsadresse" : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode" : ""}
7827
9057
  >
7828
9058
  ${this.stripeLoading ? "G\xE5r til Stripe\u2026" : `Betal ${this.payTotalLabel()} med Stripe`}
@@ -7832,7 +9062,8 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7832
9062
  <button
7833
9063
  class="payment-btn primary complete-cta"
7834
9064
  @click=${() => void this.onPay("vipps")}
7835
- ?disabled=${this.vippsLoading}
9065
+ ?disabled=${this.vippsLoading || !this.cartPayable || !this.isAddressValid || !this.hasSelectedShipping}
9066
+ title=${!this.isAddressValid ? "Vennligst fyll ut leveringsadresse" : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode" : ""}
7836
9067
  >
7837
9068
  ${this.vippsLoading ? "G\xE5r til Vipps\u2026" : `Betal ${this.payTotalLabel()} med Vipps`}
7838
9069
  </button>
@@ -7841,7 +9072,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7841
9072
  <button
7842
9073
  class="payment-btn primary complete-cta"
7843
9074
  @click=${() => void this.onPay("apple-pay")}
7844
- ?disabled=${this.applePayInProgress || !this.isAddressValid || !this.hasSelectedShipping}
9075
+ ?disabled=${this.applePayInProgress || !this.cartPayable || !this.isAddressValid || !this.hasSelectedShipping}
7845
9076
  title=${!this.isAddressValid ? "Vennligst fyll ut leveringsadresse" : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode" : ""}
7846
9077
  >
7847
9078
  ${this.applePayInProgress ? "\xC5pner\u2026" : `Betal ${this.payTotalLabel()} med Apple Pay`}
@@ -7851,7 +9082,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7851
9082
  <button
7852
9083
  class="payment-btn primary complete-cta"
7853
9084
  @click=${this.onCompleteOrder}
7854
- ?disabled=${!this.isAddressValid || !this.hasSelectedShipping}
9085
+ ?disabled=${!this.cartPayable || !this.isAddressValid || !this.hasSelectedShipping}
7855
9086
  title=${!this.isAddressValid ? "Vennligst fyll ut leveringsadresse" : !this.hasSelectedShipping ? "Vennligst velg en fraktmetode" : ""}
7856
9087
  >
7857
9088
  Betal ${this.payTotalLabel()} med ${this.methodLabel(method ?? null)}
@@ -7964,7 +9195,7 @@ var _VioCheckout = class _VioCheckout extends LitElement {
7964
9195
  </div>
7965
9196
  <div class="order-row">
7966
9197
  <span>Frakt${this.shippingMajor() > 0 && this.summaryShippingName() ? ` \u2013 ${this.summaryShippingName()}` : ""}</span>
7967
- <span>${this.vippsChoosesShipping() ? "Velges i Vipps" : this.shippingMajor() > 0 ? formatPrice(this.shippingMajor(), this.checkoutState?.currency) : this.shippingLabel}</span>
9198
+ <span>${this.shippingMajor() > 0 ? formatPrice(this.shippingMajor(), this.checkoutState?.currency) : this.shippingLabel}</span>
7968
9199
  </div>
7969
9200
  <div class="order-row total">
7970
9201
  <span>Totalt</span><span>${this.payTotalLabel()}</span>
@@ -7985,9 +9216,6 @@ __decorateClass([
7985
9216
  __decorateClass([
7986
9217
  property({ type: String, attribute: "shipping-label" })
7987
9218
  ], _VioCheckout.prototype, "shippingLabel", 2);
7988
- __decorateClass([
7989
- state()
7990
- ], _VioCheckout.prototype, "vippsShipsInApp", 2);
7991
9219
  __decorateClass([
7992
9220
  property({ type: Boolean, reflect: true })
7993
9221
  ], _VioCheckout.prototype, "express", 2);
@@ -8163,5 +9391,5 @@ function registerVioElements() {
8163
9391
  }
8164
9392
 
8165
9393
  export { VioCart, VioCheckout, VioProduct, VioProductCarousel, VioProductDetail, VioTokens, applyVioTheme, registerVioElements };
8166
- //# sourceMappingURL=chunk-L6YYYKQK.js.map
8167
- //# sourceMappingURL=chunk-L6YYYKQK.js.map
9394
+ //# sourceMappingURL=chunk-T5B74YBQ.js.map
9395
+ //# sourceMappingURL=chunk-T5B74YBQ.js.map