@salla.sa/twilight-components 2.14.589 → 2.14.591

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