@salla.sa/twilight-components 2.14.590 → 2.14.592

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.
Files changed (390) hide show
  1. package/dist/cjs/{filepond-Dt4Nihdd.js → filepond-YeXzkn77.js} +1 -1
  2. package/dist/cjs/{filepond-plugin-file-poster-DwwSoJTP.js → filepond-plugin-file-poster-BEU99tEc.js} +1 -1
  3. package/dist/cjs/{filepond-plugin-file-validate-size-DsVYh3H7.js → filepond-plugin-file-validate-size-BSsf6VIk.js} +1 -1
  4. package/dist/cjs/{filepond-plugin-file-validate-type-D5SqBZyw.js → filepond-plugin-file-validate-type-BkxQQF1Y.js} +1 -1
  5. package/dist/cjs/{filepond-plugin-image-edit-CTTRxQHm.js → filepond-plugin-image-edit-h9C4LxoR.js} +1 -1
  6. package/dist/cjs/{filepond-plugin-image-exif-orientation-CN52oBp4.js → filepond-plugin-image-exif-orientation-nDfSwZEM.js} +1 -1
  7. package/dist/cjs/{filepond-plugin-image-preview-DgSWBJVB.js → filepond-plugin-image-preview-C-FDU_4k.js} +1 -1
  8. package/dist/cjs/{helpers-DBynEhOO.js → helpers-DSseNQ1K.js} +1 -1
  9. package/dist/cjs/{index-CgZ8Pldm.js → index-DI5yx1uj.js} +11 -15
  10. package/dist/cjs/{intlTelInput-DBBilzGk.js → intlTelInput-AgidDx18.js} +1 -1
  11. package/dist/cjs/loader.cjs.js +2 -2
  12. package/dist/cjs/salla-accordion-body_2.cjs.entry.js +1 -1
  13. package/dist/cjs/salla-accordion_6.cjs.entry.js +1 -1
  14. package/dist/cjs/salla-add-product-button_5.cjs.entry.js +37 -4
  15. package/dist/cjs/salla-advertisement.cjs.entry.js +1 -1
  16. package/dist/cjs/salla-alert_2.cjs.entry.js +1 -1
  17. package/dist/cjs/salla-app-install-alert.cjs.entry.js +1 -1
  18. package/dist/cjs/salla-applied-filters.cjs.entry.js +1 -1
  19. package/dist/cjs/salla-apps-icons.cjs.entry.js +1 -1
  20. package/dist/cjs/salla-attachment-view_9.cjs.entry.js +11 -11
  21. package/dist/cjs/salla-badge.cjs.entry.js +1 -1
  22. package/dist/cjs/salla-basket-gap.cjs.entry.js +1 -1
  23. package/dist/cjs/salla-bullet-delivery_3.cjs.entry.js +45 -34
  24. package/dist/cjs/salla-cart-coupons.cjs.entry.js +1 -1
  25. package/dist/cjs/salla-cart-item-offers_2.cjs.entry.js +1 -1
  26. package/dist/cjs/salla-cart-summary-card.cjs.entry.js +1 -1
  27. package/dist/cjs/salla-cashback-banner.cjs.entry.js +1 -1
  28. package/dist/cjs/salla-comment-factors.cjs.entry.js +1 -1
  29. package/dist/cjs/salla-comment-form_9.cjs.entry.js +3 -3
  30. package/dist/cjs/salla-conditional-offer.cjs.entry.js +2664 -106
  31. package/dist/cjs/salla-contacts.cjs.entry.js +1 -1
  32. package/dist/cjs/salla-cookies-bar.cjs.entry.js +1 -1
  33. package/dist/cjs/salla-count-down.cjs.entry.js +1 -1
  34. package/dist/cjs/salla-custom-fields.cjs.entry.js +1 -1
  35. package/dist/cjs/salla-delivery-promise.cjs.entry.js +2 -2
  36. package/dist/cjs/salla-edit-order-button.cjs.entry.js +1 -1
  37. package/dist/cjs/salla-filters-widget.cjs.entry.js +1 -1
  38. package/dist/cjs/salla-filters.cjs.entry.js +1 -1
  39. package/dist/cjs/salla-fulfillment-methods.cjs.entry.js +1 -1
  40. package/dist/cjs/{salla-gift-card-redeem.cjs.entry.js → salla-gift-card-redeem_2.cjs.entry.js} +36 -1
  41. package/dist/cjs/salla-gifting.cjs.entry.js +1 -1
  42. package/dist/cjs/salla-hook.cjs.entry.js +1 -1
  43. package/dist/cjs/salla-infinite-scroll.cjs.entry.js +1 -1
  44. package/dist/cjs/salla-installment.cjs.entry.js +1 -1
  45. package/dist/cjs/salla-list-tile.cjs.entry.js +1 -1
  46. package/dist/cjs/salla-localization-modal.cjs.entry.js +1 -1
  47. package/dist/cjs/salla-login-modal.cjs.entry.js +1 -1
  48. package/dist/cjs/salla-loyalty-banner_10.cjs.entry.js +1 -1
  49. package/dist/cjs/salla-loyalty-exchange-product-card.cjs.entry.js +1 -1
  50. package/dist/cjs/salla-loyalty-points-banner.cjs.entry.js +1 -1
  51. package/dist/cjs/salla-loyalty-points-history.cjs.entry.js +1 -1
  52. package/dist/cjs/salla-loyalty-prize-item.cjs.entry.js +1 -1
  53. package/dist/cjs/salla-loyalty.cjs.entry.js +1 -1
  54. package/dist/cjs/salla-maintenance-alert.cjs.entry.js +1 -1
  55. package/dist/cjs/salla-map_3.cjs.entry.js +1 -1
  56. package/dist/cjs/salla-menu.cjs.entry.js +1 -1
  57. package/dist/cjs/salla-metadata.cjs.entry.js +1 -1
  58. package/dist/cjs/salla-multiple-bundle-product-cart_2.cjs.entry.js +1 -1
  59. package/dist/cjs/salla-multiple-bundle-product-options-modal_2.cjs.entry.js +1 -1
  60. package/dist/cjs/salla-multiple-bundle-product.cjs.entry.js +1 -1
  61. package/dist/cjs/salla-next-order-coupon.cjs.entry.js +1 -1
  62. package/dist/cjs/salla-notification-item_2.cjs.entry.js +1 -1
  63. package/dist/cjs/salla-offer-modal.cjs.entry.js +1 -1
  64. package/dist/cjs/salla-offer.cjs.entry.js +1 -1
  65. package/dist/cjs/salla-order-branch.cjs.entry.js +1 -1
  66. package/dist/cjs/salla-order-cancel.cjs.entry.js +1 -1
  67. package/dist/cjs/salla-order-details-multiple-bundle-product.cjs.entry.js +1 -1
  68. package/dist/cjs/salla-order-details-options.cjs.entry.js +1 -1
  69. package/dist/cjs/salla-order-details.cjs.entry.js +1 -1
  70. package/dist/cjs/salla-order-edit-item.cjs.entry.js +1 -1
  71. package/dist/cjs/salla-order-edit-product-card.cjs.entry.js +1 -1
  72. package/dist/cjs/salla-order-edit.cjs.entry.js +1 -1
  73. package/dist/cjs/salla-order-options-modal.cjs.entry.js +1 -1
  74. package/dist/cjs/salla-order-shipments.cjs.entry.js +1 -1
  75. package/dist/cjs/salla-order-summary.cjs.entry.js +1 -1
  76. package/dist/cjs/salla-order-totals-card.cjs.entry.js +1 -1
  77. package/dist/cjs/salla-orders.cjs.entry.js +1 -1
  78. package/dist/cjs/salla-payments.cjs.entry.js +1 -1
  79. package/dist/cjs/salla-placeholder.cjs.entry.js +1 -1
  80. package/dist/cjs/salla-price-range.cjs.entry.js +1 -1
  81. package/dist/cjs/salla-product-card-embed.cjs.entry.js +1 -1
  82. package/dist/cjs/salla-product-card.cjs.entry.js +1 -1
  83. package/dist/cjs/salla-product-size-guide.cjs.entry.js +1 -1
  84. package/dist/cjs/salla-products-list.cjs.entry.js +1 -1
  85. package/dist/cjs/salla-products-slider.cjs.entry.js +1 -1
  86. package/dist/cjs/salla-quick-order.cjs.entry.js +1 -1
  87. package/dist/cjs/salla-rating-modal-v2.cjs.entry.js +1 -1
  88. package/dist/cjs/salla-rating-modal.cjs.entry.js +1 -1
  89. package/dist/cjs/salla-review-factors-tags.cjs.entry.js +2 -2
  90. package/dist/cjs/salla-review-order-item.cjs.entry.js +1 -1
  91. package/dist/cjs/salla-scopes.cjs.entry.js +1 -1
  92. package/dist/cjs/salla-search-product-card.cjs.entry.js +3 -3
  93. package/dist/cjs/salla-search.cjs.entry.js +2 -2
  94. package/dist/cjs/salla-skeleton.cjs.entry.js +2 -2
  95. package/dist/cjs/salla-social-share.cjs.entry.js +2 -2
  96. package/dist/cjs/salla-social.cjs.entry.js +2 -2
  97. package/dist/cjs/salla-tab-content_3.cjs.entry.js +4 -4
  98. package/dist/cjs/salla-tiered-offer.cjs.entry.js +1 -1
  99. package/dist/cjs/salla-trust-badges.cjs.entry.js +1 -1
  100. package/dist/cjs/salla-user-menu.cjs.entry.js +1 -1
  101. package/dist/cjs/salla-user-profile.cjs.entry.js +1 -1
  102. package/dist/cjs/salla-user-settings.cjs.entry.js +2 -2
  103. package/dist/cjs/salla-verify.cjs.entry.js +1 -1
  104. package/dist/cjs/salla-wallet.cjs.entry.js +1 -1
  105. package/dist/cjs/salla-wishlist-actions.cjs.entry.js +2 -2
  106. package/dist/cjs/salla-wishlist-summary.cjs.entry.js +1 -1
  107. package/dist/cjs/twilight.cjs.js +2 -2
  108. package/dist/cjs/{vanilla-picker-DMOjok7F.js → vanilla-picker-DLfPr_3c.js} +1 -1
  109. package/dist/collection/components/salla-add-product-button/salla-add-product-button.js +4 -1
  110. package/dist/collection/components/salla-bullet-delivery/helpers.js +1 -1
  111. package/dist/collection/components/salla-bullet-delivery/salla-bullet-delivery.js +39 -28
  112. package/dist/collection/components/salla-conditional-offer/cart-update-window.js +34 -0
  113. package/dist/collection/components/salla-conditional-offer/interfaces.js +1 -0
  114. package/dist/collection/components/salla-conditional-offer/salla-conditional-offer.css +456 -191
  115. package/dist/collection/components/salla-conditional-offer/salla-conditional-offer.js +1790 -106
  116. package/dist/collection/components/salla-quick-buy/salla-quick-buy.js +17 -4
  117. package/dist/collection/components/salla-review-card/salla-review-card.js +1 -1
  118. package/dist/collection/components/salla-review-factors-tags/salla-review-factors-tags.js +1 -1
  119. package/dist/collection/components/salla-reviews-page/salla-reviews-page.js +1 -1
  120. package/dist/collection/components/salla-search/salla-search.js +1 -1
  121. package/dist/collection/components/salla-search-product-card/salla-search-product-card.js +2 -2
  122. package/dist/collection/components/salla-searchable-dropdown/salla-searchable-dropdown.js +4 -4
  123. package/dist/collection/components/salla-skeleton/salla-skeleton.js +1 -1
  124. package/dist/collection/components/salla-social/salla-social.js +1 -1
  125. package/dist/collection/components/salla-social-share/salla-social-share.js +1 -1
  126. package/dist/collection/components/salla-tabs/salla-tab-content.js +1 -1
  127. package/dist/collection/components/salla-tabs/salla-tab-header.js +1 -1
  128. package/dist/collection/components/salla-tabs/salla-tabs.js +1 -1
  129. package/dist/collection/components/salla-tel-input/salla-tel-input.js +1 -1
  130. package/dist/collection/components/salla-tooltip/salla-tooltip.js +1 -1
  131. package/dist/collection/components/salla-user-settings/salla-user-settings.js +1 -1
  132. package/dist/collection/components/salla-wishlist-actions/salla-wishlist-actions.js +1 -1
  133. package/dist/collection/shared/cart-free-gifts.js +448 -0
  134. package/dist/collection/shared/offer-stages.js +453 -0
  135. package/dist/components/helpers.js +2 -2
  136. package/dist/components/index.js +3 -3
  137. package/dist/components/salla-add-product-button2.js +4 -1
  138. package/dist/components/salla-bullet-delivery.js +40 -29
  139. package/dist/components/salla-conditional-offer.js +2672 -116
  140. package/dist/components/salla-delivery-promise.js +1 -1
  141. package/dist/components/salla-quick-buy2.js +34 -4
  142. package/dist/components/salla-review-card2.js +1 -1
  143. package/dist/components/salla-review-factors-tags2.js +1 -1
  144. package/dist/components/salla-reviews-page.js +1 -1
  145. package/dist/components/salla-search-product-card2.js +2 -2
  146. package/dist/components/salla-search.js +1 -1
  147. package/dist/components/salla-searchable-dropdown2.js +4 -4
  148. package/dist/components/salla-skeleton2.js +1 -1
  149. package/dist/components/salla-social-share2.js +1 -1
  150. package/dist/components/salla-social.js +1 -1
  151. package/dist/components/salla-tab-content2.js +1 -1
  152. package/dist/components/salla-tab-header2.js +1 -1
  153. package/dist/components/salla-tabs2.js +1 -1
  154. package/dist/components/salla-tel-input2.js +1 -1
  155. package/dist/components/salla-tooltip2.js +1 -1
  156. package/dist/components/salla-user-settings.js +1 -1
  157. package/dist/components/salla-wishlist-actions.js +1 -1
  158. package/dist/docs.json +21 -8
  159. package/dist/esm/{filepond-wzh5HaPr.js → filepond-C3uLKJlo.js} +1 -1
  160. package/dist/esm/{filepond-plugin-file-poster-DInGlcq6.js → filepond-plugin-file-poster-BNO8nuwN.js} +1 -1
  161. package/dist/esm/{filepond-plugin-file-validate-size-BaLOl1L7.js → filepond-plugin-file-validate-size-CMMYF-rL.js} +1 -1
  162. package/dist/esm/{filepond-plugin-file-validate-type-D_DVE5xS.js → filepond-plugin-file-validate-type-DSYhKJ7B.js} +1 -1
  163. package/dist/esm/{filepond-plugin-image-edit-B9vWgHyi.js → filepond-plugin-image-edit-lJgRMQAo.js} +1 -1
  164. package/dist/esm/{filepond-plugin-image-exif-orientation-lJrZF6xG.js → filepond-plugin-image-exif-orientation-B0tYK1GE.js} +1 -1
  165. package/dist/esm/{filepond-plugin-image-preview-Bbk7m1Yt.js → filepond-plugin-image-preview-kWmnxhXu.js} +1 -1
  166. package/dist/esm/{helpers-BJxBnTgv.js → helpers-DgRxTSyJ.js} +2 -2
  167. package/dist/esm/{index-klFskvc_.js → index-Bk24FlDL.js} +11 -15
  168. package/dist/esm/{intlTelInput-pS6pc-iA.js → intlTelInput-nODQyuq0.js} +1 -1
  169. package/dist/esm/loader.js +3 -3
  170. package/dist/esm/salla-accordion-body_2.entry.js +1 -1
  171. package/dist/esm/salla-accordion_6.entry.js +1 -1
  172. package/dist/esm/salla-add-product-button_5.entry.js +37 -4
  173. package/dist/esm/salla-advertisement.entry.js +1 -1
  174. package/dist/esm/salla-alert_2.entry.js +1 -1
  175. package/dist/esm/salla-app-install-alert.entry.js +1 -1
  176. package/dist/esm/salla-applied-filters.entry.js +1 -1
  177. package/dist/esm/salla-apps-icons.entry.js +1 -1
  178. package/dist/esm/salla-attachment-view_9.entry.js +11 -11
  179. package/dist/esm/salla-badge.entry.js +1 -1
  180. package/dist/esm/salla-basket-gap.entry.js +1 -1
  181. package/dist/esm/salla-bullet-delivery_3.entry.js +45 -34
  182. package/dist/esm/salla-cart-coupons.entry.js +1 -1
  183. package/dist/esm/salla-cart-item-offers_2.entry.js +1 -1
  184. package/dist/esm/salla-cart-summary-card.entry.js +1 -1
  185. package/dist/esm/salla-cashback-banner.entry.js +1 -1
  186. package/dist/esm/salla-comment-factors.entry.js +1 -1
  187. package/dist/esm/salla-comment-form_9.entry.js +3 -3
  188. package/dist/esm/salla-conditional-offer.entry.js +2664 -106
  189. package/dist/esm/salla-contacts.entry.js +1 -1
  190. package/dist/esm/salla-cookies-bar.entry.js +1 -1
  191. package/dist/esm/salla-count-down.entry.js +1 -1
  192. package/dist/esm/salla-custom-fields.entry.js +1 -1
  193. package/dist/esm/salla-delivery-promise.entry.js +2 -2
  194. package/dist/esm/salla-edit-order-button.entry.js +1 -1
  195. package/dist/esm/salla-filters-widget.entry.js +1 -1
  196. package/dist/esm/salla-filters.entry.js +1 -1
  197. package/dist/esm/salla-fulfillment-methods.entry.js +1 -1
  198. package/dist/esm/{salla-gift-card-redeem.entry.js → salla-gift-card-redeem_2.entry.js} +36 -2
  199. package/dist/esm/salla-gifting.entry.js +1 -1
  200. package/dist/esm/salla-hook.entry.js +1 -1
  201. package/dist/esm/salla-infinite-scroll.entry.js +1 -1
  202. package/dist/esm/salla-installment.entry.js +1 -1
  203. package/dist/esm/salla-list-tile.entry.js +1 -1
  204. package/dist/esm/salla-localization-modal.entry.js +1 -1
  205. package/dist/esm/salla-login-modal.entry.js +1 -1
  206. package/dist/esm/salla-loyalty-banner_10.entry.js +1 -1
  207. package/dist/esm/salla-loyalty-exchange-product-card.entry.js +1 -1
  208. package/dist/esm/salla-loyalty-points-banner.entry.js +1 -1
  209. package/dist/esm/salla-loyalty-points-history.entry.js +1 -1
  210. package/dist/esm/salla-loyalty-prize-item.entry.js +1 -1
  211. package/dist/esm/salla-loyalty.entry.js +1 -1
  212. package/dist/esm/salla-maintenance-alert.entry.js +1 -1
  213. package/dist/esm/salla-map_3.entry.js +1 -1
  214. package/dist/esm/salla-menu.entry.js +1 -1
  215. package/dist/esm/salla-metadata.entry.js +1 -1
  216. package/dist/esm/salla-multiple-bundle-product-cart_2.entry.js +1 -1
  217. package/dist/esm/salla-multiple-bundle-product-options-modal_2.entry.js +1 -1
  218. package/dist/esm/salla-multiple-bundle-product.entry.js +1 -1
  219. package/dist/esm/salla-next-order-coupon.entry.js +1 -1
  220. package/dist/esm/salla-notification-item_2.entry.js +1 -1
  221. package/dist/esm/salla-offer-modal.entry.js +1 -1
  222. package/dist/esm/salla-offer.entry.js +1 -1
  223. package/dist/esm/salla-order-branch.entry.js +1 -1
  224. package/dist/esm/salla-order-cancel.entry.js +1 -1
  225. package/dist/esm/salla-order-details-multiple-bundle-product.entry.js +1 -1
  226. package/dist/esm/salla-order-details-options.entry.js +1 -1
  227. package/dist/esm/salla-order-details.entry.js +1 -1
  228. package/dist/esm/salla-order-edit-item.entry.js +1 -1
  229. package/dist/esm/salla-order-edit-product-card.entry.js +1 -1
  230. package/dist/esm/salla-order-edit.entry.js +1 -1
  231. package/dist/esm/salla-order-options-modal.entry.js +1 -1
  232. package/dist/esm/salla-order-shipments.entry.js +1 -1
  233. package/dist/esm/salla-order-summary.entry.js +1 -1
  234. package/dist/esm/salla-order-totals-card.entry.js +1 -1
  235. package/dist/esm/salla-orders.entry.js +1 -1
  236. package/dist/esm/salla-payments.entry.js +1 -1
  237. package/dist/esm/salla-placeholder.entry.js +1 -1
  238. package/dist/esm/salla-price-range.entry.js +1 -1
  239. package/dist/esm/salla-product-card-embed.entry.js +1 -1
  240. package/dist/esm/salla-product-card.entry.js +1 -1
  241. package/dist/esm/salla-product-size-guide.entry.js +1 -1
  242. package/dist/esm/salla-products-list.entry.js +1 -1
  243. package/dist/esm/salla-products-slider.entry.js +1 -1
  244. package/dist/esm/salla-quick-order.entry.js +1 -1
  245. package/dist/esm/salla-rating-modal-v2.entry.js +1 -1
  246. package/dist/esm/salla-rating-modal.entry.js +1 -1
  247. package/dist/esm/salla-review-factors-tags.entry.js +2 -2
  248. package/dist/esm/salla-review-order-item.entry.js +1 -1
  249. package/dist/esm/salla-scopes.entry.js +1 -1
  250. package/dist/esm/salla-search-product-card.entry.js +3 -3
  251. package/dist/esm/salla-search.entry.js +2 -2
  252. package/dist/esm/salla-skeleton.entry.js +2 -2
  253. package/dist/esm/salla-social-share.entry.js +2 -2
  254. package/dist/esm/salla-social.entry.js +2 -2
  255. package/dist/esm/salla-tab-content_3.entry.js +4 -4
  256. package/dist/esm/salla-tiered-offer.entry.js +1 -1
  257. package/dist/esm/salla-trust-badges.entry.js +1 -1
  258. package/dist/esm/salla-user-menu.entry.js +1 -1
  259. package/dist/esm/salla-user-profile.entry.js +1 -1
  260. package/dist/esm/salla-user-settings.entry.js +2 -2
  261. package/dist/esm/salla-verify.entry.js +1 -1
  262. package/dist/esm/salla-wallet.entry.js +1 -1
  263. package/dist/esm/salla-wishlist-actions.entry.js +2 -2
  264. package/dist/esm/salla-wishlist-summary.entry.js +1 -1
  265. package/dist/esm/twilight.js +3 -3
  266. package/dist/esm/{vanilla-picker-BhmFKXIB.js → vanilla-picker-B1bTUgHP.js} +1 -1
  267. package/dist/twilight/{p-482fee31.entry.js → p-02202feb.entry.js} +1 -1
  268. package/dist/twilight/{p-f6bae25a.entry.js → p-03dffb99.entry.js} +1 -1
  269. package/dist/twilight/{p-efeae895.entry.js → p-06643011.entry.js} +1 -1
  270. package/dist/twilight/p-07174244.entry.js +4 -0
  271. package/dist/twilight/{p-55f765dc.entry.js → p-08256e67.entry.js} +1 -1
  272. package/dist/twilight/{p-a0869ee6.entry.js → p-0dc90c55.entry.js} +1 -1
  273. package/dist/twilight/{p-dd775629.entry.js → p-0e122915.entry.js} +1 -1
  274. package/dist/twilight/{p-089f0d34.entry.js → p-1b1bebf6.entry.js} +1 -1
  275. package/dist/twilight/{p-6536466c.entry.js → p-1d05ff72.entry.js} +1 -1
  276. package/dist/twilight/{p-c33ed919.entry.js → p-21802082.entry.js} +1 -1
  277. package/dist/twilight/{p-317048c4.entry.js → p-2191d792.entry.js} +1 -1
  278. package/dist/twilight/{p-32ea6fff.entry.js → p-21b3877b.entry.js} +1 -1
  279. package/dist/twilight/{p-9897606c.entry.js → p-22135b7c.entry.js} +1 -1
  280. package/dist/twilight/{p-7836ad45.entry.js → p-250262a9.entry.js} +1 -1
  281. package/dist/twilight/{p-23ccf145.entry.js → p-2fc68bfd.entry.js} +1 -1
  282. package/dist/twilight/{p-027b5233.entry.js → p-320d6b04.entry.js} +1 -1
  283. package/dist/twilight/{p-8fbf8044.entry.js → p-377642ea.entry.js} +1 -1
  284. package/dist/twilight/{p-0b10b315.entry.js → p-38418821.entry.js} +1 -1
  285. package/dist/twilight/{p-21f2167e.entry.js → p-3bf37615.entry.js} +1 -1
  286. package/dist/twilight/{p-39325cfc.entry.js → p-3e6f2bf8.entry.js} +1 -1
  287. package/dist/twilight/{p-864959d8.entry.js → p-42150f97.entry.js} +1 -1
  288. package/dist/twilight/{p-a64f2fff.entry.js → p-42e02411.entry.js} +1 -1
  289. package/dist/twilight/{p-3acbfe00.entry.js → p-43c5ec34.entry.js} +1 -1
  290. package/dist/twilight/{p-7495249f.entry.js → p-43ca88d0.entry.js} +1 -1
  291. package/dist/twilight/{p-009048d0.entry.js → p-45035389.entry.js} +1 -1
  292. package/dist/twilight/{p-48f26c20.entry.js → p-46721e68.entry.js} +1 -1
  293. package/dist/twilight/{p-0446f8e3.entry.js → p-4f76e9e2.entry.js} +1 -1
  294. package/dist/twilight/p-4fd73440.entry.js +4 -0
  295. package/dist/twilight/{p-6ea4f06c.entry.js → p-523f9816.entry.js} +1 -1
  296. package/dist/twilight/{p-a4b9f482.entry.js → p-540029a5.entry.js} +1 -1
  297. package/dist/twilight/{p-c273ff37.entry.js → p-5435dbcd.entry.js} +1 -1
  298. package/dist/twilight/{p-23e45bd7.entry.js → p-5f6a5a4b.entry.js} +1 -1
  299. package/dist/twilight/{p-bd1dbd4b.entry.js → p-5fc75260.entry.js} +1 -1
  300. package/dist/twilight/{p-d266e9ad.entry.js → p-612b6d87.entry.js} +1 -1
  301. package/dist/twilight/{p-6fcf17d4.entry.js → p-658de43b.entry.js} +1 -1
  302. package/dist/twilight/{p-31b7fbdc.entry.js → p-6673a16f.entry.js} +1 -1
  303. package/dist/twilight/{p-5b65fffc.entry.js → p-6760ec19.entry.js} +1 -1
  304. package/dist/twilight/{p-a97a0636.entry.js → p-68411931.entry.js} +1 -1
  305. package/dist/twilight/{p-85f185cc.entry.js → p-6a76acd8.entry.js} +1 -1
  306. package/dist/twilight/{p-0f951af1.entry.js → p-7038e1af.entry.js} +1 -1
  307. package/dist/twilight/{p-d35c3f7d.entry.js → p-75f32b91.entry.js} +1 -1
  308. package/dist/twilight/{p-e28f4afc.entry.js → p-7750be26.entry.js} +1 -1
  309. package/dist/twilight/{p-d524d26e.entry.js → p-79676f24.entry.js} +1 -1
  310. package/dist/twilight/{p-36f4e851.entry.js → p-7b4a8237.entry.js} +1 -1
  311. package/dist/twilight/p-7ff0c546.entry.js +4 -0
  312. package/dist/twilight/{p-294f6c8e.entry.js → p-813a40cc.entry.js} +1 -1
  313. package/dist/twilight/{p-a0f0a176.entry.js → p-83ccddd5.entry.js} +1 -1
  314. package/dist/twilight/{p-ddb65e87.entry.js → p-872d6d68.entry.js} +1 -1
  315. package/dist/twilight/{p-1988457b.entry.js → p-9048520b.entry.js} +1 -1
  316. package/dist/twilight/{p-799a0589.entry.js → p-91a1baf1.entry.js} +1 -1
  317. package/dist/twilight/{p-d566b402.entry.js → p-9e6f4ec4.entry.js} +1 -1
  318. package/dist/twilight/{p-PcxCrM8A.js → p-B-LS1xDz.js} +1 -1
  319. package/dist/twilight/p-Bk24FlDL.js +5 -0
  320. package/dist/twilight/{p-Ba21M-o3.js → p-BnmP7F4y.js} +1 -1
  321. package/dist/twilight/{p-4asjbsmx.js → p-BvUmZYa9.js} +1 -1
  322. package/dist/twilight/{p-rKbQj-u0.js → p-CHqh7vb6.js} +1 -1
  323. package/dist/twilight/{p-HZVqfCuE.js → p-CXo-IH2o.js} +1 -1
  324. package/dist/twilight/{p-7foc7ca2.js → p-CcGfWKIx.js} +1 -1
  325. package/dist/twilight/{p-BHXx247M.js → p-Ct18qgaq.js} +1 -1
  326. package/dist/twilight/{p-ClbVN5Nv.js → p-DBjpz_eG.js} +1 -1
  327. package/dist/twilight/{p-BJxBnTgv.js → p-DgRxTSyJ.js} +1 -1
  328. package/dist/twilight/{p-BcMiEHs_.js → p-DvlAEiaH.js} +1 -1
  329. package/dist/twilight/{p-78ff082d.entry.js → p-a057f532.entry.js} +1 -1
  330. package/dist/twilight/{p-a1d43865.entry.js → p-a0f1b867.entry.js} +1 -1
  331. package/dist/twilight/{p-6e45ec66.entry.js → p-a75680ae.entry.js} +1 -1
  332. package/dist/twilight/{p-6ea8b4a8.entry.js → p-ad52dcf7.entry.js} +1 -1
  333. package/dist/twilight/{p-cb47fc80.entry.js → p-b009d316.entry.js} +1 -1
  334. package/dist/twilight/{p-45a13d19.entry.js → p-b1b096d7.entry.js} +1 -1
  335. package/dist/twilight/{p-f407afee.entry.js → p-b1b345d6.entry.js} +1 -1
  336. package/dist/twilight/{p-0de54240.entry.js → p-b2c41e36.entry.js} +1 -1
  337. package/dist/twilight/{p-0746fded.entry.js → p-b37ae926.entry.js} +1 -1
  338. package/dist/twilight/{p-52d1d467.entry.js → p-b5e774db.entry.js} +1 -1
  339. package/dist/twilight/{p-7ddae4df.entry.js → p-b6dc3e12.entry.js} +1 -1
  340. package/dist/twilight/{p-9950bd4f.entry.js → p-b95a6e20.entry.js} +1 -1
  341. package/dist/twilight/{p-655c7f90.entry.js → p-bb152b39.entry.js} +1 -1
  342. package/dist/twilight/{p-d21e8092.entry.js → p-be729ca2.entry.js} +1 -1
  343. package/dist/twilight/{p-788ffe88.entry.js → p-becb6580.entry.js} +1 -1
  344. package/dist/twilight/{p-3ba09a8e.entry.js → p-c1d18b73.entry.js} +1 -1
  345. package/dist/twilight/{p-2667efd6.entry.js → p-c1d815c9.entry.js} +1 -1
  346. package/dist/twilight/{p-a0f2b1b7.entry.js → p-c313bd11.entry.js} +1 -1
  347. package/dist/twilight/{p-3de1d01b.entry.js → p-c3a2696b.entry.js} +1 -1
  348. package/dist/twilight/{p-c62b3757.entry.js → p-c9ebe466.entry.js} +1 -1
  349. package/dist/twilight/{p-0fca7ee0.entry.js → p-cc74d38b.entry.js} +1 -1
  350. package/dist/twilight/{p-34b91d42.entry.js → p-cd432c49.entry.js} +1 -1
  351. package/dist/twilight/{p-94eddc5c.entry.js → p-ce6c709b.entry.js} +1 -1
  352. package/dist/twilight/{p-b98b2cda.entry.js → p-ced74b88.entry.js} +1 -1
  353. package/dist/twilight/{p-8c7614fb.entry.js → p-d601977e.entry.js} +1 -1
  354. package/dist/twilight/{p-1d8c6adc.entry.js → p-d73307c7.entry.js} +1 -1
  355. package/dist/twilight/{p-8bc3a449.entry.js → p-d9ccdd8f.entry.js} +1 -1
  356. package/dist/twilight/{p-945eec73.entry.js → p-dc51993c.entry.js} +1 -1
  357. package/dist/twilight/{p-56ce31f2.entry.js → p-dfd212d5.entry.js} +1 -1
  358. package/dist/twilight/{p-62b8c2cc.entry.js → p-e2aa0e72.entry.js} +1 -1
  359. package/dist/twilight/{p-1a1f04ac.entry.js → p-e58b7ef9.entry.js} +1 -1
  360. package/dist/twilight/p-e7127704.entry.js +4 -0
  361. package/dist/twilight/{p-da05ae96.entry.js → p-e7ca6d06.entry.js} +1 -1
  362. package/dist/twilight/{p-6b767b19.entry.js → p-eaae379c.entry.js} +1 -1
  363. package/dist/twilight/{p-0156dfe8.entry.js → p-ed6ea140.entry.js} +1 -1
  364. package/dist/twilight/{p-53f8972b.entry.js → p-f263bba9.entry.js} +1 -1
  365. package/dist/twilight/p-f3ffe84f.entry.js +4 -0
  366. package/dist/twilight/{p-f757ba41.entry.js → p-f4f7ed38.entry.js} +1 -1
  367. package/dist/twilight/{p-0ccf7bde.entry.js → p-f5c9e513.entry.js} +1 -1
  368. package/dist/twilight/{p-ce0a4802.entry.js → p-f74ccae4.entry.js} +1 -1
  369. package/dist/twilight/{p-45cc68ce.entry.js → p-f7ee4910.entry.js} +1 -1
  370. package/dist/twilight/{p-09300a8d.entry.js → p-f8f9c563.entry.js} +1 -1
  371. package/dist/twilight/{p-74ba0473.entry.js → p-f9f0a5ee.entry.js} +1 -1
  372. package/dist/twilight/{p-a7a64c45.entry.js → p-fbee9e50.entry.js} +1 -1
  373. package/dist/twilight/twilight.esm.js +1 -1
  374. package/dist/types/components/salla-bullet-delivery/salla-bullet-delivery.d.ts +12 -0
  375. package/dist/types/components/salla-conditional-offer/cart-update-window.d.ts +31 -0
  376. package/dist/types/components/salla-conditional-offer/interfaces.d.ts +70 -13
  377. package/dist/types/components/salla-conditional-offer/salla-conditional-offer.d.ts +656 -10
  378. package/dist/types/components/salla-search/search-response.d.ts +1 -0
  379. package/dist/types/shared/cart-free-gifts.d.ts +141 -0
  380. package/dist/types/shared/offer-stages.d.ts +288 -0
  381. package/package.json +5 -5
  382. package/dist/cjs/salla-tooltip.cjs.entry.js +0 -42
  383. package/dist/esm/salla-tooltip.entry.js +0 -40
  384. package/dist/twilight/p-0e206ed7.entry.js +0 -4
  385. package/dist/twilight/p-1a875a94.entry.js +0 -4
  386. package/dist/twilight/p-8f5929a9.entry.js +0 -4
  387. package/dist/twilight/p-917ce4f9.entry.js +0 -4
  388. package/dist/twilight/p-d4f3905f.entry.js +0 -4
  389. package/dist/twilight/p-e47f5ec9.entry.js +0 -4
  390. package/dist/twilight/p-klFskvc_.js +0 -5
@@ -2,14 +2,14 @@
2
2
  * Crafted with ❤ by Salla
3
3
  */
4
4
  import { proxyCustomElement, HTMLElement, h, Host } from '@stencil/core/internal/client';
5
- import { d as defineCustomElement$3 } from './salla-skeleton2.js';
6
- import { d as defineCustomElement$2 } from './salla-tooltip2.js';
5
+ import { d as defineCustomElement$2 } from './salla-skeleton2.js';
7
6
 
8
7
  var DiscountType;
9
8
  (function (DiscountType) {
10
9
  DiscountType["PERCENTAGE"] = "percentage";
11
10
  DiscountType["FIXED"] = "fixed";
12
11
  DiscountType["FREE_PRODUCT"] = "free_product";
12
+ DiscountType["FREE_SHIPPING"] = "free_shipping";
13
13
  })(DiscountType || (DiscountType = {}));
14
14
  var OfferType;
15
15
  (function (OfferType) {
@@ -17,169 +17,2730 @@ var OfferType;
17
17
  OfferType["PRODUCT_PURCHASE"] = "order_amount";
18
18
  })(OfferType || (OfferType = {}));
19
19
 
20
- const sallaConditionalOfferCss = "/*!twlt*/:host{display:block}.fixed{position:fixed}.absolute{position:absolute}.-top-2{top:-0.5rem}.left-0{left:0px}.mb-1{margin-bottom:0.25rem}.flex{display:flex}.h-12{height:3rem}.w-12{width:3rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.gap-3{gap:0.75rem}.rounded-lg{border-radius:0.5rem}.object-cover{object-fit:cover}.text-start{text-align:start}.text-xs{font-size:0.75rem;line-height:1rem}.leading-tight{line-height:1.25}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.s-conditional-offer-container{position:relative;display:inline-flex;width:100%;flex-direction:column;align-items:center;justify-content:flex-start;gap:1.25rem;border-radius:0.375rem;--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));padding-left:1.25rem;padding-right:1.25rem;padding-top:1rem;padding-bottom:4rem}@media (min-width: 768px){.s-conditional-offer-container{padding-left:2.5rem;padding-right:2.5rem;padding-top:2rem;padding-bottom:2rem}}.s-conditional-offer-title-wrapper{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;align-self:stretch}.s-conditional-offer-title{text-align:center;font-size:1.25rem;line-height:1.75rem;font-weight:700;color:var(--color-primary-dark)}.s-conditional-offer-subtitle{display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;align-self:stretch;text-align:center;font-size:0.75rem;line-height:1rem;font-weight:400;--tw-text-opacity:1;color:rgb(64 64 64 / var(--tw-text-opacity, 1))}.s-conditional-offer-subtitle i{position:relative}.s-conditional-offer-progress-line{height:1px;align-self:stretch;background-color:var(--color-primary)}.s-conditional-offer-checkpoint-border{display:flex;height:60px;min-width:60px;flex-direction:column;align-items:center;justify-content:center;border-radius:9999px;border-width:1px;--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.s-conditional-offer-checkpoint-border-colored{border-color:var(--color-primary);color:var(--color-primary-dark)}.s-conditional-offer-product-link{display:grid;aspect-ratio:1 / 1;height:58px;place-content:center;overflow:hidden;border-radius:9999px}.s-conditional-offer-checkpoint-image-content{height:58px;width:58px;object-fit:cover}.s-conditional-offer-progress-container{position:relative;margin-bottom:2rem;display:flex;height:8rem;width:100%;align-items:center;overflow-x:auto}@media (min-width: 768px){.s-conditional-offer-progress-container{height:4rem;overflow:visible}}.s-conditional-offer-item-avatar-content{text-align:center;font-size:0.875rem;font-weight:700;line-height:1.25rem}.s-conditional-offer-item-avatar-content.active{color:var(--color-primary-dark);transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:500ms;transition-timing-function:cubic-bezier(0, 0, 0.2, 1)}.s-conditional-offer-checkpoint{position:relative;display:flex;height:60px;width:60px;flex-shrink:0;flex-direction:column;align-items:center;justify-content:center;border-radius:9999px;border-width:1px}.s-conditional-offer-progress-line-container{display:grid;width:100%;overflow:hidden;grid-template-areas:'progress-line'}.s-conditional-offer-progress-line-inactive{height:1.5px;width:100%;--tw-bg-opacity:1;background-color:rgb(209 213 219 / var(--tw-bg-opacity, 1));transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:500ms;transition-timing-function:cubic-bezier(0, 0, 0.2, 1);grid-area:progress-line}.s-conditional-offer-progress-line-active{height:1.5px;width:0%;background-color:var(--color-primary);transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:500ms;transition-timing-function:cubic-bezier(0, 0, 0.2, 1);grid-area:progress-line}.s-conditional-offer-checkpoint-label{position:absolute;top:100%;left:50%;width:max-content;--tw-translate-x:-50%;--tw-translate-y:20%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:500ms;transition-timing-function:cubic-bezier(0, 0, 0.2, 1)}.s-conditional-offer-checkpoint-label.active{color:var(--color-primary-dark);transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:500ms;transition-timing-function:cubic-bezier(0, 0, 0.2, 1)}.s-conditional-offer-checkpoint-label.first-checkpoint{--tw-translate-y:132%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.s-conditional-offer-checkpoint-label.first-checkpoint:where([dir=\"ltr\"],[dir=\"ltr\"] *){--tw-translate-x:0%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.s-conditional-offer-checkpoint-label.first-checkpoint:where([dir=\"rtl\"],[dir=\"rtl\"] *){--tw-translate-x:-100%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.s-conditional-offer-checkpoint-container{position:relative;display:flex;align-items:center}.s-conditional-offer-active-checkpoint{display:flex;justify-content:center;border-color:var(--color-primary);transition-property:all;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:500ms;transition-timing-function:cubic-bezier(0, 0, 0.2, 1)}.s-conditional-offer-skeleton-inner-container{margin-bottom:2rem;width:100%}@media (min-width: 768px){.s-conditional-offer-skeleton-inner-container{margin-bottom:0px}}.s-conditional-offer-skeleton-title{margin-bottom:1rem;display:flex;justify-content:center}.s-conditional-offer-skeleton-subtitle{margin-bottom:1.5rem;display:flex;justify-content:center}.s-conditional-offer-skeleton-checkpoints-wrapper{display:flex;align-items:center}";
20
+ /**
21
+ * Shared free-gift lookup for the cart.
22
+ *
23
+ * The gift feature is twilight-only: no theme passes `added-by-offer` /
24
+ * `free-gift-quantity` attributes. `salla-conditional-offer` resolves the gift LINES it
25
+ * lists (read-only, as information) and the cart line count from here, never from theme
26
+ * attributes.
27
+ *
28
+ * Data source: `salla.api.cart.details()` — it hits the `api.cart.show`
29
+ * route, which IS in `CartPresenter::$routesWithItems`, so the response
30
+ * carries `data.cart.items[]` with the per-item keys `added_by_offer`
31
+ * (bool), `offer_id` (int|null) and `free_gift_quantity` (int) set by
32
+ * `CartItemTransformer.php`. Those three names are a pinned
33
+ * contract — do not rename them here or on the components that consume them.
34
+ *
35
+ * We deliberately never read these fields off a `cart::updated` payload:
36
+ * that event fires from `latest()` too, and `api.cart.latest` is NOT in
37
+ * `$routesWithItems`, so a `cart::updated` payload legitimately carries no
38
+ * `items` at all. Callers must fetch on mount and call
39
+ * `refreshCartFreeGifts()` on the event — this module never derives from
40
+ * event payloads.
41
+ *
42
+ * The banner asks for the gift lines and the cart line count on the same tick (initial
43
+ * mount, and again on every `cart::updated`). Concurrent callers share a
44
+ * single in-flight promise, mirroring `_pendingOffersRequest` in
45
+ * `twilight/packages/twilight/src/api/cart.js:831-846`.
46
+ */
47
+ const EMPTY_GIFT_ITEMS = Object.freeze([]);
48
+ /**
49
+ * The gift LINES from the last parsed response. `null` is the single "nothing has loaded
50
+ * yet" flag shared with `countCache`, which keeps the two from ever disagreeing about
51
+ * which response they came from.
52
+ */
53
+ let itemsCache = null;
54
+ /**
55
+ * The cart's line count, published in the SAME pass as `itemsCache`. Only meaningful
56
+ * while `itemsCache` is non-null.
57
+ */
58
+ let countCache = null;
59
+ /**
60
+ * Whether what sits in the caches above came from a response we actually PARSED,
61
+ * as opposed to the graceful fallback a rejected lookup publishes.
62
+ *
63
+ * THIS IS THE CACHE'S WARM/COLD FLAG, and it is separate from the values on purpose.
64
+ *
65
+ * A failed lookup still has to hand its in-flight callers something usable, so the
66
+ * `.catch` below publishes `gifts: []` / `count: null`. But `[]` is TRUTHY, so a cache
67
+ * populated that way used to read as WARM: every later `getCartItemCount()` /
68
+ * `getCartFreeGiftItems()` short-circuited on it and returned the FAILURE's values
69
+ * without ever re-asking. One rejected `details()` therefore pinned the count at
70
+ * "unknown" (or, for `CartNotCreated`, at a hard `0`) for the whole page view, and
71
+ * `salla-conditional-offer` — which renders nothing until it is POSITIVELY told the cart
72
+ * holds items — vanished on a populated cart with a live offer. Nothing corrected it:
73
+ * only `refreshCartFreeGifts()` clears the cache, and that runs from `cart::updated`,
74
+ * which a shopper who does not touch the cart never emits.
75
+ *
76
+ * So a failure now leaves the cache COLD: its fallback values are still handed to the
77
+ * callers already waiting, and the next caller re-asks. Concurrent callers still share
78
+ * one request through `pendingRequest`, so the retry costs one lookup per wave, not one
79
+ * per consumer — and the cheapest failure of all, `CartNotCreated`, never reaches the
80
+ * network at all (`details()` rejects before it requests).
81
+ */
82
+ let isAuthoritative = false;
83
+ let pendingRequest = null;
84
+ // De-dupes concurrent `refreshCartFreeGifts()` callers — see that function.
85
+ let pendingRefresh = null;
86
+ // Bumped by `refreshCartFreeGifts()`. A fetch only gets to publish into
87
+ // the caches if the generation it started under is still current when it
88
+ // resolves — otherwise a request that was already in flight when the cart
89
+ // mutated would win the race and cache pre-mutation data as if it were
90
+ // fresh. Mirrors `salla.event.on('cart::updated', () => { Salla.api.cart._pendingOffersRequest = null; })`
91
+ // in `twilight/packages/twilight/src/api/cart.js:81`, which abandons its
92
+ // in-flight promise on cart update so a post-mutation caller gets a fresh
93
+ // request — the same idea, expressed as a generation check instead of
94
+ // nulling the promise (nulling it here would defeat the same-tick dedupe
95
+ // that `refreshCartFreeGifts()` relies on).
96
+ let generation = 0;
97
+ /**
98
+ * The cart identity the caches (and any in-flight request) belong to. `undefined` until
99
+ * the first lookup. See `syncCartIdentity`.
100
+ */
101
+ let cacheIdentity;
102
+ /**
103
+ * The SDK's own name for "this visitor has no cart at all".
104
+ *
105
+ * `salla.api.cart.details()` resolves its id through
106
+ * `getCurrentCartId(false, …, allowCreate = false)`, which returns `null` for a guest with
107
+ * no stored cart id rather than creating one server-side; `details()` then throws this
108
+ * (twilight/src/api/cart.js:192-199). It is a STATEMENT ABOUT THE CART, not a failure to
109
+ * reach the server — no cart means no items — so it is the one rejection that resolves to
110
+ * a hard 0 instead of "unknown".
111
+ *
112
+ * Matched on `name`, never on message text: the wording is not a contract, the name is.
113
+ */
114
+ const CART_NOT_CREATED = 'CartNotCreated';
115
+ function isCartNotCreated(error) {
116
+ return error?.name === CART_NOT_CREATED;
117
+ }
118
+ /**
119
+ * The cart id as the SDK knows it right now, synchronously, or `null` when there is none.
120
+ * Same sources `salla.api.cart.getStoredCartId()` reads (the in-memory latest cart, then
121
+ * storage), with a storage fallback for an SDK that predates it.
122
+ */
123
+ function readCartIdentity() {
124
+ try {
125
+ const id = salla.api?.cart?.getStoredCartId
126
+ ? salla.api.cart.getStoredCartId()
127
+ : salla.storage?.get?.('cart.id');
128
+ return id === undefined || id === null || id === '' ? null : String(id);
129
+ }
130
+ catch (_) {
131
+ return null;
132
+ }
133
+ }
134
+ /**
135
+ * Drops everything cached for a previous cart when the cart id has changed.
136
+ *
137
+ * The caches are otherwise only invalidated by `refreshCartFreeGifts()`, i.e. by
138
+ * `cart::updated`. A silent cross-store login writes the merged cart id directly and
139
+ * emits nothing, so without this a mounted banner would keep serving the previous
140
+ * cart's line count and gifts. Bumping `generation` also stops a request that is still
141
+ * in flight for the old cart from publishing, and the in-flight slot is released so the
142
+ * caller asks again under the new identity.
143
+ */
144
+ function syncCartIdentity() {
145
+ const current = readCartIdentity();
146
+ if (cacheIdentity !== undefined && cacheIdentity !== current) {
147
+ generation++;
148
+ itemsCache = null;
149
+ countCache = null;
150
+ isAuthoritative = false;
151
+ pendingRequest = null;
152
+ }
153
+ cacheIdentity = current;
154
+ }
155
+ function toNumber(v) {
156
+ if (v === null || v === undefined)
157
+ return 0;
158
+ if (typeof v === 'number')
159
+ return isFinite(v) ? v : 0;
160
+ if (typeof v === 'string')
161
+ return parseFloat(v) || 0;
162
+ return 0;
163
+ }
164
+ function toNullableNumber(v) {
165
+ if (v === null || v === undefined || v === '')
166
+ return null;
167
+ const n = toNumber(v);
168
+ return isFinite(n) ? n : null;
169
+ }
170
+ /**
171
+ * `details()`'s envelope nests items under `data.cart.items`.
172
+ *
173
+ * Returns `null` — NOT `[]` — when the response carried no items array at all. The two
174
+ * are different facts: `[]` is the server saying "this cart is empty", `null` is "this
175
+ * response cannot answer the question". `getCartItemCount()` is built on that
176
+ * distinction; see `extractCartCount`.
177
+ */
178
+ function findItemsArray(res) {
179
+ const items = res?.data?.cart?.items ?? res?.data?.items ?? res?.cart?.items ?? res?.items;
180
+ return Array.isArray(items) ? items : null;
181
+ }
182
+ /**
183
+ * How many LINES the cart holds, or `null` when the response could not say.
184
+ *
185
+ * `null` is load-bearing and must never be collapsed into `0`: a consumer that hides
186
+ * itself on an empty cart has to be able to tell "the cart is empty" apart from "the
187
+ * cart has not loaded yet", or it flickers (or vanishes) on a populated cart while the
188
+ * first `details()` request is still in flight.
189
+ *
190
+ * The items array is the PRIMARY signal because `api.cart.show` is in
191
+ * `CartPresenter::$routesWithItems` and therefore always emits it. `cart.count` is only
192
+ * consulted when the array is missing entirely, and it is a QUANTITY total rather than a
193
+ * line count — close enough for the only question anyone asks of it (is this zero?), and
194
+ * never reached on a well-formed response.
195
+ */
196
+ function extractCartCount(res, items) {
197
+ if (items !== null)
198
+ return items.length;
199
+ const raw = res?.data?.cart?.count ?? res?.data?.count ?? res?.cart?.count ?? res?.count;
200
+ const count = toNullableNumber(raw);
201
+ return count !== null && count >= 0 ? count : null;
202
+ }
203
+ /**
204
+ * The price a gift line would have cost.
205
+ *
206
+ * Tries the keys in the order `CartItemTransformer` fills them, and accepts both the
207
+ * `{amount, currency}` shape a serialized `Money` produces and a bare number/string.
208
+ * A zero is treated as "nothing usable": the gift's own charged price IS zero, so a 0
209
+ * here means we read the wrong key, and rendering a struck-through "0" would be worse
210
+ * than rendering no price at all.
211
+ */
212
+ function extractOriginalPrice(item) {
213
+ for (const raw of [item?.original_price, item?.product_price, item?.price]) {
214
+ const value = raw && typeof raw === 'object' ? raw.amount : raw;
215
+ const amount = toNullableNumber(value);
216
+ if (amount !== null && amount > 0)
217
+ return amount;
218
+ }
219
+ return null;
220
+ }
221
+ /**
222
+ * How many units of this line the offer granted, or `0` when the answer is "none".
223
+ *
224
+ * The two absent-ish shapes are NOT the same fact and must not collapse:
225
+ *
226
+ * • the key is MISSING (`undefined`/`null`/`''`) — an older payload, or a
227
+ * transformer that did not fill it. Nothing contradicts "the whole line is
228
+ * the gift" for an unknown line quantity, so fall back to the line.
229
+ * • the key is PRESENT and `0` — the server stating there are no free units on
230
+ * this line. That is an answer, and it must be believed.
231
+ */
232
+ function resolveFreeQuantity(item, lineQuantity) {
233
+ const raw = item?.free_gift_quantity;
234
+ const stated = raw !== undefined && raw !== null && raw !== '';
235
+ if (stated)
236
+ return toNumber(raw);
237
+ return lineQuantity > 0 ? lineQuantity : 0;
238
+ }
239
+ /**
240
+ * The gift LINES, in cart order. Only lines the server flagged `added_by_offer` with at
241
+ * least one granted free unit qualify.
242
+ */
243
+ function buildGiftItems(items) {
244
+ const gifts = [];
245
+ for (const item of items) {
246
+ if (item?.id === undefined || item?.id === null)
247
+ continue;
248
+ if (!item.added_by_offer)
249
+ continue;
250
+ const lineQuantity = toNumber(item.quantity);
251
+ const freeQuantity = resolveFreeQuantity(item, lineQuantity);
252
+ // No free units ⇒ not a gift LINE, whatever the flag says. Listing it anyway
253
+ // would put a line the shopper PAID for in full into the gifts card, labelled
254
+ // "free" — the card states what the offer granted, so a non-positive free count
255
+ // means it granted nothing here.
256
+ if (freeQuantity <= 0)
257
+ continue;
258
+ gifts.push({
259
+ id: String(item.id),
260
+ offerId: toNullableNumber(item.offer_id),
261
+ quantity: freeQuantity,
262
+ freeQuantity,
263
+ lineQuantity,
264
+ name: String(item.product_name ?? item.name ?? ''),
265
+ imageUrl: String(item.product_image?.url ?? item.product_image ?? item.image?.url ?? ''),
266
+ imageAlt: String(item.product_image?.alt ?? item.image?.alt ?? item.product_name ?? ''),
267
+ url: String(item.url ?? ''),
268
+ originalPrice: extractOriginalPrice(item),
269
+ });
270
+ }
271
+ return gifts;
272
+ }
273
+ function fetchAndCache() {
274
+ if (pendingRequest)
275
+ return pendingRequest;
276
+ // Snapshot the generation this request started under — see the
277
+ // `generation` doc comment above for why.
278
+ const startedAtGeneration = generation;
279
+ const request = Promise.resolve()
280
+ .then(() => salla.api?.cart?.details?.())
281
+ .then((res) => {
282
+ const found = findItemsArray(res);
283
+ const items = found ?? [];
284
+ // Both projections come out of the SAME response, in one pass.
285
+ return {
286
+ gifts: buildGiftItems(items),
287
+ count: extractCartCount(res, found),
288
+ // The response was reached and parsed — these values are the cart, so the cache
289
+ // they land in may be short-circuited on. See `isAuthoritative`.
290
+ ok: true,
291
+ };
292
+ })
293
+ // A failed fetch shouldn't throw into a badge/lock render path — fail
294
+ // closed to "no gift info" (or whatever we already knew) rather than
295
+ // breaking the rest of the cart UI.
296
+ //
297
+ // The count splits the failures in two. `CartNotCreated` is the SDK
298
+ // stating there is no cart, which is a definitive EMPTY and resolves to
299
+ // 0 — without it, the ordinary first-time guest sits on "unknown"
300
+ // forever, because no request will ever succeed and no `cart::updated`
301
+ // is coming to correct it. Everything else (network, 5xx) says nothing
302
+ // about the contents and still fails OPEN to `null`, so a flaky
303
+ // connection can never claim the cart is empty.
304
+ .catch((error) => ({
305
+ gifts: itemsCache ?? [],
306
+ count: isCartNotCreated(error) ? 0 : countCache,
307
+ /**
308
+ * NOT authoritative, `CartNotCreated` included.
309
+ *
310
+ * `0` stays the right ANSWER for it — no cart means no items, and a first-time
311
+ * guest must be told "empty" rather than sitting on "unknown" — but it is no longer
312
+ * a FINAL one. The very first cart lookup on a page is fired before
313
+ * `salla.onReady()` has settled, so it can be answered by an SDK that has not yet
314
+ * installed the cart id (or, earlier still, not yet installed `salla.config`, whose
315
+ * `isGuest()` the id resolution calls unguarded). Both spell a cart that is merely
316
+ * unknown as one that is empty. Leaving the cache cold lets the next lookup — from
317
+ * a bootstrapped SDK — say so, and on a cart that genuinely is empty the retry
318
+ * rejects again without a request.
319
+ */
320
+ ok: false,
321
+ }))
322
+ .then(({ gifts, count, ok }) => {
323
+ // Only publish if nothing invalidated us while we were in flight.
324
+ // Otherwise this is pre-mutation data racing a newer, authoritative
325
+ // request — publishing it would make the lookups short-circuit on stale
326
+ // data until the NEXT mutation happens to come along.
327
+ if (startedAtGeneration === generation) {
328
+ itemsCache = gifts;
329
+ countCache = count;
330
+ isAuthoritative = ok;
331
+ }
332
+ })
333
+ .finally(() => {
334
+ // Only release OUR slot: an identity change may already have replaced it.
335
+ if (pendingRequest === request)
336
+ pendingRequest = null;
337
+ });
338
+ pendingRequest = request;
339
+ return request;
340
+ }
341
+ /**
342
+ * Every free-gift LINE currently in the cart, in cart order.
343
+ *
344
+ * Shares the cache, the in-flight promise and the generation guard with
345
+ * `getCartItemCount()` — the banner asking for both still issues exactly ONE
346
+ * `details()` request per cart mutation.
347
+ */
348
+ function getCartFreeGiftItems() {
349
+ syncCartIdentity();
350
+ // `isAuthoritative`, not `itemsCache` alone — a failed lookup leaves `itemsCache` a
351
+ // truthy `[]`, which is not an answer. See `isAuthoritative`.
352
+ if (isAuthoritative && itemsCache) {
353
+ return Promise.resolve(itemsCache);
354
+ }
355
+ return fetchAndCache().then(() => itemsCache ?? EMPTY_GIFT_ITEMS);
356
+ }
357
+ /**
358
+ * Whether the cached cart picture came from a response that was actually reached and
359
+ * parsed, rather than from the fallback a rejected lookup publishes.
360
+ *
361
+ * For the ONE consumer that has to tell "the cart is empty" apart from "the lookup could
362
+ * not say, and guessed empty": `salla-conditional-offer` stops before it even asks for the
363
+ * offer when the count is `0`, which is unrecoverable if that `0` was a guess. Everything
364
+ * else should just read the values — they are already the best available answer.
365
+ *
366
+ * Synchronous and side-effect free: it reports on what is cached NOW, so a caller that
367
+ * wants a fresh answer must still await one of the lookups above.
368
+ */
369
+ function hasAuthoritativeCartData() {
370
+ syncCartIdentity();
371
+ return isAuthoritative;
372
+ }
373
+ /**
374
+ * How many LINES the cart holds — `null` while that is genuinely UNKNOWN (nothing has
375
+ * loaded yet, or the lookup failed), never `0` for either.
376
+ *
377
+ * The distinction is the whole point: `salla-conditional-offer` hides its banner on an
378
+ * empty cart, and a `0` that really meant "still loading" would make the banner flicker
379
+ * or disappear on a populated one. Shares the cache, the in-flight promise and the
380
+ * generation guard with `getCartFreeGiftItems()`, so asking for the count costs no
381
+ * request of its own.
382
+ */
383
+ function getCartItemCount() {
384
+ syncCartIdentity();
385
+ // THE ONE THAT HID THE BANNER. `itemsCache` alone read a failed lookup's `[]` as a warm
386
+ // cache and handed back its `null`/`0` count for ever — see `isAuthoritative`.
387
+ if (isAuthoritative && itemsCache) {
388
+ return Promise.resolve(countCache);
389
+ }
390
+ return fetchAndCache().then(() => countCache);
391
+ }
392
+ /**
393
+ * Invalidates the cache and re-fetches — call this from a `cart::updated`
394
+ * handler, ignoring the event payload entirely.
395
+ *
396
+ * Bumps `generation` first so that any request already in flight (started
397
+ * before this invalidation) can no longer publish into `cache` when it
398
+ * settles — mirrors `salla.event.on('cart::updated', () => { Salla.api.cart._pendingOffersRequest = null; })`
399
+ * in `twilight/packages/twilight/src/api/cart.js:81`, which abandons its
400
+ * own in-flight promise on cart update for the same reason.
401
+ *
402
+ * Then chains a fresh fetch BEHIND whatever was already in flight (rather
403
+ * than firing a second request in parallel): once that prior request
404
+ * settles — its result now guaranteed to be discarded by the generation
405
+ * check above — `fetchAndCache()` starts a request under the new
406
+ * generation, which is free to publish.
407
+ *
408
+ * `pendingRefresh` de-dupes concurrent callers of THIS function the same
409
+ * way `pendingRequest` de-dupes concurrent fetches: N components all
410
+ * invalidating within the same synchronous `cart::updated` dispatch share
411
+ * one refresh chain and therefore, ultimately, one fresh HTTP request.
412
+ *
413
+ * That sharing window is deliberately NARROW — it closes the instant the
414
+ * fresh request goes out, NOT when it comes back. Holding it open for the
415
+ * whole round trip silently coalesced two DIFFERENT cart mutations into one
416
+ * refresh: the second `cart::updated` got handed the first one's promise, so
417
+ * it never bumped `generation` and never issued a request, and the response
418
+ * that eventually published had been asked for BEFORE the second mutation
419
+ * happened. The cart then sat on pre-mutation gift data — a merged line
420
+ * showing yesterday's paid quantity beside yesterday's corrected total —
421
+ * until some later mutation happened to come along. A shopper clicking "+"
422
+ * twice inside one round trip is all it took.
423
+ */
424
+ function refreshCartFreeGifts() {
425
+ if (pendingRefresh)
426
+ return pendingRefresh;
427
+ generation++;
428
+ itemsCache = null;
429
+ countCache = null;
430
+ isAuthoritative = false;
431
+ // This invalidation already covers whatever id the cart has now.
432
+ cacheIdentity = readCartIdentity();
433
+ const priorRequest = pendingRequest ?? Promise.resolve();
434
+ const startFreshRequest = () => {
435
+ // Sharing ends here: everything that invalidated up to this point is
436
+ // described by the request about to go out, and everything after it is not.
437
+ if (pendingRefresh === refresh)
438
+ pendingRefresh = null;
439
+ return fetchAndCache();
440
+ };
441
+ // `.then(f, f)` — a prior request that REJECTED still has to be waited out
442
+ // before the replacement is issued, or the two overlap.
443
+ const refresh = priorRequest
444
+ .then(startFreshRequest, startFreshRequest)
445
+ .finally(() => {
446
+ // Only ever clear OUR slot. A later refresh may already own it (it can
447
+ // only have taken it after `startFreshRequest` released it), and nulling
448
+ // that one would un-dedupe its same-tick callers.
449
+ if (pendingRefresh === refresh)
450
+ pendingRefresh = null;
451
+ });
452
+ pendingRefresh = refresh;
453
+ return refresh;
454
+ }
455
+
456
+ /**
457
+ * TRAILING window over which a BURST of `cart::updated` events collapses into ONE refresh
458
+ * pass in `salla-conditional-offer`.
459
+ *
460
+ * `cart::updated` is not one-event-per-shopper-action. `CartEvents.updated()` is called
461
+ * from SIX call sites (twilight/src/event/cart.js:122-174) — `latestFetched`, `itemAdded`,
462
+ * `itemDeleted`, `itemUpdated`, `couponAdded`, `couponDeleted` — and a single mutation
463
+ * routinely produces more than one of them, because the mutation's own response and the
464
+ * summary refetch that follows it both land here. Each one used to cost the banner TWO
465
+ * server round trips: `offers()` from `refresh()`, and `details()` from the
466
+ * `refreshCartFreeGifts()` + `loadCart()` pair. That is now paid ONCE per burst.
467
+ *
468
+ * TRAILING, not leading: the LAST event of a burst is the one that describes the cart the
469
+ * shopper is actually looking at, so the pass has to run after it, not before it. A
470
+ * leading-edge debounce would answer from the FIRST event and need a second pass anyway.
471
+ *
472
+ * What is deliberately NOT deferred is `readCartCountFromEvent`, which stays synchronous on
473
+ * every single event — it is what hides the banner the instant the cart empties, and
474
+ * `render()` gates on `hasCartItems()` alone, so the hidden-by-default behaviour is
475
+ * unaffected by this window. Only the REQUESTS wait.
476
+ *
477
+ * 200ms is comfortably below the ~500ms `Helper.debounce` the quantity stepper already
478
+ * applies upstream (Helpers/Helper.ts:45-51), so this cannot swallow two deliberate,
479
+ * separate shopper actions into one pass — it only collapses the machine-generated repeats
480
+ * of a single one.
481
+ *
482
+ * It lives in its own module rather than beside the component's other timing constants
483
+ * because Stencil allows a `@Component()` module exactly one export — the class — and the
484
+ * spec has to assert against the REAL number rather than a copy of it.
485
+ */
486
+ const CART_UPDATE_COALESCE_MS = 200;
487
+
488
+ /**
489
+ * Multi-stage promotion progress.
490
+ *
491
+ * Pure, component-agnostic helpers for turning a shopper's current cart value
492
+ * plus a list of stage thresholds into "which stage is Locked / Current /
493
+ * Completed, and how full is each segment".
494
+ *
495
+ * Deliberately NOT shared with `salla-tiered-offer`'s `TierUtils`:
496
+ * - `TierUtils.findCurrentTierIndex` is 1-based with `0` overloaded to mean
497
+ * "no tier reached", which cannot express "stage 0 is reached".
498
+ * - It takes `TieredOfferTier[]`, a shape a conditional-offer `Discount` does
499
+ * not share a single field with.
500
+ * - It assumes its caller already sorted; the conditional-offer payload is
501
+ * emitted UNSORTED by the backend.
502
+ *
503
+ * So this module takes plain numbers, is 0-based, uses an explicit
504
+ * `NO_STAGE_REACHED` sentinel, and sorts defensively on every call.
505
+ *
506
+ * ONE deliberate exception to "pure": `getCurrencyDecimals()` reads the store's money
507
+ * precision off `salla.config` so that `getRemainingToStage()` can round AT THE SOURCE
508
+ * and every consumer — including the `products_count` path that never touches
509
+ * `salla.money()` — benefits without each of them remembering to. It is fully guarded and
510
+ * falls back to `DEFAULT_CURRENCY_DECIMALS`, so a missing SDK degrades rather than throws;
511
+ * callers that want it hermetic can pass the measure and stub `salla.config`.
512
+ *
513
+ * ---------------------------------------------------------------------------
514
+ * WHO DECIDES A TIER IS UNLOCKED
515
+ * ---------------------------------------------------------------------------
516
+ * The ENGINE does, and it now says so: every `details.discounts[]` entry carries
517
+ * `is_applied`. Threshold arithmetic here is a PROJECTION ("how far to go"), never a
518
+ * CLAIM about what the engine granted, because the engine's formula
519
+ *
520
+ * ($subtotal - $alreadyAppliedDiscount) - $excludedItemsAmount >= min_spend
521
+ *
522
+ * uses two terms that are not in the payload at all. On beta a gift tier at `min_spend`
523
+ * 300 with `current_value` 300 rendered as unlocked while the engine had refused it (the
524
+ * gift's own price is in that tier's excluded set), so no arithmetic over shipped keys
525
+ * could ever have agreed with it.
526
+ *
527
+ * BUT `is_applied` IS NOT "THE SHOPPER IS PAST THIS TIER". It answers a narrower
528
+ * question, and reading it as the broader one is what produced the second beta report.
529
+ * `CartOfferDiscount::apply()` walks the tiers, keeps the LAST one whose conditions pass
530
+ * and calls `discountItem()` ONCE after the loop, so a cart offer grants EXACTLY ONE
531
+ * reward — the best qualifying tier. Every earlier tier is therefore `is_applied: false`
532
+ * even for a shopper who sailed past it: its reward is SUPERSEDED, not out of reach. A
533
+ * cart at `current_value` 510 came back as `false / false / true` and the badge row read
534
+ * LOCKED / LOCKED / CHECKED, which is unreadable as a progress stepper.
535
+ *
536
+ * So there are TWO questions and this module answers both, separately:
537
+ * - "which tier gave the reward" — the single winner, `appliedIndex`.
538
+ * - "is the shopper past this tier" — cumulative, `met`, which is what the ✓, the
539
+ * strikethrough, the lock and the "N of M" count render from.
540
+ *
541
+ * The passed tiers carry a ✓ AND a strikethrough while the tier ahead carries a lock.
542
+ * Struck-through means superseded, so a ✓ means THRESHOLD PASSED, not REWARD GRANTED.
543
+ *
544
+ * Hence the split, and it is load-bearing:
545
+ * - `met` / `metCount` / `nextUnmetIndex` / `allMet` are the ENGINE-DERIVED verdict.
546
+ * Render from these.
547
+ * - `appliedIndex` names the one tier that actually paid out. Diagnostics only so far.
548
+ * - `lastReachedIndex` / `currentIndex` / `isComplete` / `fills` / `overallPercentage`
549
+ * are pure threshold projections. Use them for distance and bar geometry only.
550
+ *
551
+ * `is_applied` is TRI-STATE, because twilight and the Store package deploy independently:
552
+ * absent means "this backend does not report it yet" and falls back to the arithmetic,
553
+ * while `false` is a real verdict and outranks the arithmetic. See `readAppliedFlag`.
554
+ *
555
+ * `salla-tiered-offer` keeps its own copy of the defensive ascending sort and the
556
+ * completed/total ratio; that duplication is deliberate and not migrated here.
557
+ */
558
+ /** Explicit sentinel: no stage has been reached yet (or there is no next stage). */
559
+ const NO_STAGE_REACHED = -1;
560
+ var OfferStageState;
561
+ (function (OfferStageState) {
562
+ OfferStageState["LOCKED"] = "locked";
563
+ OfferStageState["CURRENT"] = "current";
564
+ OfferStageState["COMPLETED"] = "completed";
565
+ })(OfferStageState || (OfferStageState = {}));
566
+ function parseNumeric(value) {
567
+ const parsed = typeof value === 'number' ? value : parseFloat(value);
568
+ return Number.isFinite(parsed) ? parsed : null;
569
+ }
570
+ /**
571
+ * Read one tier's `is_applied` as a tri-state.
572
+ *
573
+ * `true`/`false` booleans are what `CartOffersTransformer` casts and emits. The numeric
574
+ * and string spellings are accepted too because the `s-source: mobile` pipeline already
575
+ * stringifies numeric fields in this very payload (`end_value`), and reading a `0` there
576
+ * as "absent" would silently restore the bug this exists to fix.
577
+ *
578
+ * EVERYTHING ELSE — key missing, `undefined`, `null`, `''`, an object — is ABSENT, not
579
+ * `false`: a value we cannot interpret is not the engine saying no, and must degrade to
580
+ * the arithmetic fallback rather than lock a tier the shopper has genuinely earned.
581
+ */
582
+ function readAppliedFlag(value) {
583
+ if (typeof value === 'boolean')
584
+ return value;
585
+ if (value === 1 || value === 0)
586
+ return value === 1;
587
+ if (value === 'true' || value === '1')
588
+ return true;
589
+ if (value === 'false' || value === '0')
590
+ return false;
591
+ return undefined;
592
+ }
593
+ /**
594
+ * Coerce a cart VALUE. Falling back to 0 is correct here: an unreadable
595
+ * `current_value`/`start_value` means "the shopper has made no progress".
596
+ */
597
+ function toFiniteNumber(value) {
598
+ return parseNumeric(value) ?? 0;
599
+ }
600
+ /**
601
+ * Whether a THRESHOLD is usable.
602
+ *
603
+ * Thresholds get the opposite policy to values: an unparseable threshold is DROPPED,
604
+ * never coerced to 0, because a 0 threshold is satisfied by an empty cart and would
605
+ * render as an already-COMPLETED stage — reintroducing exactly the synthetic-zeroth-
606
+ * checkpoint bug this component was rewritten to remove.
607
+ *
608
+ * Callers that keep their own parallel array of stage objects MUST filter it with this
609
+ * predicate so their index alignment with `progress.thresholds` survives the drop.
610
+ */
611
+ function isUsableThreshold(value) {
612
+ return parseNumeric(value) !== null;
613
+ }
614
+ function clamp(value, min, max) {
615
+ return Math.max(min, Math.min(value, max));
616
+ }
617
+ /**
618
+ * Fill of one segment as a 0..100 percentage.
619
+ * A zero-width (or inverted) segment — which duplicate thresholds produce — has
620
+ * no gradient to show, so it is all-or-nothing rather than a division by zero.
621
+ */
622
+ function segmentFill(value, from, to) {
623
+ if (to <= from) {
624
+ return value >= to ? 100 : 0;
625
+ }
626
+ return clamp(((value - from) / (to - from)) * 100, 0, 100);
627
+ }
628
+ /**
629
+ * Stable ascending sort of arbitrary items by a numeric threshold accessor.
630
+ *
631
+ * Ties are broken by `getIdentity` when supplied and readable on BOTH sides — the tier
632
+ * `id` (0 = base tier, 1..N = configured options in the order the engine walks them), so
633
+ * two tiers sharing a `min_spend` order by what they ARE rather than by where they
634
+ * happened to land in an unsorted payload. Without it (or when either id is unreadable)
635
+ * ties keep their original relative order, which is the previous behaviour exactly.
636
+ */
637
+ function sortByThreshold(items, getThreshold, getIdentity) {
638
+ return (items ?? [])
639
+ .map((item, index) => ({
640
+ item,
641
+ index,
642
+ threshold: toFiniteNumber(getThreshold(item)),
643
+ identity: getIdentity ? parseNumeric(getIdentity(item)) : null,
644
+ }))
645
+ .sort((a, b) => {
646
+ if (a.threshold !== b.threshold)
647
+ return a.threshold - b.threshold;
648
+ if (a.identity !== null && b.identity !== null && a.identity !== b.identity) {
649
+ return a.identity - b.identity;
650
+ }
651
+ return a.index - b.index;
652
+ })
653
+ .map(({ item }) => item);
654
+ }
655
+ /**
656
+ * Derive the full stage picture from the server's `current_value`.
657
+ *
658
+ * @param currentValue the server-computed cart value — never recomputed client-side.
659
+ * @param rawThresholds each stage's `min_spend`, in any order.
660
+ * @param rawStartValue the baseline the first segment fills from (`details.start_value`).
661
+ * @param rawApplied each stage's `is_applied`, POSITIONALLY ALIGNED WITH `rawThresholds`
662
+ * before any sorting or dropping happens here. Omit it and every tier reads as
663
+ * absent, i.e. the threshold behaviour, unchanged.
664
+ * @param rawTierValues each stage's OWN `current_value`, aligned the same way. A tier
665
+ * without a readable one is measured by `currentValue`, so omitting it — an older
666
+ * backend — changes nothing. See `OfferStageProgress.values`.
667
+ */
668
+ function computeOfferStageProgress(currentValue, rawThresholds, rawStartValue = 0, rawApplied = [], rawTierValues = []) {
669
+ // Unparseable thresholds are dropped, not zeroed — see `isUsableThreshold`.
670
+ //
671
+ // The flag travels WITH its threshold through both the drop and the sort. Sorting two
672
+ // parallel arrays separately is how an `is_applied` ends up on the wrong tier, and a
673
+ // misattributed verdict is worse than no verdict at all. The tier's own value rides
674
+ // along for the same reason.
675
+ const entries = (rawThresholds ?? [])
676
+ .map((threshold, index) => ({
677
+ index,
678
+ threshold: parseNumeric(threshold),
679
+ applied: readAppliedFlag(rawApplied?.[index]),
680
+ tierValue: parseNumeric(rawTierValues?.[index]),
681
+ }))
682
+ .filter((entry) => entry.threshold !== null)
683
+ .sort((a, b) => a.threshold - b.threshold || a.index - b.index);
684
+ const thresholds = entries.map(entry => entry.threshold);
685
+ const applied = entries.map(entry => entry.applied);
686
+ const hasEngineVerdict = applied.some(flag => flag !== undefined);
687
+ const startValue = toFiniteNumber(rawStartValue);
688
+ if (!thresholds.length) {
689
+ return {
690
+ thresholds,
691
+ met: [],
692
+ applied,
693
+ appliedIndex: NO_STAGE_REACHED,
694
+ hasEngineVerdict,
695
+ metCount: 0,
696
+ nextUnmetIndex: NO_STAGE_REACHED,
697
+ allMet: false,
698
+ lastReachedIndex: NO_STAGE_REACHED,
699
+ currentIndex: NO_STAGE_REACHED,
700
+ isComplete: false,
701
+ value: Math.max(0, toFiniteNumber(currentValue)),
702
+ values: [],
703
+ startValue,
704
+ fills: [],
705
+ overallPercentage: 0,
706
+ };
707
+ }
708
+ const finalThreshold = thresholds[thresholds.length - 1];
709
+ // THE CLAMP: a stale or overshooting `current_value` must never produce a
710
+ // >100% bar or an out-of-range stage index.
711
+ const value = clamp(toFiniteNumber(currentValue), 0, Math.max(0, finalThreshold));
712
+ // Each tier against ITS OWN progress where the backend sent one — see `values`. A tier
713
+ // value that is missing or unreadable is not "no progress": it is "not reported", and
714
+ // falls back to the offer-level value rather than to 0.
715
+ const values = entries.map(({ tierValue }) => tierValue === null ? value : clamp(tierValue, 0, Math.max(0, finalThreshold)));
716
+ let lastReachedIndex = NO_STAGE_REACHED;
717
+ for (let index = 0; index < thresholds.length; index++) {
718
+ if (values[index] >= thresholds[index]) {
719
+ lastReachedIndex = index;
720
+ }
721
+ }
722
+ const isComplete = lastReachedIndex === thresholds.length - 1;
723
+ const currentIndex = isComplete ? NO_STAGE_REACHED : lastReachedIndex + 1;
724
+ const fills = thresholds.map((threshold, index) => segmentFill(values[index], index === 0 ? startValue : thresholds[index - 1], threshold));
725
+ const overallPercentage = isComplete
726
+ ? 100
727
+ : clamp(((lastReachedIndex + 1 + (fills[currentIndex] ?? 0) / 100) / thresholds.length) * 100, 0, 100);
728
+ // ---------------------------------------------------------------------------------
729
+ // THE DECISION — three cases, in this order.
730
+ //
731
+ // (1) THE ENGINE GRANTED A TIER. `appliedIndex` is the HIGHEST tier flagged `true`, and
732
+ // every tier up to and including it is met. A cart offer pays out ONCE, at the best
733
+ // qualifying tier, so the earlier tiers are `false` because they were SUPERSEDED —
734
+ // the shopper is past them. Closing the prefix is what stops a lock rendering below
735
+ // a ✓ (the beta screenshot: `false / false / true` at `current_value` 510).
736
+ //
737
+ // HIGHEST, not first, and read AFTER the ascending sort: the payload arrives
738
+ // unsorted, so "the last tier the engine kept" is only "the furthest tier reached"
739
+ // once the rows are in threshold order. A NON-CONTIGUOUS set — `[true, false, true]`
740
+ // — should be impossible for a single-winner engine, but if one ever arrives the
741
+ // highest wins and the whole prefix reads met. That is deliberate: the alternative
742
+ // is the un-steppable row this fix exists to remove, and a superset of ✓ merely
743
+ // over-credits a tier the shopper has demonstrably paid past.
744
+ //
745
+ // (2) THE ENGINE SPOKE AND GRANTED NOTHING. Everything is locked, whatever the
746
+ // thresholds say. This is the OTHER beta cart — `current_value` 510, zero discount,
747
+ // every tier `false` — and it is the case `is_applied` was introduced for. The
748
+ // arithmetic MUST NOT be consulted here or the bug comes straight back.
749
+ //
750
+ // (3) THIS BACKEND REPORTS NOTHING (`hasEngineVerdict` false — no tier carried a
751
+ // readable flag). Only then does the cumulative threshold comparison run, exactly
752
+ // as before `is_applied` shipped. Twilight and the Store package deploy
753
+ // independently, so this path is live, not theoretical.
754
+ //
755
+ // Cases (1) and (2) are split on the WINNER, not on each tier's own flag, so `??` vs
756
+ // `||` no longer decides a single row on its own. The tri-state still decides which
757
+ // CASE we are in: an unreadable flag is absent (case 3), an explicit `false` is an
758
+ // answer that keeps us in cases 1-2. A mixed payload — some tiers flagged, some not,
759
+ // which no current backend emits — is treated as having spoken: only a `true` can name
760
+ // the winner, so an unflagged tier can never be one.
761
+ // ---------------------------------------------------------------------------------
762
+ let appliedIndex = NO_STAGE_REACHED;
763
+ for (let index = 0; index < applied.length; index++) {
764
+ if (applied[index] === true) {
765
+ appliedIndex = index;
766
+ }
767
+ }
768
+ // Case (3) closes the prefix at `lastReachedIndex` rather than comparing tier by tier.
769
+ // With one value for every tier the two are the same statement; with per-tier values
770
+ // it is what keeps `met` a prefix, on the same single-winner reasoning as case (1).
771
+ const met = hasEngineVerdict
772
+ ? thresholds.map((_, index) => index <= appliedIndex)
773
+ : thresholds.map((_, index) => index <= lastReachedIndex);
774
+ const metCount = met.filter(Boolean).length;
775
+ const nextUnmetIndex = met.findIndex(isMet => !isMet); // -1 === NO_STAGE_REACHED
776
+ const allMet = nextUnmetIndex === NO_STAGE_REACHED;
777
+ return {
778
+ thresholds,
779
+ met,
780
+ applied,
781
+ appliedIndex,
782
+ hasEngineVerdict,
783
+ metCount,
784
+ nextUnmetIndex,
785
+ allMet,
786
+ lastReachedIndex,
787
+ currentIndex,
788
+ isComplete,
789
+ value,
790
+ values,
791
+ startValue,
792
+ fills,
793
+ overallPercentage,
794
+ };
795
+ }
796
+ /**
797
+ * How the offer MEASURES its thresholds, which is also what its remainder rounds to.
798
+ *
799
+ * Mirrors `OfferType` in `salla-conditional-offer/interfaces.ts` without importing it —
800
+ * this module stays free of the component's payload types (see the header) — so a caller
801
+ * maps `details.based_on` onto it at the call site.
802
+ */
803
+ var OfferStageMeasure;
804
+ (function (OfferStageMeasure) {
805
+ /** `order_amount` — a money value, rounded to the store's currency precision. */
806
+ OfferStageMeasure["CURRENCY"] = "currency";
807
+ /** `products_count` — whole products, so the remainder is a whole number. */
808
+ OfferStageMeasure["COUNT"] = "count";
809
+ })(OfferStageMeasure || (OfferStageMeasure = {}));
810
+ /**
811
+ * The precision to fall back to when the store publishes none.
812
+ *
813
+ * 2 is the platform default (SAR/USD/AED); a store on a 3-decimal currency (KWD, BHD)
814
+ * that reports its precision gets 3, and only a store that reports NOTHING readable
815
+ * lands here.
816
+ */
817
+ const DEFAULT_CURRENCY_DECIMALS = 2;
818
+ /**
819
+ * Hard ceiling on the precision a store can ask for.
820
+ *
821
+ * The value comes off `salla.config`, i.e. off the wire, and `Math.pow(10, n)` with a
822
+ * large `n` reintroduces the very float noise this rounding exists to remove (and with
823
+ * an absurd `n`, an `Infinity` factor and a `NaN` remainder). 4 matches the cap
824
+ * `salla-cart-summary-card.roundToCurrency()` already applies to the same problem.
825
+ */
826
+ const MAX_CURRENCY_DECIMALS = 4;
827
+ /**
828
+ * Read one candidate precision, or `null` when it is not a usable one.
829
+ *
830
+ * Strict on purpose. `salla.config.get()` returns THE KEY ITSELF for a key the store did
831
+ * not publish (`config.ts:50`), so an unpublished `store.currency.decimals` arrives as
832
+ * the string `"store.currency.decimals"` — truthy, non-null, and fatal to a `?? 2`
833
+ * fallback, which is why this parses and range-checks rather than nullish-coalescing.
834
+ */
835
+ function readDecimals(raw) {
836
+ const parsed = typeof raw === 'number' ? raw : parseFloat(raw);
837
+ if (!Number.isFinite(parsed))
838
+ return null;
839
+ const decimals = Math.trunc(parsed);
840
+ return decimals >= 0 && decimals <= MAX_CURRENCY_DECIMALS ? decimals : null;
841
+ }
842
+ /**
843
+ * The store's money precision — THE ONE IMPURITY IN THIS MODULE (see the header note).
844
+ *
845
+ * Asked in the order the platform answers it:
846
+ * 1. `salla.config.currency()` — the ACTIVE currency's own precision, which is the only
847
+ * source that can differ per currency (a store displaying KWD wants 3 while its SAR
848
+ * prices want 2).
849
+ * 2. `salla.config.get('store.currency.decimals')` — the store-level key
850
+ * `salla-bought-together.tsx:142` already rounds its basket total by.
851
+ * 3. `DEFAULT_CURRENCY_DECIMALS`.
852
+ *
853
+ * Every step is guarded: this runs inside `render()`, where an SDK that is not on the
854
+ * page yet (or a `config` that throws) must degrade to the default, never take the banner
855
+ * down.
856
+ */
857
+ function getCurrencyDecimals() {
858
+ try {
859
+ const sdk = typeof salla !== 'undefined' ? salla : undefined;
860
+ const fromCurrency = readDecimals(sdk?.config?.currency?.()?.decimals);
861
+ if (fromCurrency !== null)
862
+ return fromCurrency;
863
+ const fromConfig = readDecimals(sdk?.config?.get?.('store.currency.decimals'));
864
+ if (fromConfig !== null)
865
+ return fromConfig;
866
+ }
867
+ catch (_) {
868
+ // No SDK, or a config that threw — the default is the answer.
869
+ }
870
+ return DEFAULT_CURRENCY_DECIMALS;
871
+ }
872
+ /**
873
+ * Float noise allowed when rounding a remainder UP, in units of the display precision.
874
+ * `300 - 289.6` is `10.399999999999977`, which must still read as exactly 10.40 rather
875
+ * than tip over to 10.41.
876
+ */
877
+ const CEIL_TOLERANCE = 1e-6;
878
+ /**
879
+ * How much more of the cart value is needed to reach the given stage, rounded UP at the
880
+ * display precision. Never negative.
881
+ *
882
+ * `threshold - value` is raw IEEE-754 subtraction over two server-sent floats, so
883
+ * `300 - 289.599993` is `10.400007000000012`. Nothing downstream cleans that up:
884
+ * `salla.money()` only swaps digits and appends a symbol (`base/src/helpers/numbers.ts:74-84`).
885
+ * Rounding here, at the source, covers every consumer, including the `products_count`
886
+ * path that never reaches `salla.money`.
887
+ *
888
+ * Rounding goes UP, never to the nearest unit: the displayed amount must be enough to
889
+ * unlock the tier. Rounding 10.400007 to 10.40 asks for an amount that leaves the cart
890
+ * at 299.999993, still below the threshold. Float noise is guarded (see `CEIL_TOLERANCE`)
891
+ * so an exact 10.40 stays 10.40.
892
+ *
893
+ * A count remainder is a WHOLE number, because `salla.lang.choice('blocks.header.products_count', 10.4)`
894
+ * matches no plural interval and a shopper cannot add 0.4 of a product.
895
+ *
896
+ * Only the derived remainder is rounded. `progress.values` and `progress.thresholds` are
897
+ * server facts and stay as sent: rounding them would move the unlocked/locked boundary,
898
+ * which is the engine's call.
899
+ *
900
+ * A positive remainder never becomes 0: `getGuidanceMessage` drops its line on
901
+ * `remaining <= 0`, so a genuinely unmet tier must still ask for at least one unit of the
902
+ * precision (0.01 for a 2-decimal currency, 1 for a count). Exact zero, a met tier,
903
+ * still returns 0.
904
+ */
905
+ function getRemainingToStage(index, progress, measure = OfferStageMeasure.CURRENCY) {
906
+ const threshold = progress.thresholds[index];
907
+ if (threshold === undefined) {
908
+ return 0;
909
+ }
910
+ // The distance to a tier is measured in THAT tier's own progress — see `values`.
911
+ const raw = Math.max(0, threshold - (progress.values?.[index] ?? progress.value));
912
+ if (raw === 0)
913
+ return 0;
914
+ const decimals = measure === OfferStageMeasure.COUNT ? 0 : getCurrencyDecimals();
915
+ const factor = Math.pow(10, decimals);
916
+ const rounded = Math.ceil(raw * factor - CEIL_TOLERANCE) / factor;
917
+ // Still owed something, but only float noise above zero: ask for one unit.
918
+ return rounded > 0 ? rounded : 1 / factor;
919
+ }
920
+
921
+ const sallaConditionalOfferCss = "/*!twlt*/:host{display:block}.s-conditional-offer-root{background-color:rgba(0, 137, 123, 0.06);background-color:color-mix(in srgb, var(--color-primary, #00897b) 7%, transparent)}.s-conditional-offer-badges::-webkit-scrollbar{height:3px}.s-conditional-offer-badges::-webkit-scrollbar-thumb{border-radius:9999px;background-color:rgba(0, 0, 0, 0.15)}.s-conditional-offer-badges::-webkit-scrollbar-track{background-color:transparent}.s-conditional-offer-tooltip{--s-conditional-offer-tooltip-shift:0px;--s-conditional-offer-tooltip-caret:0px;transform:translateX(calc(-50% + var(--s-conditional-offer-tooltip-shift)))}.s-conditional-offer-tooltip::before{content:\"\";position:absolute;bottom:100%;left:50%;transform:translateX(calc(-50% + var(--s-conditional-offer-tooltip-caret)));border:8px solid transparent;border-bottom-color:#fff}.s-conditional-offer-tier-icon-met,.s-conditional-offer-tier-icon-unmet{color:var(--color-primary-dark, #00695c);background-color:rgba(0, 137, 123, 0.12);background-color:color-mix(in srgb, var(--color-primary, #00897b) 14%, transparent)}.s-conditional-offer-tier:not(:last-child)::before{content:\"\";position:absolute;top:2.5rem;bottom:-1rem;inset-inline-start:1.125rem;width:1px;background-color:#d1d5db}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.block{display:block}.inline{display:inline}.flex{display:flex}.hidden{display:none}.w-6{width:1.5rem}.resize{resize:both}.gap-1{gap:0.25rem}.rounded{border-radius:var(--s-radius, 0.25rem)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.s-conditional-offer-root{margin-bottom:1.25rem;display:block;width:100%;min-width:0px;max-width:100%;border-radius:0.375rem;padding:0.75rem}@media (min-width: 768px){.s-conditional-offer-root{padding:1rem}}.s-conditional-offer-banner{display:flex;width:100%;min-width:0px;align-items:center;justify-content:space-between;gap:0.75rem}@media (min-width: 768px){.s-conditional-offer-banner{gap:1rem}}.s-conditional-offer-lead{display:flex;min-width:0px;flex:1 1 0%;align-items:center;gap:0.5rem}@media (min-width: 768px){.s-conditional-offer-lead{gap:0.75rem}}.s-conditional-offer-badges{display:flex;max-width:45%;flex-shrink:0;flex-wrap:nowrap;align-items:center;gap:0.25rem;overflow-x:auto;overscroll-behavior-x:contain;scroll-behavior:smooth;scrollbar-width:thin}.s-conditional-offer-badge{display:inline-flex;height:1.5rem;width:1.5rem;flex-shrink:0;align-items:center;justify-content:center;border-radius:9999px;font-size:11px;line-height:1;--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.s-conditional-offer-badge-met{background-color:var(--color-primary)}.s-conditional-offer-badge-unmet{--tw-bg-opacity:1;background-color:rgb(156 163 175 / var(--tw-bg-opacity, 1))}.s-conditional-offer-badge-link{width:0.5rem;flex-shrink:0;border-top-width:1px}.s-conditional-offer-badge-link-met{border-color:var(--color-primary)}.s-conditional-offer-badge-link-unmet{border-style:dashed;--tw-border-opacity:1;border-color:rgb(82 82 82 / var(--tw-border-opacity, 1))}.s-conditional-offer-badge-icon{height:0.75rem;width:0.75rem;flex-shrink:0}.s-conditional-offer-text{display:flex;min-width:0px;flex-direction:column;gap:0.125rem;text-align:start}.s-conditional-offer-heading{display:flex;min-width:0px;align-items:center;gap:0.375rem}.s-conditional-offer-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.75rem;line-height:1rem;--tw-text-opacity:1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.s-conditional-offer-info{position:relative;display:inline-flex;flex-shrink:0;align-items:center}.s-conditional-offer-info-trigger{display:inline-flex;height:1rem;width:1rem;flex-shrink:0;align-items:center;justify-content:center;border-radius:9999px;--tw-text-opacity:1;color:rgb(115 115 115 / var(--tw-text-opacity, 1));transition-property:color, background-color, border-color, text-decoration-color, fill, stroke;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:300ms}.s-conditional-offer-info-trigger:hover{color:var(--color-primary-dark)}.s-conditional-offer-info-icon{height:0.875rem;width:0.875rem;flex-shrink:0}.s-conditional-offer-message{font-size:0.875rem;line-height:1.25rem;font-weight:700;color:var(--color-primary-dark)}@media (min-width: 768px){.s-conditional-offer-message{font-size:1rem;line-height:1.5rem}}.s-conditional-offer-toggle{align-items:center;justify-content:center;gap:0.375rem;font-size:0.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(38 38 38 / var(--tw-text-opacity, 1));transition-property:color, background-color, border-color, text-decoration-color, fill, stroke;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:300ms}.s-conditional-offer-toggle:hover{color:var(--color-primary-dark)}.s-conditional-offer-toggle-inline{display:none;flex-shrink:0}@media (min-width: 768px){.s-conditional-offer-toggle-inline{display:inline-flex}}.s-conditional-offer-toggle-bar{margin-top:0.5rem;display:inline-flex;width:100%;border-radius:0.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(229 231 235 / var(--tw-border-opacity, 1));--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));padding-left:0.75rem;padding-right:0.75rem;padding-top:0.625rem;padding-bottom:0.625rem}@media (min-width: 768px){.s-conditional-offer-toggle-bar{display:none}}.s-conditional-offer-toggle-icon{font-size:1rem;line-height:1.5rem;line-height:1;transition-property:transform;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:300ms}.s-conditional-offer-toggle-icon-open{--tw-rotate:180deg;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.s-conditional-offer-panel{margin-top:0.75rem;width:100%;min-width:0px;border-radius:0.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(243 244 246 / var(--tw-border-opacity, 1));--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));padding:0.75rem}.s-conditional-offer-gifts-header{margin-bottom:0.75rem;display:flex;align-items:center;gap:0.5rem}.s-conditional-offer-gifts-icon{display:inline-flex;height:2rem;width:2rem;flex-shrink:0;align-items:center;justify-content:center;border-radius:9999px;border-width:1px;border-color:var(--color-primary);font-size:1rem;line-height:1.5rem;line-height:1;color:var(--color-primary)}.s-conditional-offer-gifts-title{font-size:0.875rem;line-height:1.25rem;font-weight:700;color:var(--color-primary-dark)}.s-conditional-offer-gifts-list{margin:0px;display:flex;list-style-type:none;flex-direction:column;gap:0.5rem;padding:0px}.s-conditional-offer-gift{display:flex;align-items:center;gap:0.75rem;border-radius:0.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(243 244 246 / var(--tw-border-opacity, 1));padding:0.5rem}.s-conditional-offer-gift-thumb{display:inline-flex;height:3.5rem;width:3.5rem;flex-shrink:0;align-items:center;justify-content:center;overflow:hidden;border-radius:0.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(243 244 246 / var(--tw-border-opacity, 1));--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));font-size:1.25rem;line-height:1.75rem;--tw-text-opacity:1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.s-conditional-offer-gift-image{height:100%;width:100%;object-fit:contain}.s-conditional-offer-gift-body{display:flex;min-width:0px;flex:1 1 0%;flex-direction:column;gap:0.125rem;text-align:start}.s-conditional-offer-gift-name{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;font-size:0.875rem;line-height:1.25rem;font-weight:700;--tw-text-opacity:1;color:rgb(38 38 38 / var(--tw-text-opacity, 1))}.s-conditional-offer-gift-prices{display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;line-height:1rem}.s-conditional-offer-gift-free{font-weight:700;--tw-text-opacity:1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.s-conditional-offer-gift-price{--tw-text-opacity:1;color:rgb(115 115 115 / var(--tw-text-opacity, 1));text-decoration-line:line-through}.s-conditional-offer-tooltip{position:absolute;top:calc(100% + 0.75rem);left:50%;z-index:10;width:min(22rem, calc(100vw - 2rem));border-radius:0.5rem;--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));padding:1rem;text-align:start;--tw-shadow:5px 10px 30px #2B2D340D;--tw-shadow-colored:5px 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.s-conditional-offer-tooltip-title{font-size:0.875rem;line-height:1.25rem;font-weight:700;color:var(--color-primary-dark)}.s-conditional-offer-tooltip-description{margin-top:0.25rem;font-size:0.75rem;line-height:1rem;line-height:1.625;--tw-text-opacity:1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.s-conditional-offer-tooltip-list{margin:0px;margin-top:0.75rem;display:flex;list-style-type:none;flex-direction:column;gap:1rem;padding:0px}.s-conditional-offer-tier{position:relative;display:flex;align-items:flex-start;gap:0.75rem}.s-conditional-offer-tier-icon{position:relative;display:inline-flex;height:2.25rem;width:2.25rem;flex-shrink:0;align-items:center;justify-content:center;border-radius:9999px;font-size:1rem;line-height:1.5rem;line-height:1}.s-conditional-offer-tier-icon-unmet{color:var(--color-primary-dark)}.s-conditional-offer-tier-status{position:absolute;bottom:-0.125rem;display:inline-flex;height:1rem;width:1rem;align-items:center;justify-content:center;border-radius:9999px;border-width:2px;--tw-border-opacity:1;border-color:rgb(255 255 255 / var(--tw-border-opacity, 1));--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));inset-inline-start:-2px}.s-conditional-offer-tier-status-met{color:var(--color-primary-dark)}.s-conditional-offer-tier-status-unmet{--tw-text-opacity:1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.s-conditional-offer-tier-status-check{height:0.75rem;width:0.75rem;flex-shrink:0}.s-conditional-offer-tier-status-lock{height:0.625rem;width:0.625rem;flex-shrink:0}.s-conditional-offer-tier-body{display:flex;min-width:0px;flex-direction:column;gap:0.125rem}.s-conditional-offer-tier-title{font-size:0.75rem;line-height:1rem;font-weight:700;color:var(--color-primary-dark)}.s-conditional-offer-tier-text{font-size:0.75rem;line-height:1rem;line-height:1.625;--tw-text-opacity:1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.s-conditional-offer-tier-text-met{text-decoration-line:line-through}.s-conditional-offer-skeleton-text{display:flex;min-width:0px;flex:1 1 0%;flex-direction:column;gap:0.375rem}";
21
922
 
923
+ /** Root of every translation key this component uses. */
924
+ const LANG = "blocks.cart_offer";
925
+ /**
926
+ * Hard ceiling on the `salla.onReady()` wait.
927
+ *
928
+ * A REJECTED bootstrap was always handled; a NEVER-SETTLING one was not — and an
929
+ * unbounded await gates the first `refresh()`, i.e. the entire component. Timing out
930
+ * costs at worst one early request, which `refresh()` already treats as a retryable,
931
+ * non-terminal condition.
932
+ */
933
+ const ON_READY_TIMEOUT_MS = 3000;
934
+ /**
935
+ * Absolute ceiling on the loading skeleton.
936
+ *
937
+ * The skeleton is a TRANSIENT state, never a resting one: past this point something has
938
+ * gone wrong that no future code change should be able to turn back into a permanent
939
+ * shimmer, and rendering nothing is strictly better than a spinner that never stops.
940
+ */
941
+ const SKELETON_TIMEOUT_MS = 8000;
942
+ /**
943
+ * Back-off for a cart lookup that could not ANSWER — one entry per retry, so the length of
944
+ * the list is the bound.
945
+ *
946
+ * An unknown count renders nothing, and nothing else re-asks: every other lookup hangs off
947
+ * `refresh()` or `cart::updated`, and a shopper who is just looking at their cart triggers
948
+ * neither. Without this a connection that drops for a second at mount hides a live offer
949
+ * for the whole page view. BOUNDED, because a lookup that keeps failing is not going to be
950
+ * rescued by asking for ever — after the last entry "hidden" is the final answer until the
951
+ * cart changes.
952
+ */
953
+ const CART_LOOKUP_RETRY_DELAYS_MS = [1000, 2000, 4000];
954
+ /**
955
+ * How a cart ITEM ROW identifies itself, in document order.
956
+ *
957
+ * TWO independent markers on purpose: `data-testid` is the platform's own convention and
958
+ * `form[id^="item-"]` is what every Twilight cart template has always emitted, so a theme
959
+ * that drops one still answers the other. Deliberately NOT a column class — see
960
+ * `CART_COLUMN_SELECTOR`.
961
+ */
962
+ const CART_ITEM_SELECTOR = '[data-testid^="store-cart-item-"], form[id^="item-"]';
963
+ /**
964
+ * The FALLBACK column selectors, used only when no item row exists — i.e. an EMPTY cart,
965
+ * where the column is real but has nothing in it.
966
+ *
967
+ * `.main-content` is raed's own class and means nothing in another theme, which is exactly
968
+ * why it can never be the primary strategy: it is consulted after the item-row lookup has
969
+ * already failed, and its failure is non-fatal.
970
+ */
971
+ const CART_COLUMN_SELECTOR = '.main-content, [data-cart-items]';
972
+ /**
973
+ * How far UP from the host we look for the row that holds the items column. The theme
974
+ * mounts the host as a direct sibling of that row (level 0); the extra levels only cover a
975
+ * theme that wraps the host in a div or two.
976
+ */
977
+ const RELOCATION_ANCESTOR_LIMIT = 3;
978
+ /**
979
+ * Bounded wait for a cart-items column the HTML parser has not produced YET.
980
+ *
981
+ * `<salla-conditional-offer>` is parsed BEFORE the two-column row that follows it, so on a
982
+ * page where the bundle is already registered the first attempt legitimately runs against a
983
+ * DOM with no cart in it. Past this deadline the full-width banner is the final answer.
984
+ */
985
+ const RELOCATION_WATCH_MS = 5000;
986
+ /**
987
+ * Clear space kept between the ⓘ popover and the viewport edge when it is nudged back
988
+ * inside. Purely cosmetic — one gutter, both edges, both directions.
989
+ */
990
+ const TOOLTIP_VIEWPORT_GUTTER_PX = 8;
991
+ /**
992
+ * How close the caret may get to the popover's own rounded corner.
993
+ *
994
+ * The caret chases the ⓘ icon; once the box has been nudged away from a viewport edge the
995
+ * icon can end up beyond the box's corner radius, and a caret drawn there detaches from
996
+ * the panel. Past this inset it stops following and the popover's EDGE points at the icon
997
+ * instead.
998
+ */
999
+ const TOOLTIP_CARET_INSET_PX = 16;
1000
+ /** Source of the per-instance suffix on every `id` this component renders — see `instanceId`. */
1001
+ let instanceCount = 0;
1002
+ /**
1003
+ * Every shopper-facing sentence frame comes from the platform's `blocks.cart_offer.*`
1004
+ * translations (read through `trans`); the reward wording itself is server-rendered as
1005
+ * `discounts[].reward_label`.
1006
+ *
1007
+ * These are the fallbacks for a storefront locale whose platform dictionary has no
1008
+ * `blocks.cart_offer` block yet: the platform resolves a locale's dictionary as a whole
1009
+ * file, with no per-key fallback, so without them such a storefront would render a banner
1010
+ * with no text at all.
1011
+ */
1012
+ const EN_FALLBACKS = {
1013
+ badges_summary: ':completed of :total offers unlocked',
1014
+ congrats: 'Congratulations! You’ve unlocked all rewards in this promotion',
1015
+ details_title: 'Offer details',
1016
+ free_gift: 'Free Gift',
1017
+ free_products_title: 'Free products selected from the offer',
1018
+ guidance_conditions_unmet: 'Your cart doesn’t meet the conditions for the next reward yet',
1019
+ guidance_next: 'Add :remaining and get :reward',
1020
+ guidance_next_generic: 'Add :remaining to unlock the next reward',
1021
+ manage_offer: 'Manage offer',
1022
+ tier_requirement: 'Spend :threshold and get :reward',
1023
+ tier_requirement_count: 'Buy :threshold and get :reward',
1024
+ tier_requirement_count_generic: 'Buy :threshold to unlock this offer',
1025
+ tier_requirement_generic: 'Spend :threshold to unlock this offer',
1026
+ tier_title: 'Offer (:number)',
1027
+ };
1028
+ /** The gift price label reuses the platform's existing "free of charge" string. */
1029
+ const FREE_LABEL_KEY = 'pages.checkout.free_charge';
1030
+ const FREE_LABEL_FALLBACK = 'Free';
1031
+ /**
1032
+ * Escape a merchant-controlled string before it is substituted into a translation
1033
+ * template that we then render with `innerHTML`.
1034
+ *
1035
+ * `innerHTML` is unavoidable for the guidance and tier lines: their amount halves carry
1036
+ * `salla.money()` markup (`<i class=sicon-sar>` when the store uses the SAR symbol).
1037
+ * `lang.js` fills placeholders with a plain string replace and escapes nothing, and
1038
+ * `reward_label` is produced by `theme_trans()`, i.e. merchant-overridable copy — so the
1039
+ * untrusted half has to be escaped here.
1040
+ */
1041
+ function escapeHtml(value) {
1042
+ const holder = document.createElement('div');
1043
+ holder.textContent = value ?? '';
1044
+ return holder.innerHTML;
1045
+ }
1046
+ /**
1047
+ * The ⓘ popover's prose, read from `details.rules` and from NOWHERE else.
1048
+ *
1049
+ * `CartOffersTransformer::transformConditionalOffer` sends the merchant's authored offer
1050
+ * message under `details.rules` and holds `description` at `''` on purpose: the SHIPPED
1051
+ * `salla-conditional-offer` renders `description` into its subtitle today, so carrying the
1052
+ * message there would have rewritten live storefront copy on every store the moment the
1053
+ * backend deployed. One value, one key — so this must NOT fall back to `description`, or
1054
+ * the two sources diverge again the first time some other producer fills it.
1055
+ *
1056
+ * Everything absent-ish collapses to `null`, i.e. render no subtitle at all rather than an
1057
+ * empty element or the string "undefined":
1058
+ *
1059
+ * - key MISSING — an older Store deploy sends no `rules` at all;
1060
+ * - EMPTY STRING — a live store payload sent `rules: ''` verbatim,
1061
+ * because `$offer->message` was null and the transformer short-circuits to `''`;
1062
+ * - WHITESPACE — `strip_tags()` on a message that was only markup leaves `" "`;
1063
+ * - an ARRAY — the `SpecialOffer` model's own `rules` column is a rule LIST, so a future
1064
+ * producer confusing the two would hand us one; keep the usable strings, drop the rest;
1065
+ * - anything else (number, object, null) is not prose and is discarded.
1066
+ */
1067
+ function readOfferRules(offer) {
1068
+ const rules = offer?.details?.rules;
1069
+ if (typeof rules === 'string') {
1070
+ const trimmed = rules.trim();
1071
+ return trimmed === '' ? null : trimmed;
1072
+ }
1073
+ if (Array.isArray(rules)) {
1074
+ const joined = rules
1075
+ .filter((entry) => typeof entry === 'string')
1076
+ .map(entry => entry.trim())
1077
+ .filter(entry => entry !== '')
1078
+ .join(' ');
1079
+ return joined === '' ? null : joined;
1080
+ }
1081
+ return null;
1082
+ }
1083
+ /*
1084
+ * ---------------------------------------------------------------------------
1085
+ * Inline icons
1086
+ * ---------------------------------------------------------------------------
1087
+ * Three inline SVGs replace the check, lock and information icon-font glyphs.
1088
+ * (The old class names are deliberately not spelled out here: the build keeps this
1089
+ * comment in a dev bundle, and a stray literal would defeat the grep that proves the
1090
+ * glyphs are gone.) They are INLINED AS JSX rather than imported through
1091
+ * `stencil-inline-svg` + `innerHTML` (the other pattern in this repo) for three
1092
+ * reasons: no build-plugin dependency in the one component that needs it, no
1093
+ * asset path to resolve once this ships as a CDN-hosted bundle, and — because
1094
+ * the badge renders once PER TIER — no duplicated `id` / `clip-path` in the DOM.
1095
+ *
1096
+ * Three invariants, all load-bearing:
1097
+ *
1098
+ * 1. `fill="currentColor"`. The met badge is teal and the unmet badge is grey
1099
+ * purely through `tier-status-met` / `tier-status-unmet`, which set `color`.
1100
+ * Hardcoding a fill here would freeze both states to one colour.
1101
+ *
1102
+ * 2. NO `width` / `height` attribute. The three sources have DIFFERENT intrinsic
1103
+ * sizes (10 / 14 / 16). Left on the element those would leak straight into the
1104
+ * layout and resize the badge each source was dropped into. Size comes from the
1105
+ * caller's utility class instead, so the chip and trigger keep the exact box they
1106
+ * have today. Beware when picking that size: NONE of the three glyphs fills its
1107
+ * viewBox. The check disc spans 83.3% of its 10-unit box, the lock 75% wide and
1108
+ * 83.3% tall of its 16-unit box, the info disc 83.3% of its 14-unit box. Size by the
1109
+ * ink you want to SEE, not by the box.
1110
+ *
1111
+ * 3. NO `id` / `clip-path` / `clipPath`. A check icon source that
1112
+ * wrapped its path in `<g clip-path="url(#clip0_...)">` shipped NO
1113
+ * `<defs>` defining that clipPath — an unresolvable clip-path reference makes
1114
+ * the browser drop the element, so the icon rendered as nothing. The wrapper is
1115
+ * stripped and only the `<path>` kept. (`information-fill.svg` and
1116
+ * `lock-fill.svg` were checked and are clean — no `<g>`, no ids, no defs.)
1117
+ *
1118
+ * All three are decorative: the accessible name lives on the ancestor (the badges
1119
+ * row's `role="img"` + "N of M" label, and the ⓘ trigger button's `aria-label`),
1120
+ * so each carries `aria-hidden` + `focusable="false"` (the latter for IE/Edge
1121
+ * legacy, which otherwise puts SVGs in the tab order).
1122
+ */
1123
+ /** Filled circle enclosing a check — the MET tier status. Source: 10x10. */
1124
+ const CheckCircleIcon = (props = {}) => (h("svg", { class: props.class, viewBox: "0 0 10 10", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", focusable: "false" }, h("path", { d: "M5.00016 9.16634C2.69898 9.16634 0.833496 7.30084 0.833496 4.99967C0.833496 2.69849 2.69898 0.833008 5.00016 0.833008C7.30133 0.833008 9.16683 2.69849 9.16683 4.99967C9.16683 7.30084 7.30133 9.16634 5.00016 9.16634ZM4.58458 6.66634L7.53087 3.72006L6.94162 3.13081L4.58458 5.48784L3.40608 4.3093L2.81683 4.89859L4.58458 6.66634Z", fill: "currentColor" })));
1125
+ /** Filled padlock — the UNMET tier status. Source: 16x16. */
1126
+ const LockIcon = (props = {}) => (h("svg", { class: props.class, viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", focusable: "false" }, h("path", { d: "M12.6667 6.66683H13.3333C13.7015 6.66683 14 6.9653 14 7.3335V14.0002C14 14.3684 13.7015 14.6668 13.3333 14.6668H2.66667C2.29848 14.6668 2 14.3684 2 14.0002V7.3335C2 6.9653 2.29848 6.66683 2.66667 6.66683H3.33333V6.00016C3.33333 3.42284 5.42267 1.3335 8 1.3335C10.5773 1.3335 12.6667 3.42284 12.6667 6.00016V6.66683ZM11.3333 6.66683V6.00016C11.3333 4.15922 9.84093 2.66683 8 2.66683C6.15905 2.66683 4.66667 4.15922 4.66667 6.00016V6.66683H11.3333ZM7.33333 9.3335V12.0002H8.66667V9.3335H7.33333Z", fill: "currentColor" })));
1127
+ /** Filled circle enclosing an `i` — the ⓘ tooltip trigger. Source: 14x14. */
1128
+ const InformationIcon = (props = {}) => (h("svg", { class: props.class, viewBox: "0 0 14 14", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", focusable: "false" }, h("path", { d: "M7.00033 12.8337C3.77866 12.8337 1.16699 10.222 1.16699 7.00033C1.16699 3.77866 3.77866 1.16699 7.00033 1.16699C10.222 1.16699 12.8337 3.77866 12.8337 7.00033C12.8337 10.222 10.222 12.8337 7.00033 12.8337ZM6.41699 6.41699V9.91699H7.58366V6.41699H6.41699ZM6.41699 4.08366V5.25033H7.58366V4.08366H6.41699Z", fill: "currentColor" })));
22
1129
  const SallaConditionalOffer$1 = /*@__PURE__*/ proxyCustomElement(class SallaConditionalOffer extends HTMLElement {
23
1130
  constructor() {
24
1131
  super();
25
1132
  this.__registerHost();
26
1133
  this.offer = null;
27
- this.products = {};
28
1134
  this.isLoading = false;
29
1135
  this.canRender = true;
1136
+ /** Free-gift cart LINES belonging to this offer — the "ادارة العرض" card. */
1137
+ this.gifts = [];
1138
+ /**
1139
+ * How many LINES the cart holds — `null` while that is genuinely UNKNOWN.
1140
+ *
1141
+ * TRI-STATE on purpose, and the whole of the empty-cart fix rests on it. `0` renders
1142
+ * NOTHING; `null` renders normally. Collapsing the two would hide the banner on every
1143
+ * populated cart for as long as the first `details()` request is in flight, which is
1144
+ * the flicker this must not trade one bug for.
1145
+ */
1146
+ this.cartItemCount = null;
1147
+ /** The tooltip is open while hovered OR while pinned by a click (the mobile path). */
1148
+ this.isTooltipHovered = false;
1149
+ this.isTooltipPinned = false;
1150
+ /** "ادارة العرض" — open by default, the gifts card starts expanded. */
1151
+ this.isGiftsOpen = true;
1152
+ /**
1153
+ * What makes this instance's `id`s its OWN. The banner can legitimately mount more than
1154
+ * once on a page, and with fixed ids the second instance's `aria-describedby` /
1155
+ * `aria-controls` resolve to the FIRST one's popover and gifts card.
1156
+ */
1157
+ this.instanceId = ++instanceCount;
1158
+ /**
1159
+ * Monotonic generation counter guarding `refresh()`. Two cart mutations in quick succession issue
1160
+ * two `offers()` requests, and nothing guarantees they SETTLE in the order they were
1161
+ * sent — without this, a slow first response can land after a fast second one and
1162
+ * render a `current_value` that is already stale.
1163
+ */
1164
+ this.refreshGeneration = 0;
1165
+ /** The same guard for the independent gift-lines lookup. */
1166
+ this.giftsGeneration = 0;
1167
+ /**
1168
+ * The open coalescing window: the timer that will run the ONE refresh pass a burst of
1169
+ * `cart::updated` events is entitled to. `null` whenever no burst is in flight.
1170
+ *
1171
+ * The generation guards above are untouched by this and still do their own job — the
1172
+ * window collapses events that have not been ASKED about yet, they discard answers to
1173
+ * questions that have since been superseded, and a burst that straddles the window
1174
+ * (an event arriving while a pass is already in flight) needs both.
1175
+ */
1176
+ this.cartRefreshTimer = null;
1177
+ /**
1178
+ * "A burst has been seen and its pass has not run yet."
1179
+ *
1180
+ * Survives a disconnect on purpose, exactly as `skeletonDeadline` does: a theme that
1181
+ * MOVES the host in the DOM mid-burst (disconnect + reconnect, same instance) would
1182
+ * otherwise drop the trailing pass and rest on pre-burst data forever, since nothing
1183
+ * re-runs `refresh()` but another `cart::updated`. `connectedCallback` re-arms it.
1184
+ */
1185
+ this.cartRefreshPending = false;
1186
+ /**
1187
+ * Set once `load()` has asked for `cart::updated`, and never cleared: it is what lets
1188
+ * `connectedCallback` tell a RECONNECT (put the listener back) from the first connect,
1189
+ * which runs before `componentWillLoad` and must leave the subscription to it.
1190
+ */
1191
+ this.wantsCartUpdates = false;
1192
+ /** Whether `cartUpdatedHandler` is on `cart::updated` RIGHT NOW — the double-registration guard. */
1193
+ this.isListeningToCartUpdates = false;
1194
+ /** The armed cart-lookup retry; `null` whenever none is. See `CART_LOOKUP_RETRY_DELAYS_MS`. */
1195
+ this.cartLookupRetryTimer = null;
1196
+ /** How many of those retries this episode has SPENT — the bound. */
1197
+ this.cartLookupRetries = 0;
1198
+ /** "A retry is owed" — survives a disconnect for the same reason `cartRefreshPending` does. */
1199
+ this.cartLookupRetryPending = false;
1200
+ /**
1201
+ * Set by the ONE branch of `refresh()` that returns without asking for the offer: the
1202
+ * cart was reported empty.
1203
+ *
1204
+ * That branch is right whenever the report is, and unrecoverable whenever it is not —
1205
+ * nothing re-runs `refresh()` except `cart::updated`, which a shopper who never
1206
+ * touches the cart never emits. So the branch now leaves this behind, and the cart
1207
+ * lookup it kicks off clears it by re-running `refresh()` if the count comes back
1208
+ * `>= 1`. See both sites; the latch is what keeps that to a recovery rather than a
1209
+ * second offer request on every load.
1210
+ */
1211
+ this.deferredByEmptyCart = false;
1212
+ /** Set by `disconnectedCallback`, so a late response cannot set state on a dead host. */
1213
+ this.isDisconnected = false;
1214
+ /** Backstop timer that guarantees the skeleton is never a resting state. */
1215
+ this.skeletonWatchdog = null;
1216
+ /**
1217
+ * WHEN the current skeleton must be gone by — the authority, with `skeletonWatchdog`
1218
+ * demoted to a mere handle.
1219
+ *
1220
+ * A timer handle answers "is a timer pending", which is not the question. The question
1221
+ * is "is this skeleton past its deadline", and the two come apart precisely when it
1222
+ * matters: a handle that was cleared, consumed, or never armed reads exactly like one
1223
+ * that is still counting down. Keeping the deadline as a timestamp means the watchdog
1224
+ * can always be REBUILT from state — which is what `ensureSkeletonIsWatched` does, on
1225
+ * every render, so a skeleton on screen can never be one nothing is watching.
1226
+ *
1227
+ * `0` means "not loading"; anything else is an absolute `Date.now()` deadline.
1228
+ */
1229
+ this.skeletonDeadline = 0;
1230
+ /** Latched once the host sits in the items column, so it can never be moved twice. */
1231
+ this.hasRelocated = false;
1232
+ /** `componentDidRender` fires BEFORE the first `componentDidLoad`; this orders them. */
1233
+ this.hasLoaded = false;
1234
+ /** Observer waiting for a column that the parser has not emitted yet. */
1235
+ this.relocationObserver = null;
1236
+ /** Deadline that bounds that wait. */
1237
+ this.relocationWatchTimer = null;
1238
+ /** Set when the wait expired: stop re-arming, keep the cheap per-render retry. */
1239
+ this.relocationWatchExpired = false;
1240
+ /** "No column found" is said ONCE per instance, not on every render. */
1241
+ this.relocationLogged = false;
1242
+ /**
1243
+ * The `cart::updated` payload is ignored for everything EXCEPT the item count — it may
1244
+ * legitimately arrive with no `items` at all (`api.cart.latest` is not in
1245
+ * `CartPresenter::$routesWithItems`). Refetch and take the server's `current_value`.
1246
+ *
1247
+ * `count` is the one field that is safe to read here, and reading it is what fixes the
1248
+ * live removal path. `SallaCartEvent.updated()` dispatches `{ ...data.cart }`
1249
+ * (twilight/src/event/cart.js:114) and writes `count` from that same object into
1250
+ * `cart.summary` one line above it, so the field is first-class — unlike `items`, which
1251
+ * genuinely is not. Taking it SYNCHRONOUSLY, before any request goes out, is the point:
1252
+ * see `readCartCountFromEvent`.
1253
+ *
1254
+ * Two independent refetches fan out from here and are NOT chained: the offer payload
1255
+ * (thresholds + reward labels) and the gift cart LINES. The gift lookup is shared
1256
+ * process-wide (`shared/cart-free-gifts`), so the item-offer badges, the summary card
1257
+ * and this banner still cost ONE `details()` request between them per mutation.
1258
+ *
1259
+ * Both of those refetches are COALESCED — see `CART_UPDATE_COALESCE_MS`. Only the
1260
+ * count read below is per-event.
1261
+ *
1262
+ * ONE reference per instance rather than an inline closure, because `salla.event.off`
1263
+ * can only remove the exact function `on` was given.
1264
+ */
1265
+ this.cartUpdatedHandler = (payload) => {
1266
+ // FIRST, and synchronously — see `readCartCountFromEvent`. `refresh()` reads
1267
+ // `cartItemCount` to decide whether to raise a skeleton at all, so the count has
1268
+ // to be current before it runs, not a request later. This is also what keeps the
1269
+ // coalescing window below invisible to the shopper on the path that matters:
1270
+ // emptying the cart hides the banner on THIS line, not when the window closes.
1271
+ this.readCartCountFromEvent(payload);
1272
+ // The cart moved, so the retry episode for the OLD cart is over; the pass below
1273
+ // re-asks, and opens a new one if that cannot answer either.
1274
+ this.clearCartLookupRetry();
1275
+ /**
1276
+ * Raises `cartRefreshPending` on THIS line, and that — not the delayed pass — is
1277
+ * what voids every cart answer older than this event: `loadCart()` publishes
1278
+ * nothing while it is up. Left valid for the length of the window, a lookup
1279
+ * landing inside it wrote its old positive count over the `0` this handler had
1280
+ * just read, and the banner came back over an emptied cart.
1281
+ */
1282
+ this.scheduleCartRefresh();
1283
+ };
30
1284
  }
1285
+ /**
1286
+ * SYNCHRONOUS on purpose — an `async` version of this hook is what made the whole
1287
+ * component invisible.
1288
+ *
1289
+ * Stencil awaits whatever `componentWillLoad` returns BEFORE it calls `render()` for
1290
+ * the first time (`dispatchHooks` → `enqueue(maybePromise, () => updateComponent(...))`).
1291
+ * An `async componentWillLoad` chaining round trips inside that promise leaves the host
1292
+ * element literally empty for the whole chain, and FOREVER if the last of them never
1293
+ * settles.
1294
+ *
1295
+ * Same posture as `salla-cart-summary-card` ("Sync: returning a promise would make
1296
+ * Stencil defer first paint"): paint now, fill in as the data lands.
1297
+ */
31
1298
  componentWillLoad() {
32
- salla.onReady().then(() => {
33
- if (!salla.config.get('store.features')?.includes('conditional-offer') || (salla.storage.get("cart") !== '' && !salla.storage.get("cart")?.summary?.count)) {
34
- throw new Error('feature or cart object does not existed');
1299
+ /**
1300
+ * The escape hatch is no longer something a single well-placed call has to get
1301
+ * right. `ensureSkeletonIsWatched()` runs from `startLoading()`, from
1302
+ * `connectedCallback` and from EVERY `componentDidRender`, so the watchdog is a
1303
+ * function of state rather than of call order: whatever raises the skeleton, the
1304
+ * render that paints it re-establishes the deadline behind it.
1305
+ *
1306
+ * Kept here all the same, as the first statement and ahead of anything that can
1307
+ * throw, for the case where `isEnabled()` is already true — it costs one boolean
1308
+ * read when it is not.
1309
+ */
1310
+ this.ensureSkeletonIsWatched();
1311
+ /**
1312
+ * The whole body is guarded. A synchronous throw here is invisible — Stencil still
1313
+ * renders the component after a throwing `componentWillLoad` — and it has now cost
1314
+ * two rounds, so it is caught and logged explicitly rather than trusted not to
1315
+ * happen.
1316
+ */
1317
+ try {
1318
+ if (this.isEnabled())
1319
+ this.startLoading();
1320
+ /**
1321
+ * `.catch` is LOAD-BEARING. `load()` is a floating promise — `componentWillLoad`
1322
+ * must stay synchronous (see above) and cannot await it — so anything throwing
1323
+ * inside it becomes an unhandled rejection: invisible, and fatal, because
1324
+ * `refresh()` is the only thing that lowers `isLoading` in the happy path.
1325
+ *
1326
+ * RECOVERY BEFORE LOGGING, here and everywhere else in this file — see `safeLog`.
1327
+ */
1328
+ this.load().catch(error => {
1329
+ this.stopLoading();
1330
+ this.safeLog('load() failed', error);
1331
+ });
1332
+ }
1333
+ catch (error) {
1334
+ this.stopLoading();
1335
+ this.safeLog('componentWillLoad threw synchronously', error);
1336
+ }
1337
+ }
1338
+ /**
1339
+ * Logging that CANNOT throw.
1340
+ *
1341
+ * `salla.logger.warn` is not safe to call during init: `base/src/logger.ts` opens every
1342
+ * call with `if (!salla.config.isDebug()) return;`, so it raises a TypeError whenever
1343
+ * `salla.config` has not been installed yet — precisely the window `componentWillLoad`
1344
+ * runs in. Every recovery path in this component used to log BEFORE it recovered, so a
1345
+ * single throw in here silently skipped the recovery it was announcing.
1346
+ */
1347
+ safeLog(message, error) {
1348
+ // The component name is passed as its OWN argument rather than interpolated into
1349
+ // `message`: the format string stays a static literal, which keeps the caller's
1350
+ // text out of the formatter.
1351
+ try {
1352
+ salla.logger.warn('salla-conditional-offer::', message, error);
1353
+ }
1354
+ catch (loggerFailure) {
1355
+ /* the SDK logger is the only sink — never let logging break the caller */
1356
+ }
1357
+ }
1358
+ /**
1359
+ * Cleared on RECONNECT, not just set on disconnect. `componentWillLoad` runs once per
1360
+ * instance, so an element that a theme moves in the DOM (disconnect + reconnect keeps
1361
+ * the same instance) would otherwise stay permanently latched as "dead" and stop
1362
+ * updating on every later `cart::updated`.
1363
+ */
1364
+ connectedCallback() {
1365
+ this.isDisconnected = false;
1366
+ /**
1367
+ * `disconnectedCallback` cleared the watchdog, and `componentWillLoad` does NOT run
1368
+ * again on reconnect — so a host the theme moves in the DOM while still loading
1369
+ * would come back with the skeleton up and no timer behind it. The deadline
1370
+ * survives the move, so what is rebuilt here is only the timer.
1371
+ */
1372
+ this.ensureSkeletonIsWatched();
1373
+ /**
1374
+ * A host that was genuinely REMOVED gave its listener up (see `disconnectedCallback`)
1375
+ * and was deaf until now, so it takes the listener back and is OWED a pass for
1376
+ * whatever it missed. Skipped on the first connect, where `load()` has not asked
1377
+ * yet, and on a plain move, where the listener never left.
1378
+ */
1379
+ if (this.wantsCartUpdates && !this.isListeningToCartUpdates && this.subscribeToCartUpdates()) {
1380
+ this.cartRefreshPending = true;
1381
+ }
1382
+ /**
1383
+ * Same contract as the watchdog above: the TIMER did not survive the move but the
1384
+ * fact that a refresh is owed did, so it is re-armed here rather than lost. A host
1385
+ * moved between the last `cart::updated` of a burst and the end of its window must
1386
+ * still converge on that burst's cart state.
1387
+ */
1388
+ if (this.cartRefreshPending)
1389
+ this.scheduleCartRefresh();
1390
+ // ...and the same again for a cart-lookup retry that was still counting down.
1391
+ if (this.cartLookupRetryPending)
1392
+ this.scheduleCartLookupRetry();
1393
+ }
1394
+ disconnectedCallback() {
1395
+ this.isDisconnected = true;
1396
+ /**
1397
+ * The timer goes; `cartRefreshPending` deliberately stays — see `connectedCallback`.
1398
+ */
1399
+ if (this.cartRefreshTimer !== null) {
1400
+ clearTimeout(this.cartRefreshTimer);
1401
+ this.cartRefreshTimer = null;
1402
+ }
1403
+ // Likewise: the timer goes, `cartLookupRetryPending` stays.
1404
+ if (this.cartLookupRetryTimer !== null) {
1405
+ clearTimeout(this.cartLookupRetryTimer);
1406
+ this.cartLookupRetryTimer = null;
1407
+ }
1408
+ /**
1409
+ * The LISTENER goes too, but only for a host that is really gone. This hook fires
1410
+ * synchronously on EVERY removal — our own relocation and a theme moving the host
1411
+ * included — and those have reconnected by the time the microtask runs, so they
1412
+ * keep the listener and miss nothing. Same posture as `salla-cart-summary-card`.
1413
+ */
1414
+ Promise.resolve().then(() => {
1415
+ if (this.isDisconnected)
1416
+ this.unsubscribeFromCartUpdates();
1417
+ });
1418
+ /**
1419
+ * The TIMER goes; the DEADLINE stays. A host the theme moves in the DOM must come
1420
+ * back to the deadline it already had rather than to a fresh full window — and
1421
+ * `clearSkeletonWatchdog()` resets both, so it is deliberately not used here.
1422
+ */
1423
+ if (this.skeletonWatchdog !== null) {
1424
+ clearTimeout(this.skeletonWatchdog);
1425
+ this.skeletonWatchdog = null;
1426
+ }
1427
+ this.stopWatchingForItemsColumn();
1428
+ }
1429
+ componentDidLoad() {
1430
+ this.hasLoaded = true;
1431
+ this.relocateIntoItemsColumn();
1432
+ }
1433
+ /**
1434
+ * Whether the host currently puts anything on the page.
1435
+ *
1436
+ * Gates the relocation: a host that renders nothing contributes no layout and must not
1437
+ * be moved around the document either. It becomes true on the render that first paints
1438
+ * real content, and `componentDidRender` runs inside the same frame as that render —
1439
+ * before the browser paints it — so the move still lands ahead of the first visible
1440
+ * pixel and there is no jump.
1441
+ */
1442
+ isShowingSomething() {
1443
+ return this.canRender && this.hasCartItems();
1444
+ }
1445
+ /**
1446
+ * Every later render re-attempts the move. Free once it has happened (one boolean
1447
+ * read), and it is what covers the ordinary late-arrival case without any timer: the
1448
+ * skeleton paints while the row is still being parsed, and the render that replaces it
1449
+ * with the offer runs against a complete DOM.
1450
+ */
1451
+ componentDidRender() {
1452
+ if (this.hasLoaded)
1453
+ this.relocateIntoItemsColumn();
1454
+ // After the popover exists and has a width, never before — it is measured here and
1455
+ // adjusted with CSS custom properties, which do NOT re-enter render().
1456
+ this.positionTooltip();
1457
+ /**
1458
+ * THE INVARIANT: a skeleton that has just been painted has a deadline behind it.
1459
+ * Restoring it here — rather than trusting whichever branch raised `isLoading` to
1460
+ * have remembered — is what makes a permanently stuck shimmer unreachable, however
1461
+ * the loading state was entered. A no-op (one boolean read) whenever we are not
1462
+ * loading, which is almost always.
1463
+ */
1464
+ this.ensureSkeletonIsWatched();
1465
+ }
1466
+ // ---------------------------------------------------------------------------------
1467
+ // Relocation — the banner belongs above the CART ITEMS, not above the whole page
1468
+ // ---------------------------------------------------------------------------------
1469
+ /**
1470
+ * Move the host into the cart-items column.
1471
+ *
1472
+ * The theme mounts this component ABOVE the two-column row (`cart.twig`), so it spans
1473
+ * the items column AND the order summary; the design puts it above the ITEMS only,
1474
+ * with the summary starting level with it. Theme changes are out of scope, so the
1475
+ * component places itself.
1476
+ *
1477
+ * MOVING rather than re-sizing is deliberate. A moved host inherits whatever width the
1478
+ * column has — at every breakpoint, in RTL and LTR, and on a resize — and on mobile,
1479
+ * where the column is the only column, the move is a no-op visually, which is exactly
1480
+ * the mobile layout. A hard-coded `calc(100% - 384px)` is raed's number and would
1481
+ * be wrong in any other theme.
1482
+ *
1483
+ * NEVER throws, and never leaves the component unrendered: a cart whose column cannot
1484
+ * be identified keeps the full-width banner. That is a degraded LAYOUT, not a degraded
1485
+ * component.
1486
+ */
1487
+ relocateIntoItemsColumn() {
1488
+ if (this.hasRelocated)
1489
+ return;
1490
+ // Nothing on screen yet: no host to place, and nothing that could jump. Retried on
1491
+ // every later render, so the move happens on the one that first shows content.
1492
+ if (!this.isShowingSomething())
1493
+ return;
1494
+ try {
1495
+ const host = this.hostEl;
1496
+ if (!host?.parentElement)
1497
+ return;
1498
+ const column = this.findItemsColumn(host);
1499
+ if (!column) {
1500
+ this.watchForItemsColumn();
1501
+ return;
35
1502
  }
36
- })
37
- .then(() => salla.api.cart.offers())
38
- .then(({ data }) => {
39
- this.offer = data.find(offer => offer.type === "conditional");
40
- if (!this.offer) {
41
- this.canRender = false;
1503
+ /**
1504
+ * Latched BEFORE the DOM write. Moving a connected element fires
1505
+ * `disconnectedCallback` + `connectedCallback` SYNCHRONOUSLY and notifies the
1506
+ * observer below, so everything that can re-enter here must already see "done".
1507
+ */
1508
+ this.hasRelocated = true;
1509
+ this.stopWatchingForItemsColumn();
1510
+ // Already exactly where it belongs — a theme that mounts it inside the column.
1511
+ if (column === host.parentElement && column.firstElementChild === host)
42
1512
  return;
1513
+ column.insertBefore(host, column.firstChild);
1514
+ }
1515
+ catch (error) {
1516
+ // A selector engine or a DOM that surprises us must not cost the banner. Latch
1517
+ // so a strategy that throws is not retried on every subsequent render.
1518
+ this.hasRelocated = true;
1519
+ this.stopWatchingForItemsColumn();
1520
+ this.safeLog('relocating the banner into the cart items column failed', error);
1521
+ }
1522
+ }
1523
+ /**
1524
+ * The cart-items column, found WITHOUT depending on any single theme's class names.
1525
+ *
1526
+ * From the host OUTWARDS: at each level look at the elements that FOLLOW it — the row
1527
+ * is always a later sibling, never an ancestor — and inside each of them look for a
1528
+ * cart ITEM row. The column is then that row's own child which contains the item, i.e.
1529
+ * precisely the element whose width the banner should inherit.
1530
+ *
1531
+ * Scoping to following siblings is what stops an unrelated `form[id^="item-"]` (a
1532
+ * mini-cart drawer, a footer widget) from being mistaken for the cart.
1533
+ */
1534
+ findItemsColumn(host) {
1535
+ let node = host;
1536
+ for (let level = 0; node && level <= RELOCATION_ANCESTOR_LIMIT; level++) {
1537
+ for (const row of this.getFollowingSiblings(node)) {
1538
+ /**
1539
+ * The host is ALREADY beside the cart items, so its own parent is the
1540
+ * column. Without this, the item row itself would be treated as the row to
1541
+ * look inside and the banner would land inside a cart item.
1542
+ */
1543
+ if (node === host && this.matches(row, CART_ITEM_SELECTOR))
1544
+ return host.parentElement;
1545
+ const column = this.findColumnWithin(row, host);
1546
+ if (column)
1547
+ return column;
43
1548
  }
44
- this.isLoading = true;
45
- this.offer.details.discounts.unshift(({ value: 0, min_spend: 0 }));
46
- return this.updateInitialOfferValue();
47
- }).then(() => {
48
- if (!this.offer)
1549
+ node = node.parentElement;
1550
+ if (!node || node === document.body || node === document.documentElement)
1551
+ break;
1552
+ }
1553
+ return null;
1554
+ }
1555
+ getFollowingSiblings(node) {
1556
+ const siblings = [];
1557
+ for (let sibling = node.nextElementSibling; sibling; sibling = sibling.nextElementSibling) {
1558
+ siblings.push(sibling);
1559
+ }
1560
+ return siblings;
1561
+ }
1562
+ /** The child of `row` that holds the cart items, if `row` is the two-column row. */
1563
+ findColumnWithin(row, host) {
1564
+ const item = row.querySelector(CART_ITEM_SELECTOR);
1565
+ const column = item ? this.climbToChildOf(item, row) : null;
1566
+ /**
1567
+ * The item markers are the PRIMARY signal because every theme emits them. The class
1568
+ * list is the fallback for a cart that is legitimately EMPTY — the column exists,
1569
+ * the item rows do not — and is never reached while an item row is present.
1570
+ */
1571
+ const found = column ?? row.querySelector(CART_COLUMN_SELECTOR);
1572
+ if (!found || found === host || found.contains(host))
1573
+ return null;
1574
+ return found;
1575
+ }
1576
+ /**
1577
+ * Walk up from `node` to the DIRECT CHILD of `ancestor` that contains it.
1578
+ *
1579
+ * Not "the parent of the item rows": a theme that nests them (`col > ul > li > form`)
1580
+ * would hand back the `<ul>`, and a banner is not a list item. The row's own child is
1581
+ * the column in every shape.
1582
+ */
1583
+ climbToChildOf(node, ancestor) {
1584
+ let current = node;
1585
+ while (current && current.parentElement && current.parentElement !== ancestor) {
1586
+ current = current.parentElement;
1587
+ }
1588
+ return current?.parentElement === ancestor ? current : null;
1589
+ }
1590
+ matches(element, selector) {
1591
+ return typeof element.matches === 'function' && element.matches(selector);
1592
+ }
1593
+ /**
1594
+ * Wait for a column that does not exist YET, then stop waiting.
1595
+ *
1596
+ * Bounded on both sides: the observer is disconnected the moment the move succeeds, and
1597
+ * a deadline retires it if it never does. Where there is no `MutationObserver` (older
1598
+ * browsers, and the spec DOM) this degrades to the per-render retry in
1599
+ * `componentDidRender`, which is the path that matters anyway.
1600
+ */
1601
+ watchForItemsColumn() {
1602
+ if (this.hasRelocated || this.relocationWatchExpired || this.relocationObserver) {
1603
+ return;
1604
+ }
1605
+ if (typeof MutationObserver === 'undefined') {
1606
+ this.logNoItemsColumn();
1607
+ return;
1608
+ }
1609
+ this.relocationObserver = new MutationObserver(() => this.relocateIntoItemsColumn());
1610
+ this.relocationObserver.observe(document.documentElement, { childList: true, subtree: true });
1611
+ this.relocationWatchTimer = setTimeout(() => {
1612
+ this.relocationWatchTimer = null;
1613
+ this.relocationWatchExpired = true;
1614
+ this.stopWatchingForItemsColumn();
1615
+ this.logNoItemsColumn();
1616
+ }, RELOCATION_WATCH_MS);
1617
+ }
1618
+ stopWatchingForItemsColumn() {
1619
+ if (this.relocationObserver) {
1620
+ try {
1621
+ this.relocationObserver.disconnect();
1622
+ }
1623
+ catch (error) {
1624
+ /* an observer that cannot be disconnected must not break a DOM move */
1625
+ }
1626
+ this.relocationObserver = null;
1627
+ }
1628
+ if (this.relocationWatchTimer !== null) {
1629
+ clearTimeout(this.relocationWatchTimer);
1630
+ this.relocationWatchTimer = null;
1631
+ }
1632
+ }
1633
+ /** Said ONCE per instance: the full-width banner is a layout note, not a per-render alarm. */
1634
+ logNoItemsColumn() {
1635
+ if (this.relocationLogged)
1636
+ return;
1637
+ this.relocationLogged = true;
1638
+ this.safeLog('no cart items column found — leaving the banner at full page width');
1639
+ }
1640
+ /**
1641
+ * Guarantee that a raised skeleton has a live deadline behind it — idempotent, cheap,
1642
+ * and called from EVERY path that can leave one on screen (`startLoading`,
1643
+ * `connectedCallback`, and `componentDidRender`).
1644
+ *
1645
+ * This is the structural answer to "a skeleton with nothing watching it". The old shape
1646
+ * armed the timer only inside `startLoading()`, and only when the HANDLE happened to be
1647
+ * null — so any path that raised `isLoading` without going through that guard, or that
1648
+ * left a stale-looking handle behind, produced a shimmer that nothing would ever end.
1649
+ * Re-checking on every render means the invariant is restored by the very act of
1650
+ * painting the skeleton, whatever raised it.
1651
+ *
1652
+ * The deadline is set ONCE per loading episode and never pushed out, so a stream of
1653
+ * `cart::updated` events cannot extend a skeleton indefinitely.
1654
+ */
1655
+ ensureSkeletonIsWatched() {
1656
+ if (!this.isLoading)
1657
+ return;
1658
+ // Already counting down towards a deadline that is still in the future.
1659
+ if (this.skeletonWatchdog !== null)
1660
+ return;
1661
+ const now = Date.now();
1662
+ if (this.skeletonDeadline === 0)
1663
+ this.skeletonDeadline = now + SKELETON_TIMEOUT_MS;
1664
+ // A deadline already in the past (a timer that was cleared by a disconnect, or one
1665
+ // that never got armed at all) is honoured on the next tick rather than waiting out
1666
+ // another full window.
1667
+ const remaining = Math.max(0, this.skeletonDeadline - now);
1668
+ this.skeletonWatchdog = setTimeout(() => {
1669
+ // Released before anything else can fail, so the handle never dangles.
1670
+ this.skeletonWatchdog = null;
1671
+ if (!this.isLoading)
49
1672
  return;
50
- return this.getProducts();
51
- }).then(() => {
52
- if (!this.offer)
1673
+ // Re-armed early, or the clock moved: finish the wait rather than firing short.
1674
+ if (Date.now() < this.skeletonDeadline) {
1675
+ this.ensureSkeletonIsWatched();
53
1676
  return;
54
- salla.event.on("cart::updated", (updatedCart) => this.updateOfferValues(updatedCart));
55
- })
56
- .catch(error => {
57
- this.canRender = false;
58
- salla.logger.warn('salla-conditional-offer:: ', error);
59
- }).finally(() => {
1677
+ }
1678
+ /**
1679
+ * RECOVERY FIRST. Logging used to come first here, which meant a `salla.logger`
1680
+ * that throws (see `safeLog`) consumed the timer WITHOUT dropping the skeleton
1681
+ * and without printing anything — indistinguishable from "the watchdog never
1682
+ * fired", and exactly what was observed live.
1683
+ *
1684
+ * Note that `safeLog` is SILENT on a production storefront: `salla.logger.warn`
1685
+ * opens with `if (!salla.config.isDebug()) return;`. "No console output" is
1686
+ * therefore never evidence that this did not run — only the dropped skeleton is.
1687
+ */
60
1688
  this.isLoading = false;
1689
+ this.skeletonDeadline = 0;
1690
+ this.safeLog(`still loading after ${SKELETON_TIMEOUT_MS}ms — dropping the skeleton`);
1691
+ }, remaining);
1692
+ }
1693
+ clearSkeletonWatchdog() {
1694
+ this.skeletonDeadline = 0;
1695
+ if (this.skeletonWatchdog === null)
1696
+ return;
1697
+ clearTimeout(this.skeletonWatchdog);
1698
+ this.skeletonWatchdog = null;
1699
+ }
1700
+ /**
1701
+ * The ONLY way INTO the skeleton. Raising it and arming the watchdog that ends it are
1702
+ * deliberately the SAME operation — keeping them apart is what let `refresh()` show a
1703
+ * skeleton that nothing was watching. The existing deadline is kept rather than
1704
+ * refreshed, so a stream of `cart::updated` events cannot push it out indefinitely.
1705
+ */
1706
+ startLoading() {
1707
+ this.isLoading = true;
1708
+ this.ensureSkeletonIsWatched();
1709
+ }
1710
+ /**
1711
+ * The ONLY way out of the skeleton, so the watchdog can never outlive it. Every
1712
+ * terminal state — loaded, no-eligible-offer, error — goes through here.
1713
+ */
1714
+ stopLoading() {
1715
+ this.isLoading = false;
1716
+ this.clearSkeletonWatchdog();
1717
+ }
1718
+ /**
1719
+ * The async half of the load, deliberately NOT awaited by `componentWillLoad` so it
1720
+ * can never gate the first paint. Every state it sets is `@State`, so each step
1721
+ * re-renders on its own.
1722
+ *
1723
+ * NOTHING here is allowed to disable the component permanently.
1724
+ */
1725
+ async load() {
1726
+ /**
1727
+ * Subscribed FIRST, and unconditionally. Subscribing only after an offer had been
1728
+ * found made every failure terminal: a cold load that saw no offer never
1729
+ * subscribed, so the one signal that could revive the component — the shopper
1730
+ * changing the cart — was never listened for.
1731
+ *
1732
+ * WRAPPED, because this runs BEFORE the SDK is known to be ready: `componentWillLoad`
1733
+ * fires as soon as the element connects, which on a real storefront can beat
1734
+ * `salla.event`'s own bootstrap. A throw here used to propagate out of `load()`,
1735
+ * whose promise nobody awaited — so `refresh()` never ran and the skeleton became
1736
+ * permanent. Losing the subscription costs live cart updates; losing `refresh()`
1737
+ * costs the entire component, so the two failures must not share a fate.
1738
+ *
1739
+ * ...and "costs live cart updates" is not a price to pay for the whole page view
1740
+ * either: a subscription that failed this early gets a second attempt once the SDK
1741
+ * says it is ready. See `subscribeWhenSdkIsReady`.
1742
+ */
1743
+ this.wantsCartUpdates = true;
1744
+ if (!this.subscribeToCartUpdates())
1745
+ this.subscribeWhenSdkIsReady();
1746
+ /**
1747
+ * Fired HERE — beside the subscription, not behind the SDK wait and not behind
1748
+ * `refresh()` — so the cart lookup races the offers request instead of queueing
1749
+ * behind it. An empty cart is answered by the FIRST of the two to land, which is
1750
+ * what keeps a shopper on an empty cart from seeing the banner flash before it
1751
+ * decides to hide. Unawaited and non-rejecting for the same reason `load()` itself
1752
+ * is: nothing about the cart lookup may gate the offer.
1753
+ */
1754
+ this.loadCart();
1755
+ await this.waitForSdk();
1756
+ await this.refresh();
1757
+ }
1758
+ /**
1759
+ * `salla.onReady()`, bounded.
1760
+ *
1761
+ * The previous shape caught a REJECTED bootstrap but could not catch a never-settling
1762
+ * one, even though its comment claimed to — and an unbounded await here is the single
1763
+ * point at which the whole component can hang with nothing logged. Racing a timer
1764
+ * turns "the SDK never signals ready" from fatal into "we ask a few seconds early",
1765
+ * which `refresh()` already handles non-terminally and retries on `cart::updated`.
1766
+ *
1767
+ * Never rejects, and absorbs a synchronous throw from `onReady` itself.
1768
+ */
1769
+ waitForSdk() {
1770
+ let ready;
1771
+ try {
1772
+ ready = Promise.resolve(salla.onReady?.());
1773
+ }
1774
+ catch (error) {
1775
+ this.safeLog('salla.onReady() threw', error);
1776
+ return Promise.resolve();
1777
+ }
1778
+ // The timer is captured and cleared: `Promise.race` abandons the loser but does
1779
+ // NOT cancel it, so a bare `setTimeout` here would leave one dangling timer per
1780
+ // mounted component (and, in specs, fire after the test that created it).
1781
+ let timer;
1782
+ const deadline = new Promise(resolve => {
1783
+ timer = setTimeout(resolve, ON_READY_TIMEOUT_MS);
1784
+ });
1785
+ return Promise.race([
1786
+ ready.catch(error => this.safeLog('salla.onReady() rejected', error)),
1787
+ deadline,
1788
+ ]).then(() => {
1789
+ clearTimeout(timer);
61
1790
  });
62
1791
  }
63
- getProducts() {
64
- const freeProductIDs = this.offer.details.discounts.filter(({ type }) => type === DiscountType.FREE_PRODUCT).map(({ value }) => value).filter(Boolean);
65
- if (freeProductIDs.length > 0) {
66
- return salla.product.fetch({ source: 'selected', source_value: freeProductIDs }).then((response) => {
67
- const data = response?.data;
68
- if (data && Array.isArray(data)) {
69
- const updatedProducts = { ...this.products };
70
- data.forEach(({ id, url, image, name, price }) => {
71
- if (id && name && image?.url && url && price !== undefined) {
72
- updatedProducts[id] = { url, image, name, price };
73
- }
74
- });
75
- this.products = updatedProducts;
76
- }
77
- });
1792
+ /**
1793
+ * Put `cartUpdatedHandler` on `cart::updated` — at most ONCE, however many paths ask
1794
+ * (`load()`, the SDK-ready retry, a reconnect).
1795
+ *
1796
+ * Never throws, and reports whether the listener is on: `salla.event.on` can throw
1797
+ * this early (see `load()`), and the caller decides what a failure is worth.
1798
+ */
1799
+ subscribeToCartUpdates() {
1800
+ if (this.isListeningToCartUpdates)
1801
+ return true;
1802
+ try {
1803
+ salla.event.on("cart::updated", this.cartUpdatedHandler);
1804
+ this.isListeningToCartUpdates = true;
1805
+ }
1806
+ catch (error) {
1807
+ this.safeLog('cart::updated subscription failed', error);
78
1808
  }
1809
+ return this.isListeningToCartUpdates;
79
1810
  }
80
- updateInitialOfferValue() {
81
- return salla.api.cart.details().then(({ data: { cart } }) => this.updateOfferValues(cart));
1811
+ /** The other half of `subscribeToCartUpdates` — see `disconnectedCallback`. */
1812
+ unsubscribeFromCartUpdates() {
1813
+ if (!this.isListeningToCartUpdates)
1814
+ return;
1815
+ // Lowered FIRST: an `off` that throws must not leave a host that believes it is
1816
+ // still subscribed, or a later reconnect would never re-register.
1817
+ this.isListeningToCartUpdates = false;
1818
+ try {
1819
+ salla.event.off("cart::updated", this.cartUpdatedHandler);
1820
+ }
1821
+ catch (error) {
1822
+ this.safeLog('cart::updated unsubscription failed', error);
1823
+ }
82
1824
  }
83
- updateOfferValues({ items, sub_total }) {
84
- const current_value = this.offer.details.based_on === OfferType.PRODUCT_COUNT ? items?.reduce((count, { quantity }) => count + quantity, 0) ?? 0 : sub_total;
85
- //we need to assign the offer object a new reference to trigger re-rendering
86
- this.offer = {
87
- ...this.offer, details: {
88
- ...this.offer?.details, current_value,
89
- }
1825
+ /**
1826
+ * The second attempt for a subscription that threw before the SDK was up.
1827
+ *
1828
+ * Hung off `salla.onReady()` ITSELF, not off `waitForSdk()`: that wait gives up after
1829
+ * `ON_READY_TIMEOUT_MS` so that it cannot hold the first paint, and an SDK that comes
1830
+ * up later than that is exactly the one this is for. Floating for the same reason —
1831
+ * nothing in `load()` awaits it, so a bootstrap that never settles costs the listener
1832
+ * and nothing else.
1833
+ */
1834
+ subscribeWhenSdkIsReady() {
1835
+ let ready;
1836
+ try {
1837
+ ready = Promise.resolve(salla.onReady?.());
1838
+ }
1839
+ catch (error) {
1840
+ // `waitForSdk()` reports this same throw; there is no readiness to wait for.
1841
+ return;
1842
+ }
1843
+ const subscribe = () => {
1844
+ // A host that went away in the meantime re-subscribes in `connectedCallback`.
1845
+ if (!this.isDisconnected)
1846
+ this.subscribeToCartUpdates();
90
1847
  };
91
- return this.offer;
92
- }
93
- renderTooltip(discount) {
94
- const productId = discount.value;
95
- const productItem = this.products[productId];
96
- const targetId = `avatar-product-${productId}`;
97
- if (!productItem) {
98
- salla.logger.error(`salla-conditional-offer:: there is no product with id (${discount.value})!`);
1848
+ // `.then(f, f)`: a bootstrap that REJECTED is still the last word the SDK will have
1849
+ // on the matter, and the attempt itself cannot throw.
1850
+ ready.then(subscribe, subscribe);
1851
+ }
1852
+ /**
1853
+ * Open (or extend) the coalescing window, so a burst of `cart::updated` events buys ONE
1854
+ * refresh pass instead of two server round trips each. See `CART_UPDATE_COALESCE_MS`.
1855
+ *
1856
+ * Restarting the timer on every event is what makes this trailing-edge: the pass runs
1857
+ * `CART_UPDATE_COALESCE_MS` after the LAST event, never after the first, so the burst
1858
+ * can never settle on a cart state the shopper has already moved past.
1859
+ */
1860
+ scheduleCartRefresh() {
1861
+ this.cartRefreshPending = true;
1862
+ if (this.cartRefreshTimer !== null)
1863
+ clearTimeout(this.cartRefreshTimer);
1864
+ this.cartRefreshTimer = null;
1865
+ // A dead host schedules nothing, but stays OWED the pass — `connectedCallback`
1866
+ // re-arms it if the theme puts the host back.
1867
+ if (this.isDisconnected)
1868
+ return;
1869
+ this.cartRefreshTimer = setTimeout(() => {
1870
+ this.cartRefreshTimer = null;
1871
+ this.runCartRefresh();
1872
+ }, CART_UPDATE_COALESCE_MS);
1873
+ }
1874
+ /**
1875
+ * The one pass a burst is entitled to — byte for byte what the `cart::updated` handler
1876
+ * used to do inline, minus the count read that now happens per event.
1877
+ *
1878
+ * The disconnect guard comes FIRST and leaves the flag ALONE, so a host that went away
1879
+ * between the last event and the end of its window stays owed the pass rather than
1880
+ * silently forfeiting it. Unreachable in practice — `disconnectedCallback` clears the
1881
+ * timer — but the two must not disagree about who owes what.
1882
+ *
1883
+ * Past that, the flag is cleared BEFORE the work, not after: an event that arrives
1884
+ * while this pass is in flight must be able to open a NEW window (and it does —
1885
+ * `scheduleCartRefresh` sets the flag again), and clearing afterwards would swallow it.
1886
+ */
1887
+ runCartRefresh() {
1888
+ if (this.isDisconnected)
1889
+ return;
1890
+ this.cartRefreshPending = false;
1891
+ this.refresh();
1892
+ refreshCartFreeGifts().then(() => this.loadCart(), () => this.loadCart());
1893
+ }
1894
+ /**
1895
+ * Take the cart's item count straight off the `cart::updated` payload.
1896
+ *
1897
+ * THIS IS THE FIX FOR THE LIVE REMOVAL PATH, and it exists because the request-based
1898
+ * count cannot answer there. When the shopper deletes their last line the cart id goes
1899
+ * away, and BOTH cart endpoints then reject rather than reporting an empty cart:
1900
+ * `details()` resolves its id through `getCurrentCartId(false, …, allowCreate=false)`,
1901
+ * which returns `null` for a guest with no stored cart id, and throws `CartNotCreated`
1902
+ * (twilight/src/api/cart.js:192-199); `offers()` throws through the same door
1903
+ * (cart.js:869-874). `refreshCartFreeGifts()` has meanwhile invalidated the shared
1904
+ * cache, so the count resolves to `null` — "unknown" — and an unknown count renders the
1905
+ * banner. That is the beta regression: a cart that is empty, reported as unknowable.
1906
+ *
1907
+ * The payload has none of that dependency. It is delivered synchronously, by the same
1908
+ * event that told us the cart changed, and it cannot fail, hang, or lose a race.
1909
+ *
1910
+ * Two sources, in order: the payload's own `count`, then the `cart.summary.count` the
1911
+ * same emitter wrote a line earlier. Both are strict — see `toCartCount` — so a payload
1912
+ * that states nothing leaves the previous value untouched rather than reading as empty.
1913
+ */
1914
+ readCartCountFromEvent(payload) {
1915
+ const count = this.toCartCount(payload?.count) ?? this.readCartCountFromStorage();
1916
+ if (count === null)
1917
+ return;
1918
+ this.cartItemCount = count;
1919
+ }
1920
+ /**
1921
+ * The SECONDARY count, for a `cart::updated` that carried no usable `count` of its own.
1922
+ *
1923
+ * `SallaCartEvent.updated()` writes `cart.summary.count` from `data.cart`
1924
+ * (twilight/src/event/cart.js:102-110) SYNCHRONOUSLY, immediately before it dispatches
1925
+ * the event — so inside a `cart::updated` handler this value is as fresh as the payload
1926
+ * and comes from the same object. `mobileBridge._cart` falls back to exactly this, for
1927
+ * exactly this reason.
1928
+ *
1929
+ * ONLY safe from inside that handler, which is why it is private to
1930
+ * `readCartCountFromEvent` and never consulted at mount: on a cold load the same key
1931
+ * can hold a count left over from a previous session, and trusting it there would hide
1932
+ * the banner on a cart that is not empty.
1933
+ *
1934
+ * Read through the public `salla.storage` facade; wrapped, because storage is not
1935
+ * guaranteed to be installed and a throw here must cost nothing.
1936
+ */
1937
+ readCartCountFromStorage() {
1938
+ try {
1939
+ return this.toCartCount(salla.storage?.get?.('cart.summary.count'));
1940
+ }
1941
+ catch (error) {
99
1942
  return null;
100
1943
  }
101
- return h("salla-tooltip", { class: 'absolute left-0 -top-2', targetId: targetId, theme: "dark" }, h("div", { class: "flex gap-3 text-start" }, productItem?.image?.url && h("img", { src: productItem.image.url, alt: productItem?.image?.alt || productItem.name || "", class: "w-12 h-12 object-cover rounded-lg flex-shrink-0", loading: "lazy", decoding: "async" }), h("div", { class: "flex-1" }, h("div", { class: "leading-tight mb-1" }, productItem.name), h("div", { class: "text-xs text-gray-300", innerHTML: salla.money(productItem.price) }))));
102
1944
  }
103
- getCheckpointContent(discount) {
104
- if (discount.type === DiscountType.PERCENTAGE)
105
- return `${discount.value}%`;
106
- if (discount.type === DiscountType.FIXED)
107
- return h("span", { innerHTML: salla.money(discount.value) });
108
- if (discount.type !== DiscountType.FREE_PRODUCT) {
109
- salla.logger.error(`salla-conditional-offer:: unexpected type (${discount.type})!`);
110
- return "";
1945
+ /**
1946
+ * Coerce a reported count, STRICTLY. Only a finite, non-negative number is an answer;
1947
+ * `undefined`, `null`, a string and a negative all mean "not stated", which leaves the
1948
+ * previous value alone — the degenerate `{ fromLatestFetched }` payload
1949
+ * (event/cart.js:85) must never read as an empty cart.
1950
+ */
1951
+ toCartCount(value) {
1952
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
1953
+ }
1954
+ getFreeLabel() {
1955
+ const text = salla.lang.get(FREE_LABEL_KEY);
1956
+ return text && text !== FREE_LABEL_KEY ? text : FREE_LABEL_FALLBACK;
1957
+ }
1958
+ /**
1959
+ * Read a `blocks.cart_offer.*` translation.
1960
+ *
1961
+ * `salla.lang.get` returns the KEY VERBATIM when a translation is missing, which would
1962
+ * put "blocks.cart_offer.congrats" in front of a shopper. A missing key reads as its
1963
+ * `EN_FALLBACKS` entry instead, with the same `:placeholder` substitution.
1964
+ */
1965
+ trans(key, replacements) {
1966
+ const fullKey = `${LANG}.${key}`;
1967
+ const text = salla.lang.get(fullKey, replacements);
1968
+ if (text && text !== fullKey)
1969
+ return text;
1970
+ return Object.entries(replacements ?? {}).reduce((out, [token, value]) => out.split(`:${token}`).join(value), EN_FALLBACKS[key]);
1971
+ }
1972
+ /**
1973
+ * The ONLY eligibility question worth asking: does this store have the feature?
1974
+ *
1975
+ * `salla.config.get` is a plain object read and cannot throw, but the whole thing is
1976
+ * still wrapped: this predicate is called from a lifecycle hook, and "the SDK is not
1977
+ * on the page yet" must degrade to "not eligible *yet*", never to an exception.
1978
+ */
1979
+ isEnabled() {
1980
+ try {
1981
+ return !!salla.config.get('store.features')?.includes('conditional-offer');
1982
+ }
1983
+ catch (error) {
1984
+ return false;
1985
+ }
1986
+ }
1987
+ /**
1988
+ * ONE banner, even when several cart offers are live the FIRST conditional
1989
+ * offer carrying at least one checkpoint wins and the rest are ignored. Merging them
1990
+ * would produce a badge row whose tiers belong to different promotions.
1991
+ */
1992
+ async fetchOffer() {
1993
+ const { data } = await salla.api.cart.offers();
1994
+ const offer = (data || []).find(offer => offer.type === "conditional");
1995
+ return offer?.details?.discounts?.length ? offer : null;
1996
+ }
1997
+ /**
1998
+ * The single entry point for "ask the server what this cart is entitled to".
1999
+ *
2000
+ * Runs on the initial load AND on every `cart::updated`, and is IDEMPOTENT and
2001
+ * REVERSIBLE in both directions — `canRender` is derived from this pass alone, so a
2002
+ * cart that becomes eligible after starting out ineligible turns the component back
2003
+ * on. No state set here is a one-way door.
2004
+ */
2005
+ async refresh() {
2006
+ const generation = ++this.refreshGeneration;
2007
+ // Re-asked on EVERY pass rather than latched at mount. `store.features` arrives
2008
+ // with the page bootstrap, but a mount that beats it must be able to recover.
2009
+ if (!this.isEnabled()) {
2010
+ this.stopLoading();
2011
+ this.canRender = false;
2012
+ return;
111
2013
  }
112
- const productItem = this.products[discount.value];
113
- if (!productItem) {
114
- salla.logger.error(`salla-conditional-offer:: there is no product with id (${discount.value})!`);
115
- return "";
2014
+ /**
2015
+ * A cart we already KNOW is empty is a terminal answer: stop, render nothing, and
2016
+ * never enter the loading state — the cheapest correct behaviour, and the one that
2017
+ * makes a skeleton over "السلة فارغة" structurally impossible rather than merely
2018
+ * short-lived. Without this, emptying the cart still raised a skeleton whenever the
2019
+ * offer had been cleared, and a hanging `offers()` then held it up for the whole
2020
+ * watchdog window.
2021
+ *
2022
+ * `=== 0` only: `null` is "not answered yet" and must still take the normal path,
2023
+ * and the offer itself is deliberately left untouched, so re-adding an item brings
2024
+ * the banner straight back without another round trip.
2025
+ */
2026
+ if (this.cartItemCount === 0) {
2027
+ this.stopLoading();
2028
+ /**
2029
+ * ...but not a DEAD END when the `0` was never corroborated. This is the only
2030
+ * branch that returns without ever asking for the offer, so a `0` that was
2031
+ * wrong makes the banner unreachable for the rest of the page view: nothing
2032
+ * else calls `refresh()` except `cart::updated`, which a shopper who does not
2033
+ * touch the cart never emits.
2034
+ *
2035
+ * And a wrong `0` is reachable. The first cart lookup is fired from `load()`
2036
+ * BEFORE `salla.onReady()` settles, so it can be answered by an SDK that has
2037
+ * not installed the cart id yet — `details()` then rejects `CartNotCreated`,
2038
+ * which the shared module reads (rightly, for a real guest) as an empty cart.
2039
+ * Re-asking is what tells the two apart, and on a cart that IS empty it costs
2040
+ * nothing: the retry rejects again without reaching the network.
2041
+ *
2042
+ * GATED ON `hasAuthoritativeCartData()`, which is the whole safety of it. A
2043
+ * `0` that came from a response we actually read is FINAL and must stay final
2044
+ * — re-asking there would answer from the pre-mutation cache and bring the
2045
+ * banner back over an emptied cart, which is the beta bug this branch exists
2046
+ * to prevent. Only a `0` the lookup GUESSED is retried.
2047
+ */
2048
+ if (!hasAuthoritativeCartData()) {
2049
+ this.deferredByEmptyCart = true;
2050
+ this.loadCart();
2051
+ }
2052
+ return;
116
2053
  }
117
- return h("a", { class: "s-conditional-offer-product-link", href: productItem.url }, h("img", { class: "s-conditional-offer-checkpoint-image-content", loading: "lazy", decoding: "async", alt: productItem?.image?.alt || "", src: productItem?.image?.url }));
2054
+ // Past the empty-cart branch, so this pass IS going to ask for the offer and there
2055
+ // is nothing left for `loadCart()` to recover.
2056
+ this.deferredByEmptyCart = false;
2057
+ // Skeleton only while there is nothing to show yet. A refresh behind an already
2058
+ // rendered offer updates in place instead of collapsing to a skeleton. Going
2059
+ // through `startLoading()` is what guarantees a watchdog exists behind a skeleton
2060
+ // this method raised — `componentWillLoad` may never have armed one, because
2061
+ // `isEnabled()` can legitimately be false at mount and true by the time we get here.
2062
+ if (this.offer)
2063
+ this.stopLoading();
2064
+ else
2065
+ this.startLoading();
2066
+ try {
2067
+ const offer = await this.fetchOffer();
2068
+ // A newer cart::updated issued its own request while this one was in flight.
2069
+ // That request reflects a later cart state, so this response is stale by
2070
+ // definition — drop it rather than re-issuing.
2071
+ if (this.isSuperseded(generation))
2072
+ return;
2073
+ if (!offer) {
2074
+ this.offer = null;
2075
+ this.canRender = false;
2076
+ return;
2077
+ }
2078
+ // A new object reference is what triggers the re-render.
2079
+ this.offer = offer;
2080
+ this.canRender = true;
2081
+ // Gift LINES are an enhancement fetched only AFTER the banner has been handed
2082
+ // to `render()`; `gifts` is `@State`, so the card appears by itself when the
2083
+ // lookup lands, and a lookup that never settles costs nothing but the card.
2084
+ // Re-asked now that `this.offer` is known, because the gift filter is keyed to
2085
+ // the offer id — the early pass in `load()` could only answer the COUNT.
2086
+ this.loadCart();
2087
+ }
2088
+ catch (error) {
2089
+ if (this.isSuperseded(generation))
2090
+ return;
2091
+ /**
2092
+ * NOT terminal, and deliberately does not touch `canRender`. A failing
2093
+ * `offers()` call is a transient network/SDK condition, not a statement that
2094
+ * the promotion does not exist.
2095
+ */
2096
+ this.safeLog('offers() failed', error);
2097
+ }
2098
+ finally {
2099
+ /**
2100
+ * The skeleton ends here, on EVERY exit path — including the two early
2101
+ * `return`s above, which `finally` still runs.
2102
+ *
2103
+ * Lowering `isLoading` used to live inside the try, AFTER the staleness guard,
2104
+ * which conflated two different questions: "whose DATA wins" (the generation) and
2105
+ * "are we still loading" (this). A superseded or throwing pass answered the
2106
+ * first and silently skipped the second, leaving the skeleton up as a resting
2107
+ * state. Only the NEWEST pass lowers it, so an early-settling stale response
2108
+ * cannot flash empty content while the authoritative request is still in
2109
+ * flight — and because `finally` is unconditional, that newest pass always
2110
+ * reaches this line.
2111
+ */
2112
+ if (generation === this.refreshGeneration)
2113
+ this.stopLoading();
2114
+ }
2115
+ }
2116
+ /**
2117
+ * The single gate on rendering anything at all: an AFFIRMATIVE "the cart holds at
2118
+ * least one item".
2119
+ *
2120
+ * Deliberately not `!== 0` and not a truthy test. `cartItemCount` is `null` until
2121
+ * something answers, and `null` must read as "do not render" — the default is hidden,
2122
+ * and only a real, non-negative count of 1 or more opens the gate.
2123
+ */
2124
+ hasCartItems() {
2125
+ return typeof this.cartItemCount === 'number' && this.cartItemCount >= 1;
2126
+ }
2127
+ /**
2128
+ * Whether this pass's response should be discarded: a newer `refresh()` has since
2129
+ * been issued (so this one describes an older cart), or the host is gone. Answers
2130
+ * "whose data wins" ONLY — never "should the skeleton stay up"; see the `finally`
2131
+ * in `refresh()`.
2132
+ */
2133
+ isSuperseded(generation) {
2134
+ return generation !== this.refreshGeneration || this.isDisconnected;
2135
+ }
2136
+ /**
2137
+ * The two things this component needs from the CART itself: how many lines it holds,
2138
+ * and which of them are this offer's free gifts.
2139
+ *
2140
+ * ONE lookup for both. `getCartItemCount` and `getCartFreeGiftItems` are two
2141
+ * projections of the same shared `details()` response and share its cache, its
2142
+ * in-flight promise and its generation guard, so asking for both costs exactly one
2143
+ * request — and, more importantly, the count and the gift lines can never describe
2144
+ * two different cart states.
2145
+ *
2146
+ * Never rejects: a failing lookup is a missing "ادارة العرض" card and an UNKNOWN
2147
+ * count, not a missing promotion and not an empty cart.
2148
+ */
2149
+ loadCart() {
2150
+ const generation = ++this.giftsGeneration;
2151
+ return Promise.all([getCartFreeGiftItems(), getCartItemCount()])
2152
+ .then(([items, count]) => {
2153
+ if (generation !== this.giftsGeneration || this.isDisconnected)
2154
+ return;
2155
+ /**
2156
+ * A `cart::updated` is waiting for its pass, so this answer describes the
2157
+ * cart BEFORE that mutation whichever way it was obtained: in flight when
2158
+ * the event arrived, or started since and served the pre-mutation count
2159
+ * without a request, because the shared cache is only invalidated when the
2160
+ * window closes. Publishing it would put a stale positive count back over
2161
+ * the event's `0`. Dropped outright: the pass re-asks, against an
2162
+ * invalidated cache.
2163
+ */
2164
+ if (this.cartRefreshPending)
2165
+ return;
2166
+ /**
2167
+ * NEVER walks a KNOWN count back to `null`. `count` is `null` whenever the
2168
+ * shared lookup could not answer — which, on the removal path, is every
2169
+ * time (see `readCartCountFromEvent`). Publishing that `null` over the 0 the
2170
+ * event already established is exactly how the banner came back from the
2171
+ * dead; an unknown answer is not new information, so it changes nothing.
2172
+ */
2173
+ if (count !== null)
2174
+ this.cartItemCount = count;
2175
+ /**
2176
+ * An answer ends the retry episode. No answer, on a cart whose count is
2177
+ * STILL unknown, buys another attempt — see `CART_LOOKUP_RETRY_DELAYS_MS`.
2178
+ * A count something else already established needs no retry: the banner's
2179
+ * gate is open or shut on it either way.
2180
+ */
2181
+ if (count !== null)
2182
+ this.clearCartLookupRetry();
2183
+ else if (this.cartItemCount === null)
2184
+ this.scheduleCartLookupRetry();
2185
+ /**
2186
+ * The cart turned out to hold items after all — so the `refresh()` that
2187
+ * bailed out before asking for the offer did so on a count that was wrong,
2188
+ * and nothing else will ever re-run it. Run it here.
2189
+ *
2190
+ * The latch is what keeps this to a RECOVERY rather than a second refresh
2191
+ * on every load: it is set by exactly one branch of `refresh()` and
2192
+ * cleared both here and by the first pass that gets past that branch, so
2193
+ * the normal path — and a store with no conditional offer at all — never
2194
+ * reaches this line. Bounded by construction: `refresh()` re-asks the cart
2195
+ * only on `count === 0`, this re-runs `refresh()` only on `count >= 1`,
2196
+ * and it can only do so while the latch is set.
2197
+ */
2198
+ if (this.deferredByEmptyCart && this.hasCartItems()) {
2199
+ this.deferredByEmptyCart = false;
2200
+ this.refresh();
2201
+ }
2202
+ /**
2203
+ * The gift card is keyed to the offer, so it can only be filled once the
2204
+ * offer is known. The early pass fired from `load()` runs BEFORE
2205
+ * `refresh()` has settled; publishing gifts from it would let another
2206
+ * promotion's gift line flash in the card, because `selectOwnGifts` has no
2207
+ * offer id to filter on yet. `refresh()` calls back here the moment it has
2208
+ * one.
2209
+ */
2210
+ this.gifts = this.offer ? this.selectOwnGifts(items) : [];
2211
+ })
2212
+ .catch(error => {
2213
+ this.safeLog('cart lookup failed', error);
2214
+ });
2215
+ }
2216
+ /**
2217
+ * Arm the next cart-lookup retry, unless one is already armed or the episode has spent
2218
+ * its attempts. See `CART_LOOKUP_RETRY_DELAYS_MS`.
2219
+ *
2220
+ * The attempt is SPENT when the timer fires, not when it is armed, so a timer a
2221
+ * disconnect cleared is re-armed by `connectedCallback` without costing one.
2222
+ */
2223
+ scheduleCartLookupRetry() {
2224
+ if (this.cartLookupRetries >= CART_LOOKUP_RETRY_DELAYS_MS.length)
2225
+ return;
2226
+ this.cartLookupRetryPending = true;
2227
+ // A dead host arms nothing, but stays owed the retry — as `scheduleCartRefresh`.
2228
+ if (this.cartLookupRetryTimer !== null || this.isDisconnected)
2229
+ return;
2230
+ this.cartLookupRetryTimer = setTimeout(() => {
2231
+ this.cartLookupRetryTimer = null;
2232
+ this.cartLookupRetryPending = false;
2233
+ // Decided HERE rather than when the timer was armed, which can be before
2234
+ // `refresh()` has answered: with the feature off, or no offer on the store,
2235
+ // there is nothing the count could reveal and so nothing worth a request.
2236
+ if (!this.canRender)
2237
+ return;
2238
+ this.cartLookupRetries++;
2239
+ this.loadCart();
2240
+ }, CART_LOOKUP_RETRY_DELAYS_MS[this.cartLookupRetries]);
2241
+ }
2242
+ /**
2243
+ * End the retry episode: a lookup answered, or the cart changed under it. The count
2244
+ * goes back to zero so a LATER failure gets its own full set of attempts.
2245
+ */
2246
+ clearCartLookupRetry() {
2247
+ this.cartLookupRetries = 0;
2248
+ this.cartLookupRetryPending = false;
2249
+ if (this.cartLookupRetryTimer === null)
2250
+ return;
2251
+ clearTimeout(this.cartLookupRetryTimer);
2252
+ this.cartLookupRetryTimer = null;
2253
+ }
2254
+ /**
2255
+ * Keep only the gift lines this offer granted.
2256
+ *
2257
+ * `CartItemTransformer::getFreeGiftData` emits `offer_id` OPTIMUS-ENCODED, exactly
2258
+ * like `CartOffersTransformer` emits `id`, so the two join directly. When the offer id
2259
+ * is unreadable we fall back to every gift line rather than hiding the card: only one
2260
+ * cart offer can be applied to a cart at a time, so an unmatched gift is far more
2261
+ * likely to be an id-shape surprise than another promotion's product.
2262
+ */
2263
+ selectOwnGifts(items) {
2264
+ const offerId = Number(this.offer?.id);
2265
+ if (!Number.isFinite(offerId))
2266
+ return items;
2267
+ const owned = items.filter(gift => gift.offerId !== null && Number(gift.offerId) === offerId);
2268
+ return owned.length ? owned : items.filter(gift => gift.offerId === null);
2269
+ }
2270
+ /**
2271
+ * Stages in ascending threshold order — the payload itself is UNSORTED.
2272
+ *
2273
+ * LOAD-BEARING: this filter and sort must mirror what `computeOfferStageProgress`
2274
+ * does internally, because every renderer pairs `stages[i]` with
2275
+ * `progress.thresholds[i]`. Both drop unusable thresholds via `isUsableThreshold` and
2276
+ * both sort ascending and stably, so the two arrays stay index-aligned.
2277
+ */
2278
+ getStages() {
2279
+ const usable = (this.offer?.details?.discounts ?? []).filter(({ min_spend }) => isUsableThreshold(min_spend));
2280
+ // Ties break on the tier `id` (0 = base tier, 1..N = options in engine order), so two
2281
+ // tiers sharing a `min_spend` order by identity rather than by their position in an
2282
+ // unsorted payload. `id` is absent on an older backend; then it is the old index tie-break.
2283
+ return sortByThreshold(usable, ({ min_spend }) => min_spend, ({ id }) => id);
2284
+ }
2285
+ /**
2286
+ * The tier picture: the ENGINE'S verdict per tier plus the threshold projection.
2287
+ *
2288
+ * `is_applied` is passed positionally from THIS array, so the flag can never be
2289
+ * attributed to the wrong tier — `stages[i]`, `progress.thresholds[i]`,
2290
+ * `progress.met[i]` and `progress.applied[i]` are one row. Alignment matters more
2291
+ * than ever now: the winner's INDEX decides where the ✓ run stops.
2292
+ *
2293
+ * Each tier's OWN `current_value` rides along the same way. It is what the distance in
2294
+ * the guidance line is measured against, and a tier that carries none — every tier, on
2295
+ * an older backend — is measured by the offer-level value exactly as before.
2296
+ */
2297
+ getProgress(stages) {
2298
+ return computeOfferStageProgress(this.offer?.details?.current_value, stages.map(({ min_spend }) => min_spend), this.offer?.details?.start_value, stages.map(({ is_applied }) => is_applied), stages.map(({ current_value }) => current_value));
2299
+ }
2300
+ /**
2301
+ * A tier's render key. `id` is the stable identity across refetches; the array
2302
+ * position is the last resort for a backend that does not send one.
2303
+ */
2304
+ tierKey(discount, index) {
2305
+ const id = Number(discount?.id);
2306
+ return Number.isFinite(id) ? `tier-${id}` : `pos-${index}`;
2307
+ }
2308
+ /**
2309
+ * How this offer MEASURES its thresholds, as `offer-stages` names it .
2310
+ *
2311
+ * The remainder is rounded to the measure — whole products for `products_count`,
2312
+ * store currency precision for everything else — so `formatRequirement` is handed a
2313
+ * value its formatter can actually render. A `products_count` offer is the one that
2314
+ * MUST be told apart: `salla.lang.choice` matches a fraction against no plural
2315
+ * interval and answers "no products".
2316
+ */
2317
+ stageMeasure() {
2318
+ return this.offer?.details?.based_on === OfferType.PRODUCT_COUNT
2319
+ ? OfferStageMeasure.COUNT
2320
+ : OfferStageMeasure.CURRENCY;
118
2321
  }
119
- getOfferType(discount) {
120
- const basedOn = this.offer.details?.based_on;
2322
+ /**
2323
+ * Format a cart value (a threshold, or a remaining amount/quantity) the way the
2324
+ * offer is measured: money for `order_amount`, a pluralised item count for
2325
+ * `products_count`. May return HTML (`salla.money`), so callers use `innerHTML`.
2326
+ */
2327
+ formatRequirement(value) {
2328
+ const basedOn = this.offer?.details?.based_on;
121
2329
  if (basedOn === OfferType.PRODUCT_COUNT) {
122
2330
  try {
123
- return salla.lang.choice("blocks.header.products_count", discount.min_spend);
2331
+ return salla.lang.choice("blocks.header.products_count", value);
124
2332
  }
125
2333
  catch (error) {
126
- // Some stores may define an invalid interval (e.g. [1,*]) in translations.
127
- // Fallback to a safe plain label so the component does not crash.
128
- salla.logger.warn("salla-conditional-offer:: invalid products_count interval", error);
129
- return `${discount.min_spend}`;
2334
+ // Some stores define an invalid interval (e.g. [1,*]) in translations.
2335
+ // safeLog, not salla.logger: this runs inside render(), where a throwing
2336
+ // logger would take the whole component down.
2337
+ this.safeLog('invalid products_count interval', error);
2338
+ return `${value}`;
130
2339
  }
131
2340
  }
132
2341
  if (basedOn === OfferType.PRODUCT_PURCHASE)
133
- return salla.money(discount.min_spend);
134
- salla.logger.warn(`salla-conditional-offer:: Unexpected offer detail's based_on value: ${basedOn}`);
135
- return `${discount.min_spend}`;
136
- }
137
- clamp(value, min, max) {
138
- return Math.max(min, Math.min(value, max));
139
- }
140
- mapValueRanges(value, initialMinRange, initialMaxRange, newMinRange, newMaxRange) {
141
- const newRange = ((value - initialMinRange) * (newMaxRange - newMinRange)) / (initialMaxRange - initialMinRange) + newMinRange;
142
- if (newRange === Number.POSITIVE_INFINITY)
143
- return 100;
144
- if (newRange === Number.NEGATIVE_INFINITY)
145
- return 0;
146
- return this.clamp(newRange, 0, 100);
147
- }
148
- getCheckPointView(discount, index) {
149
- const checkpointIndex = this.offer.details.discounts.findIndex(({ min_spend }) => min_spend === discount.min_spend);
150
- const previousCheckpointValue = this.offer.details.discounts[checkpointIndex - 1]?.min_spend ?? 0;
151
- const progressPercentage = this.mapValueRanges(this.offer.details.current_value, previousCheckpointValue, discount.min_spend, 0, 100);
152
- const isActive = discount.min_spend <= this.offer.details.current_value;
153
- return (h("div", { class: "s-conditional-offer-checkpoint-container" }, index > 0 ? [
154
- h("div", { key: "progress-line", class: "s-conditional-offer-progress-line-container" }, h("div", { class: "s-conditional-offer-progress-line-inactive" }), h("div", { class: "s-conditional-offer-progress-line-active", style: { width: `${progressPercentage}%` } })),
155
- h("div", { key: "checkpoint", class: `s-conditional-offer-checkpoint ${isActive ? "s-conditional-offer-active-checkpoint" : ""}` }, h("div", { class: `s-conditional-offer-item-avatar-content ${isActive ? "active" : ""}`, id: discount.type === DiscountType.FREE_PRODUCT ? `avatar-product-${discount.value}` : `avatar-${discount.value ?? index}` }, this.products && this.getCheckpointContent(discount)), this.products && discount.type === DiscountType.FREE_PRODUCT && this.renderTooltip(discount), h("div", { class: `s-conditional-offer-checkpoint-label ${isActive ? "active" : ""}`, innerHTML: this.getOfferType(discount) }))
156
- ] :
157
- h("div", { key: "label", class: { "s-conditional-offer-checkpoint-label": true, "first-checkpoint": index === 0, active: isActive }, innerHTML: this.getOfferType(discount) })));
2342
+ return salla.money(value);
2343
+ this.safeLog(`unexpected offer detail's based_on value: ${basedOn}`);
2344
+ return `${value}`;
2345
+ }
2346
+ /**
2347
+ * The shopper-facing reward text, SERVER-RENDERED.
2348
+ *
2349
+ * Never re-derived here. `reward_label` already carries the type-specific wording,
2350
+ * the currency the value was converted to, and the merchant's own theme-translation
2351
+ * overrides. Empty (free_shipping, unknown types) means "render nothing", which is
2352
+ * why every caller has a reward-less sentence to fall back to.
2353
+ */
2354
+ getRewardText(discount) {
2355
+ return (discount?.reward_label ?? '').trim();
2356
+ }
2357
+ /**
2358
+ * Guidance toward the first tier ABOVE the one the engine granted. Empty once
2359
+ * that is the last tier (then the congratulations line renders instead).
2360
+ *
2361
+ * The TARGET comes from the verdict (`nextUnmetIndex`), the DISTANCE from the
2362
+ * arithmetic — which is legitimate here, because "add :remaining" is a projection
2363
+ * about the shopper's cart, not a claim about what the engine did. It is measured in
2364
+ * the TARGET tier's own `current_value` when the backend sends one (see `getProgress`).
2365
+ *
2366
+ * Without that the two can disagree: the engine can withhold a tier whose threshold is
2367
+ * already met, because its own excluded-product set is not in the payload. Then the distance
2368
+ * is 0 and "Add SAR 0 and get a free gift" is false in both halves, so the line is
2369
+ * dropped rather than rendered — and we do NOT skip ahead to a farther tier, which
2370
+ * would advertise a reward past a nearer one the shopper cannot have. Nothing is the
2371
+ * honest answer; the tooltip still shows the tier as locked.
2372
+ */
2373
+ getGuidanceMessage(stages, progress) {
2374
+ if (progress.nextUnmetIndex === NO_STAGE_REACHED)
2375
+ return '';
2376
+ const remainingValue = getRemainingToStage(progress.nextUnmetIndex, progress, this.stageMeasure());
2377
+ if (remainingValue <= 0)
2378
+ return this.trans('guidance_conditions_unmet');
2379
+ const remaining = this.formatRequirement(remainingValue);
2380
+ const reward = this.getRewardText(stages[progress.nextUnmetIndex]);
2381
+ return reward
2382
+ ? this.trans('guidance_next', { remaining, reward: escapeHtml(reward) })
2383
+ : this.trans('guidance_next_generic', { remaining });
2384
+ }
2385
+ /** One tooltip row's sentence: "spend X, get Y", both halves from the offer payload. */
2386
+ getTierSentence(discount, index, progress) {
2387
+ const threshold = this.formatRequirement(progress.thresholds[index]);
2388
+ const reward = this.getRewardText(discount);
2389
+ /**
2390
+ * "Spend" is a MONEY verb, and the Arabic frame says it outright ("اشترِ بقيمة")
2391
+ * — so a products_count offer read "buy 3 products WORTH", describing a cart value
2392
+ * that no tier in it measures. The threshold half was already measure-correct
2393
+ * (@see formatRequirement); only the sentence around it assumed currency.
2394
+ */
2395
+ const isCount = this.stageMeasure() === OfferStageMeasure.COUNT;
2396
+ return reward
2397
+ ? this.trans(isCount ? 'tier_requirement_count' : 'tier_requirement', { threshold, reward: escapeHtml(reward) })
2398
+ : this.trans(isCount ? 'tier_requirement_count_generic' : 'tier_requirement_generic', { threshold });
2399
+ }
2400
+ /**
2401
+ * The reward-TYPE icon shown in a tooltip row's circle.
2402
+ *
2403
+ * `fixed` is the awkward one: the design draws the Saudi Riyal glyph, but a store
2404
+ * selling in USD must not. `sicon-sar` is used on exactly the same condition
2405
+ * `salla.money()` uses to swap the currency for that glyph
2406
+ * (`base/src/helpers/numbers.ts:78-86`), and a currency-neutral banknote otherwise.
2407
+ */
2408
+ getRewardIcon(discount) {
2409
+ switch (discount?.type) {
2410
+ case DiscountType.PERCENTAGE:
2411
+ return 'sicon-percentage';
2412
+ case DiscountType.FREE_PRODUCT:
2413
+ return 'sicon-gift';
2414
+ case DiscountType.FIXED:
2415
+ return this.usesSarSymbol() ? 'sicon-sar' : 'sicon-banknote';
2416
+ default:
2417
+ return 'sicon-tag';
2418
+ }
2419
+ }
2420
+ usesSarSymbol() {
2421
+ try {
2422
+ const symbol = salla.config.currency?.()?.symbol;
2423
+ return ['SAR', 'ر.س'].includes(symbol) && !!salla.config.get('store.settings.use_sar_symbol');
2424
+ }
2425
+ catch (error) {
2426
+ return false;
2427
+ }
2428
+ }
2429
+ // ---------------------------------------------------------------------------------
2430
+ // Tooltip — opens on hover AND on click (the click is the mobile path), and
2431
+ // is dismissible by Escape, by an outside click, and by un-hovering.
2432
+ // ---------------------------------------------------------------------------------
2433
+ get isTooltipOpen() {
2434
+ return this.isTooltipHovered || this.isTooltipPinned;
2435
+ }
2436
+ toggleTooltip() {
2437
+ this.isTooltipPinned = !this.isTooltipPinned;
2438
+ }
2439
+ closeTooltip(restoreFocus = false) {
2440
+ if (!this.isTooltipOpen)
2441
+ return;
2442
+ this.isTooltipPinned = false;
2443
+ this.isTooltipHovered = false;
2444
+ if (restoreFocus)
2445
+ this.infoTriggerEl?.focus?.();
2446
+ }
2447
+ /**
2448
+ * WHY THIS IS NOT `salla-tooltip`.
2449
+ *
2450
+ * It is a fair question — the component exists, it is shared, and this component used
2451
+ * to depend on it (master renders a `<salla-tooltip theme="dark">` product preview off
2452
+ * the badge avatars). That usage was hover-only, non-interactive and string-sized, and
2453
+ * it is exactly the shape `salla-tooltip` is built for. The ⓘ popover this design asks
2454
+ * for is not, and the gap is not one prop wide:
2455
+ *
2456
+ * - VISIBILITY IS PRIVATE TO IT. `show` is `@State`, flipped only by the component's
2457
+ * own `mouseover`/`mouseout` window listeners (salla-tooltip.tsx:26-38). There is no
2458
+ * `open` prop and no way for an owner to pin it, so the CLICK path — which is the
2459
+ * ONLY way this popover opens on a touch device — cannot be expressed at all.
2460
+ * - IT IS DELIBERATELY UNREACHABLE. `pointer-events: none` (salla-tooltip.scss:20).
2461
+ * A pinned panel the shopper cannot hover or scroll is not a mobile path.
2462
+ * - NO DISMISSAL MODEL. Escape and outside-click do not exist there, because with
2463
+ * hover-only visibility there is nothing to dismiss.
2464
+ * - NO POSITIONING TO REUSE. It is pinned by two hand-tuned magic percentages —
2465
+ * `translateX(-63%)` and `translateX(-72%)` under `:dir(ltr)` — with a caret nailed
2466
+ * to `left: 50%` and never re-aimed. There is no viewport clamp, so the exact
2467
+ * behaviour this method exists for is the one piece it does not have.
2468
+ * - IT IS SIZED FOR A STRING. `max-width: 150px` / `width: 190px` default,
2469
+ * `max-width: 330px` dark. This panel is a title, a description and one row per
2470
+ * tier.
2471
+ * - IT ANCHORS BY GLOBAL ID. `document.getElementById(targetId)` at `componentDidLoad`
2472
+ * (salla-tooltip.tsx:40-43). The ⓘ trigger has no stable document-unique id, and the
2473
+ * banner can legitimately mount more than once on a page.
2474
+ *
2475
+ * Closing all six is not "a prop, not a hack" — it is a controlled-visibility API, a
2476
+ * dismissal model, an anchoring change and a positioning engine, i.e. a rewrite of all
2477
+ * 55 lines of it. And `salla-tooltip` is SHARED: `salla-comment-item` and `salla-wallet`
2478
+ * both pass a plain `text` + `targetId` and depend on precisely the hover-only,
2479
+ * `pointer-events: none`, magic-percentage behaviour above, with a single spec between
2480
+ * them that asserts its exact rendered HTML. Rewriting it under them to serve this
2481
+ * banner trades a scoped 90 lines here for a silent regression surface there.
2482
+ *
2483
+ * So the bespoke implementation stays, and stays SMALL: everything below is the clamp
2484
+ * and the caret. The resting position is plain CSS.
2485
+ *
2486
+ * Keep the popover pointing at the ⓘ icon AND inside the viewport.
2487
+ *
2488
+ * The CSS already centres it on the trigger — that is the resting answer, and it is
2489
+ * correct in RTL and LTR alike because centring is symmetric, which is exactly why the
2490
+ * rules are written with physical `left`/`translateX` rather than `inset-inline-*`.
2491
+ * Anything logical would have to be mirrored again here, in a coordinate space
2492
+ * (`getBoundingClientRect`) that is physical no matter what the document direction is.
2493
+ *
2494
+ * All this adds on top is the CLAMP: at narrow widths a popover centred on an icon
2495
+ * that sits near the edge of the screen would hang off it, so the box is nudged back
2496
+ * inside and the caret is shifted by the same amount in the opposite direction, so it
2497
+ * stays over the icon while the box moves out from under it.
2498
+ *
2499
+ * Written as CSS CUSTOM PROPERTIES on the element, never as `@State`: this runs from
2500
+ * `componentDidRender`, and anything that re-entered render() from here would loop.
2501
+ */
2502
+ positionTooltip() {
2503
+ const tooltip = this.tooltipEl;
2504
+ const trigger = this.infoTriggerEl;
2505
+ if (!this.isTooltipOpen || !tooltip || !trigger)
2506
+ return;
2507
+ try {
2508
+ /**
2509
+ * Zeroed BEFORE measuring. The rect below already includes whatever shift the
2510
+ * previous pass wrote, so correcting incrementally would drift a little further
2511
+ * every render; from zero the measurement is absolute and one pass is exact.
2512
+ */
2513
+ this.setTooltipOffsets(tooltip, 0, 0);
2514
+ const viewport = window.innerWidth || document.documentElement?.clientWidth || 0;
2515
+ const box = tooltip.getBoundingClientRect();
2516
+ const anchor = trigger.getBoundingClientRect();
2517
+ const width = box?.width || 0;
2518
+ /**
2519
+ * No layout to measure — a spec DOM, a display:none ancestor, a browser that
2520
+ * has not painted yet. The CSS default (centred on the trigger) is already the
2521
+ * right answer everywhere except at a viewport edge, so degrade to it rather
2522
+ * than to NaN.
2523
+ */
2524
+ if (width <= 0 || viewport <= 0)
2525
+ return;
2526
+ const centre = anchor.left + anchor.width / 2;
2527
+ const ideal = centre - width / 2;
2528
+ const gutter = TOOLTIP_VIEWPORT_GUTTER_PX;
2529
+ // `Math.max` on the ceiling: a popover WIDER than the viewport has no valid
2530
+ // resting place, and pinning it to the leading gutter beats a negative range.
2531
+ const clamped = Math.min(Math.max(ideal, gutter), Math.max(gutter, viewport - gutter - width));
2532
+ const shift = clamped - ideal;
2533
+ // The caret undoes the shift so it stays over the icon — but only as far as the
2534
+ // panel's own corner; see TOOLTIP_CARET_INSET_PX.
2535
+ const limit = Math.max(0, width / 2 - TOOLTIP_CARET_INSET_PX);
2536
+ const caret = Math.min(Math.max(-shift, -limit), limit);
2537
+ this.setTooltipOffsets(tooltip, shift, caret);
2538
+ }
2539
+ catch (error) {
2540
+ // A popover that could not be measured still renders, centred — never let
2541
+ // positioning cost the content.
2542
+ this.safeLog('positioning the offer details popover failed', error);
2543
+ }
2544
+ }
2545
+ setTooltipOffsets(tooltip, shift, caret) {
2546
+ tooltip.style?.setProperty?.('--s-conditional-offer-tooltip-shift', `${Math.round(shift)}px`);
2547
+ tooltip.style?.setProperty?.('--s-conditional-offer-tooltip-caret', `${Math.round(caret)}px`);
2548
+ }
2549
+ /**
2550
+ * A resize changes which edge the popover is up against, so the clamp has to be
2551
+ * re-derived. Cheap: `positionTooltip` returns on its first line unless the popover is
2552
+ * actually open.
2553
+ */
2554
+ handleWindowResize() {
2555
+ this.positionTooltip();
2556
+ }
2557
+ handleDocumentKeydown(event) {
2558
+ // `Esc` is IE/old-Edge; harmless to keep and free to check.
2559
+ if (event.key !== 'Escape' && event.key !== 'Esc')
2560
+ return;
2561
+ this.closeTooltip(true);
2562
+ }
2563
+ /**
2564
+ * Outside click dismisses a PINNED tooltip. `composedPath` rather than
2565
+ * `event.target`, so a click on a slotted/nested element still resolves to the
2566
+ * anchor; `contains` is the fallback for the (test) environments that lack it.
2567
+ */
2568
+ handleDocumentClick(event) {
2569
+ if (!this.isTooltipPinned || !this.infoAnchorEl)
2570
+ return;
2571
+ const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
2572
+ const inside = path.length
2573
+ ? path.includes(this.infoAnchorEl)
2574
+ : this.infoAnchorEl.contains(event.target);
2575
+ if (!inside)
2576
+ this.closeTooltip();
2577
+ }
2578
+ // ---------------------------------------------------------------------------------
2579
+ // Render
2580
+ // ---------------------------------------------------------------------------------
2581
+ /**
2582
+ * The `id` of one of this instance's own elements. EVERY id and every reference to it
2583
+ * (`aria-describedby`, `aria-controls`) goes through here, so the two cannot drift.
2584
+ */
2585
+ elementId(name) {
2586
+ return `s-conditional-offer-${name}-${this.instanceId}`;
2587
+ }
2588
+ /**
2589
+ * The badge row: one small circle per tier, joined by short connectors.
2590
+ *
2591
+ * Tier count is VARIABLE, so the row is a nowrap flex line that scrolls
2592
+ * horizontally inside a bounded width instead of squeezing the message beside it.
2593
+ * RTL correctness comes from the markup: a plain flex row inherits the document's
2594
+ * `dir`, so tier 1 lands on the leading edge and the scroll origin follows it in both
2595
+ * directions. Do not add physical left/right offsets here.
2596
+ */
2597
+ renderBadges(stages, progress) {
2598
+ // The ENGINE-DERIVED count, not `lastReachedIndex + 1`: when the engine has spoken
2599
+ // it counts the tiers up to the one it GRANTED, which is not what the thresholds
2600
+ // say — a cart clearing every threshold with nothing granted counts 0 of 3.
2601
+ const completed = progress.metCount;
2602
+ return (h("div", { class: "s-conditional-offer-badges", role: "img", "aria-label": this.trans('badges_summary', {
2603
+ completed: `${completed}`,
2604
+ total: `${stages.length}`,
2605
+ }) }, stages.map((discount, index) => {
2606
+ // THE STEPPER: tiers up to the one the engine GRANTED (`is_applied`), or the
2607
+ // threshold comparison when this backend sends no flag at all. Never
2608
+ // `index <= lastReachedIndex`, and never `progress.applied[index]` — a cart
2609
+ // offer pays out once, so reading the raw flag put a lock BELOW a check.
2610
+ const isMet = progress.met[index];
2611
+ const key = this.tierKey(discount, index);
2612
+ return [
2613
+ /* The met connector is a SOLID 8x1px line in the primary teal, the unmet
2614
+ one a DASHED 1px line in a dark neutral. They sit in a 16px gap
2615
+ between two 24px badges; the 4px breathing space is the row's
2616
+ `gap-1`, so the connector element itself is just the 8px rule. */
2617
+ index > 0 ? (h("span", { key: `${key}-link`, class: {
2618
+ 's-conditional-offer-badge-link': true,
2619
+ 's-conditional-offer-badge-link-met': isMet,
2620
+ 's-conditional-offer-badge-link-unmet': !isMet,
2621
+ }, "aria-hidden": "true" })) : null,
2622
+ h("span", { key: key, class: {
2623
+ 's-conditional-offer-badge': true,
2624
+ 's-conditional-offer-badge-met': isMet,
2625
+ 's-conditional-offer-badge-unmet': !isMet,
2626
+ }, "data-tier-state": isMet ? 'met' : 'unmet', "data-tier-id": discount?.id ?? undefined }, isMet ? (h(CheckCircleIcon, { class: "s-conditional-offer-badge-icon" })) : (h(LockIcon, { class: "s-conditional-offer-badge-icon" }))),
2627
+ ];
2628
+ })));
2629
+ }
2630
+ /**
2631
+ * Spec §4 — the ⓘ popover: title, the merchant's offer message, then one row per tier.
2632
+ *
2633
+ * The prose is `details.rules`, NEVER `offer.description` — see `readOfferRules`.
2634
+ */
2635
+ renderTooltip(stages, progress) {
2636
+ const description = readOfferRules(this.offer);
2637
+ return (h("div", { class: "s-conditional-offer-tooltip", id: this.elementId('tooltip'), role: "tooltip", ref: (el) => (this.tooltipEl = el) }, h("div", { class: "s-conditional-offer-tooltip-title" }, this.offer?.title), description ? h("div", { class: "s-conditional-offer-tooltip-description" }, description) : null, h("ul", { class: "s-conditional-offer-tooltip-list" }, stages.map((discount, index) => {
2638
+ // Same single source of truth as the badge row — see `renderBadges`.
2639
+ const isMet = progress.met[index];
2640
+ return (h("li", { key: this.tierKey(discount, index), class: "s-conditional-offer-tier", "data-tier-state": isMet ? 'met' : 'unmet', "data-tier-id": discount?.id ?? undefined }, h("span", { class: {
2641
+ 's-conditional-offer-tier-icon': true,
2642
+ 's-conditional-offer-tier-icon-met': isMet,
2643
+ 's-conditional-offer-tier-icon-unmet': !isMet,
2644
+ } }, h("i", { class: this.getRewardIcon(discount), "aria-hidden": "true" }), h("span", { class: {
2645
+ 's-conditional-offer-tier-status': true,
2646
+ 's-conditional-offer-tier-status-met': isMet,
2647
+ 's-conditional-offer-tier-status-unmet': !isMet,
2648
+ }, "aria-hidden": "true" }, isMet ? (h(CheckCircleIcon, { class: "s-conditional-offer-tier-status-check" })) : (h(LockIcon, { class: "s-conditional-offer-tier-status-lock" })))), h("div", { class: "s-conditional-offer-tier-body" }, h("div", { class: "s-conditional-offer-tier-title" }, this.trans('tier_title', { number: `${index + 1}` })), h("div", { class: {
2649
+ 's-conditional-offer-tier-text': true,
2650
+ 's-conditional-offer-tier-text-met': isMet,
2651
+ }, innerHTML: this.getTierSentence(discount, index, progress) }))));
2652
+ }))));
2653
+ }
2654
+ /**
2655
+ * "ادارة العرض". Rendered TWICE with complementary responsive visibility rather than
2656
+ * moved with `order:`: desktop wants it inline with the banner row, mobile wants a
2657
+ * full-width bar BELOW the gifts card, and those are different places
2658
+ * in the flow, not a different order within one flex line.
2659
+ */
2660
+ renderGiftsToggle(variant) {
2661
+ return (h("button", { type: "button", id: this.elementId(`toggle-${variant}`), class: {
2662
+ 's-conditional-offer-toggle': true,
2663
+ 's-conditional-offer-toggle-inline': variant === 'inline',
2664
+ 's-conditional-offer-toggle-bar': variant === 'bar',
2665
+ }, "aria-expanded": this.isGiftsOpen ? 'true' : 'false', "aria-controls": this.elementId('gifts'), onClick: () => (this.isGiftsOpen = !this.isGiftsOpen) }, h("span", null, this.trans('manage_offer')), h("i", { class: {
2666
+ 'sicon-keyboard_arrow_down': true,
2667
+ 's-conditional-offer-toggle-icon': true,
2668
+ 's-conditional-offer-toggle-icon-open': this.isGiftsOpen,
2669
+ }, "aria-hidden": "true" })));
2670
+ }
2671
+ /** Spec §5 — the gifts card: one read-only row per gift line. */
2672
+ renderGiftsCard() {
2673
+ return (h("div", { class: "s-conditional-offer-panel", id: this.elementId('gifts') }, h("div", { class: "s-conditional-offer-gifts-header" }, h("span", { class: "s-conditional-offer-gifts-icon" }, h("i", { class: "sicon-gift", "aria-hidden": "true" })), h("span", { class: "s-conditional-offer-gifts-title" }, this.trans('free_products_title'))), h("ul", { class: "s-conditional-offer-gifts-list" }, this.gifts.map(gift => this.renderGiftRow(gift)))));
2674
+ }
2675
+ renderGiftRow(gift) {
2676
+ return (h("li", { class: "s-conditional-offer-gift", "data-item-id": gift.id }, h("span", { class: "s-conditional-offer-gift-thumb" }, gift.imageUrl ? (h("img", { class: "s-conditional-offer-gift-image", src: gift.imageUrl, alt: gift.imageAlt || gift.name, loading: "lazy", decoding: "async" })) : (h("i", { class: "sicon-gift", "aria-hidden": "true" }))), h("div", { class: "s-conditional-offer-gift-body" }, h("span", { class: "s-conditional-offer-gift-name" }, gift.name || this.trans('free_gift')), h("span", { class: "s-conditional-offer-gift-prices" }, h("span", { class: "s-conditional-offer-gift-free" }, this.getFreeLabel()), gift.originalPrice !== null ? (
2677
+ // innerHTML: `salla.money()` markup again.
2678
+ h("span", { class: "s-conditional-offer-gift-price", innerHTML: salla.money(gift.originalPrice) })) : null))));
158
2679
  }
159
2680
  getLoadingSkeletonView() {
160
- return h(Host, { class: "s-conditional-offer-container" }, h("div", { class: "s-conditional-offer-skeleton-inner-container" }, h("div", { class: "s-conditional-offer-skeleton-subtitle" }, h("salla-skeleton", { height: "16px", width: "30%" })), h("div", { class: "s-conditional-offer-skeleton-subtitle" }, h("salla-skeleton", { height: "16px", width: "35%" })), h("div", { class: "s-conditional-offer-skeleton-checkpoints-wrapper" }, Array(3).fill(null).map(() => ([h("salla-skeleton", { key: "checkpoint-line", height: "8px" }), h("div", { key: "checkpoint" }, h("salla-skeleton", { height: "60px", width: "60px", type: "circle" }))])))));
2681
+ return (h(Host, { class: "s-conditional-offer-root" }, h("div", { class: "s-conditional-offer-banner" }, h("div", { class: "s-conditional-offer-lead" }, h("salla-skeleton", { height: "24px", width: "80px" }), h("div", { class: "s-conditional-offer-skeleton-text" }, h("salla-skeleton", { height: "12px", width: "35%" }), h("salla-skeleton", { height: "16px", width: "60%" }))))));
161
2682
  }
162
2683
  render() {
163
2684
  if (!this.canRender)
164
2685
  return null;
2686
+ /**
2687
+ * HIDDEN IS THE DEFAULT. Nothing renders — no banner, no skeleton, no host class,
2688
+ * no layout at all — until it is POSITIVELY KNOWN that the cart holds at least one
2689
+ * item. Everything else, including "the lookup has not answered yet" and "the
2690
+ * lookup failed", is hidden.
2691
+ *
2692
+ * This is an inversion of the original tri-state, and it is what makes the whole
2693
+ * class of stuck-block bugs unreachable rather than merely unlikely. The two
2694
+ * failure modes are not symmetric: hiding when we should have shown is INVISIBLE
2695
+ * and self-corrects the moment the count lands, while showing when we should have
2696
+ * hidden is a tinted block sitting above "السلة فارغة" — which is what shipped
2697
+ * twice. An unknown cart is indistinguishable from an empty one to the shopper, so
2698
+ * it is treated as one.
2699
+ *
2700
+ * Note what this makes structurally impossible: a skeleton can only ever be
2701
+ * painted once `cartItemCount >= 1`, i.e. only when there is genuinely content on
2702
+ * the way, so a skeleton can no longer be stranded by anything that fails before
2703
+ * the cart is known.
2704
+ */
2705
+ if (!this.hasCartItems())
2706
+ return null;
2707
+ // Past this line the cart is known to hold items, so the skeleton is a REAL
2708
+ // loading state for content we know is coming — and the watchdog still sits
2709
+ // behind it for the case where the offer request never settles.
165
2710
  if (this.isLoading)
166
2711
  return this.getLoadingSkeletonView();
167
2712
  if (!this.offer)
168
2713
  return null;
169
- return h(Host, { class: "s-conditional-offer-container" }, h("div", { class: "s-conditional-offer-title-wrapper" }, h("div", { class: "s-conditional-offer-title" }, this.offer.title), this.offer.description ? h("div", { class: "s-conditional-offer-subtitle" }, this.offer.description, " ", h("i", { class: "sicon-information" })) : null), h("div", { class: "s-conditional-offer-progress-container" }, this.offer.details.discounts.map((discount, index) => (h("div", { class: { "flex-1": index > 0 }, key: discount.min_spend }, this.getCheckPointView(discount, index))))));
2714
+ // Derived ONCE per render and threaded down — `stages[i]` and
2715
+ // `progress.thresholds[i]` must be the same pair everywhere they are read.
2716
+ const stages = this.getStages();
2717
+ const progress = this.getProgress(stages);
2718
+ const guidance = this.getGuidanceMessage(stages, progress);
2719
+ const detailsTitle = this.trans('details_title');
2720
+ // Spec §2 — the accordion exists only when there is something to manage.
2721
+ const hasGifts = this.gifts.length > 0;
2722
+ return (h(Host, { class: "s-conditional-offer-root" }, h("div", { class: "s-conditional-offer-banner" }, h("div", { class: "s-conditional-offer-lead" }, this.renderBadges(stages, progress), h("div", { class: "s-conditional-offer-text" }, h("div", { class: "s-conditional-offer-heading", onMouseEnter: () => (this.isTooltipHovered = true), onMouseLeave: () => (this.isTooltipHovered = false) }, h("span", { class: "s-conditional-offer-name" }, this.offer.title), h("span", { class: "s-conditional-offer-info", ref: (el) => (this.infoAnchorEl = el) }, h("button", { type: "button", class: "s-conditional-offer-info-trigger", ref: (el) => (this.infoTriggerEl = el), "aria-label": detailsTitle, title: detailsTitle, "aria-expanded": this.isTooltipOpen ? 'true' : 'false', "aria-describedby": this.elementId('tooltip'), onClick: () => this.toggleTooltip() }, h(InformationIcon, { class: "s-conditional-offer-info-icon" })), this.isTooltipOpen ? this.renderTooltip(stages, progress) : null)), progress.allMet ? (h("div", { class: "s-conditional-offer-message", role: "status" }, this.trans('congrats'))) : guidance ? (
2723
+ // innerHTML: the `:remaining` half carries `salla.money()` markup;
2724
+ // the reward half is escaped in `getGuidanceMessage`.
2725
+ h("div", { class: "s-conditional-offer-message", innerHTML: guidance })) : null)), hasGifts ? this.renderGiftsToggle('inline') : null), hasGifts && this.isGiftsOpen ? this.renderGiftsCard() : null, hasGifts ? this.renderGiftsToggle('bar') : null));
170
2726
  }
2727
+ get hostEl() { return this; }
171
2728
  static get style() { return sallaConditionalOfferCss; }
172
2729
  }, [0, "salla-conditional-offer", {
173
2730
  "offer": [32],
174
- "products": [32],
175
2731
  "isLoading": [32],
176
- "canRender": [32]
177
- }]);
2732
+ "canRender": [32],
2733
+ "gifts": [32],
2734
+ "cartItemCount": [32],
2735
+ "isTooltipHovered": [32],
2736
+ "isTooltipPinned": [32],
2737
+ "isGiftsOpen": [32]
2738
+ }, [[9, "resize", "handleWindowResize"], [4, "keydown", "handleDocumentKeydown"], [4, "click", "handleDocumentClick"]]]);
178
2739
  function defineCustomElement$1() {
179
2740
  if (typeof customElements === "undefined") {
180
2741
  return;
181
2742
  }
182
- const components = ["salla-conditional-offer", "salla-skeleton", "salla-tooltip"];
2743
+ const components = ["salla-conditional-offer", "salla-skeleton"];
183
2744
  components.forEach(tagName => { switch (tagName) {
184
2745
  case "salla-conditional-offer":
185
2746
  if (!customElements.get(tagName)) {
@@ -187,11 +2748,6 @@ function defineCustomElement$1() {
187
2748
  }
188
2749
  break;
189
2750
  case "salla-skeleton":
190
- if (!customElements.get(tagName)) {
191
- defineCustomElement$3();
192
- }
193
- break;
194
- case "salla-tooltip":
195
2751
  if (!customElements.get(tagName)) {
196
2752
  defineCustomElement$2();
197
2753
  }